Compare commits
7
Commits
13388776e1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5ad21c3da | ||
|
|
190a8e36f1 | ||
|
|
9766e1f61c | ||
|
|
73698cd77b | ||
|
|
b1ea97c03e | ||
|
|
1980570b27 | ||
|
|
b1aa0c4483 |
@@ -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**
|
||||
|
||||
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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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. |
|
||||
|
||||
@@ -44,7 +44,7 @@ The **one named exception** is transient UI state rendering things that don't ex
|
||||
|
||||
- **A failed reload never replaces a good snapshot.** Fail-fast (01-storage-format.md) is the *initial-load* contract, where there is nothing to fall back on. Once a board is open, a watcher-triggered reload that fails (unparseable YAML, missing required fields — typically a non-atomic external write caught mid-flight) keeps the last good snapshot on screen and raises a **non-modal banner** carrying fail-fast's specifics (offending path + what's wrong). The watcher keeps watching; the next successful reload clears the banner automatically — transient breakage self-heals without the user losing the board, persistent breakage stays loudly visible. Editing is not locked out: writes go through the Writer as usual (the breakage is per-file and localized), and the reload debounce already absorbs most momentary invalid states before they surface.
|
||||
- **Duplicate-id detection heals silently** (re-ruled 2026-07-29, superseding the repairable-condition banner): the loader's board-wide dedupe (01-storage-format.md ▸ Fractal layout rules) withholds losing occurrences from every snapshot; a scheduled heal remints them through the Writer (the loader itself never writes) and a warning-tone notice reports the repair — no banner, no button, nothing waits on consent. The withheld window is one heal cycle, not a standing condition; a remint racing a vanished duplicate (repaired elsewhere, a hand-deleted copy) is a no-op, never an error.
|
||||
- **The watcher is self-reconciling, never trusted blindly** (settled): every reload is already a full tree walk producing a value-type snapshot, so recovery from any blind window is always the same act — reload. A **reconciling reload** runs on wake-from-sleep and on app re-activation (debounced; an identical tree swaps in value-equal — and, blessed 2026-07-31, the store **skips the assignment entirely** when the fresh snapshot equals the current one: assigning an equal tree into an `@Observable` property still costs a render pass, so "costs nothing visible" becomes *costs nothing*), on any FSEvents flag admitting missed events (`MustScanSubDirs`, queue overflow — degrade to the reload rather than trust the gap), and after any stream re-creation. **The skip's structural consequence — the two-counter split (blessed 2026-08-06):** once value-equal landings stop bumping the applied-snapshot generation, anything whose subject is the *walk* rather than the applied snapshot can no longer key on it. The store therefore carries two counters — snapshots **applied** (the committed-overlay hold's event, meaning unchanged) and walks **landed** with a snapshot in hand, bumped on every successful reload, equal or not; a failed reload bumps neither. The rule for choosing: **anything outside the snapshot, or about the walk itself, watches landed walks, not applied snapshots** — the card window's comment thread (comments are outside the snapshot, so a foreign comment arriving leaves the model value-equal), the comment search index (same shape), and the auto-committer's covering gate (what covers a flush is a completed walk, whether or not it found anything to show); on the applied counter each would sleep through exactly the value-equal landing it exists to notice. **Streams die and are recreated, not merely kept**: a volume unmount kills the stream with its root; the vanished-root and rename re-resolution rules (below) attach a *fresh* stream at the current root when it returns, reconciling reload included. A silently stale board — the worst failure for a files-are-truth app — is structurally excluded: every known blind window ends in a reload. **A reconcile request arriving mid-bracket is banked** (settled): the mandatory post-bracket reload delivers as the *reconciling* kind rather than app-mediated — an explicit reconciliation is never silently lost. FSEvents missed-events flags arriving mid-bracket are, by contrast, simply swallowed: the post-bracket reload is a full walk either way, and only the origin tag differs (it feeds commit attribution and the VoiceOver announcement vocabulary — a deliberate asymmetry). **The walk memoizes its parse, never its result** (blessed 2026-07-31 — a performance posture, not a semantic change): the loader may reuse the previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged — the previous snapshot *is* the memo — while directory enumeration (folder discovery, attachment listings, trash entries) stays fresh every walk, because attachment changes never touch `index.md`. The loader's contract is result-purity with cost unspecified: same tree in, same snapshot out, and the memo can only change how fast. The mtime+size trust is the git-index heuristic; a writer that defeats it — content changed, mtime and size both preserved — is outside the app's care.
|
||||
- **The watcher is self-reconciling, never trusted blindly** (settled): every reload is already a full tree walk producing a value-type snapshot, so recovery from any blind window is always the same act — reload. A **reconciling reload** runs on wake-from-sleep and on app re-activation (debounced; an identical tree swaps in value-equal — and, blessed 2026-07-31, the store **skips the assignment entirely** when the fresh snapshot equals the current one: assigning an equal tree into an `@Observable` property still costs a render pass, so "costs nothing visible" becomes *costs nothing*), on any FSEvents flag admitting missed events (`MustScanSubDirs`, queue overflow — degrade to the reload rather than trust the gap), and after any stream re-creation. **The skip's structural consequence — the two-counter split (blessed 2026-08-06):** once value-equal landings stop bumping the applied-snapshot generation, anything whose subject is the *walk* rather than the applied snapshot can no longer key on it. The store therefore carries two counters — snapshots **applied** (the committed-overlay hold's event, meaning unchanged) and walks **landed** with a snapshot in hand, bumped on every successful reload, equal or not; a failed reload bumps neither. The rule for choosing: **anything outside the snapshot, or about the walk itself, watches landed walks, not applied snapshots** — the card window's comment thread (comments are outside the snapshot, so a foreign comment arriving leaves the model value-equal), the comment search index (same shape), and the auto-committer's covering gate (what covers a flush is a completed walk, whether or not it found anything to show); on the applied counter each would sleep through exactly the value-equal landing it exists to notice. **Streams die and are recreated, not merely kept**: a volume unmount kills the stream with its root; the vanished-root and rename re-resolution rules (below) attach a *fresh* stream at the current root when it returns, reconciling reload included. A silently stale board — the worst failure for a files-are-truth app — is structurally excluded: every known blind window ends in a reload. **A reconcile request arriving mid-bracket is banked** (settled): the mandatory post-bracket reload delivers as the *reconciling* kind rather than app-mediated — an explicit reconciliation is never silently lost. FSEvents missed-events flags arriving mid-bracket are, by contrast, simply swallowed: the post-bracket reload is a full walk either way, and only the origin tag differs (it feeds commit attribution and the VoiceOver announcement vocabulary — a deliberate asymmetry). **The walk memoizes its parse, never its result** (blessed 2026-07-31 — a performance posture, not a semantic change): the loader may reuse the previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged — the previous snapshot *is* the memo — while directory enumeration (folder discovery, attachment listings, trash entries) stays fresh every walk, because attachment changes never touch `index.md`. The loader's contract is result-purity with cost unspecified: same tree in, same snapshot out, and the memo can only change how fast. The mtime+size trust is the git-index heuristic; a writer that defeats it — content changed, mtime and size both preserved — is outside the app's care (blessed 2026-08-06 as a decision on file, the boundary being reachable in practice — a byte-length-preserving edit plus deliberate utimes, a restore tool replaying old attributes — and pinned by test: git itself lives with the same blind spot, and the named tightenings — content hashing, which is the read the memo exists to avoid, or fileSystemFileNumber/generation stamps — wait for a real-world defeat, not a hypothetical one).
|
||||
- **App-initiated git churn is bracketed.** Operations the app runs itself (pull-rebase, branch switch, undo restore — 06-history-undo.md, 07-sync-collab.md) suspend watcher reloads for their duration and finish with one full reload — half-checked-out trees are never rendered. **The bracket also locks writes** (settled): for its duration the board is read-only with exactly the failed-reload lock's scope — mutating commands disable via menu validation, drops are refused, selection/navigation/search/copy-out stay live. 07's interaction-rest rule composes: the bracket starts only at gesture rest, so nothing in flight is interrupted; the lock ends with the final reload — seconds, honestly signaled by the operation's in-progress banner row (▸ The banner surface). External git activity (the user running git in a terminal) can't be bracketed: the debounce coalesces its churn, and a transiently inconsistent but parseable tree may render briefly and heals on the next event — accepted.
|
||||
- **Selection survives reloads by UUID.** Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. **A container crossing is a vanish for this purpose** (resettled 2026-07-28 — the materialized trash): re-resolution matches UUID *and* container side (board vs `.trash/`), so a foreign move that trashes a selected board card — or restores a selected trash card — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's container-boundary invariant true across reloads. The old effective-liveness ancestor walk is retired with the tombstone model — presence in the snapshot is the whole question. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is deleted or moved to the trash (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map).
|
||||
- **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The rule arms on every bracket exit, thrown operations included** (settled): an operation that fails or aborts mid-flight is precisely when the tree's state is least known, so the mandatory final reload runs regardless — succeeding, it renders whatever the operation left (often value-equal after a clean failure, whose tree is left as it was — 06-history-undo.md); failing, it locks exactly as above. **The lock's scope** (shared with the vanished-root case below) spans every window sharing the store — card windows included: every mutating command disables via menu validation — creation, delete and restore, paste, Move/Style/rename, trash operations, the popover's git controls, and the card window's write paths: the flip into Edit mode, raw-source entry and Apply, Add Attachment and the whole-window file drop, the sidebar's mutating actions, and task-list checkbox toggles (these disable in place as controls — 05-card-window.md's in-content rule) — and the board refuses drops, including drags arriving from another board's window. **The lock is a predicate every mutating entry point consults, not a menu-validation sweep** (settled): mutating paths without a menu item exist beyond the checkboxes — the attachment row's ⌫/Remove (grammar key + context menu only, 11-command-nexus.md ▸ Context menus), plain-⌫ delete, Return-creation — and each disables with its surface or follows its command twin's validation; menu validation is the lock's most visible face, never its whole mechanism. **File ▸ Duplicate and File ▸ Save as Template join the disabled set** (settled): both copy the on-disk tree, which the lock marks as gone (vanished root), unknown (this failed-reload state), or unwritable — and both are specified to run the close flush first (03-board-ui.md, 09-templates.md), which the lock's suspended saves make impossible to honor. **One carve-out: under the unwritable-location lock alone (below), Save as Template stays live** — it reads the board and writes into Application Support, the copy-out-is-a-read principle applied (archiving the read-only DMG board being inspected is a legitimate errand). **The carve-out gates on the hazard itself, open sessions, not on lock provenance** (settled): the item disables while any open Edit or raw-source session holds unsaved content — content the lock's suspended saves cannot flush, which the template would silently miss (09-templates.md's never-misses-keystrokes guarantee outranks availability) — and re-enables when those sessions settle or the lock clears. A lock standing since open never meets this state: it disables the flip into Edit mode, so no session can start beneath it and the gate is vacuously open; unsaved sessions under this lock exist only when the symmetric probe (▸ Write-failure surfacing) raised it mid-session. Duplicate stays disabled even there — its destination is the same unwritable parent (a writable-parent/unwritable-board permission split was weighed and set aside as too rare to earn the inconsistency). Drags *out* of a locked board offer the copy variant only — copy-out is a read; a ⌘-drag move's source-side delete is a write, so the modifier doesn't take. **An Edit buffer already open when the lock lands keeps its content and stays typable** — memory is not disk — but its debounced save suspends for the lock's duration; the held text's fate follows the lock's cause: a branch switch or undo restore can't leave a session open behind the lock at all (both settle editors first — 06-history-undo.md), a post-pull buffer saves on clear and wins per the sync model (05-card-window.md, 07-sync-collab.md), and a returned root saves normally (below). Selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it).
|
||||
|
||||
@@ -13,7 +13,7 @@ The board window: layout, lanes, cards, and styling. Interaction mechanics (sele
|
||||
|
||||
Toolbars are **pure enhancement**: every function they host already has a menu item + shortcut (04-interactions.md's contract), so nothing below is anyone's only path. Both windows' toolbars are **user-customizable, macOS-native** (right-click ▸ Customize Toolbar…, drag to rearrange, system overflow and icon/text display options) — the sets below are shipped defaults, not verdicts. Toolbar item labels match their menu-item titles exactly (Show Trash, Edit Body, Raw Source, …), minus any trailing ellipsis (macOS convention: "Add Attachment…" labels as Add Attachment) — one vocabulary everywhere, and the customize palette self-documents against the menus. One exception: the Undo/Redo toolbar items keep static labels — NSUndoManager rewrites their menu titles dynamically ("Undo Move Card…", 04-interactions.md ▸ Configurable bindings), which a toolbar label doesn't track.
|
||||
|
||||
- **Board window default: the search field, nothing else** — trailing, the one default item; the titlebar stays clean. ⌘F always summons search: with the field removed from the toolbar, invoking it surfaces the field transiently until the search clears. **A squeezed field expands in place; the strip answers only genuine unreachability** (blessed 2026-08-06): a space-constrained `NSSearchToolbarItem` collapses to a magnifying-glass button still in the window, and ⌘F expands and focuses it — the toolbar's own field, a better surface than the fallback — so the transient strip fires only for a truly windowless field (true overflow, or the item removed from the toolbar). **The item's overflow row is the platform's own second answer, and the duality is blessed** (2026-08-06): AppKit's overflow menu row carries a live action that widens the window until the field is usable, and suppressing it would destroy the honest overflow presentation — so a squeezed toolbar reaches search two ways with two honest resolutions: ⌘F expands the item or surfaces the strip (ours), the overflow row grows the window (the platform's) — different gestures, reasonable respective outcomes, no contradiction to resolve. **The field is the platform's own search toolbar item and grows on focus** (2026-08-01): the em-derived width is the *focused* width — applied when the field takes the keyboard, expanding via the item's own animation — and the resting width is AppKit's natural one, not the app's to set. **The two-homes width rule is focused-width parity** (ruled 2026-08-01): the width the user *types in* is the same em-derived figure whichever home the field is in — the toolbar item focused, or the transient strip; the toolbar field's resting width is outside the invariant (the strip never rests — it exists only while a search is live or focused, so it has no collapsed state to mirror). **Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out (the zoom pair is catalog-only by the same logic as everything else here — the titlebar's default stays the search field alone; each disables at its end of the ladder, and both disable mid-drag like their menu rows — Layout ▸ zoom above), Undo, Redo (the pair disabled only under locks and on empty stacks — re-ruled 2026-07-31, twice: the provider follows the board, so boards without app-managed git — repo-nested included — bind 13-native-undo.md's native stack in **every** tier and Pro git boards bind the git provider — 06-history-undo.md), Show Trash (toggle state matching the View menu checkmark). The board popover deliberately has **no toolbar item** — the window-title widget is its committed home (below), and a second entry would muddy it.
|
||||
- **Board window default: the search field, nothing else** — **centered** (ratified 2026-08-06 off the 2026-08-01 live try-out, reversing the earlier trailing ruling): the titlebar reads as a placement grammar — leading is board identity (the title widget), center is view controls (search today, the filter family if one ever grows), trailing remains the user's catalog space. The mechanism is `NSToolbar.centeredItemIdentifiers`, deliberately not a flexible-space sandwich: it centers against the window rather than leftover space, holds as catalog items install, and sits outside the autosaved configuration, so it takes effect on machines with a saved arrangement — and `defaultItems` is unchanged, keeping the pinned default-set tests standing. The known tensions were weighed and accepted at ratification: the 400pt leading widget and a centered field share the titlebar's budget in narrow windows (the squeezed field's expand-in-place answer below is the relief), and HIG's trailing-edge convention yields to the grammar. The titlebar stays clean. ⌘F always summons search: with the field removed from the toolbar, invoking it surfaces the field transiently until the search clears. **A squeezed field expands in place; the strip answers only genuine unreachability** (blessed 2026-08-06): a space-constrained `NSSearchToolbarItem` collapses to a magnifying-glass button still in the window, and ⌘F expands and focuses it — the toolbar's own field, a better surface than the fallback — so the transient strip fires only for a truly windowless field (true overflow, or the item removed from the toolbar). **The item's overflow row is the platform's own second answer, and the duality is blessed** (2026-08-06): AppKit's overflow menu row carries a live action that widens the window until the field is usable, and suppressing it would destroy the honest overflow presentation — so a squeezed toolbar reaches search two ways with two honest resolutions: ⌘F expands the item or surfaces the strip (ours), the overflow row grows the window (the platform's) — different gestures, reasonable respective outcomes, no contradiction to resolve. **The field is the platform's own search toolbar item and grows on focus** (2026-08-01): the em-derived width is the *focused* width — applied when the field takes the keyboard, expanding via the item's own animation — and the resting width is AppKit's natural one, not the app's to set. **The two-homes width rule is focused-width parity** (ruled 2026-08-01): the width the user *types in* is the same em-derived figure whichever home the field is in — the toolbar item focused, or the transient strip; the toolbar field's resting width is outside the invariant (the strip never rests — it exists only while a search is live or focused, so it has no collapsed state to mirror). **Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out (the zoom pair is catalog-only by the same logic as everything else here — the titlebar's default stays the search field alone; each disables at its end of the ladder, and both disable mid-drag like their menu rows — Layout ▸ zoom above), Undo, Redo (the pair disabled only under locks and on empty stacks — re-ruled 2026-07-31, twice: the provider follows the board, so boards without app-managed git — repo-nested included — bind 13-native-undo.md's native stack in **every** tier and Pro git boards bind the git provider — 06-history-undo.md), Show Trash (toggle state matching the View menu checkmark). The board popover deliberately has **no toolbar item** — the window-title widget is its committed home (below), and a second entry would muddy it.
|
||||
- **Card window default: Edit Body · Raw Source · Add Attachment** — the window's three committed functions, all discoverable from its toolbar; the catalog is the same trio. Edit Body is a **single toggle button** (on-state in Edit — mirroring the View ▸ Edit Body checkmark and the ⌘E/Return/Escape grammar; the pathfinder's segmented Preview|Edit is retired). Raw Source is likewise a toggle showing on-state; while source mode is active, Edit Body disables (Cancel/Apply own the exits — 05-card-window.md). Add Attachment stays enabled in every mode — attachment operations never touch `index.md`, so they're safe alongside a raw edit (the sidebar's feedback returns on exit).
|
||||
|
||||
## Lane
|
||||
@@ -33,7 +33,7 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
|
||||
|
||||
### 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.
|
||||
- **`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).
|
||||
@@ -41,10 +41,10 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
|
||||
|
||||
### Controls (settled)
|
||||
|
||||
One **style editor** component — a background palette grid and a curated symbol grid — presented from three anchors: **embedded** in the card window sidebar's Style section (05-card-window.md) and in the board popover's styling area, and as a **popover** opened by Style… from a card/lane context menu or the menu bar (Board ▸ Style…, ⌥⌘S — 11-command-nexus.md; selection-aware: it styles the selected cards or lane, and with nothing selected, the board). One component, one behavior, three anchors — replacing the pathfinder's swatch-row-plus-Style-popover split, whose functions were right and whose form wasn't.
|
||||
One **style editor** component — a background palette grid and a curated symbol grid — presented from three anchors: **embedded** in the card window sidebar's Style section (05-card-window.md) and in the board popover's styling area, and as a **popover** opened by Style… from a card/lane context menu or the menu bar (Board ▸ Style…, ⌥⌘S — 11-command-nexus.md; selection-aware: it styles the selected cards or lane, and with nothing selected, the board). One component, one behavior, three anchors — replacing the pathfinder's swatch-row-plus-Style-popover split, whose functions were right and whose form wasn't. **The anchors compose the halves they need** (2026-08-06): the card sidebar shows the symbol grid with the **color combo** (below) standing in for the background half — the narrow context the combo was built for; the board popover shows the background half only, its symbol picker beside the rename field owning the board glyph (two surfaces writing one key in one popover would read as two settings); the Style… popover carries both grids in full.
|
||||
|
||||
- **Palette-only in-app**: the background grid offers the 12 palette colors — every one AA-verified through one code path (ratified 2026-07-29: palette names route through the same runtime ink-selection seam as hand-written hex — the appearance flip picks the readable label vocabulary — and PaletteContrastTests pins that the chosen ink meets AA in both appearances for all 12 backgrounds, so palette drift can never silently break it) — plus a leading **None** well that removes the `background` key. Custom hex is not pickable in-app but stays fully honored from disk (runtime contrast, 10-accessibility.md): curated in-app, unlimited on disk.
|
||||
- **Curated symbol grid**: a hand-picked set (roughly five dozen kanban-relevant SF Symbols); its leading well is the level's default symbol and removes the `icon` key. Any other SF Symbol name works written by hand — named symbols the running OS knows, that is: inventories grow per macOS release, so a newer-OS name renders the level default on an older Mac, value preserved on disk — the palette stance again. No full-browser escape hatch in-app; the raw file is the escape hatch.
|
||||
- **Curated-first, panel-backed** (re-ratified 2026-08-06, reversing 2026-07-29's palette-only ruling): the background grid offers the 12 palette colors — every one AA-verified through one code path (ratified 2026-07-29: palette names route through the same runtime ink-selection seam as hand-written hex — the appearance flip picks the readable label vocabulary — and PaletteContrastTests pins that the chosen ink meets AA in both appearances for all 12 backgrounds, so palette drift can never silently break it) — plus a leading **None** well that removes the `background` key. Beside the grid the vocabulary now has a second, compact form: the **color combo** — a swatch-faced popup listing None, the role's twelve, the current off-palette value verbatim when there is one, and **Other…**, which opens the system Colors panel. The panel is the in-app escape hatch the 2026-07-29 ruling withheld: a pick landing exactly on a palette color stores the *name* (so a re-pick never drifts to a hex spelling), anything else stores the hex — the same unlimited vocabulary hand-editing always had, now pickable. An arbitrary pick changes no contrast story (it lands on the identical runtime ink computation hand-written hex already gets — 10-accessibility.md), and the quick-style recents stay palette-vocabulary: a panel pick never enters them.
|
||||
- **Curated symbol grid**: a hand-picked set (roughly five dozen kanban-relevant SF Symbols); its leading well is the level's default symbol and removes the `icon` key. Any other SF Symbol name works written by hand — named symbols the running OS knows, that is: inventories grow per macOS release, so a newer-OS name renders the level default on an older Mac, value preserved on disk — the palette stance again. No full-browser escape hatch in-app; the raw file is the escape hatch. (Symbols keep this stance deliberately — the 2026-08-06 color-panel reversal above is colors only: the system offers a Colors panel worth deferring to, and no symbol browser of equal standing.)
|
||||
- **Off-palette values display leniently**: a hand-written hex background or uncurated symbol shows as the current value in the editor (labeled verbatim, outside the grids); choosing any well replaces it.
|
||||
- **Batch edits**: a multi-selection shows per-dimension mixed state (no well selected, "—" where a value would read); choosing a well applies to the whole selection — one gesture, one commit on git boards.
|
||||
- **The Style… popover tracks its target set live and dismisses when it empties** (settled): its target is the selection, re-resolved across reloads by 02-architecture.md's UUID rule — a member that vanishes or flips liveness leaves the set and the mixed-state display recomputes; a set emptied by a foreign reload dismisses the popover (the inline-rename discard applied here) — it never silently retargets to the board, and nothing writes into a vanished folder (a member moved to the trash leaves the set like any other departure). **The read-only lock instead disables its wells in place** (settled): a popover open when the lock lands stays open, content disabled — the banner names why, and the lock never yanks a surface (the Edit buffer's keeps-its-place posture). The embedded anchors need no rule of their own: the card sidebar dismisses with its card's window, and the board popover's target is the board itself.
|
||||
@@ -57,7 +57,7 @@ The window-title widget opens the **board popover** — the one board-level surf
|
||||
|
||||
- **Board rename** (settled: this function stays in-app, unlike the pathfinder which dropped it with the inspector). Rename edits the board's frontmatter `title` only — the folder is never renamed by the app; the Finder document name is Finder's to change (01-storage-format.md's board-naming rule). A foreign rename landing while the popover is open resyncs the field from the snapshot only while the field is unfocused — a focused field keeps the user's keystrokes, the dirty-buffer courtesy applied here (settled).
|
||||
- **Board styling** — the embedded style editor (Styling ▸ Controls above).
|
||||
- **Git at a glance** — Pro tier surface, mode-aware, *display and daily operations only* (re-ruled 2026-07-31 — setup moved to the board settings sheet below; in the free tier this section is absent on ordinary boards and reduces to the contextual one-line Pro pointer on boards carrying an inert `.git` — 12-editions.md): the posture lines (repo-nested explanation, unreadable-repo and paused states — 06), branch display with the **switch picker**, and on remote-backed boards remote tracking (ahead/behind) with **Pull/Push** controls and the status badges (Authentication needed, queued pushes, last error — the badge points at the sheet, capture happens there). A **Board Settings…** row opens the sheet — the popover's one setup affordance.
|
||||
- **Git at a glance** — Pro tier surface, mode-aware, *display and daily operations only* (re-ruled 2026-07-31 — setup moved to the board settings sheet below; in the free tier this section is absent on ordinary boards and reduces to the contextual one-line Pro pointer on boards carrying an inert `.git` — 12-editions.md): the posture lines (repo-nested explanation, unreadable-repo and paused states — 06), branch display with the **switch picker**, and on remote-backed boards remote tracking (ahead/behind) with **Pull/Push** controls and the status badges (Authentication needed, queued pushes, last error — the badge points at the sheet, capture happens there). **A single-branch board's picker opens onto a disabled explanatory row** (ruled 2026-08-06): with creation relocated to the sheet, the menu holds only the *other* local branches, and an empty menu reads as broken — a disabled "No other branches" row teaches both why the menu is empty and where creation went. A **Board Settings…** row opens the sheet — the popover's one setup affordance. **The Pro mode-none posture is header plus that door** (blessed 2026-08-06): with no repository the section stays — "Git" and the Board Settings… row alone — rather than vanishing, so the board's git story keeps its named place in the popover and the door stands exactly where a user looking for git will look.
|
||||
|
||||
## Board settings sheet
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
|
||||
- **Multi-drag**: dragging any member of a multi-selection drags the whole selection; N contiguous shadows; drop inserts contiguously in preserved relative order — defined, for any multi-selection, as lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, top-to-bottom).
|
||||
- **Locality picks the default — the Finder volume model** (settled): within a board a drag is a **move** (rearranging); between boards it is a **copy** (transferring — the system copy badge shows over the foreign board). **⌥ always forces copy** and **⌘ always forces move**, Finder's exact modifier grammar; each is a no-op where its behavior is already the default. The badge tracks the effective operation live as the cursor crosses a board boundary.
|
||||
- **Within-board ⌥-drag copies**: originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop. Lane drags never copy *within their board* — a within-board lane duplicate is **not available by drag** (⌥ is simply ignored there: the drag stays a clean reorder and the badge never shows copy); the duplicate itself is supported, via the clipboard (Lane paste below) — the usual shape: the keyboard path is the canonical one, drag the enhancement (10-accessibility.md).
|
||||
- **Cross-board copy** (the default): cards and lanes (including multi-selections) drag between open boards; fresh-GUID duplicates land at the drop, originals stay, `created` is kept (a copy is a fork — 01-storage-format.md). Lanes copy cards and all — transferring workflow structure between boards is safe by default. A lane carries exactly its cards — the trash is board-level (`.trash/` — 03-board-ui.md), so there is nothing lane-nested to strip or carry: copy and ⌘-drag move alike transfer the lane's folder as it is (resettled 2026-07-28; the old tombstone-stripping rule is retired with the tombstone model).
|
||||
- **Cross-board copy** (the default): cards and lanes (including multi-selections) drag between open boards; fresh-GUID duplicates land at the drop, originals stay, `created` is kept (a copy is a fork — 01-storage-format.md). **Locality means the board the drag was picked up from** (ruled 2026-08-06): a drag knows where it came from — the Finder volume model — so a rename or Finder move of the source board absorbed mid-drag is not a departure: the relocation carries the live drag with it (the drag holds its source *store's* identity, not a frozen copy of its key — a frozen key would let a new board opened at the vacated path compare equal to the renamed-away one, turning a copy into a silent cross-board move, the worse failure), and a within-board reorder stays a reorder across a mid-drag rename. Until the carry ships, the recorded residue — the minted key follows the folder, so a mid-drag rename finishes a reorder as a copy — is cosmetic and bounded by the seconds a drag is in flight. Lanes copy cards and all — transferring workflow structure between boards is safe by default. A lane carries exactly its cards — the trash is board-level (`.trash/` — 03-board-ui.md), so there is nothing lane-nested to strip or carry: copy and ⌘-drag move alike transfer the lane's folder as it is (resettled 2026-07-28; the old tombstone-stripping rule is retired with the tombstone model).
|
||||
- **Cross-board move** (⌘-drag): a real filesystem move, works across volumes — identity travels. A moved folder whose UUID already exists in the destination board arrives as a fresh-UUID copy (01-storage-format.md's import-boundary rule); in a compound move (lane with cards, multi-selection) only the colliding folders are reminted — the rest is a true move (01's per-folder degradation).
|
||||
- **Files from Finder**: dropped on a card → copied into its `attachments/` (any *file* type, multi-file; card highlights while hovered). Dropped on lane empty space → creates a card with the file attached, titled with the filename without its extension (multi-file drop: one card per file). **Folders are refused at hover** (settled — the attachment model is flat top-level files, and the importer refuses directories by design): a drag containing only folders never engages — no highlight, no drop proposal, the standard incompatible-payload read; a mixed drag proposes for its files only, and the drop imports the files while a loss row (02-architecture.md's warning tone) names the skipped folders ("Folders can't be attached — 2 skipped"). The create path thereby only ever fires with at least one importable file — no card is minted for an import that cannot succeed. **Created cards land at the drop position** (settled): resolved through the same card-grid zones an ordinary card drag uses, shadow included — drops are positional everywhere, and append-at-bottom stays the creation *trio's* rule, not the drop's. A multi-file drop shows **one nominal-height shadow per incoming file** (the multi-drag precedent; when macOS withholds item counts during hover the count floors at one shadow, the commit unaffected). **A release on the lane header resolves to the topmost position** (settled — forgiving beats a dead stripe: the header's chrome roles don't collide with a file payload). **The landing shadow is the create path's whole feedback** (settled): no lane-level highlight on top — each target gets one clear signal, and the card-attach highlight exists precisely because that target has no shadow.
|
||||
- **A foreign reload mid-drag re-grounds the drag, never corrupts the drop** (settled — a two-second drag racing agent edits is the designed concurrency). Three rules compose: (1) **geometry re-derives** — the frozen-at-drag-start inputs are the *dragged items'* sizes and the physical pointer only (03-board-ui.md ▸ Motion); the analytic resting zones recompute against each new snapshot, so a foreign lane-count re-divide mid-drag just moves the zones and the next proposal targets the board as it now is. (2) **Proposals re-validate by liveness** — a proposal whose target lane vanished in the reload is invalidated; the shadow withdraws and no proposal stands until the pointer reaches a live target, and **release with no valid proposal cancels** — items return, nothing is written; a card is never filed under a vanished parent. (3) **An emptied drag cancels itself** — drag membership is already a UUID set that vanished items leave silently (02-architecture.md); when the *last* dragged item vanishes the replica dissolves and release is a no-op. Partial vanishing drops the survivors, matching the pending-cut precedent. **A cross-board lane arrival pre-divides the destination strip during hover** (settled): while a foreign lane drag proposes into a board, the destination's standard width is computed with the arriving run's units included, so the shadow draws at the width the lane will actually take — without this it overflows the strip (the pathfinder's stripWidthUnits). The first entry samples the un-widened standard for one frame before hysteresis settles — accepted, imperceptible.
|
||||
|
||||
@@ -63,7 +63,7 @@ Stacked sections under small-caps headers, in this order; quiet rows, read-optim
|
||||
|
||||
### Style
|
||||
|
||||
The card-level styling home: the **embedded style editor** — background palette grid (with the leading None well) and curated symbol grid, per 03-board-ui.md ▸ Styling ▸ Controls. Card styling is discoverable here without a context menu; the same component appears in the board popover and behind Style….
|
||||
The card-level styling home: the **Background color combo** over the **curated symbol grid** (03-board-ui.md ▸ Styling ▸ Controls, its 2026-08-06 anchor-ownership rule) — the sidebar is exactly the narrow context the combo was built for, so it stands in for the well grid's background half here, panel escape hatch included; the full grid remains the surface at the other anchors. Card styling is discoverable here without a context menu; the component family is shared with the board popover and Style….
|
||||
|
||||
### Details — unknown frontmatter keys
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
|
||||
|
||||
- **Full relative scaling** (decided): relative text styles everywhere, no fixed point sizes. Card face, lane header, and masonry metrics derive from font metrics, so layout survives the largest system text sizes; the no-horizontal-scroll invariant is untouched (lane count is the user's choice; lanes scroll vertically), and 03-board-ui.md's graceful-truncation rules apply at every scale.
|
||||
- **Board zoom is that scaling's user-facing control** (settled 2026-08-02; behavior in 03-board-ui.md ▸ Layout, rows in 11-command-nexus.md). macOS ships no system text-size setting, so the commitment above had nothing to move it — the point size the whole board derives from is read once and never changes. View ▸ Zoom In / Zoom Out / Actual Size supply the multiplier: one ladder, one effective body size, and every em multiple and every text style scaling off it together. This is an accessibility feature before it is a convenience one, which is why it is a first-class menu command with a chord rather than a setting buried in a pane, and why **the strip's own truncation rules are the acceptance test** — 03's graceful-truncation promise "at every scale" is only checkable now that a scale exists. **A zoom change announces its new level** ("Zoom 125%") through the ordinary announcement path: it is chrome, not information — nothing about the board's meaning changes — so no label, value, or trait anywhere else moves with it.
|
||||
- **Contrast is pinned to WCAG AA.** Lane and card colors render as edge accents (03-board-ui.md's top-edge band / left-edge stripe), so text never sits on them — they are supplementary decoration, never the sole carrier of information, and carry no text-contrast obligation. The ≥ 4.5:1 automatic-contrast rule binds where text does sit on a user-chosen color: the **board** background (palette pairs verified at design time; arbitrary hex computes its text color at runtime against that threshold). An `#RRGGBBAA` background with alpha computes against the color **composited over its effective backdrop** in the active appearance (the board's over the window background; light and dark resolve differently), recomputed on appearance change. Palette names and hex share one ink-selection code path — the palette's AA claim is pinned by a computed-contrast test over all 12 backgrounds in both appearances (ratified 2026-07-29). **The AA obligation binds the primary label tier** (ruled 2026-07-29): the ink seam moves the whole label hierarchy with the primary, and subordinate tiers (.secondary, .quaternary) inherit the system vocabulary's own contrast posture, which sits below 4.5:1 on any background including the system's — the platform-standard reading; the strict path for users who need more is Increase Contrast, which raises accents and washes to full alpha (▸ Visual accommodations). Increase Contrast strengthens borders and the selection indicator.
|
||||
- **Contrast is pinned to WCAG AA.** Lane and card colors render as edge accents (03-board-ui.md's top-edge band / left-edge stripe), so text never sits on them — they are supplementary decoration, never the sole carrier of information, and carry no text-contrast obligation. The ≥ 4.5:1 automatic-contrast rule binds where text does sit on a user-chosen color: the **board** background (palette pairs verified at design time; arbitrary hex computes its text color at runtime against that threshold). The 2026-08-06 color-combo reversal (03 ▸ Styling ▸ Controls) changes none of this: a panel-picked color is stored as the palette name when it lands on one, else as hex, and either spelling renders through the same runtime ink seam — in-app picking gained the freedom hand-editing always had, and the ink math was already waiting for it. No warning surface exists or is owed; the app's answer to a low-contrast pick is to choose readable ink, not to argue. An `#RRGGBBAA` background with alpha computes against the color **composited over its effective backdrop** in the active appearance (the board's over the window background; light and dark resolve differently), recomputed on appearance change. Palette names and hex share one ink-selection code path — the palette's AA claim is pinned by a computed-contrast test over all 12 backgrounds in both appearances (ratified 2026-07-29). **The AA obligation binds the primary label tier** (ruled 2026-07-29): the ink seam moves the whole label hierarchy with the primary, and subordinate tiers (.secondary, .quaternary) inherit the system vocabulary's own contrast posture, which sits below 4.5:1 on any background including the system's — the platform-standard reading; the strict path for users who need more is Increase Contrast, which raises accents and washes to full alpha (▸ Visual accommodations). Increase Contrast strengthens borders and the selection indicator.
|
||||
- **State is never color-alone**: selection is a ring plus trait, cut-pending is dim plus stated value, the trash header is hatched plus labeled — all already patterned; kept as a rule.
|
||||
- **Reduce Motion is a per-voice rule, not a feature list** (settled): movement animations go **instant**, appear/disappear transitions go **crossfade**, uniformly — every animated surface derives its reduced variant from its voice, the store's reload seam included (the largest animated surface in the app), so new surfaces never need individual rulings. The named cases — reflow-on-drag, search animate-out, the drag replica's lift and settle transitions (its 1:1 tracking never animates, like the selection marquee, which needs no variant — 03-board-ui.md ▸ Motion), the lane-resize rubber-band feedback (03-board-ui.md ▸ Lane), trash animations — are applications of the rule, not the rule itself. **Reduce Transparency**: glass underlays go solid, wherever they appear.
|
||||
- **Full Keyboard Access** (independent of VoiceOver): the board is one tab stop with arrow-key navigation within; every control — lane buttons, popover, card window, welcome — is Tab-reachable. **"Every control" is literal and includes banner-row buttons** (ruled 2026-07-29): a Dismiss or Cancel on a banner must be a Tab stop — FKA serves sighted keyboard-only users, to whom VO custom actions are invisible, and Cancel on an in-progress operation is exactly the control that cannot require a pointer. This coexists with the VoiceOver presentation (one combined row-sentence with Dismiss/Cancel as custom actions): the AX combine and the FKA focus loop are independent surfaces; the implementation may uncombine conditionally under FKA if the focus system requires it.
|
||||
|
||||
+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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
+3
-2
@@ -3,5 +3,6 @@ schema: 1
|
||||
order: 4096
|
||||
background: {x: 1}
|
||||
---
|
||||
A mapping has no sensible string reading — malformed, falls back to no
|
||||
color.
|
||||
A mapping is a legal `background` — it carries `color` and `image` subkeys
|
||||
— 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
|
||||
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
|
||||
title: Wire up the loader's stray tolerance
|
||||
order: 2048
|
||||
background: coral
|
||||
background: {color: coral}
|
||||
icon: flag.fill
|
||||
iconColor: orange
|
||||
---
|
||||
|
||||
@@ -3,7 +3,7 @@ schema: 1
|
||||
order: 1024
|
||||
title: Doing
|
||||
width: 2
|
||||
background: '#3478F6'
|
||||
background: {color: '#3478F6'}
|
||||
icon: hammer.fill
|
||||
iconColor: blue
|
||||
---
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
schema: 1
|
||||
order: 2048
|
||||
title: Done
|
||||
background: green
|
||||
background: {color: green}
|
||||
---
|
||||
Completed work lives here until someone clears it out.
|
||||
|
||||
@@ -5,7 +5,7 @@ title: "Rich Demo Board"
|
||||
created: 2026-07-01T09:00:00Z
|
||||
modified: 2026-07-26T16:41:38Z
|
||||
modified-by: claude
|
||||
background: "#1E1E1E"
|
||||
background: {color: "#1E1E1E"}
|
||||
icon: rectangle.stack.fill
|
||||
iconColor: purple
|
||||
|
||||
|
||||
@@ -203,6 +203,21 @@ struct BoardWindowHost: View {
|
||||
.onChange(of: boardSearch.isFocused) { _, _ in
|
||||
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 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".
|
||||
|
||||
@@ -109,6 +109,24 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
/// what the chrome draws from it.
|
||||
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")
|
||||
|
||||
// MARK: Attachment
|
||||
@@ -129,6 +147,7 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
addTitlebarAccessoryIfPossible()
|
||||
applyToolbarIfPossible()
|
||||
applyTitleVisibilityIfPossible()
|
||||
applyTitlebarExtensionIfPossible()
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
/// standard path runs — SwiftUI's own delegate gets its callbacks, tabbing behaves — with the
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
/// knows how to set scalars. It is also the case the forward write was designed to clear
|
||||
/// ("choosing any well replaces it" — 03-board-ui.md § 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.
|
||||
/// byte-exact: the app cannot re-emit `background: [a, b]` — or a hand-written scalar
|
||||
/// `background: green`, which the schema stopped reading when the key became a mapping — through
|
||||
/// a document edit that only writes the shapes the schema names. It is also exactly the case the
|
||||
/// forward write was designed to clear ("choosing any well replaces it" — 03-board-ui.md §
|
||||
/// 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) {
|
||||
if let value = prior.value {
|
||||
document.set(key, to: .string(value))
|
||||
} else {
|
||||
document.remove(key)
|
||||
}
|
||||
document.setStyleValue(prior.value, for: key)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
switch change {
|
||||
case .keep: break
|
||||
case let .set(value): document.set(key, to: .string(value))
|
||||
case .remove: document.remove(key)
|
||||
case let .set(value): document.setStyleValue(value, for: 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,
|
||||
deleted: boardDocument.deleted,
|
||||
background: boardDocument.background,
|
||||
backgroundImage: boardDocument.backgroundImage,
|
||||
icon: boardDocument.icon,
|
||||
iconColor: boardDocument.iconColor,
|
||||
template: boardDocument.value(for: templateKey),
|
||||
|
||||
@@ -88,6 +88,21 @@ public struct BoardModel: Sendable, Equatable {
|
||||
public let deleted: FieldValue<Date>
|
||||
|
||||
public let background: FieldValue<String>
|
||||
|
||||
/// The `background` mapping's `image` subkey — a path **relative to `rootURL`**
|
||||
/// (01-storage-format.md § Frontmatter; 03-board-ui.md § Styling ▸ Capabilities).
|
||||
///
|
||||
/// **Board-level only**, which is why `Lane` and `Card` carry no twin: a lane's and a card's
|
||||
/// colour are edge accents, and there is nothing at those levels an image could fill. The
|
||||
/// shared reader still accepts the mapping at every level for the colour's sake — one key, one
|
||||
/// reading — but this half has exactly one consumer, the board window's backdrop.
|
||||
///
|
||||
/// A **reading, not a location**: the path is resolved (and required to stay inside the board)
|
||||
/// where it is drawn, `BoardBackdrop.imageURL(named:inBoardRoot:)`, so a value that leads
|
||||
/// nowhere paints nothing and stays on disk exactly as written — the same lenient degrade an
|
||||
/// unrecognized colour gets.
|
||||
public let backgroundImage: FieldValue<String>
|
||||
|
||||
public let icon: FieldValue<String>
|
||||
public let iconColor: FieldValue<String>
|
||||
|
||||
|
||||
@@ -568,6 +568,19 @@ public enum FrontmatterKeys {
|
||||
public static let icon = "icon"
|
||||
public static let iconColor = "iconColor"
|
||||
|
||||
/// **The `background` mapping's subkeys** (01-storage-format.md § Frontmatter — the field is a
|
||||
/// mapping and nothing else, ruled 2026-08-06): `{color: "#112233", image: sunset.jpg}`, either
|
||||
/// half absent, and any other subkey an author writes tolerated and carried through
|
||||
/// (`FrontmatterDocument.setStyleValue`). A bare scalar has no reading at all.
|
||||
///
|
||||
/// **Named here without joining `schemaOwned`**, and for a plainer reason than `remote`'s: that
|
||||
/// set is what `unknownFields` subtracts from the document's *top-level* keys, and these two are
|
||||
/// inside one. A top-level `color:` or `image:` is somebody else's key and stays an unknown one.
|
||||
public enum Background {
|
||||
public static let color = "color"
|
||||
public static let image = "image"
|
||||
}
|
||||
|
||||
/// The object's kind — `board`, `lane`, `card` (01-storage-format.md § Frontmatter ▸ Common to
|
||||
/// all levels, re-ruled 2026-07-29). Written at creation of every object, backfilled on touch
|
||||
/// when absent (`IntegrityRules.healOnTouch`), and never stripped.
|
||||
|
||||
@@ -106,6 +106,13 @@ extension FrontmatterDocument {
|
||||
record(FrontmatterKeys.modifiedBy, modifiedBy)
|
||||
record(FrontmatterKeys.author, author)
|
||||
record(FrontmatterKeys.background, background)
|
||||
// **`background` can contribute two entries**, because the one key holds two readings once
|
||||
// it is written as a mapping (`FrontmatterDocument.backgroundImage`). Both are filed under
|
||||
// the key the schema spells, which is the key an author would go and fix; they are told
|
||||
// apart by their raw text, since each quotes the subvalue that could not be read. A mapping
|
||||
// whose colour reads fine and whose image does not still leaves a trace, which is the whole
|
||||
// contract here.
|
||||
record(FrontmatterKeys.background, backgroundImage)
|
||||
record(FrontmatterKeys.icon, icon)
|
||||
record(FrontmatterKeys.iconColor, iconColor)
|
||||
record(FrontmatterKeys.kind, kind)
|
||||
@@ -143,10 +150,54 @@ extension FrontmatterDocument {
|
||||
/// (`title: 2048` reads as `"2048"`). Only a sequence or mapping — no scalar reading exists
|
||||
/// — is malformed.
|
||||
public var title: FieldValue<String> { read(FrontmatterKeys.title, Self.string) }
|
||||
public var background: FieldValue<String> { read(FrontmatterKeys.background, Self.string) }
|
||||
public var icon: FieldValue<String> { read(FrontmatterKeys.icon, Self.string) }
|
||||
public var iconColor: FieldValue<String> { read(FrontmatterKeys.iconColor, Self.string) }
|
||||
|
||||
/// The `background` mapping's **colour** — a palette name or a `#RRGGBB[AA]` hex, read through
|
||||
/// the same scalar coercion every other string field uses.
|
||||
///
|
||||
/// **`background` is a mapping, and only a mapping** (01-storage-format.md § Frontmatter, ruled
|
||||
/// 2026-08-06): `{color: "#112233", image: sunset.jpg}`, either subkey absent, unknown subkeys
|
||||
/// tolerated. A bare scalar — `background: green` — has **no reading at all** and is
|
||||
/// `.malformed`: it renders as no colour, files a coerce-tier trace, and stays on disk exactly as
|
||||
/// written, which is the same lenient degrade a colour nobody can resolve already gets.
|
||||
///
|
||||
/// That is a ruling about the *schema*, not a migration: nothing had shipped when it was made, so
|
||||
/// there is no legacy spelling to keep alive, no version bump, and no healing machinery. One key,
|
||||
/// one shape, and a field whose type does not depend on which subkeys the author happened to
|
||||
/// want.
|
||||
///
|
||||
/// **One reader for all three levels, deliberately.** A lane's and a card's `background` mean
|
||||
/// colour and nothing else — they are edge accents, and only the board consumes an image
|
||||
/// (03-board-ui.md § Styling ▸ Capabilities) — but the *shape* is uniform, so a lane writes
|
||||
/// `{color: fern}` exactly as the board does and nothing below has to know which level it is
|
||||
/// reading.
|
||||
///
|
||||
/// A mapping carrying no `color` — an image-only background, or an explicit `color: null` —
|
||||
/// reads `.missing`, which is exactly "no colour" and renders the level's default; that is an
|
||||
/// absence, not a failure, and it files no trace. A `color` that is itself a sequence or mapping
|
||||
/// has no scalar reading and is `.malformed` like any other.
|
||||
public var background: FieldValue<String> {
|
||||
backgroundReading(FrontmatterKeys.Background.color, reportsShape: true)
|
||||
}
|
||||
|
||||
/// The `background` mapping's **image** — a path relative to the board root, board-only in
|
||||
/// meaning (`BoardModel` carries it; `Lane` and `Card` deliberately do not).
|
||||
///
|
||||
/// `.missing` for every shape that is not a mapping, including the retired scalar: a value the
|
||||
/// schema cannot read is **one** unreadable value, and the colour reading above already reports
|
||||
/// it. Two `.malformed`s off one key would file the same defect twice and say the file named an
|
||||
/// image when it did nothing of the kind.
|
||||
///
|
||||
/// **Where the path leads is not this layer's question.** Whether it resolves inside the board
|
||||
/// root, and whether the bytes are an image at all, belongs to the renderer
|
||||
/// (`BoardBackdrop.imageURL(named:inBoardRoot:)`); this is the document's reading of what was
|
||||
/// written, and an unresolvable path degrades exactly like an unrecognized colour — paint
|
||||
/// nothing, change nothing on disk.
|
||||
public var backgroundImage: FieldValue<String> {
|
||||
backgroundReading(FrontmatterKeys.Background.image, reportsShape: false)
|
||||
}
|
||||
|
||||
/// Width multiplier. An exact-integer reading — from an int, a double, or a numeric string —
|
||||
/// always coerces: at or above 1 to itself (`"2"`, `2.0` → `2`), below 1 to 1 (**ranges are
|
||||
/// part of the sensible reading**, 01-storage-format.md § Frontmatter, settled — the table's
|
||||
@@ -190,6 +241,36 @@ extension FrontmatterDocument {
|
||||
|
||||
// MARK: -
|
||||
|
||||
/// One subkey's reading out of the `background` mapping. It is `read(_:_:)`'s shape with one
|
||||
/// extra step, and it cannot *be* `read(_:_:)`: that helper's transform answers `nil` for "no
|
||||
/// sensible reading", where a mapping with no such subkey has to answer `.missing` — an absent
|
||||
/// subkey is an absent value, not an unreadable one, and reporting it as a coerce-tier fallback
|
||||
/// would file a defect against every image-only background in existence.
|
||||
///
|
||||
/// `reportsShape` is the whole difference between the two readers above, and it is about the
|
||||
/// **key's** shape rather than the subkey's: a value that is not a mapping at all — the retired
|
||||
/// scalar, a sequence — is one unreadable value, so exactly one reader reports it. The colour is
|
||||
/// that reader because the colour is what the key means when it has no subkeys to speak of; the
|
||||
/// image stays `.missing`, since a file that never wrote a mapping never claimed to name a
|
||||
/// picture.
|
||||
///
|
||||
/// **A subvalue's malformed raw is the parse's rendering, not a source span.** `rawValue(for:)`
|
||||
/// addresses top-level keys, so a subkey has no span to quote; the coerce record takes what the
|
||||
/// parse retained (`YAMLValue.description`), which is the most this shape can honestly offer and
|
||||
/// still names what could not be read. The key's *own* malformed raw is the span, as always.
|
||||
private func backgroundReading(_ subkey: String, reportsShape: Bool) -> FieldValue<String> {
|
||||
guard let value = value(for: FrontmatterKeys.background) else { return .missing }
|
||||
if case .null = value { return .missing }
|
||||
guard case let .mapping(pairs) = value else {
|
||||
guard reportsShape else { return .missing }
|
||||
return .malformed(raw: rawValue(for: FrontmatterKeys.background) ?? value.description)
|
||||
}
|
||||
guard let subvalue = pairs.first(where: { $0.key == .string(subkey) })?.value else { return .missing }
|
||||
if case .null = subvalue { return .missing }
|
||||
let raw = subvalue.description
|
||||
return Self.string(subvalue, raw: raw).map(FieldValue.valid) ?? .malformed(raw: raw)
|
||||
}
|
||||
|
||||
private func read<Value>(_ key: String, _ transform: (YAMLValue, String) -> Value?) -> FieldValue<Value> {
|
||||
guard let value = value(for: key) else { return .missing }
|
||||
if case .null = value { return .missing }
|
||||
|
||||
@@ -61,6 +61,12 @@ extension FrontmatterValue {
|
||||
return value
|
||||
}
|
||||
|
||||
/// The double-quoted form unconditionally — what a value inside a **flow collection** takes
|
||||
/// (`FrontmatterDocument.setStyleValue`), where `emitScalar`'s block-context round trip is not
|
||||
/// the right question: `,`, `]`, `}` and `: ` end a plain scalar in flow context and not on a
|
||||
/// line of its own.
|
||||
static func emitQuoted(_ value: String) -> String { quoted(value) }
|
||||
|
||||
private static func quoted(_ value: String) -> String {
|
||||
var out = "\""
|
||||
for scalar in value.unicodeScalars {
|
||||
|
||||
@@ -90,19 +90,28 @@ enum Accommodations {
|
||||
/// appear").
|
||||
///
|
||||
/// 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
|
||||
/// on the board is the transient search bar's `.bar` material. It is stated as a type anyway
|
||||
/// rather than inlined at that one call site, because "wherever they appear" is a standing rule
|
||||
/// and the next material to arrive should find the answer already written.
|
||||
/// (03-board-ui.md § Card face's no-carousel resettlement), so the rule's subjects on the board
|
||||
/// are the transient search bar's `.bar` material and the backdrop's title-bar frost — the
|
||||
/// "next material to arrive" this type was stated for, and it found the answer already written.
|
||||
enum Underlay: Equatable {
|
||||
/// `Material.bar` — the find-bar's own backdrop, translucent over the board beneath it.
|
||||
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.
|
||||
case solid
|
||||
|
||||
var style: AnyShapeStyle {
|
||||
switch self {
|
||||
case .glass: AnyShapeStyle(.bar)
|
||||
case .frost: AnyShapeStyle(.thinMaterial)
|
||||
case .solid: AnyShapeStyle(Color(nsColor: .windowBackgroundColor))
|
||||
}
|
||||
}
|
||||
@@ -112,6 +121,12 @@ enum Accommodations {
|
||||
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
|
||||
/// 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.
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -309,9 +309,28 @@ struct BoardInfoView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Title")
|
||||
HStack(spacing: 6) {
|
||||
// The board's own icon, inline with its name — the same field the embedded style
|
||||
// editor's symbol section below writes, offered here too since a board's identity
|
||||
// is its name *and* its glyph together (03-board-ui.md § Styling ▸ Controls). No
|
||||
// `undo:` — the board popover has none of its own, so this reaches the board's
|
||||
// stack exactly as the embedded editor's writes do.
|
||||
SymbolPicker(
|
||||
current: store.snapshot.icon.value,
|
||||
fallback: ItemSymbol.board,
|
||||
onSelect: { name in
|
||||
StyleCommand.apply(
|
||||
icon: name.map { StyleChange.set($0) } ?? .remove,
|
||||
to: .board,
|
||||
in: store,
|
||||
recents: recents
|
||||
)
|
||||
}
|
||||
)
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
BoardRenameField(store: store)
|
||||
}
|
||||
}
|
||||
.padding(inset)
|
||||
|
||||
Divider()
|
||||
@@ -324,7 +343,9 @@ struct BoardInfoView: View {
|
||||
// ("nothing selected = the board"); this embed is the surface that exists *because*
|
||||
// the board is a style target, so it can have no other target (§ Styling ▸
|
||||
// Controls: "the board popover's target is the board itself").
|
||||
StyleEditorView(store: store, recents: recents, target: .board)
|
||||
// No symbol section — the inline `SymbolPicker` beside the rename field above is
|
||||
// the board glyph's one surface in this popover.
|
||||
StyleEditorView(store: store, recents: recents, target: .board, showsSymbols: false)
|
||||
}
|
||||
|
||||
// Contextual, not standing (12-editions.md, settled 2026-07-27): an ordinary free-tier
|
||||
|
||||
@@ -499,10 +499,11 @@ struct BoardSearchBar: View {
|
||||
let store: BoardStore
|
||||
let presentation: BoardSearchPresentation
|
||||
|
||||
/// Reduce Transparency — **this bar is the board's one glass underlay** ("glass underlays go
|
||||
/// solid, wherever they appear", 10-accessibility.md; the design's own example, the card face
|
||||
/// carousel's page dots, died with the carousel). `.bar` is a material, so under the setting it
|
||||
/// becomes the opaque window background (`Accommodations.Underlay`).
|
||||
/// Reduce Transparency — this bar is one of the board's two glass underlays, beside the
|
||||
/// backdrop's title-bar frost ("glass underlays go solid, wherever they appear",
|
||||
/// 10-accessibility.md; the design's own example, the card face carousel's page dots, died with
|
||||
/// the carousel). `.bar` is a material, so under the setting it becomes the opaque window
|
||||
/// background (`Accommodations.Underlay`).
|
||||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
|
||||
@@ -138,6 +138,11 @@ enum BoardToolbar {
|
||||
specs: specs(store: store, search: search),
|
||||
defaults: defaultItems
|
||||
)
|
||||
// Centered against the window, not a flexible-space sandwich (03 ▸ Toolbar's placement
|
||||
// grammar, ratified 2026-08-06): `centeredItemIdentifiers` holds as catalog items install
|
||||
// and sits outside the autosaved configuration, so it reaches machines that saved an
|
||||
// arrangement under the old trailing default. `defaultItems` is untouched.
|
||||
controller.toolbar.centeredItemIdentifiers = [.boardSearch]
|
||||
controller.onInstalledItemsChanged = { [weak search] identifiers in
|
||||
search?.isInstalledInToolbar = identifiers.contains(.boardSearch)
|
||||
}
|
||||
|
||||
@@ -106,6 +106,11 @@ struct BoardView: View {
|
||||
/// Increase Contrast, for the marquee band's border below (10-accessibility.md; `Accommodations`).
|
||||
@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
|
||||
/// 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.
|
||||
@@ -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
|
||||
/// 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
|
||||
/// surface and draws none of the glyphs on it. Whatever colour lands below — a palette name or a
|
||||
/// hand-written hex, they reach the same place — has its text colour computed against the
|
||||
/// threshold by `BoardTextInk`, composited over the window background in the active appearance
|
||||
/// and recomputed on an appearance flip; the two subtrees that sit on this fill
|
||||
/// (`LaneView.header` and `TrashLaneView.header` — their plates are translucent washes the
|
||||
/// colour shows through, where every card carries its own opaque plate,
|
||||
/// surface and draws none of the glyphs on it. Whatever colour lands below — a palette name, a
|
||||
/// hand-written hex, or the `color` subkey of the mapping form, they reach the same place — has
|
||||
/// its text colour computed against the threshold by `BoardTextInk`, composited over the window
|
||||
/// background in the active appearance and recomputed on an appearance flip; the two subtrees
|
||||
/// that sit on this fill (`LaneView.header` and `TrashLaneView.header` — their plates are
|
||||
/// translucent washes the colour shows through, where every card carries its own opaque plate,
|
||||
/// `BoardSurface.cardPlate`) take the answer as a `\.colorScheme` override.
|
||||
///
|
||||
/// **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
|
||||
/// 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:
|
||||
/// the same lenient degrade as the other two levels, and the bytes stay as written.
|
||||
@ViewBuilder
|
||||
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
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,9 +35,16 @@ struct CardStyleSection: View {
|
||||
/// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels): a colour or symbol
|
||||
/// chosen here is a gesture *issued in this window*, so its step joins the window's session and
|
||||
/// reaches board history only inside the coarse close step. The shared editor takes it as an
|
||||
/// anchor's parameter, exactly as it takes the layout.
|
||||
/// anchor's parameter, exactly as it takes the layout — and so does the background combo below,
|
||||
/// for the same reason.
|
||||
let undo: CardWindowUndo
|
||||
|
||||
/// The trailing debounce on a live colour-panel drag (`ColorComboView`'s `onPanelChange`,
|
||||
/// opened from the combo's **Other…** row): cancelled and replaced on every tick, so only the
|
||||
/// value the user is still on ~400ms after the last one actually reaches disk. One task for the
|
||||
/// section's one combo.
|
||||
@State private var backgroundPanelCommit: Task<Void, Never>?
|
||||
|
||||
/// The live body metric, read here rather than passed in — `CardAttachmentsSection`'s pattern,
|
||||
/// so every section in this sidebar derives its geometry the same way.
|
||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
@@ -56,6 +63,10 @@ struct CardStyleSection: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
|
||||
CardSidebarSectionHeader(title: "Style")
|
||||
backgroundComboRow
|
||||
// Symbols only: the combo row above is this sidebar's whole background story
|
||||
// (03 ▸ Styling ▸ Controls, the 2026-08-06 anchor-ownership rule) — the well grid's
|
||||
// background half stays with the other anchors.
|
||||
StyleEditorView(
|
||||
store: store,
|
||||
recents: recents,
|
||||
@@ -64,11 +75,82 @@ struct CardStyleSection: View {
|
||||
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize),
|
||||
bodyPointSize: pointSize
|
||||
),
|
||||
undo: undo
|
||||
undo: undo,
|
||||
showsBackground: false
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - Background combo
|
||||
|
||||
/// The labeled **Background** row, above the well grid — a narrower, single-value alternative
|
||||
/// to it (`ColorCombo.swift`'s own doc comment): an inspector row, caption leading and a
|
||||
/// compact combo trailing, the arrangement every Xcode inspector uses for exactly this control.
|
||||
/// The combo takes just over half the row rather than filling it — sized off the same metric
|
||||
/// the sidebar's own width comes from, so the pair holds its proportions at every text size.
|
||||
private var backgroundComboRow: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text("Background")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
ColorComboView(
|
||||
role: .background,
|
||||
value: currentBackground,
|
||||
isEnabled: !store.isReadOnly,
|
||||
onChange: { commitBackground($0) },
|
||||
onPanelChange: { debounceBackground($0) }
|
||||
)
|
||||
.frame(width: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize) * 0.55)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
/// The card's `background` field, exactly as written — malformed reads as its raw text, missing
|
||||
/// reads `nil`, both `StyleFieldState.written`'s own rule (`StyleModel.swift`). The **raw**
|
||||
/// string, never a resolved colour: `ColorComboModel`'s matching needs the bytes, not what they
|
||||
/// render as.
|
||||
private var currentBackground: String? {
|
||||
let subject = store.styleSubjects(of: Self.target(forCard: cardID)).first
|
||||
return StyleFieldState.written(subject?.background ?? .missing)
|
||||
}
|
||||
|
||||
/// A discrete pick — commits immediately. `nil` removes; a name from `Palette.backgrounds` goes
|
||||
/// through `StyleCommand.apply` so it feeds `StyleRecents` exactly like a well click would
|
||||
/// ("updated on every background application from any anchor", `StyleEditor.swift`); anything
|
||||
/// else — the dynamic current-value row re-affirming a foreign name or a custom hex — writes
|
||||
/// directly, since it is not the "palette pick" recents was ever meant to remember.
|
||||
private func commitBackground(_ newValue: String?) {
|
||||
let target = Self.target(forCard: cardID)
|
||||
guard let newValue else {
|
||||
store.applyStyle(to: target, background: .remove, icon: .keep, on: undo)
|
||||
return
|
||||
}
|
||||
if Palette.backgrounds.contains(where: { $0.name == newValue }) {
|
||||
StyleCommand.apply(background: .set(newValue), to: target, in: store, recents: recents, on: undo)
|
||||
} else {
|
||||
store.applyStyle(to: target, background: .set(newValue), icon: .keep, on: undo)
|
||||
}
|
||||
}
|
||||
|
||||
/// One tick of a live colour-panel drag: cancels whatever commit was pending and schedules a new
|
||||
/// one ~400ms out, so a drag writes once it settles rather than on every pixel it passes through.
|
||||
/// Never routed through `StyleCommand.apply` — a drag that passes through a palette-exact hex
|
||||
/// mid-gesture must not spam the recents row the way a deliberate pick would.
|
||||
private func debounceBackground(_ newValue: String?) {
|
||||
backgroundPanelCommit?.cancel()
|
||||
let target = Self.target(forCard: cardID)
|
||||
backgroundPanelCommit = Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(400))
|
||||
guard !Task.isCancelled else { return }
|
||||
if let newValue {
|
||||
store.applyStyle(to: target, background: .set(newValue), icon: .keep, on: undo)
|
||||
} else {
|
||||
store.applyStyle(to: target, background: .remove, icon: .keep, on: undo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// A reusable colour-picker combo: a collapsed face split into **two zones**, Xcode's inspector
|
||||
/// colour combo's own shape — a flat swatch of the current value filling almost the whole control,
|
||||
/// and a narrow chevron trigger at the trailing edge. Clicking the swatch opens
|
||||
/// `NSColorPanel.shared` directly; clicking the trigger pops a dropdown of **None**, the role's
|
||||
/// twelve palette colours, an off-palette current value stated verbatim when there is one, and
|
||||
/// **Other…**, which hands off to that same panel — the swatch and **Other…** are two doors onto
|
||||
/// one panel takeover (`ColorComboView.Coordinator.openColorPanel()`).
|
||||
///
|
||||
/// It is the second surface `background`/`iconColor` can be set from, beside the well grid
|
||||
/// (`StyleEditor.swift`'s `StyleEditorView`) — the well grid stays exactly as it is; this is a
|
||||
/// narrower, single-value control for a context where a whole grid would not fit (`CardStyleSection`'s
|
||||
/// own labeled row).
|
||||
///
|
||||
/// ### Two halves, the same split every other file here draws
|
||||
///
|
||||
/// `ColorComboRole`, `ColorComboItem`, `ColorComboMatch` and `ColorComboModel` are the **pure model**
|
||||
/// — item lists, selection matching, hex normalization, display-name casing — every rule a test can
|
||||
/// hold without an `NSView` in sight. `ColorComboView` is the thin AppKit bridge that draws it and
|
||||
/// answers clicks, exactly the `StyleEditorLayout`/`StyleEditorView` split in `StyleEditor.swift`.
|
||||
/// (Its collapsed face has its *own*, unrelated two-zone split — swatch versus trigger,
|
||||
/// `ColorComboControl`'s own doc comment — which has nothing to do with this pure-model/view one.)
|
||||
|
||||
// MARK: - Role
|
||||
|
||||
/// Which of the two palettes a combo offers — `Palette.backgrounds` for `background`,
|
||||
/// `Palette.foregrounds` for `iconColor`/icon tints. Both tables already answer either field
|
||||
/// (`Palette.nsColor(for:)`), so a combo's *role* is only about which twelve it lists, never about
|
||||
/// which values it can resolve.
|
||||
enum ColorComboRole: Sendable, Equatable {
|
||||
case background
|
||||
case foreground
|
||||
|
||||
/// The twelve rows this picker offers.
|
||||
var palette: [PaletteColor] {
|
||||
switch self {
|
||||
case .background: Palette.backgrounds
|
||||
case .foreground: Palette.foregrounds
|
||||
}
|
||||
}
|
||||
|
||||
/// The *other* picker's twelve — consulted only to name a foreign palette value in the dynamic
|
||||
/// current-value row (`ColorComboModel.match`). Never offered as a row of this picker's own,
|
||||
/// which is what keeps "background lists backgrounds" true even though `Palette.nsColor(for:)`
|
||||
/// itself would happily resolve a foreground name.
|
||||
var otherPalette: [PaletteColor] {
|
||||
switch self {
|
||||
case .background: Palette.foregrounds
|
||||
case .foreground: Palette.backgrounds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rows and matching
|
||||
|
||||
/// One row of a `ColorComboView`'s dropdown, in display order.
|
||||
enum ColorComboItem: Equatable {
|
||||
/// Clears the field — the well grid's own leading None well, same removal.
|
||||
case none
|
||||
case separator
|
||||
/// One of `role`'s own twelve, by name. `ColorComboModel.displayName(_:)` is its title; the row
|
||||
/// is never built with anything the role's own palette doesn't list.
|
||||
case palette(String)
|
||||
/// The live value's own row — present only when the stored value matches neither `.none` nor a
|
||||
/// `.palette` row (`ColorComboModel.match` decides). `swatchValue` is the raw stored string a
|
||||
/// swatch draws from (`Palette.nsColor(for:)`, lenient exactly like `PaletteSwatch`); `title` is
|
||||
/// the display text `ColorComboModel.match` already worked out.
|
||||
case current(swatchValue: String, title: String)
|
||||
/// Opens `NSColorPanel.shared`.
|
||||
case other
|
||||
}
|
||||
|
||||
/// Which row a stored value checks — computed once and shared by the item list (`ColorComboModel.
|
||||
/// menu`) and by anything that just wants to know "what does this resolve to" without building
|
||||
/// rows, which is most of what a test wants to assert.
|
||||
enum ColorComboMatch: Equatable {
|
||||
case none
|
||||
case palette(String)
|
||||
case current(swatchValue: String, title: String)
|
||||
}
|
||||
|
||||
/// The full dropdown for one role at one value: its rows, and the index of the checked one.
|
||||
struct ColorComboMenu: Equatable {
|
||||
let items: [ColorComboItem]
|
||||
/// Always a valid index into `items` — the None row exists in every menu, so there is always at
|
||||
/// least one candidate to fall back to.
|
||||
let selectedIndex: Int
|
||||
}
|
||||
|
||||
// MARK: - The pure model
|
||||
|
||||
/// The whole of what a `ColorComboView` shows, as pure functions of `role` and a stored value —
|
||||
/// no `NSView`, no store, nothing a `ColorComboTests` case can't hold still.
|
||||
enum ColorComboModel {
|
||||
|
||||
// MARK: Display
|
||||
|
||||
/// Kebab-case palette name → Title Case with hyphens as spaces: `"light-cayenne"` →
|
||||
/// `"Light Cayenne"`, `"smokey-rich-eggplant"` → `"Smokey Rich Eggplant"` — the one place a
|
||||
/// palette name becomes a row's title rather than its stored spelling.
|
||||
static func displayName(_ name: String) -> String {
|
||||
name.split(separator: "-")
|
||||
.map { $0.isEmpty ? "" : $0.prefix(1).uppercased() + $0.dropFirst() }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
// MARK: Matching
|
||||
|
||||
/// Which row `value` checks, given `role`:
|
||||
/// - `nil` → `.none`.
|
||||
/// - a name in `role`'s own palette → `.palette(name)`, matched exactly — `Palette`'s own
|
||||
/// case-sensitive rule, unchanged here.
|
||||
/// - a hex that, normalized, equals one of `role`'s palette hexes → that colour's `.palette`
|
||||
/// match, **by name** — a panel pick landing exactly on a palette colour selects the name, so
|
||||
/// picking it again from the panel later re-emits the name rather than drifting to a hex.
|
||||
/// - anything else (a foreign palette name, a custom hex, or unresolvable garbage) → `.current`,
|
||||
/// titled with the other picker's display name when `value` is one of *its* twelve, else
|
||||
/// `value` itself, uppercased when it looks like hex and left verbatim otherwise.
|
||||
static func match(role: ColorComboRole, value: String?) -> ColorComboMatch {
|
||||
guard let value else { return .none }
|
||||
if role.palette.contains(where: { $0.name == value }) {
|
||||
return .palette(value)
|
||||
}
|
||||
if let normalized = normalizedHex(value),
|
||||
let hit = role.palette.first(where: { normalizedHex($0.hex) == normalized }) {
|
||||
return .palette(hit.name)
|
||||
}
|
||||
return .current(swatchValue: value, title: currentTitle(role: role, value: value))
|
||||
}
|
||||
|
||||
/// The dynamic current-value row's title — the other table's display name when `value` is one
|
||||
/// of its twelve, the raw string otherwise (hex shown uppercase, matching `NSColor.
|
||||
/// paletteHexString`'s own casing so a stored value and a freshly panel-picked one read alike).
|
||||
private static func currentTitle(role: ColorComboRole, value: String) -> String {
|
||||
if let foreign = role.otherPalette.first(where: { $0.name == value }) {
|
||||
return displayName(foreign.name)
|
||||
}
|
||||
return value.hasPrefix("#") ? value.uppercased() : value
|
||||
}
|
||||
|
||||
// MARK: Item list
|
||||
|
||||
/// The dropdown's full row list and which row is checked, for `role` at `value`: **None**,
|
||||
/// separator, the twelve, then — only when `match` lands on `.current` — that dynamic row,
|
||||
/// separator, **Other…**.
|
||||
static func menu(role: ColorComboRole, value: String?) -> ColorComboMenu {
|
||||
var items: [ColorComboItem] = [.none, .separator]
|
||||
items.append(contentsOf: role.palette.map { .palette($0.name) })
|
||||
|
||||
let selectedIndex: Int
|
||||
switch match(role: role, value: value) {
|
||||
case .none:
|
||||
selectedIndex = 0
|
||||
case let .palette(name):
|
||||
selectedIndex = items.firstIndex(of: .palette(name)) ?? 0
|
||||
case let .current(swatchValue, title):
|
||||
items.append(.current(swatchValue: swatchValue, title: title))
|
||||
selectedIndex = items.count - 1
|
||||
}
|
||||
|
||||
items.append(.separator)
|
||||
items.append(.other)
|
||||
return ColorComboMenu(items: items, selectedIndex: selectedIndex)
|
||||
}
|
||||
|
||||
// MARK: Hex normalization
|
||||
|
||||
/// `#RRGGBB`/`#RRGGBBAA` → uppercase, alpha-`FF` collapsed to six digits — the string-side half
|
||||
/// of the round trip `NSColor.paletteHexString` builds (Palette.swift), used here purely for
|
||||
/// **comparison**: two spellings of the same opaque colour normalize to the same string, so a
|
||||
/// stored `#b6071eff` matches a palette entry's `#B6071E` exactly as a bare `#b6071e` would.
|
||||
/// `nil` for anything that isn't `#` followed by six or eight hex digits, so a malformed value
|
||||
/// never accidentally matches a palette colour by coincidence.
|
||||
static func normalizedHex(_ value: String) -> String? {
|
||||
var upper = value.uppercased()
|
||||
guard upper.hasPrefix("#") else { return nil }
|
||||
let digits = upper.dropFirst()
|
||||
guard digits.count == 6 || digits.count == 8, digits.allSatisfy(\.isHexDigit) else { return nil }
|
||||
if digits.count == 8, digits.hasSuffix("FF") {
|
||||
upper.removeLast(2)
|
||||
}
|
||||
return upper
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
|
||||
/// The AppKit bridge: a two-zone collapsed face (`ColorComboControl`) whose dropdown is
|
||||
/// `ColorComboModel.menu(role:value:)` — built exactly as it always was, just handed to the control
|
||||
/// to pop instead of being assigned as an `NSPopUpButton`'s own `menu`. Two click targets sharing one
|
||||
/// menu-plus-panel contract is the one thing `NSPopUpButton` cannot do on its own: it has exactly one
|
||||
/// hit zone for exactly one action.
|
||||
struct ColorComboView: NSViewRepresentable {
|
||||
|
||||
let role: ColorComboRole
|
||||
/// The raw stored value — a palette name or a hand-written hex, exactly as the frontmatter field
|
||||
/// carries it. Never a resolved `Color`: matching needs the string, not what it renders as.
|
||||
let value: String?
|
||||
let isEnabled: Bool
|
||||
/// One discrete row picked — **None**, one of the twelve, or the dynamic current-value row.
|
||||
/// Fired once, synchronously; the call site commits it immediately.
|
||||
var onChange: @MainActor (String?) -> Void
|
||||
/// One tick of a live `NSColorPanel` drag opened from **Other…** or the swatch zone — fires
|
||||
/// repeatedly while the user is still adjusting the colour. Kept separate from `onChange`
|
||||
/// because the two halves of this control's contract differ at the call site
|
||||
/// (`CardStyleSection`): a discrete pick commits immediately, a panel tick is the caller's to
|
||||
/// debounce and never feeds style recents.
|
||||
var onPanelChange: @MainActor (String?) -> Void
|
||||
|
||||
/// The width `sizeThatFits` hands back when SwiftUI has no concrete proposal to fill — an
|
||||
/// unconstrained measuring pass, not the normal case. The normal case is a finite proposal (this
|
||||
/// view sits under `.frame(maxWidth: .infinity)` in the sidebar row, `CardActionsSection`'s
|
||||
/// Delete button's own trick), which this default never has to stand in for.
|
||||
private static let defaultFaceWidth: CGFloat = 120
|
||||
|
||||
func makeNSView(context: Context) -> ColorComboControl {
|
||||
let control = ColorComboControl(frame: .zero)
|
||||
// The swatch zone's one job: open the same panel takeover **Other…** does. A closure, not a
|
||||
// target/action pair — there is exactly one caller and no `NSMenuItem`-style Objective-C
|
||||
// boundary to cross for it.
|
||||
control.onSwatchClick = { [weak coordinator = context.coordinator] in
|
||||
coordinator?.openColorPanel()
|
||||
}
|
||||
return control
|
||||
}
|
||||
|
||||
func updateNSView(_ control: ColorComboControl, context: Context) {
|
||||
context.coordinator.role = role
|
||||
context.coordinator.onChange = onChange
|
||||
context.coordinator.onPanelChange = onPanelChange
|
||||
control.isEnabled = isEnabled
|
||||
context.coordinator.rebuild(control, value: value)
|
||||
}
|
||||
|
||||
/// Obeys whatever width SwiftUI proposes, like any other control — never the widest menu item,
|
||||
/// which is what the old caller-supplied `width` input existed to work around. Height is the
|
||||
/// control's own fitting height (`ColorComboControl.intrinsicContentSize`, about half the old
|
||||
/// regular `NSPopUpButton`'s — the whole point of this rework); width is the proposal's when it
|
||||
/// is an actual number, else `defaultFaceWidth`, since a `nil`/infinite proposal happens on an
|
||||
/// unconstrained measuring pass, not a sidebar row.
|
||||
func sizeThatFits(_ proposal: ProposedViewSize, nsView: ColorComboControl, context: Context) -> CGSize? {
|
||||
let width: CGFloat
|
||||
if let proposed = proposal.width, proposed.isFinite {
|
||||
width = proposed
|
||||
} else {
|
||||
width = Self.defaultFaceWidth
|
||||
}
|
||||
return CGSize(width: width, height: nsView.intrinsicContentSize.height)
|
||||
}
|
||||
|
||||
/// Detaches the colour panel's target/action if this coordinator still holds them — "last-writer
|
||||
/// wins" for any control that took the panel over afterward (this view's own doc comment).
|
||||
static func dismantleNSView(_ control: ColorComboControl, coordinator: Coordinator) {
|
||||
coordinator.detachColorPanel()
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(role: role, onChange: onChange, onPanelChange: onPanelChange)
|
||||
}
|
||||
|
||||
// MARK: Coordinator
|
||||
|
||||
/// The one object every menu action and the colour panel's action target — a class because
|
||||
/// `NSColorPanel.setTarget(_:)` needs something with reference identity to detach from later,
|
||||
/// and `@MainActor` because every AppKit call it makes has to be.
|
||||
@MainActor
|
||||
final class Coordinator: NSObject {
|
||||
fileprivate var role: ColorComboRole
|
||||
fileprivate var currentValue: String?
|
||||
fileprivate var onChange: @MainActor (String?) -> Void
|
||||
fileprivate var onPanelChange: @MainActor (String?) -> Void
|
||||
|
||||
/// ~44×14pt — a menu row's swatch, wide enough beside its title to read as a colour sample
|
||||
/// rather than a bullet.
|
||||
private static let menuSwatchSize = NSSize(width: 44, height: 14)
|
||||
|
||||
/// Whichever coordinator most recently took the shared panel over — `NSColorPanel` exposes
|
||||
/// `setTarget(_:)`/`setAction(_:)` but no matching getter, so "is it still mine to detach"
|
||||
/// has nowhere to live but here. `weak`, so a coordinator that never got around to detaching
|
||||
/// (a window closed from under it) does not keep the next owner from being collected either.
|
||||
private static weak var currentPanelOwner: Coordinator?
|
||||
|
||||
init(
|
||||
role: ColorComboRole,
|
||||
onChange: @escaping @MainActor (String?) -> Void,
|
||||
onPanelChange: @escaping @MainActor (String?) -> Void
|
||||
) {
|
||||
self.role = role
|
||||
self.onChange = onChange
|
||||
self.onPanelChange = onPanelChange
|
||||
}
|
||||
|
||||
/// Rebuilds the dropdown for `value` and hands the control the menu, its checked item (the
|
||||
/// popup anchor `ColorComboControl.popUpMenu()` positions against, and the source of its
|
||||
/// accessibility value), and the value its swatch zone should draw. Cheap enough — a dozen
|
||||
/// rows, a fistful of small menu-row images — to redo wholesale on every SwiftUI update
|
||||
/// rather than diffing against what was there before.
|
||||
func rebuild(_ control: ColorComboControl, value: String?) {
|
||||
currentValue = value
|
||||
let menu = NSMenu()
|
||||
let built = ColorComboModel.menu(role: role, value: value)
|
||||
var checkedItem: NSMenuItem?
|
||||
for (index, item) in built.items.enumerated() {
|
||||
if item == .separator {
|
||||
menu.addItem(.separator())
|
||||
continue
|
||||
}
|
||||
let menuItem = self.menuItem(for: item)
|
||||
let isChecked = index == built.selectedIndex
|
||||
menuItem.state = isChecked ? .on : .off
|
||||
menu.addItem(menuItem)
|
||||
if isChecked { checkedItem = menuItem }
|
||||
}
|
||||
control.comboMenu = menu
|
||||
control.checkedItem = checkedItem
|
||||
control.swatchValue = value
|
||||
}
|
||||
|
||||
/// See `ColorComboView.dismantleNSView(_:coordinator:)`.
|
||||
func detachColorPanel() {
|
||||
guard Coordinator.currentPanelOwner === self else { return }
|
||||
let panel = NSColorPanel.shared
|
||||
panel.setTarget(nil)
|
||||
panel.setAction(nil)
|
||||
Coordinator.currentPanelOwner = nil
|
||||
}
|
||||
|
||||
private func menuItem(for item: ColorComboItem) -> NSMenuItem {
|
||||
switch item {
|
||||
case .none:
|
||||
let menuItem = NSMenuItem(title: "None", action: #selector(selectNone), keyEquivalent: "")
|
||||
menuItem.target = self
|
||||
menuItem.image = PaletteSwatch.rectImage(for: nil, size: Self.menuSwatchSize)
|
||||
return menuItem
|
||||
|
||||
case let .palette(name):
|
||||
let menuItem = NSMenuItem(
|
||||
title: ColorComboModel.displayName(name),
|
||||
action: #selector(selectValue(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
menuItem.target = self
|
||||
menuItem.representedObject = name
|
||||
menuItem.image = PaletteSwatch.rectImage(for: name, size: Self.menuSwatchSize)
|
||||
return menuItem
|
||||
|
||||
case let .current(swatchValue, title):
|
||||
let menuItem = NSMenuItem(title: title, action: #selector(selectValue(_:)), keyEquivalent: "")
|
||||
menuItem.target = self
|
||||
menuItem.representedObject = swatchValue
|
||||
menuItem.image = PaletteSwatch.rectImage(for: swatchValue, size: Self.menuSwatchSize)
|
||||
return menuItem
|
||||
|
||||
case .other:
|
||||
let menuItem = NSMenuItem(title: "Other…", action: #selector(openColorPanel), keyEquivalent: "")
|
||||
menuItem.target = self
|
||||
return menuItem
|
||||
|
||||
case .separator:
|
||||
// Unreached: `rebuild` handles `.separator` before calling this. Kept so the switch
|
||||
// stays total against a case list a future row could still grow.
|
||||
return NSMenuItem.separator()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func selectNone() {
|
||||
onChange(nil)
|
||||
}
|
||||
|
||||
@objc private func selectValue(_ sender: NSMenuItem) {
|
||||
onChange(sender.representedObject as? String)
|
||||
}
|
||||
|
||||
/// Seeds the shared panel with the current resolved colour (black when there isn't one),
|
||||
/// takes it over — "don't fight over the panel if something else takes it later" (this
|
||||
/// view's own doc comment) — and asks for continuous updates, which is what makes a drag on
|
||||
/// the panel's own sliders call `changeColor(_:)` on every tick rather than only on release.
|
||||
///
|
||||
/// Two callers, one takeover: the dropdown's own **Other…** row (`#selector` target above)
|
||||
/// and `ColorComboControl`'s swatch-zone click (wired in `ColorComboView.makeNSView`) —
|
||||
/// Xcode's own two-zone combo opens the same panel from either half, and this is the one
|
||||
/// place that happens.
|
||||
@objc func openColorPanel() {
|
||||
let panel = NSColorPanel.shared
|
||||
panel.showsAlpha = true
|
||||
panel.color = currentValue.flatMap(Palette.nsColor(for:)) ?? .black
|
||||
panel.setTarget(self)
|
||||
panel.setAction(#selector(changeColor(_:)))
|
||||
Coordinator.currentPanelOwner = self
|
||||
panel.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
/// The panel's own action, continuous while the user drags: normalizes what it picked to
|
||||
/// this app's stored-value vocabulary and hands it to `onPanelChange` — the palette name
|
||||
/// when the colour lands exactly on one of `role`'s twelve, the hex otherwise. The name-wins
|
||||
/// rule is the same one `ColorComboModel.match` applies to a value already on disk.
|
||||
@objc private func changeColor(_ sender: NSColorPanel) {
|
||||
guard let hex = sender.color.paletteHexString else { return }
|
||||
onPanelChange(Palette.name(forHex: hex, in: role.palette) ?? hex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Two-zone NSControl
|
||||
|
||||
/// The collapsed face: a custom control in Xcode's inspector colour combo's own shape — a flat
|
||||
/// swatch filling almost the whole control, and a fixed-width chevron trigger at the trailing edge.
|
||||
/// The swatch zone opens the Colors panel directly (`ColorComboView.Coordinator.openColorPanel()`);
|
||||
/// the trigger zone pops the dropdown `ColorComboView.Coordinator.rebuild(_:value:)` builds. Neither
|
||||
/// zone owns a bezel or a cell of its own — everything both draw and hit-test is computed straight
|
||||
/// from `bounds` on every pass, so there is nothing cached here the way the face image the
|
||||
/// `NSPopUpButton` this replaces used to keep (`swatchValue`'s `didSet` just marks a redraw).
|
||||
///
|
||||
/// Plain internal, not `private`/`fileprivate`, even though nothing outside this file constructs one
|
||||
/// directly: it is `ColorComboView`'s `NSViewType`, an associated-type witness the compiler requires
|
||||
/// to be at least as visible as `ColorComboView` itself (internal, usable module-wide) — same
|
||||
/// reasoning as the un-modified-access `Coordinator` a few lines up.
|
||||
final class ColorComboControl: NSControl {
|
||||
|
||||
/// The value the swatch zone currently draws — a palette name or a hand-written hex, exactly as
|
||||
/// `ColorComboView.Coordinator.rebuild(_:value:)` hands it over on every SwiftUI update.
|
||||
var swatchValue: String? {
|
||||
didSet {
|
||||
guard swatchValue != oldValue else { return }
|
||||
needsDisplay = true
|
||||
}
|
||||
}
|
||||
|
||||
/// The dropdown the trigger zone pops, and the row within it that should read as checked — both
|
||||
/// `Coordinator.rebuild(_:value:)`'s to hand over on every rebuild, always together (`checkedItem`
|
||||
/// is always one of `comboMenu`'s own items). This control never builds a row itself; it only
|
||||
/// positions and pops what it is given.
|
||||
var comboMenu: NSMenu?
|
||||
var checkedItem: NSMenuItem?
|
||||
|
||||
/// Fired by a click anywhere in the swatch zone — wired once, in `ColorComboView.makeNSView`, to
|
||||
/// the coordinator's `openColorPanel()`. `@MainActor`, this file's own established convention for
|
||||
/// a stored closure an AppKit callback fires (`onChange`/`onPanelChange` above), even though this
|
||||
/// control's own methods are already implicitly MainActor-isolated as an `NSResponder` subclass.
|
||||
var onSwatchClick: (@MainActor () -> Void)?
|
||||
|
||||
override var isEnabled: Bool {
|
||||
get { super.isEnabled }
|
||||
set {
|
||||
super.isEnabled = newValue
|
||||
needsDisplay = true
|
||||
}
|
||||
}
|
||||
|
||||
/// About half the old regular `NSPopUpButton`'s height — the whole point of this rework. Width
|
||||
/// is `NSView.noIntrinsicMetric`: this control obeys whatever SwiftUI proposes, exactly as the
|
||||
/// button it replaces did.
|
||||
override var intrinsicContentSize: NSSize {
|
||||
NSSize(width: NSView.noIntrinsicMetric, height: 14)
|
||||
}
|
||||
|
||||
/// The fixed-width trigger strip at the trailing edge, full height — the geometry this whole
|
||||
/// control exists to draw: "a flat swatch occupying the control, a chevron trigger at the
|
||||
/// trailing edge."
|
||||
private static let triggerWidth: CGFloat = 16
|
||||
/// The swatch zone's padding before its rounded rect — two points rather than a bare hairline,
|
||||
/// so the control's own field (`drawField()`) reads as a visible ring around the colour instead
|
||||
/// of being covered by it.
|
||||
private static let swatchPadding: CGFloat = 2
|
||||
private static let cornerRadius: CGFloat = 3
|
||||
/// The field's own radius — a point more than the swatch's, so the two rounded rects run
|
||||
/// concentric instead of pinching at the corners.
|
||||
private static let fieldRadius: CGFloat = 4
|
||||
|
||||
private var triggerRect: NSRect {
|
||||
NSRect(x: bounds.maxX - Self.triggerWidth, y: bounds.minY, width: Self.triggerWidth, height: bounds.height)
|
||||
}
|
||||
|
||||
private var swatchZone: NSRect {
|
||||
NSRect(x: bounds.minX, y: bounds.minY, width: bounds.width - Self.triggerWidth, height: bounds.height)
|
||||
}
|
||||
|
||||
// MARK: Drawing
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
||||
context.saveGState()
|
||||
defer { context.restoreGState() }
|
||||
// A transparency layer, not a flat `setAlpha` around each shape: the swatch's underlay,
|
||||
// fill and stroke overlap, and drawing each at reduced alpha independently would let the
|
||||
// stroke double up over the fill beneath it. Compositing the whole disabled face as one
|
||||
// layer avoids that.
|
||||
if !isEnabled {
|
||||
context.setAlpha(0.35)
|
||||
context.beginTransparencyLayer(auxiliaryInfo: nil)
|
||||
}
|
||||
drawField()
|
||||
drawSwatch()
|
||||
drawTrigger()
|
||||
if !isEnabled {
|
||||
context.endTransparencyLayer()
|
||||
}
|
||||
}
|
||||
|
||||
/// The control's own field: a bordered, filled rounded rect over the whole bounds, under both
|
||||
/// zones — what makes the swatch and the trigger read as one control rather than two shapes
|
||||
/// floating beside each other. Standard control materials: `controlBackgroundColor` fill,
|
||||
/// `separatorColor` hairline, the half-point inset keeping the stroke on whole pixels.
|
||||
private func drawField() {
|
||||
let path = NSBezierPath(
|
||||
roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5),
|
||||
xRadius: Self.fieldRadius,
|
||||
yRadius: Self.fieldRadius
|
||||
)
|
||||
NSColor.controlBackgroundColor.setFill()
|
||||
path.fill()
|
||||
NSColor.separatorColor.setStroke()
|
||||
path.lineWidth = 1
|
||||
path.stroke()
|
||||
}
|
||||
|
||||
/// The colour rect, drawn exactly like `PaletteSwatch.rectImage`: a `textBackgroundColor`
|
||||
/// underlay so a translucent stored colour composites the same way in light and dark, the
|
||||
/// resolved colour on top, a `separatorColor` hairline stroke last. `nil`/unresolvable value →
|
||||
/// underlay + stroke only, the same "there is no colour, so show none" rule.
|
||||
private func drawSwatch() {
|
||||
let inset = swatchZone.insetBy(dx: Self.swatchPadding, dy: Self.swatchPadding)
|
||||
let path = NSBezierPath(roundedRect: inset, xRadius: Self.cornerRadius, yRadius: Self.cornerRadius)
|
||||
NSColor.textBackgroundColor.setFill()
|
||||
path.fill()
|
||||
if let swatchValue, let color = Palette.nsColor(for: swatchValue) {
|
||||
color.setFill()
|
||||
path.fill()
|
||||
}
|
||||
NSColor.separatorColor.setStroke()
|
||||
path.lineWidth = 1
|
||||
path.stroke()
|
||||
}
|
||||
|
||||
/// The trigger: a small vertically-centred rounded **square**, `controlAccentColor`-filled, with
|
||||
/// a white `chevron.up.chevron.down` centred inside — the standard `NSPopUpButton` indicator's
|
||||
/// own look, redrawn here since this control has no bezel of its own to borrow one from.
|
||||
private func drawTrigger() {
|
||||
let side = triggerRect.height - 2 * Self.swatchPadding
|
||||
let square = NSRect(
|
||||
x: triggerRect.midX - side / 2,
|
||||
y: triggerRect.midY - side / 2,
|
||||
width: side,
|
||||
height: side
|
||||
)
|
||||
let path = NSBezierPath(roundedRect: square, xRadius: Self.cornerRadius, yRadius: Self.cornerRadius)
|
||||
NSColor.controlAccentColor.setFill()
|
||||
path.fill()
|
||||
|
||||
let config = NSImage.SymbolConfiguration(pointSize: 7, weight: .bold)
|
||||
.applying(.init(paletteColors: [.white]))
|
||||
guard let chevron = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: nil)?
|
||||
.withSymbolConfiguration(config)
|
||||
else { return }
|
||||
let size = chevron.size
|
||||
chevron.draw(in: NSRect(
|
||||
x: square.midX - size.width / 2,
|
||||
y: square.midY - size.height / 2,
|
||||
width: size.width,
|
||||
height: size.height
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: Events
|
||||
|
||||
/// Point-in-trigger-zone pops the dropdown; anywhere else in the control fires the swatch click
|
||||
/// — the two-zone split this whole rework exists for. A disabled control answers neither.
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard isEnabled else { return }
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
if triggerRect.contains(point) {
|
||||
popUpMenu()
|
||||
} else {
|
||||
onSwatchClick?()
|
||||
}
|
||||
}
|
||||
|
||||
override var acceptsFirstResponder: Bool { isEnabled }
|
||||
|
||||
/// Space and Return pop the dropdown — the one keyboard path into this control. There is
|
||||
/// currently no keyboard equivalent for the swatch zone's direct panel launch; see this class's
|
||||
/// own doc comment and the file's top-level report for what a full accessibility pass would add.
|
||||
override func keyDown(with event: NSEvent) {
|
||||
guard isEnabled else {
|
||||
super.keyDown(with: event)
|
||||
return
|
||||
}
|
||||
switch event.keyCode {
|
||||
case 49, 36, 76: // Space, Return, keypad Enter
|
||||
popUpMenu()
|
||||
default:
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard popup placement: `comboMenu` is asked to land `checkedItem` at the control's own top
|
||||
/// edge, the same non-pulldown anchor `NSPopUpButton` itself uses so the checked row appears
|
||||
/// where the control's own face is rather than wherever the pointer happened to be.
|
||||
private func popUpMenu() {
|
||||
guard let comboMenu else { return }
|
||||
comboMenu.popUp(positioning: checkedItem, at: NSPoint(x: 0, y: bounds.height), in: self)
|
||||
}
|
||||
|
||||
// MARK: Accessibility
|
||||
|
||||
override func accessibilityRole() -> NSAccessibility.Role? { .popUpButton }
|
||||
|
||||
/// The checked row's own title — "Light Cayenne", "None", a bare hex — exactly what the dropdown
|
||||
/// itself would show ticked, since `Coordinator.rebuild(_:value:)` hands this control the very
|
||||
/// item it built the menu from rather than a copy.
|
||||
override func accessibilityValue() -> Any? { checkedItem?.title }
|
||||
}
|
||||
+55
-6
@@ -62,11 +62,13 @@ enum Palette {
|
||||
]
|
||||
}
|
||||
|
||||
// The pathfinder's panel round-trip helpers (`NSColor.paletteHexString`, `Palette.name(forHex:)`)
|
||||
// stay unported: they exist to turn a colour the *system picker* returned back into a palette name,
|
||||
// and this app has no colour picker — "custom hex is not pickable in-app" (03 § Styling ▸ Controls)
|
||||
// makes the whole round trip a surface that doesn't exist. Its swatch drawing, on the other hand, is
|
||||
// below: a menu can only render `Image`/`Text`, so the quick-style row's dots have to be pictures.
|
||||
// The pathfinder's panel round-trip helpers, ported below (`NSColor.paletteHexString`,
|
||||
// `Palette.name(forHex:in:)`): the reusable colour-picker combo (`ColorComboView`, ColorCombo.swift)
|
||||
// is the surface that finally needs them — a colour the *system picker* returns has to become a
|
||||
// stored value the same way a palette pick already does: the palette NAME when the colour lands
|
||||
// exactly on one of the twelve, the hex otherwise. Its swatch drawing, unchanged in spirit, is
|
||||
// below: a menu can only render `Image`/`Text`, so both the quick-style row's dots and the combo's
|
||||
// rows have to be pictures.
|
||||
|
||||
// MARK: - Menu swatches
|
||||
|
||||
@@ -102,6 +104,28 @@ enum PaletteSwatch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// A wide rectangular swatch for `value` — `ColorComboView`'s own rows and its collapsed face,
|
||||
/// which are wide and short rather than the quick-style row's small dots (hence a sibling
|
||||
/// function rather than a parameter on `circleImage`: the two shapes are never interchangeable at
|
||||
/// their call sites). `nil` draws the border alone, exactly `circleImage`'s "there is no colour,
|
||||
/// so show none" — the collapsed face's **None** state and the dropdown's own **None** row both
|
||||
/// call this with `nil` rather than a sentinel string.
|
||||
static func rectImage(for value: String?, size: NSSize) -> NSImage {
|
||||
let color = value.flatMap(Palette.nsColor(for:))
|
||||
return NSImage(size: size, flipped: false) { rect in
|
||||
let inset = rect.insetBy(dx: 0.5, dy: 0.5)
|
||||
let path = NSBezierPath(rect: inset)
|
||||
NSColor.textBackgroundColor.setFill()
|
||||
path.fill()
|
||||
color?.setFill()
|
||||
path.fill()
|
||||
NSColor.separatorColor.setStroke()
|
||||
path.lineWidth = 1
|
||||
path.stroke()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Palette {
|
||||
@@ -134,9 +158,18 @@ extension Palette {
|
||||
guard let value = field.value else { return nil }
|
||||
return color(named: value)
|
||||
}
|
||||
|
||||
/// The name of `palette`'s entry whose hex matches `hex`, case-insensitively — the pathfinder's
|
||||
/// round trip, ported for `ColorComboView`'s panel handoff: a colour the system picker returns
|
||||
/// comes back as `NSColor.paletteHexString`'s canonical `#RRGGBB[AA]`, and this is what turns
|
||||
/// that back into "the user picked Light Cayenne" instead of leaving it as an anonymous hex.
|
||||
/// `nil` when nothing in `palette` matches, which the caller reads as "store the hex instead."
|
||||
static func name(forHex hex: String, in palette: [PaletteColor]) -> String? {
|
||||
palette.first { $0.hex.caseInsensitiveCompare(hex) == .orderedSame }?.name
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hex → colour
|
||||
// MARK: - Hex ↔ colour
|
||||
|
||||
extension NSColor {
|
||||
/// `#RRGGBB` or `#RRGGBBAA` → `NSColor` in **sRGB** — the colour space the hex digits name,
|
||||
@@ -160,4 +193,20 @@ extension NSColor {
|
||||
alpha: alpha
|
||||
)
|
||||
}
|
||||
|
||||
/// The reverse of `init?(paletteHex:)`: `#RRGGBB`, or `#RRGGBBAA` when the colour is
|
||||
/// translucent — full opacity collapses to six digits rather than a trailing `FF`, so a colour
|
||||
/// that round-trips through the panel without the user touching the opacity slider is written
|
||||
/// exactly as a curated palette entry would be. `nil` only for a colour space **sRGB** cannot
|
||||
/// convert into, which no picker swatch or palette entry here ever is.
|
||||
var paletteHexString: String? {
|
||||
guard let srgb = usingColorSpace(.sRGB) else { return nil }
|
||||
let red = Int((srgb.redComponent * 255).rounded())
|
||||
let green = Int((srgb.greenComponent * 255).rounded())
|
||||
let blue = Int((srgb.blueComponent * 255).rounded())
|
||||
let alpha = Int((srgb.alphaComponent * 255).rounded())
|
||||
return alpha >= 255
|
||||
? String(format: "#%02X%02X%02X", red, green, blue)
|
||||
: String(format: "#%02X%02X%02X%02X", red, green, blue, alpha)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,8 @@ struct StyleEditorLayout: Equatable {
|
||||
|
||||
// MARK: - The editor
|
||||
|
||||
/// The style editor: a background section and a symbol section, each a leading "no value" well
|
||||
/// The style editor: a background section and a symbol section (each omissible — `showsBackground`/
|
||||
/// `showsSymbols`, since 03's anchors compose the halves they need), each a leading "no value" well
|
||||
/// followed by its grid, with the target set's current value stated beside the section title.
|
||||
///
|
||||
/// ### What it shows for a batch
|
||||
@@ -250,6 +251,18 @@ struct StyleEditorView: View {
|
||||
/// a colour chosen in a card window is one of that window's session gestures.
|
||||
var undo: CardWindowUndo?
|
||||
|
||||
/// Whether the symbol section appears at all. On everywhere but the board info popover, whose
|
||||
/// inline `SymbolPicker` beside the rename field owns the board's glyph now — two surfaces
|
||||
/// writing the same key in one popover would make the second read as a different setting.
|
||||
var showsSymbols: Bool = true
|
||||
|
||||
/// Whether the background section appears at all. On everywhere but the card window sidebar,
|
||||
/// where the labeled `ColorComboView` row is the background story (03 ▸ Styling ▸ Controls,
|
||||
/// the 2026-08-06 anchor-ownership rule): the sidebar is the narrow context the combo was
|
||||
/// built for, and grid-plus-combo over one value read as two settings — `showsSymbols`'
|
||||
/// reasoning, pointed the other way.
|
||||
var showsBackground: Bool = true
|
||||
|
||||
/// The live body metric, read here rather than passed in — `CardStyleSection`'s pattern, so
|
||||
/// every anchor derives its geometry the same way (10-accessibility.md's full-relative-scaling
|
||||
/// rule).
|
||||
@@ -263,10 +276,16 @@ struct StyleEditorView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: StyleEditorLayout.sectionSpacing(bodyPointSize: pointSize)) {
|
||||
targetCaption(count: subjects.count)
|
||||
if showsBackground {
|
||||
backgroundSection(background, layout: layout)
|
||||
}
|
||||
if showsBackground && showsSymbols {
|
||||
Divider()
|
||||
}
|
||||
if showsSymbols {
|
||||
symbolSection(icon, layout: layout)
|
||||
}
|
||||
}
|
||||
.padding(layout.padding)
|
||||
.frame(width: layout.width)
|
||||
// The read-only lock and the focused-editor rule disable every mutating surface, not only
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// **A reusable SF Symbol picker** — a single well showing the resolved symbol, opening a curated
|
||||
/// grid with a search escape hatch (03-board-ui.md § Styling ▸ Controls: "its leading well is the
|
||||
/// level's default symbol and removes the `icon` key … Any other SF Symbol name works written by
|
||||
/// hand … No full-browser escape hatch in-app; the raw file is the escape hatch"). `StyleEditor.swift`
|
||||
/// already builds that grid once, aimed at `background`/`icon` together and multiplexed across three
|
||||
/// anchors; this file builds the *symbol half alone*, aimed at any single field a caller names, so a
|
||||
/// control that only ever needs one glyph — a saved search, a smart filter, a future per-item
|
||||
/// affordance — is not forced to carry the style editor's background section or its `BoardStore`
|
||||
/// coupling to get one.
|
||||
///
|
||||
/// ### Why the curated set differs from `CuratedSymbols`
|
||||
///
|
||||
/// `CuratedSymbols.all` is grouped by what a *board item* is (status/flow, containers, people…) —
|
||||
/// this control has no board item in mind, so `SymbolPickerCatalog.defaultSet` is a smaller,
|
||||
/// ungrouped 36 chosen for the general "boards and projects" case instead. The two lists are free to
|
||||
/// diverge; nothing here reads the other.
|
||||
///
|
||||
/// ### The one thing `CuratedSymbols` never needed
|
||||
///
|
||||
/// The style editor's curated grid has no search and no full-catalog fallback ("no full-browser
|
||||
/// escape hatch in-app" is a statement about *that* surface). This picker adds one anyway, because a
|
||||
/// general-purpose control cannot assume its 36 will always contain what the caller is after — a
|
||||
/// search with nothing to search would just move the dead end from "no matching well" to "no way to
|
||||
/// look further".
|
||||
|
||||
// MARK: - The symbol catalogs
|
||||
|
||||
/// The picker's two symbol lists: the curated 36-glyph grid it opens with, and the OS's full
|
||||
/// inventory it searches into once the grid alone isn't enough.
|
||||
enum SymbolPickerCatalog {
|
||||
|
||||
/// The picker's curated grid, in order — a general "boards and projects" set rather than the
|
||||
/// style editor's kanban-item groupings, chosen so a first-run picker with no caller-supplied
|
||||
/// `symbols` still shows something broadly useful. A stored constant, not a computed property,
|
||||
/// for `CuratedSymbols.all`'s own reason: the list is the design decision, and `available` is the
|
||||
/// only thing the OS gets a say in.
|
||||
static let defaultSet: [String] = [
|
||||
"star", "flag", "heart", "bolt", "flame", "leaf", "drop", "sun.max", "moon", "sparkles",
|
||||
"tag", "bookmark", "pin", "bell", "paperplane", "tray", "folder", "archivebox", "doc.text",
|
||||
"list.bullet", "checklist", "calendar", "clock", "hammer", "wrench.and.screwdriver",
|
||||
"paintbrush", "lightbulb", "brain", "book", "graduationcap", "briefcase", "cart", "house",
|
||||
"airplane", "gamecontroller", "globe",
|
||||
]
|
||||
|
||||
/// The set this Mac can actually draw — `CuratedSymbols.available`'s rule, mirrored: a curated
|
||||
/// list is a convenience, never a claim about the running system.
|
||||
static var available: [String] { defaultSet.filter(ItemSymbol.exists) }
|
||||
|
||||
/// Where the OS keeps the full SF Symbols inventory — read-only system metadata, present on
|
||||
/// every Mac that ships SF Symbols at all.
|
||||
private static let defaultBundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle"
|
||||
|
||||
/// The default path's catalog, loaded once. A `static let` rather than a `lazy var`: the load is
|
||||
/// synchronous and the result is a plain `[String]` — Sendable, immutable once computed — so
|
||||
/// Swift's usual thread-safe one-time global initialization is the whole of the "cache" this
|
||||
/// needs, with no actor to hang it off.
|
||||
private static let cachedFullCatalog: [String] = load(bundlePath: defaultBundlePath)
|
||||
|
||||
/// Every SF Symbol name the running OS knows, sorted and deduplicated — the search grid's source.
|
||||
///
|
||||
/// **Not filtered through `ItemSymbol.exists`.** The plist this reads already reflects the
|
||||
/// running OS's own inventory (it *is* the OS's inventory), and running a few thousand
|
||||
/// `NSImage(systemSymbolName:)` lookups against it on every search keystroke would be pure cost
|
||||
/// for an answer the file has already given for free. A curated list is different: it is a
|
||||
/// hand-written guess that might be stale, and only guesses need checking.
|
||||
///
|
||||
/// `bundlePath` defaults to the real system location and is cached there; any other path — the
|
||||
/// test suite's nonexistent one, chiefly — reloads (and re-falls-back) on every call, which is
|
||||
/// the honest cost of asking a question the cache was never built to answer.
|
||||
static func fullCatalog(bundlePath: String = defaultBundlePath) -> [String] {
|
||||
bundlePath == defaultBundlePath ? cachedFullCatalog : load(bundlePath: bundlePath)
|
||||
}
|
||||
|
||||
/// The plist read, and its one fallback: a bundle that won't open, a resource that isn't there,
|
||||
/// or a `"symbols"` key that isn't the dictionary this format has always used all read the same
|
||||
/// way — as "no inventory to read" — rather than as three different failure modes to chase. The
|
||||
/// merged curated set is never empty, so the picker always has *something* to search, even on a
|
||||
/// system whose metadata this reader cannot make sense of.
|
||||
private static func load(bundlePath: String) -> [String] {
|
||||
guard
|
||||
let bundle = Bundle(path: bundlePath),
|
||||
let plistPath = bundle.path(forResource: "name_availability", ofType: "plist"),
|
||||
let data = FileManager.default.contents(atPath: plistPath),
|
||||
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil),
|
||||
let root = plist as? [String: Any],
|
||||
let symbols = root["symbols"] as? [String: Any]
|
||||
else {
|
||||
return Set(defaultSet + CuratedSymbols.all).sorted()
|
||||
}
|
||||
return symbols.keys.sorted()
|
||||
}
|
||||
|
||||
/// `symbols` narrowed to the names matching `query` — pure, so the AND semantics and the
|
||||
/// order-preservation are assertable without a picker on screen.
|
||||
///
|
||||
/// Whitespace-trimmed first, and an empty result of that is "no query", not "match nothing" — a
|
||||
/// freshly opened search field must show the full catalog, not a blank grid. A non-empty query
|
||||
/// splits into whitespace-separated tokens, every one of which must appear, case-insensitively,
|
||||
/// somewhere in the name: `"wrench screw"` finds `wrench.and.screwdriver` the way a Spotlight-style
|
||||
/// search would, rather than requiring the words adjacent or in order.
|
||||
static func filter(_ query: String, in symbols: [String]) -> [String] {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return symbols }
|
||||
let tokens = trimmed.split(whereSeparator: { $0.isWhitespace }).map { $0.lowercased() }
|
||||
return symbols.filter { name in
|
||||
let lowered = name.lowercased()
|
||||
return tokens.allSatisfy { lowered.contains($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geometry
|
||||
|
||||
/// The picker's font-derived geometry — well side, well spacing, the fixed 6×6 grid, and the
|
||||
/// popover's own padding — following `StyleEditorLayout`'s derivation rather than restating it: the
|
||||
/// base well side and spacing are read straight off `StyleEditorLayout`'s statics, then the grid's
|
||||
/// wells and glyphs scale up by `gridScale` — a deliberate, user-tuned enlargement (the picker's grid
|
||||
/// is this popover's whole subject, where the style editor's is one section among several), still
|
||||
/// anchored to the shared base so the two components move together at every text size. The at-rest
|
||||
/// button keeps the unscaled side (`restSide`) — it sits inline with a text field and matches that
|
||||
/// field's height, not the grid's. Only the shape wraps a picker's own frame around them — six
|
||||
/// columns fixed (not a
|
||||
/// caller-configurable count, since a picker has no anchor-width story the way `StyleEditorLayout`'s
|
||||
/// sidebar/popover split does), and a total padded width that is fixed for the same reason the
|
||||
/// style editor's popover frame is: a popover is a window this app sizes, and a resizing one across
|
||||
/// keystrokes would be distracting rather than helpful.
|
||||
struct SymbolPickerLayout: Equatable {
|
||||
|
||||
static let columns = 6
|
||||
static let rows = 6
|
||||
/// The grid's enlargement over the style editor's well size — glyphs read at a glance rather
|
||||
/// than in miniature.
|
||||
static let gridScale: CGFloat = 1.3
|
||||
|
||||
/// The at-rest button's side — the unscaled base, matched to the style editor's wells and to
|
||||
/// the text-field height the button sits beside.
|
||||
var restSide: CGFloat
|
||||
/// The glyph's own point size inside a grid well — the body size under `gridScale`, since a
|
||||
/// symbol renders at the font size, not the frame; a bigger well alone would just add margin.
|
||||
var glyphPointSize: CGFloat
|
||||
var wellSide: CGFloat
|
||||
var wellSpacing: CGFloat
|
||||
/// The gap between the search field and the grid below it — one figure rather than a pixel
|
||||
/// literal, so Dynamic Type moves it with everything else (10-accessibility.md's full-relative-
|
||||
/// scaling rule).
|
||||
var searchSpacing: CGFloat
|
||||
/// The popover's own inset, on all four sides.
|
||||
var contentPadding: CGFloat
|
||||
var gridWidth: CGFloat
|
||||
/// The search grid's scroll cap — six rows tall, so a long result list scrolls inside the popover
|
||||
/// rather than growing it.
|
||||
var gridHeight: CGFloat
|
||||
/// The grid's width plus its padding on both sides — the popover's fixed width.
|
||||
var popoverWidth: CGFloat
|
||||
|
||||
static func metrics(bodyPointSize: CGFloat) -> SymbolPickerLayout {
|
||||
let baseSide = StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize)
|
||||
let side = (baseSide * gridScale).rounded()
|
||||
let spacing = StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize)
|
||||
let padding = StyleEditorLayout.sectionSpacing(bodyPointSize: bodyPointSize)
|
||||
let gridWidth = (side * CGFloat(columns) + spacing * CGFloat(columns - 1)).rounded()
|
||||
let gridHeight = (side * CGFloat(rows) + spacing * CGFloat(rows - 1)).rounded()
|
||||
return SymbolPickerLayout(
|
||||
restSide: baseSide,
|
||||
glyphPointSize: (bodyPointSize * gridScale).rounded(),
|
||||
wellSide: side,
|
||||
wellSpacing: spacing,
|
||||
searchSpacing: spacing,
|
||||
contentPadding: padding,
|
||||
gridWidth: gridWidth,
|
||||
gridHeight: gridHeight,
|
||||
popoverWidth: (gridWidth + padding * 2).rounded()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The control
|
||||
|
||||
/// A single symbol well that opens a curated grid — the reusable primitive `03-board-ui.md`'s
|
||||
/// full-browser refusal ("the raw file is the escape hatch") leaves room for: not a new in-app way to
|
||||
/// hand-edit `icon`, but a control any caller can aim at one symbol field without wiring up a
|
||||
/// `BoardStore`, a `StyleTarget`, or the two-dimension batch machinery `StyleEditorView` carries for
|
||||
/// the board's own background+icon editor.
|
||||
///
|
||||
/// **View-local state only** — the popover's presented flag lives here, its search text lives with
|
||||
/// the popover content. Nothing about a store, an undo stack, or a target set is known to this type;
|
||||
/// `onSelect` is the whole of its contract with a caller, exactly as a `Picker`'s `selection` binding
|
||||
/// would be.
|
||||
struct SymbolPicker: View {
|
||||
|
||||
/// The committed symbol name, or `nil` for "no override" — read alongside `fallback` rather than
|
||||
/// pre-resolved by the caller, so this view (and only this view) has to know the lenient-render
|
||||
/// rule (`ItemSymbol.name(_:fallback:)`'s rule, restated for a plain `String?` since a caller here
|
||||
/// may have no `FieldValue` at all).
|
||||
let current: String?
|
||||
/// The level default shown when `current` is absent or unresolvable, and the grid's leading well.
|
||||
let fallback: String
|
||||
/// The curated grid's contents. Defaults to `SymbolPickerCatalog.available` so a caller with no
|
||||
/// opinion gets the general-purpose set; a caller styling a specific domain (a template chooser,
|
||||
/// say) can supply its own.
|
||||
var symbols: [String] = SymbolPickerCatalog.available
|
||||
/// Whether the popover offers the search field and full-catalog fallback at all. `false` collapses
|
||||
/// the picker to the curated grid alone — a caller with no use for the OS's whole inventory
|
||||
/// (a fixed small vocabulary) is not forced to carry the search chrome anyway.
|
||||
var searchable: Bool = true
|
||||
/// The name to set, or `nil` to clear back to the default — mirrors `StyleChange`'s `set`/`remove`
|
||||
/// split without importing that type, since a caller outside the styling system has no `StyleChange`
|
||||
/// to hand back.
|
||||
let onSelect: (String?) -> Void
|
||||
|
||||
@State private var isPresented = false
|
||||
|
||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
|
||||
/// What the well actually draws — `current` if this system can resolve it, `fallback` otherwise.
|
||||
/// The same lenient rule `ItemSymbol.name(_:fallback:)` states for a `FieldValue`, restated here
|
||||
/// because this control's `current` is already a plain optional string by the time it arrives.
|
||||
private var resolvedName: String {
|
||||
if let current, ItemSymbol.exists(current) { return current }
|
||||
return fallback
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let layout = SymbolPickerLayout.metrics(bodyPointSize: pointSize)
|
||||
Button {
|
||||
isPresented = true
|
||||
} label: {
|
||||
Image(systemName: resolvedName)
|
||||
.imageScale(.medium)
|
||||
.frame(width: layout.restSide, height: layout.restSide)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.help("Symbol")
|
||||
.accessibilityLabel("Symbol")
|
||||
.accessibilityValue(resolvedName)
|
||||
.popover(isPresented: $isPresented, arrowEdge: .bottom) {
|
||||
SymbolPickerPopoverContent(
|
||||
current: current,
|
||||
fallback: fallback,
|
||||
symbols: symbols,
|
||||
searchable: searchable,
|
||||
layout: layout,
|
||||
onSelect: { name in
|
||||
onSelect(name)
|
||||
isPresented = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The popover's content
|
||||
|
||||
/// The popover's body: the search field (when `searchable`), and either the curated grid or a
|
||||
/// live search result — never both, since a query and the at-rest curated set answer the same
|
||||
/// question two different ways.
|
||||
private struct SymbolPickerPopoverContent: View {
|
||||
|
||||
let current: String?
|
||||
let fallback: String
|
||||
let symbols: [String]
|
||||
let searchable: Bool
|
||||
let layout: SymbolPickerLayout
|
||||
let onSelect: (String?) -> Void
|
||||
|
||||
@State private var query = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: layout.searchSpacing) {
|
||||
if searchable {
|
||||
searchField
|
||||
}
|
||||
resultBody
|
||||
}
|
||||
.padding(layout.contentPadding)
|
||||
.frame(width: layout.popoverWidth)
|
||||
}
|
||||
|
||||
private var searchField: some View {
|
||||
TextField("Search Symbols", text: $query)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
// **Escape steps outward one layer per press** (`BoardRenameField`'s idiom, the app's
|
||||
// standing Escape grammar): a non-empty query clears itself and keeps the popover open,
|
||||
// an empty one lets the press through to the popover's own dismissal.
|
||||
.onKeyPress(.escape) {
|
||||
guard !query.isEmpty else { return .ignored }
|
||||
query = ""
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var resultBody: some View {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
SymbolWellGrid(wells: curatedWells, layout: layout) { well in
|
||||
onSelect(well.isDefault ? nil : well.name)
|
||||
}
|
||||
} else {
|
||||
let matches = SymbolPickerCatalog.filter(query, in: SymbolPickerCatalog.fullCatalog())
|
||||
if matches.isEmpty {
|
||||
Text("No matches")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, layout.wellSpacing)
|
||||
} else {
|
||||
ScrollView(.vertical) {
|
||||
SymbolWellGrid(wells: matchWells(matches), layout: layout) { well in
|
||||
onSelect(well.name)
|
||||
}
|
||||
}
|
||||
.frame(height: layout.gridHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The at-rest grid: the leading default well, then up to 35 more from `symbols` — 03-board-ui.md
|
||||
/// § Styling ▸ Controls' "leading well is the level's default symbol" rule, restated for this
|
||||
/// control's plain-optional `current`/`fallback` pair.
|
||||
///
|
||||
/// `fallback` is dropped from the trailing set if present, so the default is never drawn twice —
|
||||
/// which is also why the trailing set is 35 rather than 36: the two together fill the 6×6 grid
|
||||
/// exactly when `fallback` was one of `symbols` to begin with (as it is for the card level, whose
|
||||
/// default `doc.text` sits inside `SymbolPickerCatalog.defaultSet`), and fall one well short of
|
||||
/// full when it wasn't (board and lane) — a quieter outcome than a grid that overflows its own
|
||||
/// 6×6 cap.
|
||||
private var curatedWells: [SymbolPickerWell] {
|
||||
let isDefaultSelected = current.map { !ItemSymbol.exists($0) } ?? true
|
||||
var wells = [SymbolPickerWell(
|
||||
id: 0,
|
||||
name: fallback,
|
||||
label: "Default (\(fallback))",
|
||||
isSelected: isDefaultSelected,
|
||||
isDefault: true
|
||||
)]
|
||||
let trailing = symbols.filter { $0 != fallback }
|
||||
for (index, name) in trailing.prefix(SymbolPickerLayout.columns * SymbolPickerLayout.rows - 1).enumerated() {
|
||||
wells.append(SymbolPickerWell(
|
||||
id: index + 1,
|
||||
name: name,
|
||||
label: name,
|
||||
isSelected: current == name,
|
||||
isDefault: false
|
||||
))
|
||||
}
|
||||
return wells
|
||||
}
|
||||
|
||||
private func matchWells(_ matches: [String]) -> [SymbolPickerWell] {
|
||||
matches.enumerated().map { index, name in
|
||||
SymbolPickerWell(id: index, name: name, label: name, isSelected: current == name, isDefault: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wells
|
||||
|
||||
/// One well in either grid: what it draws, what it is called, and whether it is the leading default.
|
||||
private struct SymbolPickerWell: Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let label: String
|
||||
let isSelected: Bool
|
||||
/// Whether this is the leading "no override" well — drawn quieter (`StyleWellFace`'s
|
||||
/// `.defaultSymbol` treatment) so "no symbol set" and "this symbol set" read differently at a
|
||||
/// glance, and selected by `onSelect(nil)` rather than `onSelect(well.name)`.
|
||||
let isDefault: Bool
|
||||
}
|
||||
|
||||
/// One well's face: the glyph, tinted by whether it is the default. `StyleEditor.swift`'s
|
||||
/// `StyleWellFace` already draws this exact shape, but as a `private` type it is not this file's to
|
||||
/// reach — a small sibling here, rather than widening that file's access for one caller outside it.
|
||||
private struct SymbolWellFace: View {
|
||||
|
||||
let name: String
|
||||
let isDefault: Bool
|
||||
let size: CGFloat
|
||||
/// The glyph's font size — set explicitly (`SymbolPickerLayout.glyphPointSize`) rather than
|
||||
/// inherited, since the grid's enlargement lives in the font, not the frame.
|
||||
let glyphPointSize: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: ItemSymbol.exists(name) ? name : "questionmark.square.dashed")
|
||||
.font(.system(size: glyphPointSize))
|
||||
.foregroundStyle(isDefault ? AnyShapeStyle(.secondary) : AnyShapeStyle(.primary))
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
/// One grid of wells: Tab-reachable buttons, arrow-navigable as a grid — `StyleWellGrid`'s pattern,
|
||||
/// mirrored rather than shared for the same reason `SymbolWellFace` is its own type. The duplication
|
||||
/// is small (one `move(_:)` handler) and the alternative — exporting `StyleWellGrid` generically out
|
||||
/// of the style editor — would widen a file whose whole point is staying anchor-agnostic to a second,
|
||||
/// unrelated caller.
|
||||
private struct SymbolWellGrid: View {
|
||||
|
||||
let wells: [SymbolPickerWell]
|
||||
let layout: SymbolPickerLayout
|
||||
let onSelect: (SymbolPickerWell) -> Void
|
||||
|
||||
@FocusState private var focused: Int?
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(
|
||||
columns: Array(
|
||||
repeating: GridItem(.flexible(minimum: layout.wellSide), spacing: layout.wellSpacing),
|
||||
count: SymbolPickerLayout.columns
|
||||
),
|
||||
spacing: layout.wellSpacing
|
||||
) {
|
||||
ForEach(wells) { well in
|
||||
Button {
|
||||
onSelect(well)
|
||||
} label: {
|
||||
SymbolWellFace(
|
||||
name: well.name,
|
||||
isDefault: well.isDefault,
|
||||
size: layout.wellSide,
|
||||
glyphPointSize: layout.glyphPointSize
|
||||
)
|
||||
.overlay(selectionRing(well.isSelected))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable()
|
||||
.focused($focused, equals: well.id)
|
||||
.help(well.label)
|
||||
.accessibilityLabel(well.label)
|
||||
.accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
.onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
|
||||
move(press.key)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectionRing(_ isSelected: Bool) -> some View {
|
||||
RoundedRectangle(cornerRadius: max(1, (layout.wellSide * 0.25).rounded()))
|
||||
.strokeBorder(
|
||||
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
||||
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
|
||||
)
|
||||
.padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
|
||||
}
|
||||
|
||||
/// One step per press, clamped at the ends — `StyleWellGrid.move(_:)`'s rule, restated for this
|
||||
/// grid's own fixed column count.
|
||||
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
|
||||
let delta: Int
|
||||
switch key {
|
||||
case .leftArrow: delta = -1
|
||||
case .rightArrow: delta = 1
|
||||
case .upArrow: delta = -SymbolPickerLayout.columns
|
||||
case .downArrow: delta = SymbolPickerLayout.columns
|
||||
default: return .ignored
|
||||
}
|
||||
let current = focused ?? 0
|
||||
let next = min(max(0, current + delta), wells.count - 1)
|
||||
focused = next
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The board background's **mapping form** — the write side that has to preserve what the app has no
|
||||
/// control over, and the path rule that decides where an `image:` may point (01-storage-format.md §
|
||||
/// Frontmatter; 03-board-ui.md § Styling ▸ Capabilities).
|
||||
///
|
||||
/// The *readings* live with their siblings in `FrontmatterTests`; what is here is everything that is
|
||||
/// new machinery rather than a new value: `FrontmatterDocument.setStyleValue` (BackgroundField.swift)
|
||||
/// and `BoardBackdrop.imageURL(named:inBoardRoot:)`.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private func document(_ frontmatter: String) throws -> FrontmatterDocument {
|
||||
try FrontmatterDocument.parse("---\n\(frontmatter)\n---\nbody\n")
|
||||
}
|
||||
|
||||
/// The `background:` line as it now reads on disk, or `nil` when the key is gone.
|
||||
private func backgroundLine(_ document: FrontmatterDocument) -> String? {
|
||||
document.serialized()
|
||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.first { $0.hasPrefix("background:") }
|
||||
.map(String.init)
|
||||
}
|
||||
|
||||
// MARK: - Writing into the mapping
|
||||
|
||||
@Suite("Board background ▸ the write preserves the mapping")
|
||||
struct BackgroundWriteTests {
|
||||
|
||||
/// The whole point of the field: the app owns the colour well and nothing else, so a colour
|
||||
/// change on a board carrying an image has to come back still carrying it.
|
||||
@Test("Setting a colour replaces the subkey and keeps the image")
|
||||
func setKeepsTheImage() throws {
|
||||
var document = try document("background: {color: fern, image: sunset.jpg}")
|
||||
document.setStyleValue("dark-teal", for: FrontmatterKeys.background)
|
||||
|
||||
#expect(document.background == .valid("dark-teal"))
|
||||
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
||||
#expect(backgroundLine(document) == "background: {color: \"dark-teal\", image: \"sunset.jpg\"}")
|
||||
}
|
||||
|
||||
/// A colour written into a mapping that had none joins it rather than replacing it — the
|
||||
/// image-only board is exactly the board the style editor is most likely to be opened on.
|
||||
@Test("Setting a colour on an image-only background adds the subkey")
|
||||
func setAddsTheSubkeyToAnImageOnlyMapping() throws {
|
||||
var document = try document("background: {image: sunset.jpg}")
|
||||
document.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||
|
||||
#expect(document.background == .valid("chalk"))
|
||||
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
||||
}
|
||||
|
||||
/// Unknown subkeys ride along like unknown keys do — nothing in the app knows what `blend:`
|
||||
/// means and nothing in the app is entitled to drop it.
|
||||
@Test("Unknown subkeys survive a colour change, in their own positions")
|
||||
func setPreservesUnknownSubkeys() throws {
|
||||
var document = try document("background: {blend: multiply, color: fern, opacity: 0.5, tags: [a, b]}")
|
||||
document.setStyleValue("obsidian", for: FrontmatterKeys.background)
|
||||
|
||||
#expect(backgroundLine(document)
|
||||
== "background: {blend: \"multiply\", color: \"obsidian\", opacity: 0.5, tags: [\"a\", \"b\"]}")
|
||||
}
|
||||
|
||||
/// The None well removes the *colour*, not the background: an image the user never chose in the
|
||||
/// app must not disappear because they cleared a colour (03-board-ui.md § Styling ▸ Controls).
|
||||
@Test("The None well drops the colour subkey alone")
|
||||
func removeDropsOnlyTheColour() throws {
|
||||
var document = try document("background: {color: fern, image: sunset.jpg}")
|
||||
document.setStyleValue(nil, for: FrontmatterKeys.background)
|
||||
|
||||
#expect(document.background == .missing)
|
||||
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
||||
#expect(backgroundLine(document) == "background: {image: \"sunset.jpg\"}")
|
||||
}
|
||||
|
||||
/// A mapping the removal empties takes the key with it — `background: {}` is a key that says
|
||||
/// nothing, and the removal's contract is that the field is gone.
|
||||
@Test("A mapping emptied by the removal takes the key with it")
|
||||
func removeDropsAnEmptiedKey() throws {
|
||||
var document = try document("background: {color: fern}")
|
||||
document.setStyleValue(nil, for: FrontmatterKeys.background)
|
||||
|
||||
#expect(document.background == .missing)
|
||||
#expect(!document.contains(FrontmatterKeys.background))
|
||||
#expect(backgroundLine(document) == nil)
|
||||
}
|
||||
|
||||
/// A block mapping is the same value as a flow one and is merged the same way. The spelling is
|
||||
/// what does not survive — the span editor rewrites a key's value as one line — which is the
|
||||
/// documented limit of the verbatim promise on the one key being written.
|
||||
@Test("A block mapping merges, collapsing to flow form")
|
||||
func blockMappingCollapsesToFlow() throws {
|
||||
var document = try FrontmatterDocument.parse(
|
||||
"---\nschema: 1\nbackground:\n color: fern\n image: sunset.jpg\nicon: tray\n---\nbody\n"
|
||||
)
|
||||
document.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||
|
||||
#expect(document.background == .valid("chalk"))
|
||||
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
||||
#expect(document.serialized() == """
|
||||
---
|
||||
schema: 1
|
||||
background: {color: "chalk", image: "sunset.jpg"}
|
||||
icon: tray
|
||||
---
|
||||
body
|
||||
|
||||
""")
|
||||
}
|
||||
|
||||
/// **The app always writes the mapping** (01-storage-format.md § Frontmatter): a key that was
|
||||
/// absent gets one, and a key holding a shape the schema cannot read — the retired scalar, a
|
||||
/// sequence — is *replaced* by one, which is the malformed-value-cleared posture ("choosing any
|
||||
/// well replaces it").
|
||||
@Test("A colour written onto an absent or unreadable key lands as a mapping")
|
||||
func alwaysWritesTheMapping() throws {
|
||||
var absent = try document("schema: 1")
|
||||
absent.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||
#expect(backgroundLine(absent) == "background: {color: \"chalk\"}")
|
||||
#expect(absent.background == .valid("chalk"))
|
||||
|
||||
var scalar = try document("background: fern")
|
||||
scalar.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||
#expect(backgroundLine(scalar) == "background: {color: \"chalk\"}")
|
||||
|
||||
var sequence = try document("background: [a, b]")
|
||||
sequence.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||
#expect(backgroundLine(sequence) == "background: {color: \"chalk\"}")
|
||||
}
|
||||
|
||||
/// A removal over a shape with no subkeys to keep is simply a removal — there is no mapping to
|
||||
/// preserve half of, and nothing was readable to begin with.
|
||||
@Test("A removal over a scalar or an absent key just removes it")
|
||||
func removalOverANonMappingRemovesTheKey() throws {
|
||||
var scalar = try document("background: fern")
|
||||
scalar.setStyleValue(nil, for: FrontmatterKeys.background)
|
||||
#expect(!scalar.contains(FrontmatterKeys.background))
|
||||
|
||||
var absent = try document("schema: 1")
|
||||
absent.setStyleValue(nil, for: FrontmatterKeys.background)
|
||||
#expect(!absent.contains(FrontmatterKeys.background))
|
||||
#expect(absent.serialized() == "---\nschema: 1\n---\nbody\n")
|
||||
}
|
||||
|
||||
/// The other style keys are untouched by any of this: their values *are* strings, and a
|
||||
/// hand-written `icon: {a: 1}` is a malformed value the write exists to clear — never something
|
||||
/// to merge a `color:` subkey into.
|
||||
@Test("Title and icon keep the plain scalar path")
|
||||
func otherKeysStayScalar() throws {
|
||||
var titled = try document("title: Old")
|
||||
titled.setStyleValue("New", for: FrontmatterKeys.title)
|
||||
#expect(titled.title == .valid("New"))
|
||||
#expect(titled.serialized() == "---\ntitle: New\n---\nbody\n")
|
||||
|
||||
var mappedIcon = try document("icon: {a: 1}")
|
||||
mappedIcon.setStyleValue("tray", for: FrontmatterKeys.icon)
|
||||
#expect(mappedIcon.icon == .valid("tray"))
|
||||
#expect(mappedIcon.serialized() == "---\nicon: tray\n---\nbody\n")
|
||||
}
|
||||
|
||||
/// The emitted mapping is read back by the very reader the app uses, values with YAML-significant
|
||||
/// characters included — the reason strings are always quoted in flow context.
|
||||
@Test("An awkward colour and path round-trip through the emitted mapping")
|
||||
func awkwardValuesRoundTrip() throws {
|
||||
var document = try document("background: {image: \"a, b}.jpg\"}")
|
||||
document.setStyleValue("#ff8800", for: FrontmatterKeys.background)
|
||||
|
||||
let reparsed = try FrontmatterDocument.parse(document.serialized())
|
||||
#expect(reparsed.background == .valid("#ff8800"))
|
||||
#expect(reparsed.backgroundImage == .valid("a, b}.jpg"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Where an image may point
|
||||
|
||||
@Suite("Board background ▸ the image path stays inside the board")
|
||||
struct BackgroundImagePathTests {
|
||||
|
||||
private let root = URL(fileURLWithPath: "/Users/someone/Boards/Work.kanban", isDirectory: true)
|
||||
|
||||
@Test("A plain name and a nested path resolve inside the board")
|
||||
func resolvesRelativePaths() {
|
||||
#expect(BoardBackdrop.imageURL(named: "sunset.jpg", inBoardRoot: root)?.path
|
||||
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
||||
#expect(BoardBackdrop.imageURL(named: "art/backdrops/sunset.png", inBoardRoot: root)?.path
|
||||
== "/Users/someone/Boards/Work.kanban/art/backdrops/sunset.png")
|
||||
}
|
||||
|
||||
/// The check is about where the path *ends up*, not how it is spelled: a climb that lands back
|
||||
/// inside the board is an ordinary file in it.
|
||||
@Test("A path that climbs and returns is still inside")
|
||||
func resolvesPathsThatStandardizeBackInside() {
|
||||
#expect(BoardBackdrop.imageURL(named: "art/../sunset.jpg", inBoardRoot: root)?.path
|
||||
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
||||
#expect(BoardBackdrop.imageURL(named: "./sunset.jpg", inBoardRoot: root)?.path
|
||||
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
||||
}
|
||||
|
||||
/// A `.kanban` folder is a document — it gets copied, zipped and handed to somebody else — so a
|
||||
/// background that only works on the Mac it was written on resolves to nothing at all.
|
||||
@Test("Escapes and absolute paths resolve to nothing")
|
||||
func rejectsEscapes() {
|
||||
#expect(BoardBackdrop.imageURL(named: "../escape.jpg", inBoardRoot: root) == nil)
|
||||
#expect(BoardBackdrop.imageURL(named: "art/../../escape.jpg", inBoardRoot: root) == nil)
|
||||
#expect(BoardBackdrop.imageURL(named: "/etc/passwd", inBoardRoot: root) == nil)
|
||||
// Appended rather than rejected, an absolute path would land as `<root>/etc/passwd` and pass
|
||||
// containment while naming a file the author plainly did not mean.
|
||||
#expect(BoardBackdrop.imageURL(named: "/sunset.jpg", inBoardRoot: root) == nil)
|
||||
#expect(BoardBackdrop.imageURL(named: "", inBoardRoot: root) == nil)
|
||||
#expect(BoardBackdrop.imageURL(named: ".", inBoardRoot: root) == nil)
|
||||
}
|
||||
|
||||
/// The trailing separator in the containment test, doing its job: a sibling whose name merely
|
||||
/// starts with this board's is a different board.
|
||||
@Test("A sibling folder with a prefixed name is not inside")
|
||||
func rejectsAPrefixedSibling() {
|
||||
#expect(BoardBackdrop.imageURL(named: "../Work.kanban.backup/sunset.jpg", inBoardRoot: root) == nil)
|
||||
}
|
||||
|
||||
/// `~` is not expanded and is not special: only a shell ever meant a home folder by it, and a
|
||||
/// file honestly named that way sits in the board like any other.
|
||||
@Test("A tilde is an ordinary character in a file name")
|
||||
func treatsTildeAsAnOrdinaryCharacter() {
|
||||
#expect(BoardBackdrop.imageURL(named: "~notes.png", inBoardRoot: root)?.path
|
||||
== "/Users/someone/Boards/Work.kanban/~notes.png")
|
||||
}
|
||||
|
||||
/// The whole-model reading, which is what the view and the window chrome ask: no key, an
|
||||
/// unresolvable one, and a good one.
|
||||
@Test("The board-level reading follows the field and the path rule")
|
||||
func readsTheBoardsOwnField() throws {
|
||||
func board(_ frontmatter: String) throws -> BoardModel {
|
||||
BoardModel(
|
||||
rootURL: root,
|
||||
schema: 1,
|
||||
title: .missing,
|
||||
created: .missing,
|
||||
modified: .missing,
|
||||
modifiedBy: .missing,
|
||||
deleted: .missing,
|
||||
background: .missing,
|
||||
backgroundImage: try document(frontmatter).backgroundImage,
|
||||
icon: .missing,
|
||||
iconColor: .missing,
|
||||
template: nil,
|
||||
lanes: [],
|
||||
document: try document(frontmatter)
|
||||
)
|
||||
}
|
||||
|
||||
#expect(BoardBackdrop.imageURL(for: try board("background: {color: fern}"), root: root) == nil)
|
||||
#expect(BoardBackdrop.imageURL(for: try board("background: {image: ../x.jpg}"), root: root) == nil)
|
||||
#expect(BoardBackdrop.imageURL(for: try board("background: {image: sunset.jpg}"), root: root)?.path
|
||||
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
||||
}
|
||||
|
||||
/// **The window-chrome predicate** (`BoardWindowHost`): a board paints a background of its own
|
||||
/// when a colour resolves or an image path lands inside the board — a path that could never
|
||||
/// paint anything leaves the standard chrome alone.
|
||||
@Test("The chrome predicate answers for colour, image, both and neither")
|
||||
func answersTheChromePredicate() throws {
|
||||
func board(_ frontmatter: String) throws -> BoardModel {
|
||||
let parsed = try document(frontmatter)
|
||||
return BoardModel(
|
||||
rootURL: root,
|
||||
schema: 1,
|
||||
title: .missing,
|
||||
created: .missing,
|
||||
modified: .missing,
|
||||
modifiedBy: .missing,
|
||||
deleted: .missing,
|
||||
background: parsed.background,
|
||||
backgroundImage: parsed.backgroundImage,
|
||||
icon: .missing,
|
||||
iconColor: .missing,
|
||||
template: nil,
|
||||
lanes: [],
|
||||
document: parsed
|
||||
)
|
||||
}
|
||||
|
||||
#expect(BoardBackdrop.isCustom(try board("schema: 1"), root: root) == false)
|
||||
#expect(BoardBackdrop.isCustom(try board("background: {color: fern}"), root: root))
|
||||
#expect(BoardBackdrop.isCustom(try board("background: {image: sunset.jpg}"), root: root))
|
||||
#expect(BoardBackdrop.isCustom(try board("background: {color: fern, image: sunset.jpg}"), root: root))
|
||||
// Neither half resolves: an unrecognized colour name and a path that leaves the board.
|
||||
#expect(BoardBackdrop.isCustom(try board("background: {color: mauve, image: /tmp/x.jpg}"), root: root) == false)
|
||||
}
|
||||
}
|
||||
@@ -541,7 +541,7 @@ struct ShownTrashDiffTests {
|
||||
let retitled = try fixture.snapshot()
|
||||
try fixture.item(
|
||||
".trash/\(trashedLaneID)",
|
||||
"---\nschema: 1\ntitle: Shipped\norder: 1024\nkind: lane\nbackground: blue\n---\n\n"
|
||||
"---\nschema: 1\ntitle: Shipped\norder: 1024\nkind: lane\nbackground: {color: blue}\n---\n\n"
|
||||
)
|
||||
let styled = BoardDiff.between(retitled, try fixture.snapshot(), includingTrash: true)
|
||||
#expect(styled.lanes.isEmpty, "no accent is rendered, so nothing visible changed")
|
||||
|
||||
@@ -483,7 +483,7 @@ struct CardSessionStalenessTests {
|
||||
// A foreign styling of the same card: the session wrote the body and nothing else, so the
|
||||
// step names no style field to be stale against (13 ▸ Rules, the field-level predicate).
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(cardPath), operation: .style(title: nil)) {
|
||||
$0.set(FrontmatterKeys.background, to: .string("blue"))
|
||||
$0.setStyleValue("blue", for: FrontmatterKeys.background)
|
||||
}
|
||||
|
||||
window.board.undo()
|
||||
|
||||
@@ -39,7 +39,7 @@ struct CardDetailsKeyTests {
|
||||
created: 2026-01-01T09:00:00Z
|
||||
modified: 2026-02-02T09:00:00Z
|
||||
modified-by: claude
|
||||
background: mint
|
||||
background: {color: mint}
|
||||
icon: flag
|
||||
iconColor: carnation
|
||||
project: overlay-rewritten
|
||||
@@ -99,7 +99,7 @@ struct CardDetailsKeyTests {
|
||||
schema: 1
|
||||
title: Styled
|
||||
order: 1024
|
||||
background: mint
|
||||
background: {color: mint}
|
||||
icon: flag
|
||||
iconColor: carnation
|
||||
created: 2026-01-01T09:00:00Z
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `ColorComboView`'s pure model (`ColorCombo.swift`): item-list composition, selection matching,
|
||||
/// hex normalization and display-name casing — every rule stated where a test can hold it without
|
||||
/// an `NSView`, matching `PaletteTests.swift`'s own split between the vocabulary and the view.
|
||||
///
|
||||
/// One outer suite, nested by concern — swift-testing discovers nested types as sub-suites, which
|
||||
/// is what lets `-only-testing:KanbanTests/ColorComboTests` run the whole file while each concern
|
||||
/// still reads as its own group, `PaletteTests.swift`'s top-level-structs style scoped one level in.
|
||||
struct ColorComboTests {
|
||||
|
||||
// MARK: - Display names
|
||||
|
||||
struct DisplayName {
|
||||
|
||||
@Test func kebabCaseBecomesTitleCaseWithHyphensAsSpaces() {
|
||||
#expect(ColorComboModel.displayName("light-cayenne") == "Light Cayenne")
|
||||
#expect(ColorComboModel.displayName("smokey-rich-eggplant") == "Smokey Rich Eggplant")
|
||||
#expect(ColorComboModel.displayName("obsidian") == "Obsidian")
|
||||
#expect(ColorComboModel.displayName("deep-sky-blue") == "Deep Sky Blue")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hex normalization
|
||||
|
||||
struct HexNormalization {
|
||||
|
||||
@Test func sixDigitHexUppercasesUnchanged() {
|
||||
#expect(ColorComboModel.normalizedHex("#b6071e") == "#B6071E")
|
||||
#expect(ColorComboModel.normalizedHex("#B6071E") == "#B6071E")
|
||||
}
|
||||
|
||||
/// Alpha below full opacity is meaningful and stays — only a fully-opaque suffix collapses.
|
||||
@Test func eightDigitHexWithPartialAlphaStaysEightDigits() {
|
||||
#expect(ColorComboModel.normalizedHex("#b6071e80") == "#B6071E80")
|
||||
}
|
||||
|
||||
/// `#RRGGBBFF` — fully opaque, spelled with an explicit alpha byte — collapses to the
|
||||
/// six-digit form, so it compares equal to a bare `#RRGGBB` written for the same colour.
|
||||
@Test func fullyOpaqueEightDigitHexCollapsesToSixDigits() {
|
||||
#expect(ColorComboModel.normalizedHex("#b6071eFF") == "#B6071E")
|
||||
#expect(ColorComboModel.normalizedHex("#B6071EFF") == ColorComboModel.normalizedHex("#B6071E"))
|
||||
}
|
||||
|
||||
@Test func malformedOrUnprefixedValuesNormalizeToNil() {
|
||||
for value in ["", "#", "#12", "#12345", "#1234567", "#GGGGGG", "B6071E", "light-cayenne"] {
|
||||
#expect(ColorComboModel.normalizedHex(value) == nil, "'\(value)' should not normalize")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection matching
|
||||
|
||||
struct Matching {
|
||||
|
||||
@Test func nilValueMatchesNone() {
|
||||
#expect(ColorComboModel.match(role: .background, value: nil) == .none)
|
||||
#expect(ColorComboModel.match(role: .foreground, value: nil) == .none)
|
||||
}
|
||||
|
||||
@Test func aNameInTheRolesOwnPaletteMatchesThatRowExactly() {
|
||||
#expect(ColorComboModel.match(role: .background, value: "light-cayenne") == .palette("light-cayenne"))
|
||||
#expect(ColorComboModel.match(role: .foreground, value: "fern") == .palette("fern"))
|
||||
}
|
||||
|
||||
/// Names are matched exactly, like `Palette.nsColor(for:)` — a near-miss is not "the same
|
||||
/// row", it is an off-palette value with its own dynamic row.
|
||||
@Test func aNearMissNameIsNotAPaletteMatch() {
|
||||
let match = ColorComboModel.match(role: .background, value: "Light-Cayenne")
|
||||
guard case let .current(_, title) = match else {
|
||||
Issue.record("expected .current, got \(match)")
|
||||
return
|
||||
}
|
||||
#expect(title == "Light-Cayenne")
|
||||
}
|
||||
|
||||
/// A hex that normalizes to one of the role's own palette hexes selects the **name**, not
|
||||
/// the hex — case-insensitively, and with a fully-opaque `#RRGGBBFF` collapsing exactly like
|
||||
/// a bare `#RRGGBB` would.
|
||||
@Test func aHexEqualToAPaletteColorsHexMatchesItsNamedRow() {
|
||||
#expect(ColorComboModel.match(role: .background, value: "#B6071E") == .palette("light-cayenne"))
|
||||
#expect(ColorComboModel.match(role: .background, value: "#b6071e") == .palette("light-cayenne"))
|
||||
#expect(ColorComboModel.match(role: .background, value: "#B6071EFF") == .palette("light-cayenne"))
|
||||
#expect(ColorComboModel.match(role: .background, value: "#b6071eff") == .palette("light-cayenne"))
|
||||
}
|
||||
|
||||
/// Partial alpha keeps a value off the palette rows even when its RGB matches one exactly —
|
||||
/// the stored colour is genuinely translucent, which no palette entry is.
|
||||
@Test func aTranslucentHexNeverMatchesAnOpaquePaletteColor() {
|
||||
let match = ColorComboModel.match(role: .background, value: "#B6071E80")
|
||||
guard case let .current(swatchValue, _) = match else {
|
||||
Issue.record("expected .current, got \(match)")
|
||||
return
|
||||
}
|
||||
#expect(swatchValue == "#B6071E80")
|
||||
}
|
||||
|
||||
/// A name from the *other* picker's table — `carnation` is foreground-only — is not one of
|
||||
/// `.background`'s twelve, so it falls to the dynamic row, titled with its own display name
|
||||
/// since the other table does know it.
|
||||
@Test func aForeignPaletteNameFallsToTheDynamicRowNamedFromTheOtherTable() {
|
||||
let match = ColorComboModel.match(role: .background, value: "carnation")
|
||||
#expect(match == .current(swatchValue: "carnation", title: "Carnation"))
|
||||
}
|
||||
|
||||
/// A custom hex nowhere in either table: the dynamic row states it verbatim, uppercased.
|
||||
@Test func aCustomHexFallsToTheDynamicRowUppercased() {
|
||||
let match = ColorComboModel.match(role: .background, value: "#123456")
|
||||
#expect(match == .current(swatchValue: "#123456", title: "#123456"))
|
||||
let lowercase = ColorComboModel.match(role: .background, value: "#abcdef")
|
||||
#expect(lowercase == .current(swatchValue: "#abcdef", title: "#ABCDEF"))
|
||||
}
|
||||
|
||||
/// Unresolvable garbage — neither a name either table knows nor a parseable hex — falls to
|
||||
/// the dynamic row exactly as written, no casing applied.
|
||||
@Test func garbageFallsToTheDynamicRowVerbatim() {
|
||||
let match = ColorComboModel.match(role: .background, value: "chartreuse")
|
||||
#expect(match == .current(swatchValue: "chartreuse", title: "chartreuse"))
|
||||
}
|
||||
|
||||
/// The three names shared by both tables (`obsidian`, `aluminum`, `chalk`) are in *both*
|
||||
/// roles' own palettes, so they match directly and never reach the "foreign name" branch.
|
||||
@Test func namesSharedByBothTablesMatchDirectlyInEitherRole() {
|
||||
#expect(ColorComboModel.match(role: .background, value: "obsidian") == .palette("obsidian"))
|
||||
#expect(ColorComboModel.match(role: .foreground, value: "obsidian") == .palette("obsidian"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Item list composition
|
||||
|
||||
struct Menu {
|
||||
|
||||
/// None first, a separator, then exactly the role's twelve, in the palette's own order.
|
||||
@Test func baseOrderIsNoneSeparatorThenTheRolesTwelve() {
|
||||
let menu = ColorComboModel.menu(role: .background, value: nil)
|
||||
var expected: [ColorComboItem] = [.none, .separator]
|
||||
expected.append(contentsOf: Palette.backgrounds.map { .palette($0.name) })
|
||||
expected.append(contentsOf: [.separator, .other])
|
||||
#expect(menu.items == expected)
|
||||
}
|
||||
|
||||
@Test func foregroundRoleListsTheForegroundTwelveNotTheBackgroundTwelve() {
|
||||
let menu = ColorComboModel.menu(role: .foreground, value: nil)
|
||||
let paletteNames = menu.items.compactMap { item -> String? in
|
||||
if case let .palette(name) = item { return name }
|
||||
return nil
|
||||
}
|
||||
#expect(paletteNames == Palette.foregrounds.map(\.name))
|
||||
}
|
||||
|
||||
/// `Other…` is always last, and there is never more than one dynamic row.
|
||||
@Test func otherIsAlwaysLast() {
|
||||
for value in [nil, "obsidian", "carnation", "#123456", "chartreuse"] {
|
||||
let menu = ColorComboModel.menu(role: .background, value: value)
|
||||
#expect(menu.items.last == .other)
|
||||
}
|
||||
}
|
||||
|
||||
/// No dynamic row, and no selected item beyond the palette rows, when the value is `nil` or
|
||||
/// one of the role's own twelve.
|
||||
@Test func noDynamicRowWhenTheValueIsNoneOrAPaletteName() {
|
||||
let none = ColorComboModel.menu(role: .background, value: nil)
|
||||
#expect(!none.items.contains { if case .current = $0 { return true }; return false })
|
||||
#expect(none.selectedIndex == 0)
|
||||
#expect(none.items[none.selectedIndex] == .none)
|
||||
|
||||
let named = ColorComboModel.menu(role: .background, value: "dark-teal")
|
||||
#expect(!named.items.contains { if case .current = $0 { return true }; return false })
|
||||
#expect(named.items[named.selectedIndex] == .palette("dark-teal"))
|
||||
}
|
||||
|
||||
/// A foreign or unresolvable value inserts exactly one dynamic row, immediately before the
|
||||
/// trailing separator and `Other…`, and it is the checked row.
|
||||
@Test func dynamicRowAppearsOnlyForAForeignValueAndIsSelected() {
|
||||
let menu = ColorComboModel.menu(role: .background, value: "#123456")
|
||||
let dynamicRows = menu.items.filter { if case .current = $0 { return true }; return false }
|
||||
#expect(dynamicRows.count == 1)
|
||||
#expect(menu.items[menu.selectedIndex] == .current(swatchValue: "#123456", title: "#123456"))
|
||||
// Immediately before the trailing separator + Other…
|
||||
#expect(
|
||||
Array(menu.items.suffix(3)) ==
|
||||
[.current(swatchValue: "#123456", title: "#123456"), .separator, .other]
|
||||
)
|
||||
}
|
||||
|
||||
/// A hex landing exactly on a palette colour selects that named row and adds no dynamic row
|
||||
/// at all — the same shape as picking the name directly.
|
||||
@Test func anExactHexMatchProducesNoDynamicRow() {
|
||||
let byName = ColorComboModel.menu(role: .background, value: "light-cayenne")
|
||||
let byHex = ColorComboModel.menu(role: .background, value: "#B6071E")
|
||||
#expect(byName.items == byHex.items)
|
||||
#expect(byName.selectedIndex == byHex.selectedIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@ struct CommitMessageSingleEventTests {
|
||||
let restyled = try compose { fixture in
|
||||
try fixture.item(
|
||||
"\(Ident.lane1)/\(Ident.card1)",
|
||||
"---\nschema: 1\ntitle: Fix login\norder: 1024\nbackground: blue\n---\n\n"
|
||||
"---\nschema: 1\ntitle: Fix login\norder: 1024\nbackground: {color: blue}\n---\n\n"
|
||||
)
|
||||
}
|
||||
#expect(restyled == "Restyle card 'Fix login'")
|
||||
|
||||
@@ -352,7 +352,7 @@ struct FixtureCoercionTests {
|
||||
let cardTitleSeq = "40000000-0000-4000-8000-000000000004"
|
||||
let cardIconColorInt = "50000000-0000-4000-8000-000000000005"
|
||||
let cardBackgroundMap = "60000000-0000-4000-8000-000000000006"
|
||||
let cardBackgroundInt = "70000000-0000-4000-8000-000000000007"
|
||||
let cardBackgroundScalar = "70000000-0000-4000-8000-000000000007"
|
||||
let cardDeletedBad = "80000000-0000-4000-8000-000000000008"
|
||||
|
||||
let result = try loadFixture("Valid/coercion.kanban")
|
||||
@@ -371,8 +371,14 @@ struct FixtureCoercionTests {
|
||||
#expect(try card(cardTitleInt).title == .valid("2048"))
|
||||
#expect(try card(cardTitleSeq).title == .malformed(raw: "[a, b]"))
|
||||
#expect(try card(cardIconColorInt).iconColor == .valid("42"))
|
||||
#expect(try card(cardBackgroundMap).background == .malformed(raw: "{x: 1}"))
|
||||
#expect(try card(cardBackgroundInt).background == .valid("12345"))
|
||||
// **`background` is a mapping and only a mapping** (01-storage-format.md § Frontmatter,
|
||||
// ruled 2026-08-06). So the two background cards say opposite things about one key:
|
||||
// `{x: 1}` is a perfectly legal mapping that simply names neither subkey — no colour, no
|
||||
// trace, the unknown subkey riding along like any unknown key — while the bare scalar
|
||||
// `12345` has no reading at all, which is the golden pin on the retired shape.
|
||||
#expect(try card(cardBackgroundMap).background == .missing)
|
||||
#expect(try card(cardBackgroundScalar).background == .malformed(raw: "12345"))
|
||||
#expect(try card(cardBackgroundScalar).background.rawText == "12345")
|
||||
|
||||
let deletedBad = try card(cardDeletedBad)
|
||||
#expect(deletedBad.deleted == .malformed(raw: "definitely-not-a-date"))
|
||||
|
||||
@@ -551,8 +551,8 @@ struct FrontmatterLenientFieldTests {
|
||||
|
||||
@Test func wellFormedLenientValues() throws {
|
||||
#expect(try document("title: My Board").title == .valid("My Board"))
|
||||
#expect(try document("background: \"#ff8800\"").background == .valid("#ff8800"))
|
||||
#expect(try document("background: slate").background == .valid("slate"))
|
||||
#expect(try document("background: {color: \"#ff8800\"}").background == .valid("#ff8800"))
|
||||
#expect(try document("background: {color: slate}").background == .valid("slate"))
|
||||
#expect(try document("icon: tray.full").icon == .valid("tray.full"))
|
||||
#expect(try document("iconColor: teal").iconColor == .valid("teal"))
|
||||
#expect(try document("width: 3").width == .valid(3))
|
||||
@@ -565,7 +565,7 @@ struct FrontmatterLenientFieldTests {
|
||||
@Test func scalarsOfTheWrongTypeCoerceToTheirSourceText() throws {
|
||||
#expect(try document("title: 2048").title == .valid("2048"))
|
||||
#expect(try document("title: true").title == .valid("true"))
|
||||
#expect(try document("background: 42").background == .valid("42"))
|
||||
#expect(try document("background: {color: 42}").background == .valid("42"))
|
||||
#expect(try document("iconColor: true").iconColor == .valid("true"))
|
||||
#expect(try document("icon: 2026-07-26T16:41:38Z").icon == .valid("2026-07-26T16:41:38Z"))
|
||||
}
|
||||
@@ -575,7 +575,7 @@ struct FrontmatterLenientFieldTests {
|
||||
@Test func aTrailingCommentIsNotPartOfACoercedValue() throws {
|
||||
#expect(try document("title: 2048 # note").title == .valid("2048"))
|
||||
#expect(try document("title: true # note").title == .valid("true"))
|
||||
#expect(try document("background: 42\t# tabbed").background == .valid("42"))
|
||||
#expect(try document("background: {color: 42}\t# tabbed").background == .valid("42"))
|
||||
#expect(try document("icon: 2026-07-26T16:41:38Z # when").icon == .valid("2026-07-26T16:41:38Z"))
|
||||
}
|
||||
|
||||
@@ -583,8 +583,8 @@ struct FrontmatterLenientFieldTests {
|
||||
/// at all, so a read must not treat it as a comment.
|
||||
@Test func aHashInsideAQuotedValueIsNotTrimmed() throws {
|
||||
#expect(try document("title: \"2048 # note\"").title == .valid("2048 # note"))
|
||||
#expect(try document("background: \"#ff8800\"").background == .valid("#ff8800"))
|
||||
#expect(try document("background: \"#ff8800\" # brand orange").background == .valid("#ff8800"))
|
||||
#expect(try document("background: {color: \"#ff8800\"}").background == .valid("#ff8800"))
|
||||
#expect(try document("background: {color: \"#ff8800\"} # brand orange").background == .valid("#ff8800"))
|
||||
}
|
||||
|
||||
/// A sequence or mapping has no scalar reading at all; the raw text it falls back to stops at
|
||||
@@ -608,6 +608,115 @@ struct FrontmatterLenientFieldTests {
|
||||
#expect(try document("title: [a, b]").title == .malformed(raw: "[a, b]"))
|
||||
}
|
||||
|
||||
// MARK: `background` is a mapping and only a mapping
|
||||
|
||||
/// **The retired scalar** (01-storage-format.md § Frontmatter, ruled 2026-08-06 before anything
|
||||
/// shipped — one shape, no legacy spelling, no migration): `background: green` has no reading at
|
||||
/// all. It is `.malformed` like any other unreadable value, which renders as no colour and
|
||||
/// leaves the bytes exactly as written.
|
||||
@Test func aScalarBackgroundHasNoReading() throws {
|
||||
#expect(try document("background: green").background == .malformed(raw: "green"))
|
||||
#expect(try document("background: \"#ff8800\"").background == .malformed(raw: "\"#ff8800\""))
|
||||
#expect(try document("background: 42").background == .malformed(raw: "42"))
|
||||
// The raw stops at the comment like every other read, since a comment is the line's.
|
||||
#expect(try document("background: green # my colour").background == .malformed(raw: "green"))
|
||||
}
|
||||
|
||||
/// One unreadable value is reported **once**: the colour reading owns the key's shape, and the
|
||||
/// image stays silent about a file that never wrote a mapping to name a picture in.
|
||||
@Test func onlyTheColourReportsANonMappingShape() throws {
|
||||
#expect(try document("background: green").backgroundImage == .missing)
|
||||
#expect(try document("background: [red, blue]").backgroundImage == .missing)
|
||||
#expect(try document("background: 42").backgroundImage == .missing)
|
||||
#expect(try document("schema: 1").backgroundImage == .missing)
|
||||
#expect(try document("background: green").coercedFields
|
||||
== [CoercedField(key: "background", raw: "green")])
|
||||
}
|
||||
|
||||
/// The mapping form, both halves present — flow and block spellings are one YAML value and
|
||||
/// therefore one reading.
|
||||
@Test func aMappingBackgroundReadsBothSubkeys() throws {
|
||||
let flow = try document("background: {color: \"#112233\", image: sunset.jpg}")
|
||||
#expect(flow.background == .valid("#112233"))
|
||||
#expect(flow.backgroundImage == .valid("sunset.jpg"))
|
||||
|
||||
let block = try FrontmatterDocument.parse(
|
||||
"---\nbackground:\n color: fern\n image: art/sunset.jpg\n---\nbody\n"
|
||||
)
|
||||
#expect(block.background == .valid("fern"))
|
||||
#expect(block.backgroundImage == .valid("art/sunset.jpg"))
|
||||
}
|
||||
|
||||
/// Either half may be absent, and an absent half is `.missing` — not malformed. An image-only
|
||||
/// background is a board with no colour, which is the level's default and not a fallback.
|
||||
@Test func eitherSubkeyMayBeAbsent() throws {
|
||||
let colorOnly = try document("background: {color: chalk}")
|
||||
#expect(colorOnly.background == .valid("chalk"))
|
||||
#expect(colorOnly.backgroundImage == .missing)
|
||||
|
||||
let imageOnly = try document("background: {image: sunset.jpg}")
|
||||
#expect(imageOnly.background == .missing)
|
||||
#expect(imageOnly.backgroundImage == .valid("sunset.jpg"))
|
||||
|
||||
let empty = try document("background: {}")
|
||||
#expect(empty.background == .missing)
|
||||
#expect(empty.backgroundImage == .missing)
|
||||
}
|
||||
|
||||
/// An explicit null subkey reads exactly like an absent one — `FieldValue.missing` already
|
||||
/// treats `background: null` that way, and a subkey is no different.
|
||||
@Test func nullSubkeysReadAsMissing() throws {
|
||||
let nulls = try document("background: {color: null, image: ~}")
|
||||
#expect(nulls.background == .missing)
|
||||
#expect(nulls.backgroundImage == .missing)
|
||||
}
|
||||
|
||||
/// Unknown subkeys are tolerated on the way in exactly as unknown *keys* are — they mean
|
||||
/// nothing to either reading and cost it nothing.
|
||||
@Test func unknownSubkeysAreTolerated() throws {
|
||||
let extra = try document("background: {opacity: 0.5, color: fern, blend: multiply}")
|
||||
#expect(extra.background == .valid("fern"))
|
||||
#expect(extra.backgroundImage == .missing)
|
||||
}
|
||||
|
||||
/// Inside the mapping the subvalues coerce like any other scalar, quoted or not; a subvalue with
|
||||
/// no scalar reading at all is malformed, quoting the subvalue rather than the whole span —
|
||||
/// a subkey has no source span of its own to quote.
|
||||
@Test func subkeyScalarsCoerceAndCollectionsAreMalformed() throws {
|
||||
#expect(try document("background: {color: 42}").background == .valid("42"))
|
||||
#expect(try document("background: {image: \"sun set.jpg\"}").backgroundImage == .valid("sun set.jpg"))
|
||||
#expect(try document("background: {color: [a, b]}").background == .malformed(raw: "[a, b]"))
|
||||
#expect(try document("background: {image: {a: 1}}").backgroundImage == .malformed(raw: "{a: 1}"))
|
||||
// The other half of a mapping with one bad subkey still reads perfectly well.
|
||||
#expect(try document("background: {color: [a, b], image: sunset.jpg}").backgroundImage == .valid("sunset.jpg"))
|
||||
}
|
||||
|
||||
/// **The coerce tier's trace covers both readings** (01-storage-format.md § Frontmatter: "every
|
||||
/// silent recovery leaves a trace"). Both are filed under the key the schema spells, and are told
|
||||
/// apart by the subvalue each quotes — so a mapping whose colour reads fine and whose image does
|
||||
/// not is still visible.
|
||||
@Test func bothBackgroundReadingsReachTheCoerceRecord() throws {
|
||||
#expect(try document("background: {color: fern, image: [a, b]}").coercedFields
|
||||
== [CoercedField(key: "background", raw: "[a, b]")])
|
||||
#expect(try document("background: {color: [a], image: [b]}").coercedFields
|
||||
== [CoercedField(key: "background", raw: "[a]"), CoercedField(key: "background", raw: "[b]")])
|
||||
// A sequence is one unreadable value and is reported once — the image reading stays silent
|
||||
// about a shape that never claimed to name one.
|
||||
#expect(try document("background: [red, blue]").coercedFields
|
||||
== [CoercedField(key: "background", raw: "[red, blue]")])
|
||||
#expect(try document("background: {image: sunset.jpg}").coercedFields.isEmpty)
|
||||
}
|
||||
|
||||
/// The engine reads the shape; it never rewrites it. A mapping background round-trips
|
||||
/// byte-identically like every other value the app did not touch.
|
||||
@Test func aMappingBackgroundRoundTripsVerbatim() throws {
|
||||
let text = "---\nschema: 1\nbackground:\n color: fern\n image: art/sunset.jpg\n blend: multiply\n---\nbody\n"
|
||||
let document = try FrontmatterDocument.parse(text)
|
||||
#expect(document.serialized() == text)
|
||||
#expect(document.background == .valid("fern"))
|
||||
#expect(document.backgroundImage == .valid("art/sunset.jpg"))
|
||||
}
|
||||
|
||||
/// Only a fractional or non-numeric reading has no sensible width at all — a sequence,
|
||||
/// mapping, or scalar with no integer reading whatsoever stays malformed and renders as the
|
||||
/// default 1.
|
||||
|
||||
@@ -460,11 +460,11 @@ struct ObjectKindWriteTests {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.background, to: .string("fern")) }
|
||||
) { $0.setStyleValue("fern", for: FrontmatterKeys.background) }
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url(Ident.lane1),
|
||||
operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.background, to: .string("fern")) }
|
||||
) { $0.setStyleValue("fern", for: FrontmatterKeys.background) }
|
||||
|
||||
#expect(try kind(of: "\(Ident.lane1)/\(Ident.card1)", in: fixture) == .valid("card"))
|
||||
#expect(try kind(of: Ident.lane1, in: fixture) == .valid("lane"))
|
||||
@@ -498,7 +498,7 @@ struct ObjectKindWriteTests {
|
||||
let folder = try fixture.item("notes", Item.rich(order: "1024", title: "Hand-made"))
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) {
|
||||
$0.set(FrontmatterKeys.background, to: .string("fern"))
|
||||
$0.setStyleValue("fern", for: FrontmatterKeys.background)
|
||||
}
|
||||
|
||||
#expect(try kind(of: "notes", in: fixture) == .missing)
|
||||
|
||||
@@ -51,9 +51,9 @@ private func makeBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", boardIndex)
|
||||
try fixture.item(Ident.lane1, styled(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: fern", "iconColor: chalk"]))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: {color: fern}", "iconColor: chalk"]))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second"))
|
||||
try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: chalk", "icon: tray"]))
|
||||
try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: {color: chalk}", "icon: tray"]))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third"))
|
||||
try fixture.item(Ident.lane3, Item.uneditable)
|
||||
try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone"))
|
||||
@@ -120,7 +120,7 @@ struct StyleWriteTests {
|
||||
store.applyStyle(to: .items([card2]), background: .set("smokey-ocean"))
|
||||
|
||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
||||
#expect(after.contains("background: smokey-ocean"))
|
||||
#expect(after.contains("background: {color: \"smokey-ocean\"}"))
|
||||
#expect(!after.contains("icon:"), "the untouched dimension writes no key at all")
|
||||
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
|
||||
#expect(untouchedLines(after) == untouchedLines(before))
|
||||
@@ -141,13 +141,13 @@ struct StyleWriteTests {
|
||||
store.applyStyle(to: .items([card1]), background: .set("dark-teal"), icon: .set("flag"))
|
||||
|
||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||
#expect(after.contains("background: dark-teal"))
|
||||
#expect(after.contains("background: {color: \"dark-teal\"}"))
|
||||
#expect(after.contains("icon: flag"))
|
||||
// "iconColor: resolved — schema yes, control no" (03 § Styling ▸ Capabilities): the field
|
||||
// renders when hand-written and the app offers no control for it, so a style write must
|
||||
// carry it through untouched like any unknown key.
|
||||
#expect(after.contains("iconColor: chalk"))
|
||||
#expect(!after.contains("background: fern"), "the old value is replaced, not duplicated")
|
||||
#expect(!after.contains("fern"), "the old value is replaced, not duplicated")
|
||||
}
|
||||
|
||||
@Test("The None and default wells remove their key rather than writing a blank value")
|
||||
@@ -182,8 +182,8 @@ struct StyleWriteTests {
|
||||
|
||||
#expect(log.begins == 1)
|
||||
#expect(log.ends == 1)
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("background: light-cayenne"))
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: light-cayenne"))
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("background: {color: \"light-cayenne\"}"))
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: {color: \"light-cayenne\"}"))
|
||||
}
|
||||
|
||||
@Test("A value a target already carries writes nothing — per target and per dimension")
|
||||
@@ -195,13 +195,13 @@ struct StyleWriteTests {
|
||||
log.attach(to: store)
|
||||
let untouchedCard = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
// `card1` is already `fern` and `card2` has no background at all: only the second file may
|
||||
// `card1` is already `{color: fern}` and `card2` has no background at all: only the second file may
|
||||
// move. A well clicked twice must not stamp `modified` or mint a commit on what was already
|
||||
// right (`setLaneWidth`'s rule).
|
||||
store.applyStyle(to: .items([card1, card2]), background: .set("fern"))
|
||||
|
||||
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == untouchedCard)
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: fern"))
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: {color: \"fern\"}"))
|
||||
#expect(log.begins == 1, "the batch still opens exactly one bracket for the target that moved")
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ struct StyleWriteTests {
|
||||
log.attach(to: store)
|
||||
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
// Both dimensions already read this way: `background: fern` is set and `icon` is absent, so
|
||||
// Both dimensions already read this way: the colour is already `fern` and `icon` is absent, so
|
||||
// the removal is a no-op too.
|
||||
store.applyStyle(to: .items([card1]), background: .set("fern"), icon: .remove)
|
||||
|
||||
@@ -234,7 +234,7 @@ struct StyleWriteTests {
|
||||
store.applyStyle(to: .items([card2]), background: .set("shale"))
|
||||
|
||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
||||
#expect(after.contains("background: shale"))
|
||||
#expect(after.contains("background: {color: \"shale\"}"))
|
||||
#expect(!after.contains("[a, b]"))
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ struct StyleWriteTests {
|
||||
store.applyStyle(to: .board, background: .set("intense-cool-shale"), icon: .set("square.stack"))
|
||||
|
||||
let after = try fixture.indexText("")
|
||||
#expect(after.contains("background: intense-cool-shale"))
|
||||
#expect(after.contains("background: {color: \"intense-cool-shale\"}"))
|
||||
#expect(after.contains("icon: square.stack"))
|
||||
#expect(after.contains("iconColor: carnation"))
|
||||
#expect(after.contains("Board description."))
|
||||
@@ -258,6 +258,38 @@ struct StyleWriteTests {
|
||||
#expect(lane(lane1, in: model)?.background.isMissing == true)
|
||||
}
|
||||
|
||||
/// **The mapping form, through the real writer** (03-board-ui.md § Styling ▸ Capabilities; the
|
||||
/// unit-level claims are `BackgroundWriteTests`'). The app has a control for the colour and none
|
||||
/// for the image, so the whole gesture — well, then None — has to leave the image standing.
|
||||
@Test("A colour change on a board carrying an image preserves the image, and None drops only the colour")
|
||||
func preservesABackgroundImage() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", """
|
||||
---
|
||||
schema: 1
|
||||
title: Board
|
||||
background: {color: fern, image: art/sunset.jpg}
|
||||
---
|
||||
Board description.
|
||||
|
||||
""")
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.applyStyle(to: .board, background: .set("dark-teal"))
|
||||
|
||||
var model = try load(fixture)
|
||||
#expect(model.background == .valid("dark-teal"))
|
||||
#expect(model.backgroundImage == .valid("art/sunset.jpg"))
|
||||
#expect(try fixture.indexText("").contains("Board description."))
|
||||
|
||||
store.applyStyle(to: .board, background: .remove)
|
||||
|
||||
model = try load(fixture)
|
||||
#expect(model.background.isMissing)
|
||||
#expect(model.backgroundImage == .valid("art/sunset.jpg"), "the None well removes the colour, not the picture")
|
||||
}
|
||||
|
||||
@Test("Vanished and trashed targets are skipped silently")
|
||||
func skipsTargetsThatRenderNowhere() throws {
|
||||
let fixture = try makeBoard()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **The symbol picker's pure seams** (03-board-ui.md § Styling ▸ Controls, the general-purpose
|
||||
/// picker `SymbolPicker.swift` builds beside the style editor's own curated grid): the curated
|
||||
/// default set, the full-catalog loader and its cache, and the search filter's AND semantics.
|
||||
/// SwiftUI rendering — the well grid, the popover's arrow-key navigation — is deliberately untested,
|
||||
/// exactly as `StyleEditor.swift`'s own wells are.
|
||||
@Suite("SymbolPicker ▸ the curated default set")
|
||||
struct SymbolPickerCatalogDefaultSetTests {
|
||||
|
||||
@Test("Exactly 36 entries, all unique")
|
||||
func shape() {
|
||||
#expect(SymbolPickerCatalog.defaultSet.count == 36)
|
||||
#expect(Set(SymbolPickerCatalog.defaultSet).count == SymbolPickerCatalog.defaultSet.count)
|
||||
}
|
||||
|
||||
@Test("Every entry is one this system can actually draw — pins the list against typos")
|
||||
func everyNameResolves() {
|
||||
let missing = SymbolPickerCatalog.defaultSet.filter { !ItemSymbol.exists($0) }
|
||||
#expect(missing.isEmpty, "unknown SF Symbol names: \(missing)")
|
||||
#expect(SymbolPickerCatalog.available.count == SymbolPickerCatalog.defaultSet.count)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("SymbolPicker ▸ filter")
|
||||
struct SymbolPickerFilterTests {
|
||||
|
||||
@Test("An empty or whitespace-only query returns the input unchanged")
|
||||
func emptyQueryIsANoOp() {
|
||||
let symbols = ["star", "flag", "heart"]
|
||||
#expect(SymbolPickerCatalog.filter("", in: symbols) == symbols)
|
||||
#expect(SymbolPickerCatalog.filter(" ", in: symbols) == symbols)
|
||||
#expect(SymbolPickerCatalog.filter("\t\n", in: symbols) == symbols)
|
||||
}
|
||||
|
||||
@Test("A single token matches case-insensitively, as a substring")
|
||||
func singleTokenSubstring() {
|
||||
let symbols = ["star", "star.fill", "flag", "flag.checkered"]
|
||||
#expect(SymbolPickerCatalog.filter("star", in: symbols) == ["star", "star.fill"])
|
||||
#expect(SymbolPickerCatalog.filter("STAR", in: symbols) == ["star", "star.fill"])
|
||||
#expect(SymbolPickerCatalog.filter("Fla", in: symbols) == ["flag", "flag.checkered"])
|
||||
}
|
||||
|
||||
@Test("Multiple tokens are an AND — every token must appear somewhere in the name")
|
||||
func multiTokenIsAnAnd() {
|
||||
let symbols = ["wrench.and.screwdriver", "screwdriver", "wrench"]
|
||||
#expect(SymbolPickerCatalog.filter("wrench screw", in: symbols) == ["wrench.and.screwdriver"])
|
||||
#expect(SymbolPickerCatalog.filter("screw wrench", in: symbols) == ["wrench.and.screwdriver"])
|
||||
}
|
||||
|
||||
@Test("Input order is preserved")
|
||||
func orderPreserved() {
|
||||
let symbols = ["zebra.star", "apple.star", "mango.star"]
|
||||
#expect(SymbolPickerCatalog.filter("star", in: symbols) == symbols)
|
||||
}
|
||||
|
||||
@Test("No match returns an empty array")
|
||||
func noMatchIsEmpty() {
|
||||
#expect(SymbolPickerCatalog.filter("xyzzy-nonexistent", in: ["star", "flag"]).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("SymbolPicker ▸ the full catalog")
|
||||
struct SymbolPickerFullCatalogTests {
|
||||
|
||||
@Test("The default path returns a sorted, unique, non-empty list containing well-known names")
|
||||
func defaultPathLoads() {
|
||||
let catalog = SymbolPickerCatalog.fullCatalog()
|
||||
#expect(!catalog.isEmpty)
|
||||
#expect(catalog == catalog.sorted())
|
||||
#expect(Set(catalog).count == catalog.count)
|
||||
#expect(catalog.contains("star"))
|
||||
#expect(catalog.contains("folder"))
|
||||
}
|
||||
|
||||
@Test("Two calls against the default path return identical results — the cache is coherent")
|
||||
func cacheIsCoherent() {
|
||||
#expect(SymbolPickerCatalog.fullCatalog() == SymbolPickerCatalog.fullCatalog())
|
||||
}
|
||||
|
||||
@Test("A nonexistent bundle path falls back to the merged curated set, sorted and unique")
|
||||
func nonexistentPathFallsBack() {
|
||||
let expected = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.all).sorted()
|
||||
#expect(SymbolPickerCatalog.fullCatalog(bundlePath: "/nonexistent") == expected)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ schema: 1
|
||||
title: Styled
|
||||
order: 3072
|
||||
project: lanework # agent overlay
|
||||
background: blue
|
||||
background: {color: blue}
|
||||
icon: star
|
||||
created: 2026-01-01T09:00:00Z
|
||||
---
|
||||
@@ -991,7 +991,7 @@ private enum Foreign {
|
||||
|
||||
static func restyle(_ fixture: WriterFixture, _ path: String, background: String) throws {
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.background, to: .string(background))
|
||||
document.setStyleValue(background, for: FrontmatterKeys.background)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -262,13 +262,15 @@ struct IncreaseContrastTests {
|
||||
@Suite("Accommodations ▸ Reduce Transparency")
|
||||
struct ReduceTransparencyTests {
|
||||
|
||||
/// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board's one
|
||||
/// surviving material is the transient search bar's `.bar` — the design's own example, the card
|
||||
/// face carousel's page dots, died with the carousel (03-board-ui.md § Card face).
|
||||
@Test("The one glass underlay goes solid")
|
||||
/// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board carries
|
||||
/// two materials — the transient search bar's `.bar` and the backdrop's title-bar frost — and
|
||||
/// the rule is one rule: both take the same solid, whatever their weights without it.
|
||||
@Test("Both glass underlays go solid")
|
||||
func glassGoesSolid() {
|
||||
#expect(Accommodations.underlay(reduceTransparency: false) == .glass)
|
||||
#expect(Accommodations.underlay(reduceTransparency: true) == .solid)
|
||||
#expect(Accommodations.frost(reduceTransparency: false) == .frost)
|
||||
#expect(Accommodations.frost(reduceTransparency: true) == .solid)
|
||||
}
|
||||
|
||||
/// The washes are not glass — they composite at an alpha rather than sampling a backdrop — but
|
||||
|
||||
@@ -146,7 +146,7 @@ struct WriteFidelityMinimalTouchTests {
|
||||
try step("style write", targeting: ["\(Ident.lane1)/\(Ident.card2)"]) {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card2)"), operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.background, to: .string("blue")) }
|
||||
) { $0.setStyleValue("blue", for: FrontmatterKeys.background) }
|
||||
}
|
||||
try step("rename", targeting: ["\(Ident.lane2)/\(Ident.card3)"]) {
|
||||
try BoardWriter.updateIndex(
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user