Compare commits
17
Commits
b1ea97c03e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aefaf23ce | ||
|
|
b0ffff1aa1 | ||
|
|
c686242a14 | ||
|
|
caaa0c0776 | ||
|
|
ccf55f1d49 | ||
|
|
38ff520ae9 | ||
|
|
42fb10aa65 | ||
|
|
43f87a538b | ||
|
|
1e21d8cd60 | ||
|
|
51cf994cb9 | ||
|
|
f126614b56 | ||
|
|
eef0a4539f | ||
|
|
cfee4a4b41 | ||
|
|
d5ad21c3da | ||
|
|
190a8e36f1 | ||
|
|
9766e1f61c | ||
|
|
73698cd77b |
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: opus48
|
||||||
|
description: General-purpose implementation agent pinned to Claude Opus 4.8 (capacity fallback when Opus 5 is overloaded). Use for complex coding tasks dispatched from the main session.
|
||||||
|
model: claude-opus-4-8
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a senior software engineer implementing well-specified tasks in this repository. Follow the task brief you are given exactly: read the referenced design docs and existing code patterns before writing, match surrounding idiom, keep pure logic in testable seams, and verify with the build/test commands specified in the brief. Report results as raw data in your final message per the brief's report format.
|
||||||
@@ -12,3 +12,7 @@ xcuserdata/
|
|||||||
|
|
||||||
# App Store credentials (appstore-publish skill)
|
# App Store credentials (appstore-publish skill)
|
||||||
.env.release
|
.env.release
|
||||||
|
|
||||||
|
# Claude Code — personal permission grants stay local
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
**August 2026**
|
||||||
|
|
||||||
|
The app's appearance can now be set to Light, Dark, or Auto from the View menu or the toolbar's new Appearance item.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Clicking a card or lane now selects it immediately, instead of pausing for about half a second.
|
||||||
|
|
||||||
**July 2026**
|
**July 2026**
|
||||||
|
|
||||||
Version 2.0: Lanework's first release — a kanban app whose boards are ordinary folders of Markdown files on your Mac.
|
Version 2.0: Lanework's first release — a kanban app whose boards are ordinary folders of Markdown files on your Mac.
|
||||||
@@ -73,3 +83,5 @@ On git boards, everything done in a card window becomes a single history entry w
|
|||||||
Switch or create branches from the board popover, with an explicit save-or-discard step protecting unsaved card edits.
|
Switch or create branches from the board popover, with an explicit save-or-discard step protecting unsaved card edits.
|
||||||
|
|
||||||
The name and email on a board's history entries are editable in the board popover and stored in the board's own repository.
|
The name and email on a board's history entries are editable in the board popover and stored in the board's own repository.
|
||||||
|
|
||||||
|
Zoom the board in and out from the View menu (⌘+ and ⌘−, ⌘0 for actual size), and the size you settle on is remembered across launches.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ MyBoard.kanban/ ← board = the document
|
|||||||
| `created` | ISO-8601 | no | Set at creation, with timezone |
|
| `created` | ISO-8601 | no | Set at creation, with timezone |
|
||||||
| `modified` | ISO-8601 | no | Updated on every app write **that rewrites this `index.md`** — see below |
|
| `modified` | ISO-8601 | no | Updated on every app write **that rewrites this `index.md`** — see below |
|
||||||
| `modified-by` | string | no | Self-reported writer identity, set by external writers only; the app clears it on every write — see below |
|
| `modified-by` | string | no | Self-reported writer identity, set by external writers only; the app clears it on every write — see below |
|
||||||
| `background` | string | no | Palette name or `#RRGGBB[AA]` — see 03-board-ui.md |
|
| `background` | mapping | no | `{color: …, image: …}` — `color` is a palette name or `#RRGGBB[AA]` hex (03-board-ui.md), `image` a file path. Both subkeys optional, either half may stand alone, unknown subkeys preserved verbatim like any unknown key. `image` is a path **relative to the board root** (absolute paths and any path escaping the root resolve to nothing) and is **board-level only**: lanes and cards read `color` and ignore the rest. **A mapping is the only shape** (ruled 2026-08-06, before anything shipped — so no legacy spelling, no version bump, no migration): a bare scalar `background: green` has no reading, rendering as no color, coerce-tier logged, bytes preserved — the standard lenient degrade. |
|
||||||
| `icon` | string | no | SF Symbol name, per-level defaults |
|
| `icon` | string | no | SF Symbol name, per-level defaults |
|
||||||
| `iconColor` | string | no | Palette name or hex |
|
| `iconColor` | string | no | Palette name or hex |
|
||||||
| `kind` | string | no | The object's kind — `board`, `lane`, `card` (`comment` — its storage schema now specified, Enhanced schema below). **Written at creation of every object** (re-ruled 2026-07-29 — consistency across the schema, even where position already answers). The value — never the key's mere presence — names the kind, and consumers that consult it trust the value outright: no stripping, no corroboration machinery. Only consequential inside `.trash/` today, where position can't answer (Deletion below); everywhere else it is redundant with position (level is position) and carried for uniformity. Missing on an older object, it **backfills on touch** — the integrity service's on-touch heal (Validation and healing below), never a scheduled sweep. |
|
| `kind` | string | no | The object's kind — `board`, `lane`, `card` (`comment` — its storage schema now specified, Enhanced schema below). **Written at creation of every object** (re-ruled 2026-07-29 — consistency across the schema, even where position already answers). The value — never the key's mere presence — names the kind, and consumers that consult it trust the value outright: no stripping, no corroboration machinery. Only consequential inside `.trash/` today, where position can't answer (Deletion below); everywhere else it is redundant with position (level is position) and carried for uniformity. Missing on an older object, it **backfills on touch** — the integrity service's on-touch heal (Validation and healing below), never a scheduled sweep. |
|
||||||
|
|||||||
@@ -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.
|
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 and Appearance** — both **centered** (ratified 2026-08-06 off the 2026-08-01 live try-out, reversing the earlier trailing ruling; Appearance joined the default set 2026-08-07 — 11-command-nexus.md ▸ View ▸ Appearance): the titlebar reads as a placement grammar — leading is board identity (the title widget), center is view controls (search and the app's Auto/Light/Dark override 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, unlike Appearance below — the titlebar's default is the search field plus Appearance, nothing more; 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), Appearance (a pull-down of Auto/Light/Dark — 11-command-nexus.md ▸ View ▸ Appearance; the one catalog command that also ships as a default item, centered beside search rather than reached through Customize). 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).
|
- **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
|
## Lane
|
||||||
@@ -33,7 +33,7 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
|
|||||||
|
|
||||||
### Capabilities (settled)
|
### Capabilities (settled)
|
||||||
|
|
||||||
- **`background`** on board / lane / card: palette name (kebab-case, hand-editable) or `#RRGGBB[AA]` hex. Board color paints the board window's content background (the surface behind and between lanes). Lane and card color are **edge accents, not fills** (settled in the pathfinder's treatment shootout — its settings matrix of C-series lane / K-series card variants landed on **C7 · full-column top edge** and **K1 · left edge stripe**): a lane's color paints a full-width band along its top edge, a card's a stripe along its left edge; the surfaces themselves keep the standard chrome, so colored title text never sits on a colored fill.
|
- **`background`** on board / lane / card: a mapping, `{color: …, image: …}`, and only a mapping (settled 2026-08-06 — 01-storage-format.md § Frontmatter; a bare scalar has no reading and paints nothing). `color` is a palette name (kebab-case, hand-editable) or a `#RRGGBB[AA]` hex; either subkey may stand alone. Board color paints the board window's content background (the surface behind and between lanes). **A board's background may also carry an image**: the `image` path is relative to the board root so the picture travels with the document. Color and image both paint the **full window** — the content runs under a transparent title bar, with a frosted strip across the title-bar/toolbar band keeping the chrome legible over them; the extended chrome applies only while the board has a background of its own, and a board without one keeps the standard chrome unchanged. The image draws over the color, scaled to fill and cropped, with the color standing in while it loads or if it can't be read; an unresolvable path paints nothing, the lenient degrade an unrecognized color already gets. **There is no in-app control for the image** — the raw file is the escape hatch (the stance custom hex held until the 2026-08-06 combo reversal; for images it stands) — and **the board-level ink rule still derives from the color reading alone**: an image makes no AA claim (10-accessibility.md), since a picture has no single luminance to threshold against. Lane and card color are **edge accents, not fills** (settled in the pathfinder's treatment shootout — its settings matrix of C-series lane / K-series card variants landed on **C7 · full-column top edge** and **K1 · left edge stripe**): a lane's color paints a full-width band along its top edge, a card's a stripe along its left edge; the surfaces themselves keep the standard chrome, so colored title text never sits on a colored fill.
|
||||||
- **The standard chrome is the pathfinder's surface stack** (settled 2026-08-06): the window keeps the neutral system background; every lane wears a quiet quaternary-wash plate (the trash plate's own figure — translucent, so a board-chosen color shows through and the board-level ink rule keeps its premise; opaque under Reduce Transparency, the trash precedent); every card sits on an opaque `controlBackgroundColor` plate — white over the washed lane in light appearance, a step *darker* than the window in dark. One plate value for every face a card draws (resting, replicas, placeholder, arriving), so a card is the same object wherever it renders.
|
- **The standard chrome is the pathfinder's surface stack** (settled 2026-08-06): the window keeps the neutral system background; every lane wears a quiet quaternary-wash plate (the trash plate's own figure — translucent, so a board-chosen color shows through and the board-level ink rule keeps its premise; opaque under Reduce Transparency, the trash precedent); every card sits on an opaque `controlBackgroundColor` plate — white over the washed lane in light appearance, a step *darker* than the window in dark. One plate value for every face a card draws (resting, replicas, placeholder, arriving), so a card is the same object wherever it renders.
|
||||||
- **`icon`**: SF Symbol per item with per-level defaults (board `rectangle.split.3x1`, lane `square.stack`, card `doc.text`).
|
- **`icon`**: SF Symbol per item with per-level defaults (board `rectangle.split.3x1`, lane `square.stack`, card `doc.text`).
|
||||||
- **`iconColor`**: resolved — **schema yes, control no**. The field renders when hand-written (tint palette name or hex); the app offers no control for it (Controls below).
|
- **`iconColor`**: resolved — **schema yes, control no**. The field renders when hand-written (tint palette name or hex); the app offers no control for it (Controls below).
|
||||||
@@ -41,10 +41,10 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
|
|||||||
|
|
||||||
### Controls (settled)
|
### 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-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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Stacked sections under small-caps headers, in this order; quiet rows, read-optim
|
|||||||
|
|
||||||
### Style
|
### 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
|
### 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -47,11 +47,17 @@ The single source of truth for **every command and action the app can perform**
|
|||||||
| Board | Pull / Push | — (no default) | Remote-backed boards only (07); disabled during 06's abnormal-state pause (the whole git surface holds) and on an unresolvable remote (07's one-time remote picker case); popover twins exist |
|
| Board | Pull / Push | — (no default) | Remote-backed boards only (07); disabled during 06's abnormal-state pause (the whole git surface holds) and on an unresolvable remote (07's one-time remote picker case); popover twins exist |
|
||||||
| Board | Board Settings… | — (no default) | Opens the board settings sheet (03 — the 2026-07-31 popover/sheet split); popover row twins it |
|
| Board | Board Settings… | — (no default) | Opens the board settings sheet (03 — the 2026-07-31 popover/sheet split); popover row twins it |
|
||||||
| View | Show Trash (checkmark toggle) | — (no default) | Board window — ⇧⌘T is deliberately left to the system's Show Tab Bar: window tabbing stays enabled (settled; see Standard macOS furniture), so the chord is the system's; assign one via the remapping mechanism if wanted (04 ▸ Configurable bindings) |
|
| View | Show Trash (checkmark toggle) | — (no default) | Board window — ⇧⌘T is deliberately left to the system's Show Tab Bar: window tabbing stays enabled (settled; see Standard macOS furniture), so the chord is the system's; assign one via the remapping mechanism if wanted (04 ▸ Configurable bindings) |
|
||||||
|
| View | Zoom In | ⌘+ | Board window; steps the board's zoom one rung up the ladder (03 ▸ Layout — zoom). Disabled at the top rung, and while a drag session is in flight (a drag freezes geometry the level feeds — the dragged run's heights and the resting-layout cache) |
|
||||||
|
| View | Zoom Out | ⌘− | Board window; the twin, one rung down. Disabled at the bottom rung and under the same guard |
|
||||||
|
| View | Actual Size | ⌘0 | Board window; returns to 100%, where the strip renders exactly what it rendered before zoom existed. Disabled when already there, and under the same guard. The level is app-wide and persisted across restarts (the Show Comments precedent) — a zoom is a viewing comfort, not a property of any one board |
|
||||||
| View | Edit Body (checkmark toggle) | ⌘E | Card window; disabled while Raw Source is active |
|
| View | Edit Body (checkmark toggle) | ⌘E | Card window; disabled while Raw Source is active |
|
||||||
| View | Show Comments (checkmark toggle) | — (no default) | Card window; app-wide, persisted across restarts (re-ruled 2026-07-29 — no content-derived auto-show; 05 ▸ The comments column) |
|
| View | Show Comments (checkmark toggle) | — (no default) | Card window; app-wide, persisted across restarts (re-ruled 2026-07-29 — no content-derived auto-show; 05 ▸ The comments column) |
|
||||||
| View | Comments Beside Body (checkmark toggle) | — (no default) | Card window; checked = side-by-side (default), unchecked = body over comments; app-wide, persisted (05 ▸ Composition) |
|
| View | Comments Beside Body (checkmark toggle) | — (no default) | Card window; checked = side-by-side (default), unchecked = body over comments; app-wide, persisted (05 ▸ Composition) |
|
||||||
| View | Raw Source (checkmark toggle; toggling off = Apply) | ⌥⌘E | Card window |
|
| View | Raw Source (checkmark toggle; toggling off = Apply) | ⌥⌘E | Card window |
|
||||||
| View | History | — (no default) | Card window; focuses the sidebar History section (05); git boards only — section absent, item disabled on mode none / repo-nested |
|
| View | History | — (no default) | Card window; focuses the sidebar History section (05); git boards only — section absent, item disabled on mode none / repo-nested |
|
||||||
|
| View | Appearance ▸ Auto | — (no default) | Everywhere (no board or card window needed); app-wide, persisted across restarts — follows the system appearance; radio-exclusive with Light/Dark, checkmark on the active one (03-board-ui.md ▸ Toolbar) |
|
||||||
|
| View | Appearance ▸ Light | — (no default) | Everywhere; app-wide, persisted across restarts; radio-exclusive with Auto/Dark |
|
||||||
|
| View | Appearance ▸ Dark | — (no default) | Everywhere; app-wide, persisted across restarts; radio-exclusive with Auto/Light |
|
||||||
| Window | Welcome to Lanework | — (no default) | Everywhere; shows (or focuses) the welcome window (02 ▸ Launch and window lifecycle) |
|
| Window | Welcome to Lanework | — (no default) | Everywhere; shows (or focuses) the welcome window (02 ▸ Launch and window lifecycle) |
|
||||||
|
|
||||||
## Fixed grammar keys (G)
|
## Fixed grammar keys (G)
|
||||||
|
|||||||
@@ -0,0 +1,755 @@
|
|||||||
|
<title>Board Background Swatches</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--paper:#FAFAF7;
|
||||||
|
--ink:#1D1C1A;
|
||||||
|
--ink-2:#6E6A63;
|
||||||
|
--line:#E4E1DA;
|
||||||
|
--chip:#F0EEE8;
|
||||||
|
--accent:#5B6E8C;
|
||||||
|
--paper-a85: rgba(250,250,247,.85);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark){
|
||||||
|
:root:not([data-theme="light"]){
|
||||||
|
--paper:#181715;
|
||||||
|
--ink:#ECEAE5;
|
||||||
|
--ink-2:#98938A;
|
||||||
|
--line:#2E2C28;
|
||||||
|
--chip:#242220;
|
||||||
|
--accent:#8FA3C0;
|
||||||
|
--paper-a85: rgba(24,23,21,.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"]{
|
||||||
|
--paper:#181715;
|
||||||
|
--ink:#ECEAE5;
|
||||||
|
--ink-2:#98938A;
|
||||||
|
--line:#2E2C28;
|
||||||
|
--chip:#242220;
|
||||||
|
--accent:#8FA3C0;
|
||||||
|
--paper-a85: rgba(24,23,21,.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after{ box-sizing:border-box; }
|
||||||
|
|
||||||
|
body{
|
||||||
|
margin:0;
|
||||||
|
background:var(--paper);
|
||||||
|
color:var(--ink);
|
||||||
|
font-family:-apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap{ max-width:1240px; margin:0 auto; padding:0 32px 64px; }
|
||||||
|
|
||||||
|
.page-head{ padding:32px 0 16px; }
|
||||||
|
.page-head h1{ font-size:22px; font-weight:600; margin:0 0 6px; }
|
||||||
|
.page-head .subtitle{ font-size:13px; color:var(--ink-2); margin:0; }
|
||||||
|
|
||||||
|
.controlbar{
|
||||||
|
position:sticky; top:0; z-index:10;
|
||||||
|
display:flex; align-items:center; gap:20px; row-gap:10px; flex-wrap:wrap;
|
||||||
|
padding:12px 0;
|
||||||
|
background:var(--paper-a85);
|
||||||
|
-webkit-backdrop-filter:blur(8px);
|
||||||
|
backdrop-filter:blur(8px);
|
||||||
|
border-bottom:1px solid var(--line);
|
||||||
|
margin-bottom:28px;
|
||||||
|
}
|
||||||
|
.chipgroup{ display:flex; gap:6px; flex-wrap:wrap; }
|
||||||
|
.chip{
|
||||||
|
font:inherit; font-size:12px; padding:5px 11px; border-radius:999px;
|
||||||
|
border:1px solid var(--line); background:var(--chip); color:var(--ink);
|
||||||
|
cursor:pointer; line-height:1.3;
|
||||||
|
}
|
||||||
|
.chip:hover{ border-color:var(--accent); }
|
||||||
|
.chip.active{ background:var(--accent); border-color:var(--accent); color:var(--paper); }
|
||||||
|
.chip:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
|
||||||
|
|
||||||
|
.toggle{ display:flex; align-items:center; gap:6px; font-size:12px; color:var(--ink); cursor:pointer; user-select:none; }
|
||||||
|
.toggle input{ width:14px; height:14px; accent-color:var(--accent); }
|
||||||
|
.toggle input:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
|
||||||
|
|
||||||
|
.section-label{ font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-2); margin:0 0 10px; }
|
||||||
|
|
||||||
|
.axes{ margin-bottom:40px; }
|
||||||
|
.axes-list{ display:grid; grid-template-columns:1fr 1fr; gap:7px 32px; margin:0; }
|
||||||
|
.axis{ display:flex; gap:6px; font-size:13px; line-height:1.5; }
|
||||||
|
.axis dt{ margin:0; font-weight:600; color:var(--ink-2); flex:0 0 auto; }
|
||||||
|
.axis dd{ margin:0; color:var(--ink-2); }
|
||||||
|
@media (max-width:720px){ .axes-list{ grid-template-columns:1fr; } }
|
||||||
|
|
||||||
|
.family{ margin-bottom:40px; }
|
||||||
|
.family h2{ font-size:15px; font-weight:600; margin:0 0 4px; }
|
||||||
|
.family .recipe{ font-size:13px; color:var(--ink-2); margin:0 0 14px; max-width:820px; }
|
||||||
|
|
||||||
|
.grid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(260px,1fr)); gap:14px; }
|
||||||
|
|
||||||
|
.card{ cursor:pointer; }
|
||||||
|
.frame{
|
||||||
|
aspect-ratio:16/10; border-radius:8px; border:1px solid var(--line);
|
||||||
|
overflow:hidden; background:var(--chip);
|
||||||
|
}
|
||||||
|
.frame svg, .lightbox-frame svg{ display:block; width:100%; height:100%; }
|
||||||
|
.caption{ display:flex; justify-content:space-between; align-items:baseline; margin-top:6px; font-size:11px; gap:8px; }
|
||||||
|
.caption .swatch-id{ font-family:ui-monospace,"SF Mono",Menlo,monospace; color:var(--ink); }
|
||||||
|
.caption .note{ color:var(--ink-2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
|
||||||
|
.board-overlay{ display:none; }
|
||||||
|
body.show-overlay .board-overlay{ display:inline; }
|
||||||
|
|
||||||
|
.lightbox{
|
||||||
|
position:fixed; inset:0; z-index:100;
|
||||||
|
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px;
|
||||||
|
background:rgba(0,0,0,.55);
|
||||||
|
opacity:0; pointer-events:none;
|
||||||
|
transition:opacity .12s ease;
|
||||||
|
}
|
||||||
|
.lightbox.open{ opacity:1; pointer-events:auto; }
|
||||||
|
.lightbox-frame{
|
||||||
|
width:min(92vw, 1100px); aspect-ratio:16/10; border-radius:10px; overflow:hidden;
|
||||||
|
box-shadow:0 24px 60px rgba(0,0,0,.45);
|
||||||
|
background:var(--chip);
|
||||||
|
}
|
||||||
|
.lightbox-id{ font-family:ui-monospace,"SF Mono",Menlo,monospace; font-size:12px; color:#fff; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce){
|
||||||
|
.lightbox{ transition:none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="wrap">
|
||||||
|
<header class="page-head">
|
||||||
|
<h1>Board Background Swatches</h1>
|
||||||
|
<p class="subtitle">96 procedural candidates — base layer × filler layer. Toggle the board overlay to judge them under content.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="controlbar">
|
||||||
|
<div class="chipgroup" id="family-chips" role="group" aria-label="Family filter">
|
||||||
|
<button type="button" class="chip active" data-family="all" aria-pressed="true">All</button>
|
||||||
|
<button type="button" class="chip" data-family="whisper" aria-pressed="false">Whisper</button>
|
||||||
|
<button type="button" class="chip" data-family="confetti" aria-pressed="false">Confetti</button>
|
||||||
|
<button type="button" class="chip" data-family="aurora" aria-pressed="false">Aurora</button>
|
||||||
|
<button type="button" class="chip" data-family="contours" aria-pressed="false">Contours</button>
|
||||||
|
<button type="button" class="chip" data-family="drift" aria-pressed="false">Drift</button>
|
||||||
|
<button type="button" class="chip" data-family="bubbles" aria-pressed="false">Bubbles</button>
|
||||||
|
<button type="button" class="chip" data-family="terrazzo" aria-pressed="false">Terrazzo</button>
|
||||||
|
<button type="button" class="chip" data-family="graph" aria-pressed="false">Graph</button>
|
||||||
|
<button type="button" class="chip" data-family="waves" aria-pressed="false">Waves</button>
|
||||||
|
<button type="button" class="chip" data-family="dusk" aria-pressed="false">Dusk</button>
|
||||||
|
<button type="button" class="chip" data-family="starfield" aria-pressed="false">Starfield</button>
|
||||||
|
<button type="button" class="chip" data-family="slate" aria-pressed="false">Slate</button>
|
||||||
|
</div>
|
||||||
|
<div class="chipgroup" id="tone-chips" role="group" aria-label="Tone filter">
|
||||||
|
<button type="button" class="chip active" data-tone="all" aria-pressed="true">All</button>
|
||||||
|
<button type="button" class="chip" data-tone="light" aria-pressed="false">Light</button>
|
||||||
|
<button type="button" class="chip" data-tone="dark" aria-pressed="false">Dark</button>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="overlay-toggle">
|
||||||
|
<span>Board overlay</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="axes">
|
||||||
|
<p class="section-label">Axes</p>
|
||||||
|
<dl class="axes-list">
|
||||||
|
<div class="axis"><dt>Base</dt><dd>solid · linear 2/3-stop (angle) · radial/corner glow · soft-blob mesh</dd></div>
|
||||||
|
<div class="axis"><dt>Hue strategy</dt><dd>mono · analogous · complementary accent · multicolor</dd></div>
|
||||||
|
<div class="axis"><dt>HSB family</dt><dd>pastel · muted · deep/dark</dd></div>
|
||||||
|
<div class="axis"><dt>Filler shape</dt><dd>dots · rings · blobs/chips · capsules · triangles · plus-signs · contour lines · sine bands · grid</dd></div>
|
||||||
|
<div class="axis"><dt>Density</dt><dd>sparse → dense</dd></div>
|
||||||
|
<div class="axis"><dt>Size</dt><dd>uniform vs power-law</dd></div>
|
||||||
|
<div class="axis"><dt>Placement</dt><dd>uniform scatter · diagonal band · corner-weighted · grid-jitter</dd></div>
|
||||||
|
<div class="axis"><dt>Filler color</dt><dd>same-hue tint · accent hue · multicolor · alpha-only</dd></div>
|
||||||
|
<div class="axis"><dt>Opacity</dt><dd>whisper 4–10% → visible 15–25%</dd></div>
|
||||||
|
<div class="axis"><dt>Depth</dt><dd>crisp vs gradient-soft, layered sizes</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="family" data-family="whisper" data-tone="light">
|
||||||
|
<h2>Whisper</h2>
|
||||||
|
<p class="recipe">A vertical pastel gradient ground carries roughly sixty faint dots at 6–10% opacity — the quietest family in the set.</p>
|
||||||
|
<div class="grid" id="grid-whisper"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="family" data-family="confetti" data-tone="light">
|
||||||
|
<h2>Confetti</h2>
|
||||||
|
<p class="recipe">A near-neutral ground is scattered with about seventy tiny circles, rotated squares and triangles across three related hues.</p>
|
||||||
|
<div class="grid" id="grid-confetti"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="family" data-family="aurora" data-tone="light">
|
||||||
|
<h2>Aurora</h2>
|
||||||
|
<p class="recipe">A flat pastel ground holds four to six large soft-edged gradient blobs that overlap and drift toward one corner, mesh-gradient style.</p>
|
||||||
|
<div class="grid" id="grid-aurora"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="family" data-family="contours" data-tone="light">
|
||||||
|
<h2>Contours</h2>
|
||||||
|
<p class="recipe">A flat ground is crossed by fourteen thin wavy horizontal lines built from summed sine harmonics, evoking topographic contours.</p>
|
||||||
|
<div class="grid" id="grid-contours"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="family" data-family="drift" data-tone="light">
|
||||||
|
<h2>Drift</h2>
|
||||||
|
<p class="recipe">A diagonal two-tone gradient ground carries rotated capsule shapes rejection-sampled into a diagonal band across the canvas.</p>
|
||||||
|
<div class="grid" id="grid-drift"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="family" data-family="bubbles" data-tone="light">
|
||||||
|
<h2>Bubbles</h2>
|
||||||
|
<p class="recipe">A top-left radial gradient ground carries a power-law scatter of circles — a few large, more medium, many small — mixing filled discs with stroked rings.</p>
|
||||||
|
<div class="grid" id="grid-bubbles"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="family" data-family="terrazzo" data-tone="light">
|
||||||
|
<h2>Terrazzo</h2>
|
||||||
|
<p class="recipe">A near-neutral ground is covered in about fifty-five small irregular polygon chips across four related colors, like stone terrazzo.</p>
|
||||||
|
<div class="grid" id="grid-terrazzo"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="family" data-family="graph" data-tone="light">
|
||||||
|
<h2>Graph</h2>
|
||||||
|
<p class="recipe">A pale ground is ruled into a 24px grid, with a few plus-mark intersections and small accent dots — graph paper.</p>
|
||||||
|
<div class="grid" id="grid-graph"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="family" data-family="waves" data-tone="light">
|
||||||
|
<h2>Waves</h2>
|
||||||
|
<p class="recipe">A horizontal gradient ground is layered with six translucent sine-wave bands, phase-shifted and stacked toward the bottom third like dunes.</p>
|
||||||
|
<div class="grid" id="grid-waves"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="family" data-family="dusk" data-tone="dark">
|
||||||
|
<h2>Dusk</h2>
|
||||||
|
<p class="recipe">A deep vertical gradient ground holds large soft gradient blobs and a faint horizon glow low in the frame — moody, no small shapes.</p>
|
||||||
|
<div class="grid" id="grid-dusk"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="family" data-family="starfield" data-tone="dark">
|
||||||
|
<h2>Starfield</h2>
|
||||||
|
<p class="recipe">A dark radial ground is scattered with about ninety tiny stars, a handful haloed and brighter, plus two faint nebula blobs.</p>
|
||||||
|
<div class="grid" id="grid-starfield"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="family" data-family="slate" data-tone="dark">
|
||||||
|
<h2>Slate</h2>
|
||||||
|
<p class="recipe">A flat dark ground carries the same wavy contour lines as Contours, with small accent dots resting on the lines — dark topographic.</p>
|
||||||
|
<div class="grid" id="grid-slate"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lightbox" id="lightbox">
|
||||||
|
<div class="lightbox-frame" id="lightbox-frame"></div>
|
||||||
|
<div class="lightbox-id" id="lightbox-id"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ---------- seeded PRNG ----------
|
||||||
|
function xmur3(str){
|
||||||
|
let h = 1779033703 ^ str.length;
|
||||||
|
for(let i=0;i<str.length;i++){
|
||||||
|
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
|
||||||
|
h = (h << 13) | (h >>> 19);
|
||||||
|
}
|
||||||
|
return function(){
|
||||||
|
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
||||||
|
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
||||||
|
h ^= h >>> 16;
|
||||||
|
return h >>> 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function mulberry32(a){
|
||||||
|
return function(){
|
||||||
|
let t = (a += 0x6D2B79F5);
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function seededRng(id){
|
||||||
|
const seed = xmur3(id)();
|
||||||
|
return mulberry32(seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- small helpers ----------
|
||||||
|
function mod360(h){ return ((h % 360) + 360) % 360; }
|
||||||
|
function hsl(h,s,l){ return "hsl(" + mod360(h) + "," + s + "%," + l + "%)"; }
|
||||||
|
function rnd(rng,min,max){ return min + rng() * (max - min); }
|
||||||
|
function rndInt(rng,min,max){ return Math.floor(rnd(rng,min,max+1)); }
|
||||||
|
function pick(rng,arr){ return arr[Math.floor(rng()*arr.length)]; }
|
||||||
|
function pad2(n){ return String(n).padStart(2,"0"); }
|
||||||
|
function f1(n){ return n.toFixed(1); }
|
||||||
|
|
||||||
|
function linGrad(id,x1,y1,x2,y2,stops){
|
||||||
|
let s = "";
|
||||||
|
for(let i=0;i<stops.length;i++){
|
||||||
|
s += '<stop offset="' + stops[i][0] + '" stop-color="' + stops[i][1] + '"/>';
|
||||||
|
}
|
||||||
|
return '<linearGradient id="' + id + '" x1="' + x1 + '" y1="' + y1 + '" x2="' + x2 + '" y2="' + y2 + '">' + s + '</linearGradient>';
|
||||||
|
}
|
||||||
|
function radGrad(id,cx,cy,r,stops){
|
||||||
|
let s = "";
|
||||||
|
for(let i=0;i<stops.length;i++){
|
||||||
|
const st = stops[i];
|
||||||
|
const op = st.length > 2 ? st[2] : 1;
|
||||||
|
s += '<stop offset="' + st[0] + '" stop-color="' + st[1] + '" stop-opacity="' + op + '"/>';
|
||||||
|
}
|
||||||
|
return '<radialGradient id="' + id + '" cx="' + cx + '" cy="' + cy + '" r="' + r + '">' + s + '</radialGradient>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function triPoints(cx,cy,size,rotDeg){
|
||||||
|
const r = size * 0.7;
|
||||||
|
const rot = rotDeg * Math.PI / 180;
|
||||||
|
const pts = [];
|
||||||
|
for(let k=0;k<3;k++){
|
||||||
|
const a = rot + k*(Math.PI*2/3) - Math.PI/2;
|
||||||
|
pts.push(f1(cx + r*Math.cos(a)) + "," + f1(cy + r*Math.sin(a)));
|
||||||
|
}
|
||||||
|
return pts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// shared wavy-contour-line builder, used by "contours" and "slate"
|
||||||
|
function contourLines(rng,count,spacing,startY,stroke,opacity,widthAttr){
|
||||||
|
let out = "";
|
||||||
|
const allPoints = [];
|
||||||
|
for(let i=0;i<count;i++){
|
||||||
|
const baseY = startY + i*spacing;
|
||||||
|
const harmonics = rndInt(rng,2,3);
|
||||||
|
const comps = [];
|
||||||
|
let wsum = 0;
|
||||||
|
for(let k=0;k<harmonics;k++){
|
||||||
|
const c = { freq: rnd(rng,1,3), phase: rnd(rng,0,Math.PI*2), weight: rnd(rng,0.3,1) };
|
||||||
|
comps.push(c);
|
||||||
|
wsum += c.weight;
|
||||||
|
}
|
||||||
|
const amp = rnd(rng,6,14);
|
||||||
|
const pts = [];
|
||||||
|
for(let x=0;x<=480;x+=8){
|
||||||
|
let s = 0;
|
||||||
|
for(let k=0;k<comps.length;k++){
|
||||||
|
const c = comps[k];
|
||||||
|
s += c.weight * Math.sin(c.freq*(x/480)*Math.PI*2 + c.phase);
|
||||||
|
}
|
||||||
|
const y = baseY + amp*(s/wsum);
|
||||||
|
pts.push([x,y]);
|
||||||
|
}
|
||||||
|
allPoints.push(pts);
|
||||||
|
let d = "";
|
||||||
|
for(let p=0;p<pts.length;p++){
|
||||||
|
d += (p===0 ? "M" : "L") + f1(pts[p][0]) + "," + f1(pts[p][1]) + " ";
|
||||||
|
}
|
||||||
|
out += '<path d="' + d + '" stroke="' + stroke + '" stroke-width="' + widthAttr + '" fill="none" opacity="' + opacity + '"/>';
|
||||||
|
}
|
||||||
|
return { svg: out, lines: allPoints };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- board overlay (shared across all families) ----------
|
||||||
|
function buildOverlay(rng, tone){
|
||||||
|
const laneFill = tone === "dark" ? "rgba(20,20,24,0.40)" : "rgba(255,255,255,0.38)";
|
||||||
|
const cardFill = tone === "dark" ? "#26262B" : "#FFFFFF";
|
||||||
|
const cardStroke = tone === "dark" ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.08)";
|
||||||
|
const titleFill = tone === "dark" ? "rgba(22,22,26,0.55)" : "rgba(255,255,255,0.55)";
|
||||||
|
let g = '<g class="board-overlay">';
|
||||||
|
g += '<rect x="0" y="0" width="480" height="34" fill="' + titleFill + '"/>';
|
||||||
|
const laneX = [16,168,320];
|
||||||
|
for(let li=0; li<laneX.length; li++){
|
||||||
|
const lx = laneX[li];
|
||||||
|
g += '<rect x="' + lx + '" y="40" width="144" height="250" rx="8" fill="' + laneFill + '"/>';
|
||||||
|
let y = 48;
|
||||||
|
const count = rndInt(rng,2,4);
|
||||||
|
for(let c=0;c<count;c++){
|
||||||
|
const h = rnd(rng,26,44);
|
||||||
|
if(y + h > 282) break;
|
||||||
|
g += '<rect x="' + (lx+8) + '" y="' + f1(y) + '" width="128" height="' + f1(h) + '" rx="5" fill="' + cardFill + '" stroke="' + cardStroke + '"/>';
|
||||||
|
y += h + 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g += "</g>";
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 12 families ----------
|
||||||
|
function famWhisper(rng,H,gid){
|
||||||
|
const baseId = gid + "-base";
|
||||||
|
let s = "<defs>" + linGrad(baseId,"0","0","0","1",[["0%",hsl(H,45,94)],["100%",hsl(H,40,88)]]) + "</defs>";
|
||||||
|
s += '<rect width="480" height="300" fill="url(#' + baseId + ')"/>';
|
||||||
|
let filler = "";
|
||||||
|
for(let i=0;i<60;i++){
|
||||||
|
const cx = rnd(rng,0,480), cy = rnd(rng,0,300), r = rnd(rng,2,5), op = rnd(rng,0.06,0.10);
|
||||||
|
filler += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="' + hsl(H,50,55) + '" opacity="' + op.toFixed(2) + '"/>';
|
||||||
|
}
|
||||||
|
return s + filler;
|
||||||
|
}
|
||||||
|
|
||||||
|
function famConfetti(rng,H,gid){
|
||||||
|
let s = '<rect width="480" height="300" fill="' + hsl(H,12,95) + '"/>';
|
||||||
|
const hues = [H, H+120, H+240];
|
||||||
|
let filler = "";
|
||||||
|
for(let i=0;i<70;i++){
|
||||||
|
const type = pick(rng,["circle","square","triangle"]);
|
||||||
|
const hue = pick(rng,hues);
|
||||||
|
const color = hsl(hue,55,55);
|
||||||
|
const size = rnd(rng,3,8);
|
||||||
|
const x = rnd(rng,0,480), y = rnd(rng,0,300);
|
||||||
|
const op = rnd(rng,0.10,0.16).toFixed(2);
|
||||||
|
if(type === "circle"){
|
||||||
|
filler += '<circle cx="' + f1(x) + '" cy="' + f1(y) + '" r="' + f1(size/2) + '" fill="' + color + '" opacity="' + op + '"/>';
|
||||||
|
} else if(type === "square"){
|
||||||
|
const rot = rnd(rng,0,360).toFixed(1);
|
||||||
|
filler += '<rect x="' + f1(x-size/2) + '" y="' + f1(y-size/2) + '" width="' + f1(size) + '" height="' + f1(size) + '" fill="' + color + '" opacity="' + op + '" transform="rotate(' + rot + ' ' + f1(x) + ' ' + f1(y) + ')"/>';
|
||||||
|
} else {
|
||||||
|
const rot = rnd(rng,0,360);
|
||||||
|
filler += '<polygon points="' + triPoints(x,y,size,rot) + '" fill="' + color + '" opacity="' + op + '"/>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s + filler;
|
||||||
|
}
|
||||||
|
|
||||||
|
function famAurora(rng,H,gid){
|
||||||
|
const corners = [[0,0],[480,0],[0,300],[480,300]];
|
||||||
|
const corner = pick(rng,corners);
|
||||||
|
const n = rndInt(rng,4,6);
|
||||||
|
let defs = "", circles = "";
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
const r = rnd(rng,90,180);
|
||||||
|
const rx = rnd(rng,0,480), ry = rnd(rng,0,300);
|
||||||
|
const t = rnd(rng,0.3,0.7);
|
||||||
|
const cx = rx + (corner[0]-rx)*t;
|
||||||
|
const cy = ry + (corner[1]-ry)*t;
|
||||||
|
const offset = pick(rng,[-25,25]);
|
||||||
|
const gidL = gid + "-aur-" + i;
|
||||||
|
defs += radGrad(gidL,"50%","50%","50%",[["0%",hsl(H+offset,55,80),0.55],["100%",hsl(H+offset,55,80),0]]);
|
||||||
|
circles += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="url(#' + gidL + ')"/>';
|
||||||
|
}
|
||||||
|
let s = "<defs>" + defs + "</defs>";
|
||||||
|
s += '<rect width="480" height="300" fill="' + hsl(H,35,93) + '"/>';
|
||||||
|
return s + circles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function famContours(rng,H,gid){
|
||||||
|
let s = '<rect width="480" height="300" fill="' + hsl(H,30,92) + '"/>';
|
||||||
|
const res = contourLines(rng,14,22,6,hsl(H,45,60),0.14,1.2);
|
||||||
|
return s + res.svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function famDrift(rng,H,gid){
|
||||||
|
const baseId = gid + "-base";
|
||||||
|
let s = "<defs>" + linGrad(baseId,"0","0","1","1",[["0%",hsl(H,40,93)],["100%",hsl(H+30,40,89)]]) + "</defs>";
|
||||||
|
s += '<rect width="480" height="300" fill="url(#' + baseId + ')"/>';
|
||||||
|
const a = 300, b = -480;
|
||||||
|
const denom = Math.sqrt(a*a + b*b);
|
||||||
|
let count = 0, tries = 0, filler = "";
|
||||||
|
while(count < 40 && tries < 2000){
|
||||||
|
tries++;
|
||||||
|
const x = rnd(rng,0,480), y = rnd(rng,0,300);
|
||||||
|
const dist = Math.abs(a*x + b*y) / denom;
|
||||||
|
if(dist < 90){
|
||||||
|
const len = rnd(rng,20,70), thick = rnd(rng,4,7);
|
||||||
|
const op = rnd(rng,0.08,0.14).toFixed(2);
|
||||||
|
filler += '<rect x="' + f1(-len/2) + '" y="' + f1(-thick/2) + '" width="' + f1(len) + '" height="' + f1(thick) + '" rx="' + f1(thick/2) + '" fill="' + hsl(H,45,60) + '" opacity="' + op + '" transform="translate(' + f1(x) + ' ' + f1(y) + ') rotate(-32)"/>';
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s + filler;
|
||||||
|
}
|
||||||
|
|
||||||
|
function famBubbles(rng,H,gid){
|
||||||
|
const baseId = gid + "-base";
|
||||||
|
let s = "<defs>" + radGrad(baseId,"0%","0%","100%",[["0%",hsl(H,45,95)],["100%",hsl(H,40,88)]]) + "</defs>";
|
||||||
|
s += '<rect width="480" height="300" fill="url(#' + baseId + ')"/>';
|
||||||
|
const groups = [[5,40,80,0.05],[15,12,30,0.08],[40,2,8,0.12]];
|
||||||
|
let filler = "";
|
||||||
|
for(let gi=0; gi<groups.length; gi++){
|
||||||
|
const n = groups[gi][0], rmin = groups[gi][1], rmax = groups[gi][2], op = groups[gi][3];
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
const cx = rnd(rng,0,480), cy = rnd(rng,0,300), r = rnd(rng,rmin,rmax);
|
||||||
|
const ring = rng() < 0.4;
|
||||||
|
if(ring){
|
||||||
|
filler += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="none" stroke="' + hsl(H,50,55) + '" stroke-width="1.5" opacity="' + op + '"/>';
|
||||||
|
} else {
|
||||||
|
filler += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="' + hsl(H,50,55) + '" opacity="' + op + '"/>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s + filler;
|
||||||
|
}
|
||||||
|
|
||||||
|
function famTerrazzo(rng,H,gid){
|
||||||
|
let s = '<rect width="480" height="300" fill="' + hsl(H,10,94) + '"/>';
|
||||||
|
const colors = [hsl(H,45,60), hsl(H+40,40,55), hsl(H-40,40,50), hsl(40,8,55)];
|
||||||
|
let filler = "";
|
||||||
|
for(let i=0;i<55;i++){
|
||||||
|
const cx = rnd(rng,0,480), cy = rnd(rng,0,300);
|
||||||
|
const sides = rndInt(rng,5,7);
|
||||||
|
const rot = rnd(rng,0,Math.PI*2);
|
||||||
|
const color = pick(rng,colors);
|
||||||
|
const pts = [];
|
||||||
|
for(let k=0;k<sides;k++){
|
||||||
|
const ang = rot + k*(Math.PI*2/sides);
|
||||||
|
const r = rnd(rng,4,14);
|
||||||
|
pts.push(f1(cx + r*Math.cos(ang)) + "," + f1(cy + r*Math.sin(ang)));
|
||||||
|
}
|
||||||
|
filler += '<polygon points="' + pts.join(" ") + '" fill="' + color + '" opacity="0.18"/>';
|
||||||
|
}
|
||||||
|
return s + filler;
|
||||||
|
}
|
||||||
|
|
||||||
|
function famGraph(rng,H,gid){
|
||||||
|
let s = '<rect width="480" height="300" fill="' + hsl(H,25,96) + '"/>';
|
||||||
|
const pitch = 24;
|
||||||
|
const cols = Math.floor(480/pitch);
|
||||||
|
const rows = Math.floor(300/pitch);
|
||||||
|
let grid = "";
|
||||||
|
for(let c=0;c<=cols;c++){
|
||||||
|
const x = c*pitch;
|
||||||
|
grid += '<line x1="' + x + '" y1="0" x2="' + x + '" y2="300" stroke="' + hsl(H,35,65) + '" stroke-width="1" opacity="0.10"/>';
|
||||||
|
}
|
||||||
|
for(let r=0;r<=rows;r++){
|
||||||
|
const y = r*pitch;
|
||||||
|
grid += '<line x1="0" y1="' + y + '" x2="480" y2="' + y + '" stroke="' + hsl(H,35,65) + '" stroke-width="1" opacity="0.10"/>';
|
||||||
|
}
|
||||||
|
let plus = "";
|
||||||
|
for(let i=0;i<12;i++){
|
||||||
|
const gx = rndInt(rng,0,cols)*pitch, gy = rndInt(rng,0,rows)*pitch;
|
||||||
|
plus += '<line x1="' + (gx-4) + '" y1="' + gy + '" x2="' + (gx+4) + '" y2="' + gy + '" stroke="' + hsl(H,35,65) + '" stroke-width="1" opacity="0.25"/>';
|
||||||
|
plus += '<line x1="' + gx + '" y1="' + (gy-4) + '" x2="' + gx + '" y2="' + (gy+4) + '" stroke="' + hsl(H,35,65) + '" stroke-width="1" opacity="0.25"/>';
|
||||||
|
}
|
||||||
|
let dots = "";
|
||||||
|
const nd = rndInt(rng,3,4);
|
||||||
|
for(let i=0;i<nd;i++){
|
||||||
|
const gx = rndInt(rng,0,cols)*pitch, gy = rndInt(rng,0,rows)*pitch;
|
||||||
|
dots += '<circle cx="' + gx + '" cy="' + gy + '" r="3" fill="' + hsl(H,55,55) + '" opacity="0.35"/>';
|
||||||
|
}
|
||||||
|
return s + grid + plus + dots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function famWaves(rng,H,gid){
|
||||||
|
const baseId = gid + "-base";
|
||||||
|
let s = "<defs>" + linGrad(baseId,"0","0","1","0",[["0%",hsl(H,38,94)],["100%",hsl(H,34,89)]]) + "</defs>";
|
||||||
|
s += '<rect width="480" height="300" fill="url(#' + baseId + ')"/>';
|
||||||
|
let filler = "";
|
||||||
|
for(let i=0;i<6;i++){
|
||||||
|
const baseline = 190 + i*18;
|
||||||
|
const amp = rnd(rng,10,24);
|
||||||
|
const freq = rnd(rng,1,2);
|
||||||
|
const phase = rnd(rng,0,Math.PI*2);
|
||||||
|
let d = "M0,300 ";
|
||||||
|
for(let x=0;x<=480;x+=12){
|
||||||
|
const y = baseline + amp*Math.sin(freq*(x/480)*Math.PI*2 + phase);
|
||||||
|
d += "L" + x + "," + f1(y) + " ";
|
||||||
|
}
|
||||||
|
d += "L480,300 Z";
|
||||||
|
filler += '<path d="' + d + '" fill="' + hsl(H,40,80) + '" opacity="0.10"/>';
|
||||||
|
}
|
||||||
|
return s + filler;
|
||||||
|
}
|
||||||
|
|
||||||
|
function famDusk(rng,H,gid){
|
||||||
|
const baseId = gid + "-base";
|
||||||
|
let defs = linGrad(baseId,"0","0","0","1",[["0%",hsl(H,35,16)],["100%",hsl(H,45,9)]]);
|
||||||
|
const n = rndInt(rng,4,5);
|
||||||
|
let blobs = "";
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
const r = rnd(rng,90,180);
|
||||||
|
const cx = rnd(rng,0,480), cy = rnd(rng,0,220);
|
||||||
|
const offset = pick(rng,[-30,30]);
|
||||||
|
const gidL = gid + "-dusk-" + i;
|
||||||
|
defs += radGrad(gidL,"50%","50%","50%",[["0%",hsl(H+offset,55,35),0.5],["100%",hsl(H+offset,55,35),0]]);
|
||||||
|
blobs += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="url(#' + gidL + ')"/>';
|
||||||
|
}
|
||||||
|
const horizonId = gid + "-horizon";
|
||||||
|
defs += radGrad(horizonId,"50%","50%","50%",[["0%",hsl(H,50,30),0.45],["100%",hsl(H,50,30),0]]);
|
||||||
|
const horizon = '<ellipse cx="240" cy="270" rx="320" ry="70" fill="url(#' + horizonId + ')"/>';
|
||||||
|
return "<defs>" + defs + "</defs>" + '<rect width="480" height="300" fill="url(#' + baseId + ')"/>' + blobs + horizon;
|
||||||
|
}
|
||||||
|
|
||||||
|
function famStarfield(rng,H,gid){
|
||||||
|
const baseId = gid + "-base";
|
||||||
|
let defs = radGrad(baseId,"50%","50%","75%",[["0%",hsl(H,40,14)],["100%",hsl(H,45,7)]]);
|
||||||
|
let nebula = "";
|
||||||
|
for(let i=0;i<2;i++){
|
||||||
|
const cx = rnd(rng,0,480), cy = rnd(rng,0,300), r = rnd(rng,120,200);
|
||||||
|
const nId = gid + "-neb-" + i;
|
||||||
|
defs += radGrad(nId,"50%","50%","50%",[["0%",hsl(H+40,50,30),0.3],["100%",hsl(H+40,50,30),0]]);
|
||||||
|
nebula += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="url(#' + nId + ')"/>';
|
||||||
|
}
|
||||||
|
const brightIdx = new Set();
|
||||||
|
while(brightIdx.size < 8){ brightIdx.add(rndInt(rng,0,89)); }
|
||||||
|
let stars = "";
|
||||||
|
for(let i=0;i<90;i++){
|
||||||
|
const cx = rnd(rng,0,480), cy = rnd(rng,0,300);
|
||||||
|
if(brightIdx.has(i)){
|
||||||
|
const haloId = gid + "-star-" + i;
|
||||||
|
defs += radGrad(haloId,"50%","50%","50%",[["0%",hsl(H,30,88),0.9],["100%",hsl(H,30,88),0]]);
|
||||||
|
const r = rnd(rng,0.6,1.8);
|
||||||
|
stars += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="6" fill="url(#' + haloId + ')"/>';
|
||||||
|
stars += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="' + hsl(H,30,88) + '" opacity="0.9"/>';
|
||||||
|
} else {
|
||||||
|
const r = rnd(rng,0.6,1.8);
|
||||||
|
const op = rnd(rng,0.25,0.5).toFixed(2);
|
||||||
|
stars += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="' + hsl(H,30,88) + '" opacity="' + op + '"/>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "<defs>" + defs + "</defs>" + '<rect width="480" height="300" fill="url(#' + baseId + ')"/>' + nebula + stars;
|
||||||
|
}
|
||||||
|
|
||||||
|
function famSlate(rng,H,gid){
|
||||||
|
let s = '<rect width="480" height="300" fill="' + hsl(H,18,13) + '"/>';
|
||||||
|
const res = contourLines(rng,14,22,6,hsl(H,35,55),0.12,1.2);
|
||||||
|
s += res.svg;
|
||||||
|
let dots = "";
|
||||||
|
for(let i=0;i<10;i++){
|
||||||
|
const li = rndInt(rng,0,res.lines.length-1);
|
||||||
|
const line = res.lines[li];
|
||||||
|
const pi = rndInt(rng,0,line.length-1);
|
||||||
|
const pt = line[pi];
|
||||||
|
const r = rnd(rng,2,3);
|
||||||
|
dots += '<circle cx="' + f1(pt[0]) + '" cy="' + f1(pt[1]) + '" r="' + f1(r) + '" fill="' + hsl(H,60,60) + '" opacity="0.5"/>';
|
||||||
|
}
|
||||||
|
return s + dots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- registry ----------
|
||||||
|
const HUES = [
|
||||||
|
{ name:"clay", h:8 },
|
||||||
|
{ name:"amber", h:38 },
|
||||||
|
{ name:"olive", h:80 },
|
||||||
|
{ name:"forest", h:140 },
|
||||||
|
{ name:"teal", h:175 },
|
||||||
|
{ name:"sky", h:215 },
|
||||||
|
{ name:"iris", h:262 },
|
||||||
|
{ name:"rose", h:335 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const FAMILIES = [
|
||||||
|
{ id:"whisper", tone:"light", gen:famWhisper },
|
||||||
|
{ id:"confetti", tone:"light", gen:famConfetti },
|
||||||
|
{ id:"aurora", tone:"light", gen:famAurora },
|
||||||
|
{ id:"contours", tone:"light", gen:famContours },
|
||||||
|
{ id:"drift", tone:"light", gen:famDrift },
|
||||||
|
{ id:"bubbles", tone:"light", gen:famBubbles },
|
||||||
|
{ id:"terrazzo", tone:"light", gen:famTerrazzo },
|
||||||
|
{ id:"graph", tone:"light", gen:famGraph },
|
||||||
|
{ id:"waves", tone:"light", gen:famWaves },
|
||||||
|
{ id:"dusk", tone:"dark", gen:famDusk },
|
||||||
|
{ id:"starfield", tone:"dark", gen:famStarfield },
|
||||||
|
{ id:"slate", tone:"dark", gen:famSlate }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- render ----------
|
||||||
|
let openState = null;
|
||||||
|
const lightbox = document.getElementById("lightbox");
|
||||||
|
const lightboxFrame = document.getElementById("lightbox-frame");
|
||||||
|
const lightboxId = document.getElementById("lightbox-id");
|
||||||
|
|
||||||
|
function openLightbox(svgEl, id){
|
||||||
|
openState = { svgEl:svgEl, parent:svgEl.parentNode, next:svgEl.nextSibling };
|
||||||
|
lightboxFrame.appendChild(svgEl);
|
||||||
|
lightboxId.textContent = id;
|
||||||
|
lightbox.classList.add("open");
|
||||||
|
}
|
||||||
|
function closeLightbox(){
|
||||||
|
if(!openState) return;
|
||||||
|
if(openState.next){
|
||||||
|
openState.parent.insertBefore(openState.svgEl, openState.next);
|
||||||
|
} else {
|
||||||
|
openState.parent.appendChild(openState.svgEl);
|
||||||
|
}
|
||||||
|
openState = null;
|
||||||
|
lightbox.classList.remove("open");
|
||||||
|
}
|
||||||
|
lightbox.addEventListener("click", function(e){
|
||||||
|
if(e.target === lightbox) closeLightbox();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", function(e){
|
||||||
|
if(e.key === "Escape") closeLightbox();
|
||||||
|
});
|
||||||
|
|
||||||
|
let renderedCount = 0;
|
||||||
|
|
||||||
|
function renderAll(){
|
||||||
|
for(let fi=0; fi<FAMILIES.length; fi++){
|
||||||
|
const fam = FAMILIES[fi];
|
||||||
|
const grid = document.getElementById("grid-" + fam.id);
|
||||||
|
for(let hi=0; hi<HUES.length; hi++){
|
||||||
|
const hue = HUES[hi];
|
||||||
|
const id = fam.id + "-" + pad2(hi+1);
|
||||||
|
const rng = seededRng(id);
|
||||||
|
const inner = fam.gen(rng, hue.h, id);
|
||||||
|
const overlay = buildOverlay(rng, fam.tone);
|
||||||
|
const svgMarkup = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 300" data-id="' + id + '">' + inner + overlay + "</svg>";
|
||||||
|
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "card";
|
||||||
|
card.dataset.family = fam.id;
|
||||||
|
card.dataset.tone = fam.tone;
|
||||||
|
|
||||||
|
const frame = document.createElement("div");
|
||||||
|
frame.className = "frame";
|
||||||
|
frame.innerHTML = svgMarkup;
|
||||||
|
const svgEl = frame.firstElementChild;
|
||||||
|
|
||||||
|
const caption = document.createElement("div");
|
||||||
|
caption.className = "caption";
|
||||||
|
const idSpan = document.createElement("span");
|
||||||
|
idSpan.className = "swatch-id";
|
||||||
|
idSpan.textContent = id;
|
||||||
|
const noteSpan = document.createElement("span");
|
||||||
|
noteSpan.className = "note";
|
||||||
|
noteSpan.textContent = hue.name;
|
||||||
|
caption.appendChild(idSpan);
|
||||||
|
caption.appendChild(noteSpan);
|
||||||
|
|
||||||
|
card.appendChild(frame);
|
||||||
|
card.appendChild(caption);
|
||||||
|
card.addEventListener("click", function(){ openLightbox(svgEl, id); });
|
||||||
|
|
||||||
|
grid.appendChild(card);
|
||||||
|
renderedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- filters ----------
|
||||||
|
let activeFamily = "all", activeTone = "all";
|
||||||
|
const familyChips = document.querySelectorAll("#family-chips .chip");
|
||||||
|
const toneChips = document.querySelectorAll("#tone-chips .chip");
|
||||||
|
|
||||||
|
function applyFilters(){
|
||||||
|
const sections = document.querySelectorAll(".family");
|
||||||
|
for(let i=0;i<sections.length;i++){
|
||||||
|
const sec = sections[i];
|
||||||
|
const fam = sec.dataset.family, tone = sec.dataset.tone;
|
||||||
|
const visible = (activeFamily === "all" || activeFamily === fam) && (activeTone === "all" || activeTone === tone);
|
||||||
|
sec.style.display = visible ? "" : "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
familyChips.forEach(function(chip){
|
||||||
|
chip.addEventListener("click", function(){
|
||||||
|
familyChips.forEach(function(c){ c.classList.remove("active"); c.setAttribute("aria-pressed","false"); });
|
||||||
|
chip.classList.add("active");
|
||||||
|
chip.setAttribute("aria-pressed","true");
|
||||||
|
activeFamily = chip.dataset.family;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
toneChips.forEach(function(chip){
|
||||||
|
chip.addEventListener("click", function(){
|
||||||
|
toneChips.forEach(function(c){ c.classList.remove("active"); c.setAttribute("aria-pressed","false"); });
|
||||||
|
chip.classList.add("active");
|
||||||
|
chip.setAttribute("aria-pressed","true");
|
||||||
|
activeTone = chip.dataset.tone;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById("overlay-toggle").addEventListener("change", function(e){
|
||||||
|
document.body.classList.toggle("show-overlay", e.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAll();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Board background image set — exploration notes
|
||||||
|
|
||||||
|
Status: **first sweep published, awaiting swatch review** (2026-08-07). Not a numbered design doc — this is working material for producing a set of bundled background images; the schema and rendering side is already settled in 03-board-ui.md § Styling ▸ Capabilities.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A set of shippable background images for boards. The app side needs nothing new: `background: {image: <path>}` relative to the board root, drawn scaled-to-fill and cropped under the transparent title bar with the frosted strip (BoardBackdropImage.swift), decoded at a 3072px ceiling (`BoardBackdrop.maximumPixelSize`), no in-app editor — the raw file is the escape hatch. So the deliverable is purely the artwork: PNG files generated from procedural recipes.
|
||||||
|
|
||||||
|
## The model
|
||||||
|
|
||||||
|
Every background = **base layer × filler layer**.
|
||||||
|
|
||||||
|
Axes swept or reserved:
|
||||||
|
|
||||||
|
- **Base**: solid · linear gradient (2/3-stop, angle) · radial/corner glow · soft-blob mesh
|
||||||
|
- **Hue strategy**: monochrome · analogous · complementary accent · multicolor
|
||||||
|
- **HSB family**: pastel · muted/dusty · deep-dark
|
||||||
|
- **Filler shapes**: dots · rings · blob chips · capsules · triangles · plus-signs · contour lines · sine bands · grid
|
||||||
|
- **Density** sparse→dense; **size distribution** uniform vs power-law; **placement** uniform scatter · diagonal band · corner-weighted · grid-jitter
|
||||||
|
- **Filler color**: same-hue tint · accent hue · multicolor · alpha-only; **opacity** whisper 4–10% → visible 15–25%; **depth** crisp vs gradient-soft, layered sizes
|
||||||
|
|
||||||
|
Deliberately left out of sweep 1 (add to surviving families in sweep 2): grain/noise texture, two-layer depth (large blurred behind small crisp), clustered placement, outlined-only variants.
|
||||||
|
|
||||||
|
## Sweep 1 — the gallery
|
||||||
|
|
||||||
|
- **Artifact (preview)**: https://claude.ai/code/artifact/afa8f1d8-ded0-4d7b-9785-f4b7eb4f8008
|
||||||
|
- **Generator source**: `board-backgrounds-gallery.html` (this folder) — single self-contained HTML, all swatches procedural SVG off a seeded PRNG (xmur3 + mulberry32, seeded from the swatch ID string), so IDs are stable references across reloads and rebuilds. To republish after edits: publish this file via the Artifact tool passing the URL above as `url` to keep the link.
|
||||||
|
- **96 swatches** = 12 families × 8 hues. Hue wheel (shared by all families): clay 8° · amber 38° · olive 80° · forest 140° · teal 175° · sky 215° · iris 262° · rose 335°. IDs are `family-NN` (`aurora-05` = aurora × teal).
|
||||||
|
- **Board overlay toggle** draws translucent lane plates + opaque card plates + title strip over every swatch — the judging condition, since real backgrounds sit under exactly that stack (lanes are a translucent quaternary wash, cards opaque plates).
|
||||||
|
|
||||||
|
Families (1–9 light, 10–12 dark):
|
||||||
|
|
||||||
|
| Family | Recipe |
|
||||||
|
|---|---|
|
||||||
|
| whisper | pastel vertical gradient + faint scattered dots — the quietest |
|
||||||
|
| confetti | neutral ground + small multicolor shapes (H, H+120, H+240) |
|
||||||
|
| aurora | 4–6 large soft radial-gradient blobs, mesh look |
|
||||||
|
| contours | topographic wavy polylines, mono |
|
||||||
|
| drift | diagonal band of −32°-tilted capsules over diagonal gradient |
|
||||||
|
| bubbles | power-law circles/rings over corner glow |
|
||||||
|
| terrazzo | irregular 5–7-gon stone chips, four colors |
|
||||||
|
| graph | 24px graph-paper grid + plus-signs + accent dots |
|
||||||
|
| waves | 6 layered sine dunes toward the bottom |
|
||||||
|
| dusk | dark gradient, large glow blobs, horizon ellipse |
|
||||||
|
| starfield | tiny stars w/ gradient halos + faint nebulae on dark radial |
|
||||||
|
| slate | dark topographic lines + accent dots on flat dark ground |
|
||||||
|
|
||||||
|
Rendering constraints baked into the generator (keep for sweep 2): no per-swatch SVG filters (all glow via radial gradients fading to alpha 0 — 96 filters would crawl), ≤~120 filler elements per swatch, gradient defs namespaced by swatch ID, light grounds ≥82% lightness / dark ≤20%.
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
1. Review with overlay on; pick surviving families / specific IDs and direction tweaks ("aurora but duskier", "contours denser").
|
||||||
|
2. Sweep 2: deepen winners, add the reserved axes (grain, two-layer depth, clusters, outlines).
|
||||||
|
3. Settle final recipes → render real PNGs at 3072px long edge (canvas render of the same recipes, or a small script) → decide packaging (bundled set offered how? templates carry them? drag-in only?) — packaging is an open question, nothing ruled yet.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Drag/Drop Smoothness Analysis
|
||||||
|
|
||||||
|
Where the drag experience actually spends its time, which pre-checks earn their place on the hot path and which don't, and where optimistic allowances are worth making. Written 2026-08-06 from a full read of the pipeline (`BoardDrops.swift`, `DragSession.swift`, `DragAutoScroller.swift`, `DropSlotMath.swift`, the commit path in `BoardStore.swift`, the watcher in `FolderWatcher.swift`) against the model in DRAG-REORDER.md and the measurements in RENDER-INSTRUMENTATION.md. This is an analysis document: findings are prioritized and sequenced but nothing here is implemented yet.
|
||||||
|
|
||||||
|
The headline, stated up front: **the mid-drag hot path is already in good shape, and the felt latency lives almost entirely in the release.** The per-sample arithmetic is microseconds on any realistic board; the pause between mouse-up and the card being real is a 200ms debounce plus a whole-strip render pass, and one honesty bug can stretch a *failed* drop into a 1.5-second freeze. The biggest wins are not in doing less checking mid-drag — they are in making the echo arrive fast, making the landing frame cheap, and making the handoff structurally incapable of blinking.
|
||||||
|
|
||||||
|
## Anatomy of a drag, as the profiler sees it
|
||||||
|
|
||||||
|
**Pickup** (once per gesture): the handle's `.onDrag` closure guards `!isReadOnly, !isEditingInline`, resolves the dragged run's lanes with one O(board) scan, JSON-encodes the payload, and calls `DragSession.begin` — which freezes `cardHeights`/`laneUnits` (the only frozen geometry), clears the `RestingLayoutCache`, and arms the watchdog and the modifier-flip monitor. All synchronous, all trivial. The expensive thing that *also* happens at pickup is not the drag's fault: click-select precedes the drag, and a selection change re-runs **every card body on the board** (RENDER-INSTRUMENTATION.md — "Selection is O(board) in card bodies").
|
||||||
|
|
||||||
|
**Per sample** (every `dropUpdated`, and again on every autoscroll frame that actually scrolled): `revalidateProposal()` — an O(lanes) `.contains(where:)` — then the shared retarget: cursor conversion, a `RestingLayoutCache` lookup (hit = a struct-equality key check), `DropSlotMath` zone arithmetic over one masonry column, and a `propose()` that early-outs when the slot didn't change. The strip-side retargets (`retargetLanes`, `retargetCardsFromStrip`) allocate a filtered lane array and a display-units array per sample — the one piece of per-sample work the cache does not cover. On a ≤12-lane board this whole chain is single-digit microseconds; at 120Hz it is not where frames go.
|
||||||
|
|
||||||
|
**Release**: `commitDrop()` re-runs the guard ladder once (revalidate, this-board check, survivors, mixed-kind, the `TrashDrop.accepts` re-ask), dispatches to the store writer — a synchronous `performWrite` whose disk work is one `order`-field rewrite or one folder rename — and arms the **committed-overlay hold**: the board keeps drawing the proposed arrangement until the echo snapshot lands or `CommittedHold.timeout` (1.5s) gives up.
|
||||||
|
|
||||||
|
**Echo**: `performWrite`'s bracket closes → `FolderWatcher.endBracket()` schedules the mandatory post-bracket reload → **200ms trailing debounce** → off-main tree walk (cheap; `ParseMemo` re-parses only the touched files) → main-actor `land()` with the animated snapshot assignment → `BoardView`'s `snapshotGeneration` watch calls `DragSession.handOff`, dissolving the hold.
|
||||||
|
|
||||||
|
So the release pause = write (~1–10ms) + **200ms debounce (dominant)** + walk (a few ms warm) + apply/render. The `drop-release-pause` signpost measures exactly this span, and its healthy outcome today is bounded below by the debounce.
|
||||||
|
|
||||||
|
## Findings, prioritized
|
||||||
|
|
||||||
|
### P1 — Expedite the app-mediated echo (~180ms off every drop)
|
||||||
|
|
||||||
|
The 200ms trailing debounce exists to coalesce foreign FSEvents bursts. But a drop commit is not a burst the watcher has to wait out — the store *knows* it just wrote, the bracket already owes exactly one delivery, and the user is staring at the gap. Add a surgical `FolderWatcher.expedite()`: if `bracketDepth == 0 && pendingOrigin != nil`, cancel the armed debounce and fire the owed delivery now. Expose it through the store and call it from `commitDrop()` right after the writer returns.
|
||||||
|
|
||||||
|
This is deliberately **not** a shorter global debounce — `endBracket` serves every `performWrite` (card saves, inline edits, heals), and dropping the settled 200ms figure everywhere would turn write bursts into per-write walks. It is also **not** a violation of one-way flow: nothing mutates the snapshot from the write path; the delivery the bracket already owed just fires earlier, and the store still learns the new order by walking disk.
|
||||||
|
|
||||||
|
Races, analyzed: our own FSEvents still in kernel flight arrive after the fast reload and schedule a `.foreign` delivery → one redundant memoized walk → value-equal → assignment skipped, `snapshotGeneration` unmoved, nothing visible. A foreign write landing between our write and the fast reload folds under the `.appMediated` label — the same accepted blur `WatchOrigin.merged` already documents. POSIX guarantees the walk sees the completed writes, because `performWrite`'s FileManager work returned before `expedite` was called. All benign; the cost is one extra no-op walk per drop.
|
||||||
|
|
||||||
|
Expected result: `drop-release-pause` outcome `echo` at ~10–50ms instead of ~250–400ms. This also all but closes the rapid-successive-drag window (see P5). Tests: FolderWatcherTests additions (expedite fires the owed delivery once, respects open brackets, no-ops with nothing pending).
|
||||||
|
|
||||||
|
### P2 — Honest failure path: don't arm a hold for a write that didn't happen
|
||||||
|
|
||||||
|
`commitDrop()` arms `session.commit(into: store)` unconditionally (`BoardDrops.swift:978`), but the drop-path store methods return `Void` and swallow failure via `try? performWrite`. A *failed* write therefore posts its banner immediately — and then leaves the dropped arrangement frozen on screen for the full 1.5s `CommittedHold.timeout` before animating back. By this repo's own definition (`drop-release-pause` outcome `timeout` is "a bug, not a slow frame") and 03-board-ui.md § Motion's promise ("a failed write discards the proposal and the board animates back"), this is a bug, not a design.
|
||||||
|
|
||||||
|
The fix is plumbing a fact the writers already compute: make `moveCards`, `copyCards`, `moveLanes`, `restoreLanes`, `receiveCards`, `receiveLanes`, `deleteByDrag`, `deleteLanesByDrag` return `@discardableResult Bool`; arm the hold only on `true`, else `cancelDrop()` — the immediate animated snap-back, banner already posted. A refused no-op arrangement also stops arming a pointless 1.5s hold, which de-noises the signpost and makes `CommittedHold`'s own doc comment ("every drop path refuses a no-op arrangement before it opens a write bracket") true end-to-end.
|
||||||
|
|
||||||
|
### P3 — The headerInk hoist: make the landing frame cheap
|
||||||
|
|
||||||
|
Every echo reload — the one *inside* the release window — currently re-runs every lane body on the board, because `LaneView.body → headerInk` reads `store.snapshot.background` and Observation tracks whole properties (RENDER-INSTRUMENTATION.md — "the lane gate is never asked on a snapshot change"). The fix is already designed there: resolve the ink once in `BoardView` and pass it down as a compared parameter, the way `slotWidth` and `columns` already are. The tripwire is armed: `theLaneCostFollowsTheBoard` fails in the good direction when this lands, and the container budget in `aOneCardEditIsNotAWholeBoardRebuild` drops from `laneCount` to the pathfinder's 4.
|
||||||
|
|
||||||
|
This matters more once P1 lands: with the debounce gone, apply/render becomes the dominant share of the pause, and this is the cheapest way to shrink it. It also smooths the *other* moment the drag model cares about — a foreign reload landing mid-flight, where a whole-strip body storm currently rides the re-grounding.
|
||||||
|
|
||||||
|
### P4 — Shadow identity handoff: the echo becomes a content swap
|
||||||
|
|
||||||
|
Today the hold renders `DragShadow`s keyed `"shadow:\(index)"`, and the echo swaps a shadow-identity ForEach element for a card-identity element — an insert/remove, exactly the shape the create placeholder deliberately avoids ("a committed placeholder is already keyed by the arriving card's identity… the ForEach element is neither inserted nor removed — only its content changes", `LaneView.swift` at the placeholder). While the session is settled (`hold != nil`), key each held shadow slot by its member's `ItemID` — the session knows `members` in flatten order. The handoff then swaps content in place, structurally incapable of running the appear transition or a one-frame blink, under Reduce Motion or not.
|
||||||
|
|
||||||
|
With P1 this largely answers 03-board-ui.md § Motion's reopened release-presentation question with the cheapest possible answer: **nothing moves at all** — the card face materializes in its slot ~30–60ms after mouse-up, under AppKit's own drag-image fade. (Shortening that fade is not reachable: SwiftUI's `.onDrag` never exposes the `NSDraggingSession`.) A brief landed-highlight pulse in the selection-wash vocabulary remains available as optional polish, but prototype it only after P1+P4 — the fast echo may make any additional presentation unnecessary.
|
||||||
|
|
||||||
|
### P5 — Rapid successive drags: dissolved by P1, document only
|
||||||
|
|
||||||
|
Drag #2's `begin()` clears drag #1's hold, and until the echo lands the board regresses to the stale arrangement — the just-dropped card visibly snaps back, then jumps forward mid-drag when the echo re-grounds the zones. Today that window is ~250–400ms and reachable by a fast user; after P1 it is ~30–60ms and effectively unreachable. `handOff` itself is race-free (all main-actor, root+generation-guarded hold, `expire` self-checks, `begin` cancels the timeout). No mechanism needed; note the residual micro-window in DRAG-REORDER.md when P1 lands.
|
||||||
|
|
||||||
|
### M1 — Selection-gated card bodies: the pickup frame
|
||||||
|
|
||||||
|
`CardFaceView.body` reads `store.selection`, so selecting one card re-runs all 180 faces — and pickup *is* a selection change, so this O(board) body storm lands on the exact frame the pickup lift starts. The fix is the design change RENDER-INSTRUMENTATION.md already names: each face takes its own selected-ness (and the selection count, for the badge) as compared parameters through `CardFaceView.==`, making a selection change cost only the faces whose state flipped. Preserve the counter-invariant (`selectionStillRepaints`: a selected card must still repaint) and update `ViewEquatableTests`' comparison list. This is the likeliest source of a *pickup* hitch on large boards — the O(board) payload scan is not (see the rejected list).
|
||||||
|
|
||||||
|
### M2 — File-drop importable-count cache
|
||||||
|
|
||||||
|
The Finder-drop path runs `FinderDrop.importableCount` **twice per sample** (`acceptsFileDrop`, then the shadow count), each doing UTType-database conformance checks per provider — the only genuinely non-trivial per-sample system call in any drag mode. Cache the count on `DragSession` beside `fileTarget`, computed on the first sample of a hover, cleared with the file target and the file watchdog, keyed defensively on provider count. The commit is unaffected (it counts resolved URLs, not providers).
|
||||||
|
|
||||||
|
### M3 — Strip-side caches (allocation hygiene, lowest yield)
|
||||||
|
|
||||||
|
`retargetLanes` and `retargetCardsFromStrip` allocate a filtered lane array and a display-units array per sample, and `revalidateProposal` scans lanes per callback. A sibling cache to `RestingLayoutCache` — same keying discipline, same session lifecycle — holding the display-units array, its hidden-filtered variant, and a lane-ID `Set` (making revalidation O(1)) removes all of it. Honest sizing: at ≤12 lanes this is microseconds; the value is allocation pressure and pattern consistency, not visible frames. Do it last, or not at all if profiling says done. The reactive alternative — invalidating the proposal from the `snapshotGeneration` watch instead of polling per sample — is not recommended: it trades a provably-cheap check for an ordering dependency, and the re-grounding contract ("at the top of every callback and again at release") is pinned in prose and behavior.
|
||||||
|
|
||||||
|
### Wishlist — pre-flush the git seam at pickup
|
||||||
|
|
||||||
|
`GitAutoCommitter.noteWillWrite()` is rare (only when the window holds an uncommitted foreign change) but is the single worst possible release stall when it fires: a synchronous stage/commit plus HEAD materialize plus loader walks, all before the drop's write. The optimistic allowance: when a drag **begins** on a board whose window `holdsForeignChanges`, kick the flush then — mid-drag reloads are already a designed-for scenario (the re-grounding trio), and the release then finds nothing to flush. Medium complexity; keep as a wishlist note until the stall is ever observed in a trace.
|
||||||
|
|
||||||
|
## Rejected, with reasons
|
||||||
|
|
||||||
|
These were analyzed and turned down; recorded so they aren't re-litigated.
|
||||||
|
|
||||||
|
- **Full optimistic snapshot mutation at commit.** The `CommittedHold` already renders the proposed arrangement — members lifted, siblings reflowed, slot held — so the only perceptual delta versus mutating the snapshot is card-face-vs-shadow at the slot, which P1+P4 close for ~50ms of exposure. Breaking the one-way-flow invariant would buy that sliver at the price of reconcile-on-echo logic, snapshot rollback on write failure (the current failure story is trivially honest *because* the snapshot never lies), and re-deriving `EchoLedger`/`BoardDiff` semantics. The invariant stays.
|
||||||
|
- **Incremental/targeted reload.** `ParseMemo` already makes the echo walk parse ~2 files; what remains is directory enumeration, deliberately never memoized because attachments and loose files don't touch `index.md`. A touched-lanes re-parse would fork "the snapshot is rebuilt purely from disk" into two code paths to save single-digit milliseconds. P1 removes 200ms; this would remove ~5.
|
||||||
|
- **Async off-main `BoardWriter`.** Same-volume APFS renames and one-file order rewrites are sub-millisecond metadata ops; async buys nothing and costs the failure-after-hold problem — a banner about a drop the user watched succeed. If the one unbounded case (cross-board copy of a lane with heavy attachment trees) ever shows in a trace, handle that operation with a progress affordance, not the drop architecture.
|
||||||
|
- **Collapsing the commit-time guard ladder.** It runs once per release — not on any frame path — and the `TrashDrop.accepts` re-ask exists because ⌥ can change after the proposal stood with no callback reporting it; removing it converts a promised copy into a delete. `ModifierFlipTests` pins this. Zero smoothness gain, real correctness risk.
|
||||||
|
- **Dropping the commit no-op guard.** The `DropSlotMath.applied` recompute is once per release, ~30 comparisons, and is what keeps a drag that ends where it started from stamping `modified` and minting a git commit — a disk/history invariant, not a UI one.
|
||||||
|
- **Indexing the pickup payload scan.** ≤360 iterations once per gesture, microseconds. A cardID→laneID index invalidated per snapshot buys nothing measurable; the pickup hitch, if felt, is M1's selection storm plus replica rendering.
|
||||||
|
- **Caching `TrashDrop.accepts` per hover.** Six boolean clauses, no allocation, deliberately uncached for modifier freshness. Already free.
|
||||||
|
- **Allocation-free `DropSlotMath` / caching `MasonryPlacement.frames`.** The frames depend on `placement.origin`, which moves with every autoscroll step — the registry's live placement is deliberately uncached ("derived at event time", four stores and a divide). Caching origin-relative frames and translating per sample is the same O(n) with more machinery, churning the most heavily tested pure math in the app.
|
||||||
|
- **Gating the autoscroll retarget.** Already correct: `step()` returns before `didScroll` unless the clamped target moved > 0.01pt; frames where nothing scrolls cost one cursor conversion and an engagement test.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
No new instrumentation is needed; the existing instruments were built for exactly these claims.
|
||||||
|
|
||||||
|
- **P1/P2**: the `drop-release-pause` signpost — healthy drops move from ~250–400ms `echo` to ~10–50ms; failed drops stop producing `timeout` outcomes at all.
|
||||||
|
- **P3**: `theLaneCostFollowsTheBoard` fails in the good direction and gets rewritten to pin the fixed behavior; the container budget in `aOneCardEditIsNotAWholeBoardRebuild` tightens from `laneCount` to 4.
|
||||||
|
- **M1**: `selectionStillRepaints` keeps its non-zero floor; a new budget pins selection cost to the flipped faces.
|
||||||
|
- **M2/M3**: `RestingLayoutCacheTests`-style build/reuse counters on the new caches; `DropSlotMathTests`, `DragAutoScrollMathTests`, `ModifierFlipTests`, `DragWriteTests` all continue to pin the behavior none of this may change.
|
||||||
|
|
||||||
|
## Sequencing
|
||||||
|
|
||||||
|
P2 first (small, fixes a real bug, de-noises the signpost) → P1 (the latency win; validate with the signpost) → P3 (rides the same window; flips the tripwire) → P4 (handoff identity) — then reassess the open release-presentation question with the fast echo in hand before designing any pulse. M1 next if pickup hitches are felt on large boards; M2 with any file-drop work; M3 only if profiling still shows the strip allocations after everything above.
|
||||||
+1
-1
@@ -34,7 +34,7 @@ Lanes get this for free and the invariant is worth stating: a within-board lane
|
|||||||
Two stability rules on top:
|
Two stability rules on top:
|
||||||
|
|
||||||
- **Exact-boundary tie** — a cursor resting on a boundary pixel keeps the current proposal when it adjoins that boundary; the shadow can never oscillate on a single pixel.
|
- **Exact-boundary tie** — a cursor resting on a boundary pixel keeps the current proposal when it adjoins that boundary; the shadow can never oscillate on a single pixel.
|
||||||
- **Own-slot pickup** — zones derive from the resting layout, so picking an item up over its original spot proposes its own slot: a no-op, no reflow, and the store's commits refuse to write for it.
|
- **Own-slot pickup** — zones derive from the resting layout, so picking an item up over its original spot proposes its own slot: a no-op, no reflow, and the store's commits refuse to write for it. The own slot is also **seeded at `begin`** (`DragSession.begin`'s `seed`), in the same transaction that lifts the run out, so "at drag start it replaces the item's original space" is true from the very first frame: without the seed the vacated gap closes un-animated and springs back open at the first `dropUpdated` — a shuffle carrying no information. The seed bypasses `propose` (a pickup is not a new landing spot, so no alignment tick), and the first real sample's re-propose of the same slot is the early-out's ordinary silence. A ⌥-pickup seeds nothing — a copy's resting layout keeps the originals in place, so there is no vacated space to hold, and the first sample answers as it always did.
|
||||||
|
|
||||||
## The lane strip's resting layout is arithmetic
|
## The lane strip's resting layout is arithmetic
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never
|
|||||||
| `tombstones.kanban` | A tombstoned lane and a tombstoned card, both still on disk and still in the snapshot, flagged (`isDeleted`) rather than removed. Also proves a tombstoned lane doesn't recursively flag its own un-deleted children. |
|
| `tombstones.kanban` | A tombstoned lane and a tombstoned card, both still on disk and still in the snapshot, flagged (`isDeleted`) rather than removed. Also proves a tombstoned lane doesn't recursively flag its own un-deleted children. |
|
||||||
| `duplicate-order-tie-break.kanban` | Three cards sharing one `order` in one lane, and two lanes sharing one `order` — both broken by folder name, ascending. |
|
| `duplicate-order-tie-break.kanban` | Three cards sharing one `order` in one lane, and two lanes sharing one `order` — both broken by folder name, ascending. |
|
||||||
| `unknown-key-order.kanban` | Unknown/reserved frontmatter keys interleaved with schema-owned ones at board, lane, and card level — `document.unknownFields` must preserve exactly the order they were written in. |
|
| `unknown-key-order.kanban` | Unknown/reserved frontmatter keys interleaved with schema-owned ones at board, lane, and card level — `document.unknownFields` must preserve exactly the order they were written in. |
|
||||||
| `coercion.kanban` | Lenient-field coercion and fallback: wrong-type scalars that coerce (`title: 2048`, `iconColor: 42`, `background: 12345`, `width: "3"`) versus ones with no sensible reading that fall back to the default (`title: [a, b]`, `background: {x: 1}`, `width: 1.5`), plus a `deleted` with an unusable timestamp that still tombstones. |
|
| `coercion.kanban` | Lenient-field coercion and fallback: wrong-type scalars that coerce (`title: 2048`, `iconColor: 42`, `width: "3"`) versus ones with no sensible reading that fall back to the default (`title: [a, b]`, `width: 1.5`, and `background: 12345` — a bare scalar, which `background` no longer has a reading for at all), plus `background: {x: 1}` — a legal mapping naming neither subkey, so no color and no trace — and a `deleted` with an unusable timestamp that still tombstones. |
|
||||||
| `duplicate-top-level-keys.kanban` | A top-level key written twice — at board, lane (the strict `order` field), and card level. Last occurrence wins; **not** a fail-fast case (settled, newer than the original card text). Round-tripped to prove the earlier occurrence survives on disk, invisible only to reads. |
|
| `duplicate-top-level-keys.kanban` | A top-level key written twice — at board, lane (the strict `order` field), and card level. Last occurrence wins; **not** a fail-fast case (settled, newer than the original card text). Round-tripped to prove the earlier occurrence survives on disk, invisible only to reads. |
|
||||||
| `board-level-deleted.kanban` | A board-level `deleted:` key — legal per the frontmatter table but meaningless; ignored + warned, rest of the board loads normally. |
|
| `board-level-deleted.kanban` | A board-level `deleted:` key — legal per the frontmatter table but meaningless; ignored + warned, rest of the board loads normally. |
|
||||||
| `optional-keys.kanban` | `order` and `schema` optional below the board root (re-ruled 2026-07-31). One lane holds a ranked card plus every order-less shape — no key, an explicit null, `order: banana`, `order: .nan` — which all read as append-at-end in folder-name order; the strip holds a ranked lane, a `schema`-less one, and an order-less one. Also the golden case for the minimum agent card: a card whose whole frontmatter is a title. |
|
| `optional-keys.kanban` | `order` and `schema` optional below the board root (re-ruled 2026-07-31). One lane holds a ranked card plus every order-less shape — no key, an explicit null, `order: banana`, `order: .nan` — which all read as append-at-end in folder-name order; the strip holds a ranked lane, a `schema`-less one, and an order-less one. Also the golden case for the minimum agent card: a card whose whole frontmatter is a title. |
|
||||||
|
|||||||
+3
-2
@@ -3,5 +3,6 @@ schema: 1
|
|||||||
order: 4096
|
order: 4096
|
||||||
background: {x: 1}
|
background: {x: 1}
|
||||||
---
|
---
|
||||||
A mapping has no sensible string reading — malformed, falls back to no
|
A mapping is a legal `background` — it carries `color` and `image` subkeys
|
||||||
color.
|
— but this one names neither, so there is no color to read and the card
|
||||||
|
falls back to none.
|
||||||
|
|||||||
+2
@@ -3,3 +3,5 @@ schema: 1
|
|||||||
order: 5120
|
order: 5120
|
||||||
background: 12345
|
background: 12345
|
||||||
---
|
---
|
||||||
|
`background` is a mapping and only a mapping, so a bare scalar has no
|
||||||
|
reading at all — malformed, no color, bytes preserved.
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
schema: 1
|
schema: 1
|
||||||
title: Wire up the loader's stray tolerance
|
title: Wire up the loader's stray tolerance
|
||||||
order: 2048
|
order: 2048
|
||||||
background: coral
|
background: {color: coral}
|
||||||
icon: flag.fill
|
icon: flag.fill
|
||||||
iconColor: orange
|
iconColor: orange
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ schema: 1
|
|||||||
order: 1024
|
order: 1024
|
||||||
title: Doing
|
title: Doing
|
||||||
width: 2
|
width: 2
|
||||||
background: '#3478F6'
|
background: {color: '#3478F6'}
|
||||||
icon: hammer.fill
|
icon: hammer.fill
|
||||||
iconColor: blue
|
iconColor: blue
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
schema: 1
|
schema: 1
|
||||||
order: 2048
|
order: 2048
|
||||||
title: Done
|
title: Done
|
||||||
background: green
|
background: {color: green}
|
||||||
---
|
---
|
||||||
Completed work lives here until someone clears it out.
|
Completed work lives here until someone clears it out.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ title: "Rich Demo Board"
|
|||||||
created: 2026-07-01T09:00:00Z
|
created: 2026-07-01T09:00:00Z
|
||||||
modified: 2026-07-26T16:41:38Z
|
modified: 2026-07-26T16:41:38Z
|
||||||
modified-by: claude
|
modified-by: claude
|
||||||
background: "#1E1E1E"
|
background: {color: "#1E1E1E"}
|
||||||
icon: rectangle.stack.fill
|
icon: rectangle.stack.fill
|
||||||
iconColor: purple
|
iconColor: purple
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
/// It starts nothing the board-open path waits on: the listener writes cached facts that a
|
/// It starts nothing the board-open path waits on: the listener writes cached facts that a
|
||||||
/// *later* composition may read, and never reaches into a session that is already open
|
/// *later* composition may read, and never reaches into a session that is already open
|
||||||
/// (`ProEntitlement`).
|
/// (`ProEntitlement`).
|
||||||
|
///
|
||||||
|
/// **The appearance override applies here too, for the same reason.** `AppearanceStore.init`
|
||||||
|
/// only reads; this is the one call that hands its answer to `NSApp` — the global side effect
|
||||||
|
/// `KanbanApp.init` must not carry, since a unit-test host runs that `init` on every launch
|
||||||
|
/// (`AppearanceStore.applyCurrent`).
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
appModel?.entitlement.start()
|
appModel?.entitlement.start()
|
||||||
|
appModel?.appearance.applyCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **The close is respected.** "Closing the last board window leaves the app windowless (menu bar
|
/// **The close is respected.** "Closing the last board window leaves the app windowless (menu bar
|
||||||
|
|||||||
@@ -114,6 +114,39 @@ public enum AppPreferences {
|
|||||||
/// the key is declared here with its neighbours for `WindowID`'s reason.
|
/// the key is declared here with its neighbours for `WindowID`'s reason.
|
||||||
public static let quickStyleBackgroundsKey = "quickStyleBackgrounds"
|
public static let quickStyleBackgroundsKey = "quickStyleBackgrounds"
|
||||||
|
|
||||||
|
/// **The board's zoom level** — "app-wide and persisted across restarts" (11-command-nexus.md
|
||||||
|
/// ▸ View ▸ Actual Size; 03-board-ui.md ▸ Layout — zoom). A rung on `BoardZoom.levels`, stored as
|
||||||
|
/// the multiplier itself. Read and written by `BoardZoomStore`, which owns the ladder's rules; the
|
||||||
|
/// key is declared here with its neighbours for `WindowID`'s reason.
|
||||||
|
///
|
||||||
|
/// **Every read goes through `BoardZoom.normalize`**, and this one cannot use the
|
||||||
|
/// `object(forKey:) as? Double ?? default` idiom its neighbours use to tell "off" from "never set"
|
||||||
|
/// — the trap here is worse than an ambiguous default. `double(forKey:)` answers 0 for an unset
|
||||||
|
/// key, and 0 is not merely a wrong level: it drives every `BoardMetrics.em` multiple to its 1pt
|
||||||
|
/// floor and draws a board of hairlines. Normalising is what makes an unset, hand-edited or
|
||||||
|
/// stale-from-a-future-build value indistinguishable from a legal one downstream.
|
||||||
|
public static let boardZoomLevelKey = "boardZoomLevel"
|
||||||
|
|
||||||
|
// MARK: The appearance override
|
||||||
|
|
||||||
|
/// **View ▸ Appearance** (11-command-nexus.md) — Auto / Light / Dark, app-wide and persisted
|
||||||
|
/// across restarts (03-board-ui.md ▸ Toolbar). Read and written by `AppearanceStore`, which owns
|
||||||
|
/// the override's rules; the key is declared here with its neighbours for `WindowID`'s reason.
|
||||||
|
///
|
||||||
|
/// **Absent key = Auto.** Setting Auto removes the key rather than writing a third spelling of it
|
||||||
|
/// (the remove-at-default family — a default lane width and an empty rename both do the same), and
|
||||||
|
/// a stored string that is neither "light" nor "dark" — a hand edit, a future build's value read by
|
||||||
|
/// an older one — degrades to Auto rather than refusing to resolve.
|
||||||
|
public static let appearanceKey = "appearance"
|
||||||
|
|
||||||
|
/// The stored override, read the same lenient way `AppearanceStore.init` does. Not itself on that
|
||||||
|
/// type's read path — it takes its own injectable `defaults` rather than always reading
|
||||||
|
/// `.standard` — but declared here with a reader for the shape every other preference in this enum
|
||||||
|
/// keeps (`showComments`'s).
|
||||||
|
public static var appearance: AppAppearance? {
|
||||||
|
UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppAppearance.init(rawValue:))
|
||||||
|
}
|
||||||
|
|
||||||
/// The cached subscription facts behind the tier decision (12-editions.md ▸ The entitlement) —
|
/// The cached subscription facts behind the tier decision (12-editions.md ▸ The entitlement) —
|
||||||
/// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`.
|
/// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`.
|
||||||
///
|
///
|
||||||
@@ -252,6 +285,20 @@ public final class AppModel {
|
|||||||
/// board-scoped and this list deliberately is not.
|
/// board-scoped and this list deliberately is not.
|
||||||
public let styleRecents: StyleRecents
|
public let styleRecents: StyleRecents
|
||||||
|
|
||||||
|
/// The board's app-wide zoom level (03-board-ui.md ▸ Layout — zoom). Owned here for
|
||||||
|
/// `styleRecents`' reason exactly: app-scoped, persisted beside it, and reached by every board
|
||||||
|
/// window through the environment — while the menu rows and the toolbar buttons, which live
|
||||||
|
/// outside every scene's environment, reach it through this object.
|
||||||
|
public let zoom: BoardZoomStore
|
||||||
|
|
||||||
|
/// The app-wide appearance override (11-command-nexus.md ▸ View ▸ Appearance; 03-board-ui.md ▸
|
||||||
|
/// Toolbar). Owned here for `zoom`'s reason exactly: app-scoped, persisted beside it, and reached
|
||||||
|
/// by the View-menu picker and the board-toolbar item alike — both live outside a board's own
|
||||||
|
/// environment (the menu bar entirely, the toolbar through `WindowToolbarController`), so an
|
||||||
|
/// `@Observable` object both can hold is the only thing keeping them from becoming two answers to
|
||||||
|
/// one question.
|
||||||
|
public let appearance: AppearanceStore
|
||||||
|
|
||||||
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||||
///
|
///
|
||||||
/// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the
|
/// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the
|
||||||
@@ -668,6 +715,8 @@ public final class AppModel {
|
|||||||
) {
|
) {
|
||||||
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
|
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
|
||||||
styleRecents = StyleRecents(defaults: preferences)
|
styleRecents = StyleRecents(defaults: preferences)
|
||||||
|
zoom = BoardZoomStore(defaults: preferences)
|
||||||
|
appearance = AppearanceStore(defaults: preferences)
|
||||||
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
|
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
|
||||||
// Reads the cached facts and nothing else — no StoreKit API is touched until
|
// Reads the cached facts and nothing else — no StoreKit API is touched until
|
||||||
// `ProEntitlement.start()`, which the app's launch calls and a test host never does.
|
// `ProEntitlement.start()`, which the app's launch calls and a test host never does.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - View ▸ Appearance
|
||||||
|
|
||||||
|
/// View ▸ Appearance — Auto / Light / Dark (11-command-nexus.md ▸ View; 03-board-ui.md ▸ Toolbar).
|
||||||
|
///
|
||||||
|
/// **App-wide and always enabled**, unlike `ZoomCommands` beside it in the View menu: appearance is a
|
||||||
|
/// preference about how *every* window in the app draws, welcome included, so this row needs no
|
||||||
|
/// `@FocusedValue` scoping and no board window in front — `NewBoardCommand`'s posture (everywhere, no
|
||||||
|
/// focus required) rather than `ZoomCommands`' (board windows only).
|
||||||
|
///
|
||||||
|
/// A `Picker` rather than three independent toggles: SwiftUI renders one placed directly in a
|
||||||
|
/// menu-bar command group as a submenu — "Appearance" as its title, "Auto" / "Light" / "Dark" as its
|
||||||
|
/// rows, a checkmark on whichever is selected — which is the three-way exclusive choice a trio of
|
||||||
|
/// `Toggle`s cannot express (nothing stops more than one, or none, from reading as checked). `nil` is
|
||||||
|
/// the Auto tag; `AppearanceStore.setOverride` is the single write path the board-toolbar item shares
|
||||||
|
/// (`BoardZoomStore.step`'s rule — a toolbar item is a menu command with a different face, never a
|
||||||
|
/// second implementation of it).
|
||||||
|
struct AppearanceCommands: View {
|
||||||
|
|
||||||
|
/// A plain `let` rather than an `@Environment` read, `ZoomCommands`' reason: menu commands live in
|
||||||
|
/// the menu bar, outside every scene's environment; the row re-renders on a change because
|
||||||
|
/// `AppModel` is `@Observable` (`NewBoardCommand`'s pattern).
|
||||||
|
let appModel: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker("Appearance", selection: Binding(
|
||||||
|
get: { appModel.appearance.override },
|
||||||
|
set: { appModel.appearance.setOverride($0) }
|
||||||
|
)) {
|
||||||
|
Text("Auto").tag(nil as AppAppearance?)
|
||||||
|
Text("Light").tag(AppAppearance.light as AppAppearance?)
|
||||||
|
Text("Dark").tag(AppAppearance.dark as AppAppearance?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import AppKit
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
// MARK: - AppAppearance
|
||||||
|
|
||||||
|
/// The app-wide appearance override — Auto (follow system) / Light / Dark (11-command-nexus.md ▸
|
||||||
|
/// View ▸ Appearance; 03-board-ui.md ▸ Toolbar).
|
||||||
|
///
|
||||||
|
/// There is no `.auto` case: following the system is the *absence* of an override, which is what makes
|
||||||
|
/// "no stored key" the one honest spelling of it (`AppearanceStore`, `AppPreferences.appearanceKey`) —
|
||||||
|
/// a third case would need its own reading of what it means to override with "don't override".
|
||||||
|
public enum AppAppearance: String, CaseIterable, Sendable {
|
||||||
|
case light
|
||||||
|
case dark
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AppearanceStore
|
||||||
|
|
||||||
|
/// The app's one appearance override, app-wide and persisted (11-command-nexus.md ▸ View ▸ Appearance;
|
||||||
|
/// 03-board-ui.md ▸ Toolbar).
|
||||||
|
///
|
||||||
|
/// `BoardZoomStore`'s shape exactly, and for its reasons. Two consumers need change notification a
|
||||||
|
/// property wrapper in a view cannot give them: the board toolbar's picker item, whose checkmarks are
|
||||||
|
/// read fresh whenever AppKit opens its menu rather than polled (`WindowToolbarController`), and the
|
||||||
|
/// View-menu picker, which lives outside every scene's environment and reaches `AppModel` as a plain
|
||||||
|
/// `let` the same way `ZoomCommands` reaches `zoom`. An `@Observable` object over an injectable
|
||||||
|
/// `UserDefaults` is what serves both without either one mirroring the other's state.
|
||||||
|
///
|
||||||
|
/// ### Why not `@AppStorage`, like Show Comments
|
||||||
|
///
|
||||||
|
/// Show Comments has exactly one write path and exactly one thing reading it back — the checkbox
|
||||||
|
/// itself. This preference has two independent controls that must never drift the way the toolbar's
|
||||||
|
/// zoom buttons and the View-menu zoom rows must not (`BoardZoomStore.setLevel`'s rule), and the
|
||||||
|
/// toolbar's is AppKit underneath — an `NSMenuToolbarItem` cannot bind to `@AppStorage` at all.
|
||||||
|
///
|
||||||
|
/// ### Why the write path applies live, unlike zoom's
|
||||||
|
///
|
||||||
|
/// A zoom level only ever feeds a board's own drawing, so persisting it is enough — the board reads it
|
||||||
|
/// back through the environment. An appearance override is a statement about the whole app's chrome,
|
||||||
|
/// every open window included, so the setter both persists *and* calls the apply seam in the same
|
||||||
|
/// beat: there is no reload and no window that has to be told twice.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class AppearanceStore {
|
||||||
|
|
||||||
|
/// The current override. `nil` is Auto — the app follows the system appearance.
|
||||||
|
public private(set) var override: AppAppearance?
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
/// The one seam that touches `NSApp` — injected so a test can prove the setter's whole contract
|
||||||
|
/// (persist, then apply) without a live application object, `BoardZoomStore.defaults`'s reason
|
||||||
|
/// turned toward AppKit rather than `UserDefaults`.
|
||||||
|
@ObservationIgnored
|
||||||
|
private let apply: (NSAppearance.Name?) -> Void
|
||||||
|
|
||||||
|
/// - Parameters:
|
||||||
|
/// - defaults: the domain to persist in — injected for `BoardZoomStore`'s reason: a test holds
|
||||||
|
/// its own rather than touching the developer's real appearance.
|
||||||
|
/// - apply: what "make it so" means. Defaulted to the real thing; a test hands in a recording
|
||||||
|
/// closure instead so it never touches `NSApp`.
|
||||||
|
public init(
|
||||||
|
defaults: UserDefaults = .standard,
|
||||||
|
apply: @escaping (NSAppearance.Name?) -> Void = { name in
|
||||||
|
NSApp.appearance = name.map { NSAppearance(named: $0) } ?? nil
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
self.defaults = defaults
|
||||||
|
self.apply = apply
|
||||||
|
// A string, not an enum-backed scalar: the key is absent for Auto (the remove-at-default
|
||||||
|
// idiom a default lane width and an empty rename already use), and any value that survives to
|
||||||
|
// here but is neither "light" nor "dark" — a hand edit, a future build's spelling read by an
|
||||||
|
// older one — degrades to Auto rather than refusing to resolve. `AppAppearance.init(rawValue:)`
|
||||||
|
// already answers `nil` for anything it does not recognise, so the lenient read costs nothing
|
||||||
|
// beyond the `flatMap`.
|
||||||
|
override = defaults.string(forKey: AppPreferences.appearanceKey).flatMap(AppAppearance.init(rawValue:))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The pure resolver
|
||||||
|
|
||||||
|
/// What an override means to AppKit — no `NSApp`, no live application, provable with nothing but
|
||||||
|
/// the enum (`BoardZoom.normalize`'s reason: the rule is a function, and the object around it is
|
||||||
|
/// only that function's persistence and observability).
|
||||||
|
///
|
||||||
|
/// `nonisolated`, unlike everything else here: it touches no actor-isolated state, and marking it
|
||||||
|
/// so is what lets a plain (non-`@MainActor`) test call it directly, the same freedom
|
||||||
|
/// `BoardZoom.normalize` has by living outside `BoardZoomStore` entirely.
|
||||||
|
public nonisolated static func appearanceName(for override: AppAppearance?) -> NSAppearance.Name? {
|
||||||
|
switch override {
|
||||||
|
case .light: .aqua
|
||||||
|
case .dark: .darkAqua
|
||||||
|
case nil: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Writing
|
||||||
|
|
||||||
|
/// Sets the override, persists it, and applies it — the single write path the View-menu picker and
|
||||||
|
/// the board-toolbar item share (`BoardZoomStore.setLevel`'s rule: the two faces of one command
|
||||||
|
/// must never become two implementations of it).
|
||||||
|
///
|
||||||
|
/// **Auto removes the key** rather than writing a third spelling of it: the preference is meant to
|
||||||
|
/// read as "no override on file" to anyone who inspects it, the same bargain a default lane width
|
||||||
|
/// and an empty rename already keep.
|
||||||
|
///
|
||||||
|
/// **An unchanged value writes and applies nothing**, `BoardZoomStore.setLevel`'s own guard and for
|
||||||
|
/// the same load-bearing reason: `@Observable` notifies on every assignment, equal or not, so an
|
||||||
|
/// ungated write would invalidate every observer of `override` — the picker's checkmarks, the
|
||||||
|
/// toolbar controller's tracked validation — on a no-op, and hand the apply seam a repeat call for
|
||||||
|
/// nothing every one of its callers would have to tolerate.
|
||||||
|
public func setOverride(_ newValue: AppAppearance?) {
|
||||||
|
guard newValue != override else { return }
|
||||||
|
override = newValue
|
||||||
|
if let newValue {
|
||||||
|
defaults.set(newValue.rawValue, forKey: AppPreferences.appearanceKey)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: AppPreferences.appearanceKey)
|
||||||
|
}
|
||||||
|
apply(Self.appearanceName(for: newValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-applies the stored override — launch's whole job
|
||||||
|
/// (`AppDelegate.applicationDidFinishLaunching`). `init` above already read the value; this is the
|
||||||
|
/// method that hands it to AppKit, kept separate from `init` so building a store — including in a
|
||||||
|
/// test, including as `AppModel`'s own construction — is never itself a global side effect.
|
||||||
|
public func applyCurrent() {
|
||||||
|
apply(Self.appearanceName(for: override))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,11 @@ struct BoardWindowHost: View {
|
|||||||
// 10-accessibility.md's full-relative-scaling rule): at a large system text size a
|
// 10-accessibility.md's full-relative-scaling rule): at a large system text size a
|
||||||
// 640×400 floor would be narrower than two lane headers, and "every lane is always on
|
// 640×400 floor would be narrower than two lane headers, and "every lane is always on
|
||||||
// screen" would degrade into a strip of truncation.
|
// screen" would degrade into a strip of truncation.
|
||||||
|
//
|
||||||
|
// **The *system* size, not the zoomed one** (03-board-ui.md ▸ Layout — zoom: "Zoom never
|
||||||
|
// moves the window"). Zooming to 200% must not push a floor up under a window the user
|
||||||
|
// already sized: moving the window belongs to the right-edge lane drag alone, and a
|
||||||
|
// minimum that grew with the level would resize every open board from a menu item.
|
||||||
.frame(
|
.frame(
|
||||||
minWidth: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).width,
|
minWidth: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).width,
|
||||||
minHeight: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).height
|
minHeight: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).height
|
||||||
@@ -193,6 +198,11 @@ struct BoardWindowHost: View {
|
|||||||
openCard: openCard,
|
openCard: openCard,
|
||||||
search: boardSearch
|
search: boardSearch
|
||||||
)
|
)
|
||||||
|
// **The zoom level enters here and nowhere else** (03-board-ui.md ▸ Layout — zoom).
|
||||||
|
// On `BoardView` rather than on the `VStack`, deliberately: the level is the *board's*
|
||||||
|
// ruler, so the banner strip and the transient search bar above it — chrome, not the
|
||||||
|
// board — stay at the system's size, as do the sheets and popovers this window hosts.
|
||||||
|
.environment(\.boardZoom, appModel.zoom.context)
|
||||||
}
|
}
|
||||||
// The transient strip's two dismissal inputs (`BoardSearchPresentation
|
// The transient strip's two dismissal inputs (`BoardSearchPresentation
|
||||||
// .transientPersists`): it stays while a query is filtering the board or while the field
|
// .transientPersists`): it stays while a query is filtering the board or while the field
|
||||||
@@ -203,6 +213,21 @@ struct BoardWindowHost: View {
|
|||||||
.onChange(of: boardSearch.isFocused) { _, _ in
|
.onChange(of: boardSearch.isFocused) { _, _ in
|
||||||
boardSearch.dismissTransientIfCleared(query: store.searchQuery)
|
boardSearch.dismissTransientIfCleared(query: store.searchQuery)
|
||||||
}
|
}
|
||||||
|
// **The window chrome follows the board's background** (03-board-ui.md § Styling ▸
|
||||||
|
// Capabilities): a board that paints one runs its content the full height of the frame
|
||||||
|
// under a transparent title bar, with `BoardView.boardBackground`'s frosted strip
|
||||||
|
// keeping the widget and the toolbar legible over it; a board that paints none keeps
|
||||||
|
// the standard chrome untouched.
|
||||||
|
//
|
||||||
|
// Here rather than in `configureWindow` because it is not a wiring fact but a *live*
|
||||||
|
// one: `background` is hand-editable, the watcher reloads on a change to `index.md`, and
|
||||||
|
// the chrome has to follow the reading in both directions. `initial: true` because the
|
||||||
|
// first render is already a level, not a change — this is the board's first statement
|
||||||
|
// about its chrome, and the loading half deliberately made none
|
||||||
|
// (`HostedWindowController.extendsUnderTitlebar`).
|
||||||
|
.onChange(of: BoardBackdrop.isCustom(store.snapshot, root: store.rootURL), initial: true) { _, custom in
|
||||||
|
windowController.setExtendsContentUnderTitlebar(custom)
|
||||||
|
}
|
||||||
// **The board settings sheet** (03-board-ui.md ▸ Board settings sheet) — presented from
|
// **The board settings sheet** (03-board-ui.md ▸ Board settings sheet) — presented from
|
||||||
// the board window's own content, which is what makes it modal to *this* board rather
|
// the board window's own content, which is what makes it modal to *this* board rather
|
||||||
// than to the app: "a board-scoped, titled, sectioned sheet on the board window".
|
// than to the app: "a board-scoped, titled, sectioned sheet on the board window".
|
||||||
@@ -235,7 +260,7 @@ struct BoardWindowHost: View {
|
|||||||
/// Opens a card's window. `openWindow(value:)` with a ref that already has a window focuses it,
|
/// Opens a card's window. `openWindow(value:)` with a ref that already has a window focuses it,
|
||||||
/// so "at most one card window per card (reopen focuses)" needs no bookkeeping here
|
/// so "at most one card window per card (reopen focuses)" needs no bookkeeping here
|
||||||
/// (02-architecture.md § Windows).
|
/// (02-architecture.md § Windows).
|
||||||
private var openCard: (ItemID) -> Void {
|
private var openCard: @MainActor (ItemID) -> Void {
|
||||||
{ cardID in
|
{ cardID in
|
||||||
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
|
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
|
||||||
}
|
}
|
||||||
@@ -746,7 +771,13 @@ struct BoardWindowHost: View {
|
|||||||
// accessory's reason exactly: it carries the store, and it is a board window's, not every
|
// accessory's reason exactly: it carries the store, and it is a board window's, not every
|
||||||
// hosted window's. Its search item is the search field's home, and it is what tells
|
// hosted window's. Its search item is the search field's home, and it is what tells
|
||||||
// `boardSearch` whether that home still exists.
|
// `boardSearch` whether that home still exists.
|
||||||
windowController.installToolbar(BoardToolbar.controller(store: store, search: boardSearch))
|
windowController.installToolbar(BoardToolbar.controller(
|
||||||
|
store: store,
|
||||||
|
search: boardSearch,
|
||||||
|
zoom: appModel.zoom,
|
||||||
|
appearance: appModel.appearance,
|
||||||
|
session: appModel.dragSession
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Closing
|
// MARK: - Closing
|
||||||
|
|||||||
@@ -183,10 +183,16 @@ struct TemplateChooserView: View {
|
|||||||
) {
|
) {
|
||||||
ForEach(rows) { row in
|
ForEach(rows) { row in
|
||||||
TemplateCard(row: row, isSelected: row.id == selected?.id)
|
TemplateCard(row: row, isSelected: row.id == selected?.id)
|
||||||
.onTapGesture { selection = row.id }
|
// A double click is how a chooser is answered without reaching for a
|
||||||
// The list convention welcome's recents use, for the same reason: a
|
// button (welcome's list convention). One recogniser branching on
|
||||||
// double click is how a chooser is answered without reaching for a button.
|
// `PointerClick.count`, never a second two-tap one — stacked, it delays
|
||||||
.onTapGesture(count: 2) { choose() }
|
// the single click by the whole double-click interval; simultaneous, it
|
||||||
|
// still holds clicks on a view with no drag source (`PointerClick`). The
|
||||||
|
// first click of the pair selects the tile, which is also what aims
|
||||||
|
// `choose()` at the clicked row.
|
||||||
|
.onTapGesture {
|
||||||
|
if PointerClick.count > 1 { choose() } else { selection = row.id }
|
||||||
|
}
|
||||||
// **Tab-reachable, and a button to the accessibility tree** — the tile is
|
// **Tab-reachable, and a button to the accessibility tree** — the tile is
|
||||||
// the chooser's one act of choosing, so it has to be a control rather than a
|
// the chooser's one act of choosing, so it has to be a control rather than a
|
||||||
// decorated rectangle that happens to answer clicks (10-accessibility.md ▸
|
// decorated rectangle that happens to answer clicks (10-accessibility.md ▸
|
||||||
|
|||||||
@@ -109,6 +109,24 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
|||||||
/// what the chrome draws from it.
|
/// what the chrome draws from it.
|
||||||
private var titleVisibility: NSWindow.TitleVisibility?
|
private var titleVisibility: NSWindow.TitleVisibility?
|
||||||
|
|
||||||
|
/// Whether this window's content runs the full height of the frame, under a transparent title
|
||||||
|
/// bar — **a board window carrying a custom background**, and nothing else (03-board-ui.md §
|
||||||
|
/// Styling ▸ Capabilities: the board's colour or image "paints the full window"; `BoardView
|
||||||
|
/// .boardBackground` draws the frosted strip that keeps the chrome legible over it).
|
||||||
|
///
|
||||||
|
/// `nil` leaves AppKit's own posture untouched, exactly as `titleVisibility` does — the welcome,
|
||||||
|
/// bootstrap and card windows have no opinion, and neither does a board window while it loads
|
||||||
|
/// (the flag is driven off the snapshot, which does not exist yet). `nil` and `false` therefore
|
||||||
|
/// render identically; they differ only in whether this controller has *said* anything, which is
|
||||||
|
/// what keeps the loading half from having to state a default it does not own.
|
||||||
|
///
|
||||||
|
/// A slot rather than a one-shot write, and **repeat-safe rather than install-once** — the
|
||||||
|
/// `hideTitle` pattern, for a stronger version of its reason: the value has to survive the
|
||||||
|
/// provisional-window swap (`detach()`), *and* it genuinely changes over a window's life. A
|
||||||
|
/// `background:` edited on disk reloads the snapshot, and the chrome follows it in both
|
||||||
|
/// directions.
|
||||||
|
private var extendsUnderTitlebar: Bool?
|
||||||
|
|
||||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
|
||||||
|
|
||||||
// MARK: Attachment
|
// MARK: Attachment
|
||||||
@@ -129,6 +147,7 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
|||||||
addTitlebarAccessoryIfPossible()
|
addTitlebarAccessoryIfPossible()
|
||||||
applyToolbarIfPossible()
|
applyToolbarIfPossible()
|
||||||
applyTitleVisibilityIfPossible()
|
applyTitleVisibilityIfPossible()
|
||||||
|
applyTitlebarExtensionIfPossible()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Puts the previous delegate back and takes the titlebar accessory and toolbar off the window —
|
/// Puts the previous delegate back and takes the titlebar accessory and toolbar off the window —
|
||||||
@@ -233,6 +252,39 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
|||||||
window.titleVisibility = titleVisibility
|
window.titleVisibility = titleVisibility
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Content under the title bar
|
||||||
|
|
||||||
|
/// Runs this window's content the full height of its frame, under a transparent title bar — or
|
||||||
|
/// puts the standard chrome back (see `extendsUnderTitlebar`).
|
||||||
|
///
|
||||||
|
/// Safe whenever the caller learns the answer — before the window exists (held, applied at
|
||||||
|
/// `attach`) or after (applied now) — and safe to call repeatedly with the same value, which
|
||||||
|
/// matters more here than for `hideTitle`: the board window drives this off its snapshot, so it
|
||||||
|
/// is called on every reload that changes the reading and on plenty that do not.
|
||||||
|
///
|
||||||
|
/// **Not undone at `detach`**, `titleVisibility`'s posture: the slot survives the provisional-
|
||||||
|
/// window swap and reapplies itself to whichever window attaches next, and a window that is
|
||||||
|
/// genuinely going away takes its chrome with it.
|
||||||
|
func setExtendsContentUnderTitlebar(_ flag: Bool) {
|
||||||
|
extendsUnderTitlebar = flag
|
||||||
|
applyTitlebarExtensionIfPossible()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two AppKit knobs the effect needs, and they are one decision: `fullSizeContentView` is
|
||||||
|
/// what lets the content view reach under the title bar, and `titlebarAppearsTransparent` is
|
||||||
|
/// what stops the title bar from painting its own material over it. Either alone is a visible
|
||||||
|
/// half-state — an opaque bar over the board, or a board that stops at a bar that no longer
|
||||||
|
/// draws.
|
||||||
|
private func applyTitlebarExtensionIfPossible() {
|
||||||
|
guard let window, let extendsUnderTitlebar else { return }
|
||||||
|
window.titlebarAppearsTransparent = extendsUnderTitlebar
|
||||||
|
if extendsUnderTitlebar {
|
||||||
|
window.styleMask.insert(.fullSizeContentView)
|
||||||
|
} else {
|
||||||
|
window.styleMask.remove(.fullSizeContentView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Closes the window for real, after the flush has run. `performClose` rather than `close` so the
|
/// Closes the window for real, after the flush has run. `performClose` rather than `close` so the
|
||||||
/// standard path runs — SwiftUI's own delegate gets its callbacks, tabbing behaves — with the
|
/// standard path runs — SwiftUI's own delegate gets its callbacks, tabbing behaves — with the
|
||||||
/// flag telling our own `windowShouldClose` to stand aside.
|
/// flag telling our own `windowShouldClose` to stand aside.
|
||||||
|
|||||||
@@ -83,6 +83,18 @@ struct ToolbarItemSpec {
|
|||||||
make: (_ willBeInsertedIntoToolbar: Bool) -> NSSearchField,
|
make: (_ willBeInsertedIntoToolbar: Bool) -> NSSearchField,
|
||||||
install: (NSSearchToolbarItem) -> Void
|
install: (NSSearchToolbarItem) -> Void
|
||||||
)
|
)
|
||||||
|
/// A pull-down of mutually exclusive choices — **Appearance** (03-board-ui.md ▸ Toolbar): an
|
||||||
|
/// `NSMenuToolbarItem`, item image plus indicator, whose menu lists `options` in order.
|
||||||
|
/// `selected()` names the option index carrying the checkmark, read fresh whenever AppKit
|
||||||
|
/// opens the menu rather than polled — the same freshness every other menu row in the app
|
||||||
|
/// gets (`validateMenuItem(_:)`) — and `select(_:)` is a chosen row's whole action. The one
|
||||||
|
/// behavior with no `activate()` of its own: firing lives in the dropdown's rows, not in the
|
||||||
|
/// item itself, the way `responderAction`'s lives in the responder chain rather than here.
|
||||||
|
case picker(
|
||||||
|
options: [(title: String, symbol: String?)],
|
||||||
|
selected: () -> Int?,
|
||||||
|
select: (Int) -> Void
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The vocabulary rule applied: an item that mirrors a menu row takes that row's title, minus a
|
/// The vocabulary rule applied: an item that mirrors a menu row takes that row's title, minus a
|
||||||
@@ -121,7 +133,7 @@ struct ToolbarItemSpec {
|
|||||||
switch behavior {
|
switch behavior {
|
||||||
case let .button(isEnabled, _): isEnabled()
|
case let .button(isEnabled, _): isEnabled()
|
||||||
case let .toggle(isEnabled, _, _): isEnabled()
|
case let .toggle(isEnabled, _, _): isEnabled()
|
||||||
case .responderAction, .searchField: true
|
case .responderAction, .searchField, .picker: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,17 +141,18 @@ struct ToolbarItemSpec {
|
|||||||
var isOn: Bool? {
|
var isOn: Bool? {
|
||||||
switch behavior {
|
switch behavior {
|
||||||
case let .toggle(_, isOn, _): isOn()
|
case let .toggle(_, isOn, _): isOn()
|
||||||
case .button, .responderAction, .searchField: nil
|
case .button, .responderAction, .searchField, .picker: nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Firing the item: a button performs, a toggle flips. A no-op for the two kinds AppKit drives
|
/// Firing the item: a button performs, a toggle flips. A no-op for the kinds AppKit drives itself
|
||||||
/// itself.
|
/// or that fire from somewhere other than the item's own primary action (`.picker`'s dropdown
|
||||||
|
/// rows).
|
||||||
func activate() {
|
func activate() {
|
||||||
switch behavior {
|
switch behavior {
|
||||||
case let .button(_, perform): perform()
|
case let .button(_, perform): perform()
|
||||||
case let .toggle(_, isOn, setOn): setOn(!isOn())
|
case let .toggle(_, isOn, setOn): setOn(!isOn())
|
||||||
case .responderAction, .searchField: break
|
case .responderAction, .searchField, .picker: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,6 +315,8 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
|||||||
// window-scoped wiring, so it is handed none.
|
// window-scoped wiring, so it is handed none.
|
||||||
install: willBeInsertedIntoToolbar ? install : nil
|
install: willBeInsertedIntoToolbar ? install : nil
|
||||||
)
|
)
|
||||||
|
case let .picker(options, _, _):
|
||||||
|
return makePickerItem(spec, options: options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +421,41 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A pull-down of mutually exclusive options — **Appearance**, so far the one item of this shape.
|
||||||
|
///
|
||||||
|
/// `selected`/`select` are deliberately not captured here: every row's action and every row's
|
||||||
|
/// validation re-fetch the spec fresh from `specs[identifier]` (`pickerItemFired(_:)`,
|
||||||
|
/// `validateMenuItem(_:)`), the same indirection `itemFired(_:)` and `toggleFired(_:)` already use
|
||||||
|
/// for their own specs — so a spec rebuilt between two menu presentations is never read stale.
|
||||||
|
private func makePickerItem(_ spec: ToolbarItemSpec, options: [(title: String, symbol: String?)]) -> NSToolbarItem {
|
||||||
|
let item = NSMenuToolbarItem(itemIdentifier: spec.identifier)
|
||||||
|
decorate(item, with: spec)
|
||||||
|
// "Pull-down: item image + indicator" — the item's own glyph draws at rest, the indicator
|
||||||
|
// chevron shows there is a menu, and the rows are what actually name Auto/Light/Dark.
|
||||||
|
item.showsIndicator = true
|
||||||
|
|
||||||
|
let menu = NSMenu()
|
||||||
|
for (index, option) in options.enumerated() {
|
||||||
|
let menuItem = NSMenuItem(
|
||||||
|
title: option.title,
|
||||||
|
action: #selector(pickerItemFired(_:)),
|
||||||
|
keyEquivalent: ""
|
||||||
|
)
|
||||||
|
menuItem.target = self
|
||||||
|
// The row's position in `options`, not an identifier of its own — `select(_:)` and
|
||||||
|
// `selected()` both speak in this same index, which is what lets one closure pair stand
|
||||||
|
// for every row rather than one closure per option.
|
||||||
|
menuItem.tag = index
|
||||||
|
menuItem.representedObject = spec.identifier.rawValue
|
||||||
|
if let symbol = option.symbol {
|
||||||
|
menuItem.image = NSImage(systemSymbolName: symbol, accessibilityDescription: option.title)
|
||||||
|
}
|
||||||
|
menu.addItem(menuItem)
|
||||||
|
}
|
||||||
|
item.menu = menu
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
/// The three strings every item carries: the toolbar label, the palette label (the same string —
|
/// The three strings every item carries: the toolbar label, the palette label (the same string —
|
||||||
/// one vocabulary), and the tooltip, which is what a user of an icon-only toolbar reads.
|
/// one vocabulary), and the tooltip, which is what a user of an icon-only toolbar reads.
|
||||||
private func decorate(_ item: NSToolbarItem, with spec: ToolbarItemSpec) {
|
private func decorate(_ item: NSToolbarItem, with spec: ToolbarItemSpec) {
|
||||||
@@ -444,13 +494,30 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
|||||||
revalidate()
|
revalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A row in a `.picker` item's own dropdown — **Appearance**'s Auto/Light/Dark, fired straight
|
||||||
|
/// from the menu rather than through `itemFired(_:)`, since the item has no primary action of its
|
||||||
|
/// own (`ToolbarItemSpec.activate()` is a no-op for `.picker`).
|
||||||
|
@objc private func pickerItemFired(_ sender: NSMenuItem) {
|
||||||
|
guard let raw = sender.representedObject as? String,
|
||||||
|
let spec = specs[NSToolbarItem.Identifier(raw)],
|
||||||
|
case let .picker(_, _, select) = spec.behavior
|
||||||
|
else { return }
|
||||||
|
select(sender.tag)
|
||||||
|
revalidate()
|
||||||
|
}
|
||||||
|
|
||||||
/// The overflow menu's copy of an item validates like the item itself — including the checkmark,
|
/// The overflow menu's copy of an item validates like the item itself — including the checkmark,
|
||||||
/// which is where a toggle's on-state goes when the menu is its face (03 ▸ Toolbar: "the system
|
/// which is where a toggle's on-state goes when the menu is its face (03 ▸ Toolbar: "the system
|
||||||
/// overflow").
|
/// overflow") — and where a `.picker` row's checkmark goes too, against its own index rather than
|
||||||
|
/// against `isOn` (which answers `nil` for the whole item, having no single on-state to give).
|
||||||
@objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
|
@objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
|
||||||
guard let raw = menuItem.representedObject as? String,
|
guard let raw = menuItem.representedObject as? String,
|
||||||
let spec = specs[NSToolbarItem.Identifier(raw)]
|
let spec = specs[NSToolbarItem.Identifier(raw)]
|
||||||
else { return true }
|
else { return true }
|
||||||
|
if case let .picker(_, selected, _) = spec.behavior {
|
||||||
|
menuItem.state = selected() == menuItem.tag ? .on : .off
|
||||||
|
return true
|
||||||
|
}
|
||||||
if let isOn = spec.isOn {
|
if let isOn = spec.isOn {
|
||||||
menuItem.state = isOn ? .on : .off
|
menuItem.state = isOn ? .on : .off
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,17 +345,21 @@ extension BoardStore {
|
|||||||
/// the write — or removes the key, which is what "before" means for a field that was not there.
|
/// the write — or removes the key, which is what "before" means for a field that was not there.
|
||||||
///
|
///
|
||||||
/// A **malformed** prior reads as a removal, and that is the one place an inverse is not
|
/// A **malformed** prior reads as a removal, and that is the one place an inverse is not
|
||||||
/// byte-exact: the app cannot re-emit `background: [a, b]` through a document edit that only
|
/// byte-exact: the app cannot re-emit `background: [a, b]` — or a hand-written scalar
|
||||||
/// knows how to set scalars. It is also the case the forward write was designed to clear
|
/// `background: green`, which the schema stopped reading when the key became a mapping — through
|
||||||
/// ("choosing any well replaces it" — 03-board-ui.md § Styling ▸ Controls), so the undo lands the
|
/// a document edit that only writes the shapes the schema names. It is also exactly the case the
|
||||||
/// item on the app's own reading of that field rather than resurrecting a value nothing could
|
/// forward write was designed to clear ("choosing any well replaces it" — 03-board-ui.md §
|
||||||
/// read.
|
/// Styling ▸ Controls), so the undo lands the item on the app's own reading of that field rather
|
||||||
|
/// than resurrecting a value nothing could read.
|
||||||
|
///
|
||||||
|
/// The **mapping** case needs no branch of its own here and gets none: `setStyleValue` edits the
|
||||||
|
/// `color` subkey and leaves the rest (BackgroundField.swift), so an undo on a board with an
|
||||||
|
/// image restores the colour the board had — including restoring it to *absent* — without
|
||||||
|
/// disturbing the image the forward write already preserved. What the inverse does not promise
|
||||||
|
/// is the author's subkey order when the colour was absent before: a restored colour that had no
|
||||||
|
/// pair to go back to is appended, like any newly written subkey.
|
||||||
static func restore(_ prior: FieldValue<String>, to key: String, in document: inout FrontmatterDocument) {
|
static func restore(_ prior: FieldValue<String>, to key: String, in document: inout FrontmatterDocument) {
|
||||||
if let value = prior.value {
|
document.setStyleValue(prior.value, for: key)
|
||||||
document.set(key, to: .string(value))
|
|
||||||
} else {
|
|
||||||
document.remove(key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The style fields a gesture actually set — **one entry per dimension it did not `.keep`**, so a
|
/// The style fields a gesture actually set — **one entry per dimension it did not `.keep`**, so a
|
||||||
|
|||||||
+16
-3
@@ -240,15 +240,28 @@ struct KanbanApp: App {
|
|||||||
ToolbarCommands()
|
ToolbarCommands()
|
||||||
|
|
||||||
// The View menu. `CommandGroupPlacement.toolbar` *is* View — the menu the toolbar's own
|
// The View menu. `CommandGroupPlacement.toolbar` *is* View — the menu the toolbar's own
|
||||||
// items live in — which is where 11-command-nexus.md files Show Trash. A divider separates it
|
// items live in — which is where 11-command-nexus.md files Show Trash. Three dividers split it
|
||||||
// from the card window's three view-state rows below: one board-scoped toggle, then a
|
// by scope, which is the only grouping the inventory implies: the board's toggle, then the
|
||||||
// card-scoped trio.
|
// board's zoom ladder, then the card window's view-state rows, then the app-wide appearance
|
||||||
|
// override — last, because unlike everything above it, it needs no window in front at all.
|
||||||
|
//
|
||||||
|
// "Zoom In" / "Zoom Out" / "Actual Size" rather than a single "Zoom": the system's own Window
|
||||||
|
// menu already carries a row titled Zoom, and titles are the remapping mechanism's key, so a
|
||||||
|
// second one would collide (the rule this file's own header states).
|
||||||
CommandGroup(after: .toolbar) {
|
CommandGroup(after: .toolbar) {
|
||||||
ShowTrashCommand()
|
ShowTrashCommand()
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
ZoomCommands(appModel: appModel)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
CardViewCommands()
|
CardViewCommands()
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
AppearanceCommands(appModel: appModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open
|
// The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open
|
||||||
|
|||||||
@@ -1931,11 +1931,16 @@ public final class BoardStore: HealHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Through `setStyleValue` rather than `set`/`remove` directly, which is this gesture's whole
|
||||||
|
/// answer to `background` being a mapping (BackgroundField.swift): the colour is written *into*
|
||||||
|
/// the key rather than over it, so a board carrying `background: {color: …, image: …}` comes out
|
||||||
|
/// of a colour change still carrying its image. `icon` takes the plain scalar path through the
|
||||||
|
/// same call.
|
||||||
private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) {
|
private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) {
|
||||||
switch change {
|
switch change {
|
||||||
case .keep: break
|
case .keep: break
|
||||||
case let .set(value): document.set(key, to: .string(value))
|
case let .set(value): document.setStyleValue(value, for: key)
|
||||||
case .remove: document.remove(key)
|
case .remove: document.setStyleValue(nil, for: key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// The board's zoom level: one number, app-wide, persisted (11-command-nexus.md ▸ View ▸ Actual Size
|
||||||
|
/// — "app-wide and persisted across restarts"; 03-board-ui.md ▸ Layout — zoom).
|
||||||
|
///
|
||||||
|
/// `StyleRecents`' shape exactly, for its reasons. The *rules* — what the rungs are, how stepping
|
||||||
|
/// terminates, what an illegal stored value becomes — are `BoardZoom`'s pure functions; this object is
|
||||||
|
/// their persistence and their observability, and `defaults` is injectable so a test drives a suite of
|
||||||
|
/// its own rather than the user's.
|
||||||
|
///
|
||||||
|
/// ### Why app-wide, and not per board
|
||||||
|
///
|
||||||
|
/// A zoom level describes how this user likes to read, not what a board is. It has no business in a
|
||||||
|
/// lane's frontmatter (where `width` lives — that *is* board data, shared with every collaborator and
|
||||||
|
/// every agent), and no business in `BoardRegistry` either: a board opened on a laptop and on a studio
|
||||||
|
/// display wants the same *preference* applied, not a per-board memory of a window that no longer
|
||||||
|
/// exists. It sits beside Show Comments and Comments Beside Body, which are app-wide and persisted for
|
||||||
|
/// the same reason.
|
||||||
|
///
|
||||||
|
/// ### Why `@Observable` rather than `@AppStorage`
|
||||||
|
///
|
||||||
|
/// Two consumers need change notification that a property wrapper in a view cannot give them. The
|
||||||
|
/// board toolbar's validation is *observed*, not polled — `WindowToolbarController.trackValidationState`
|
||||||
|
/// re-arms `withObservationTracking` over each spec's `isEnabled`, so Zoom In greys out at the top rung
|
||||||
|
/// only if the level it reads is observable. And menu commands live outside every scene's environment,
|
||||||
|
/// so they receive `AppModel` as a plain `let` and re-render only because it is `@Observable`. One
|
||||||
|
/// observable holder serves both, and the level has exactly one home rather than a mirror per surface.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class BoardZoomStore {
|
||||||
|
|
||||||
|
/// The current rung — always a member of `BoardZoom.levels`, guaranteed by construction: the
|
||||||
|
/// initialiser normalises what it reads and `setLevel` normalises what it is given, so no reader
|
||||||
|
/// anywhere has to ask whether its level is legal.
|
||||||
|
public private(set) var level: CGFloat
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
/// - Parameter defaults: the domain to persist in. Injected for `StyleRecents`' reason — a test
|
||||||
|
/// must be able to hold its own without touching the user's.
|
||||||
|
public init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
// `object(forKey:)` rather than `double(forKey:)` so an unset key arrives as nil rather than as
|
||||||
|
// 0, and `normalize` rather than `?? 1.0` so a hand-edited or stale value lands on a rung too.
|
||||||
|
// Either way the answer is legal; the distinction only decides *which* legal value an absent
|
||||||
|
// key becomes, and both roads lead to Actual Size.
|
||||||
|
level = BoardZoom.normalize((defaults.object(forKey: AppPreferences.boardZoomLevelKey) as? Double) ?? Double(BoardZoom.actualSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Moving
|
||||||
|
|
||||||
|
/// Sets the level to the nearest legal rung and persists it.
|
||||||
|
///
|
||||||
|
/// The single write path — Zoom In, Zoom Out, Actual Size and the two toolbar buttons all arrive
|
||||||
|
/// here, which is what keeps the menu row and its toolbar twin from being two implementations of
|
||||||
|
/// one command (the `BoardStore.setTrashVisible` precedent).
|
||||||
|
///
|
||||||
|
/// **An unchanged level writes nothing and publishes nothing**, and unlike `StyleRecents.record`'s
|
||||||
|
/// unconditional write that guard is load-bearing rather than an optimisation. `@Observable`
|
||||||
|
/// notifies on *every* set, equal or not, so an ungated assignment would invalidate the board
|
||||||
|
/// window's zoom environment on a no-op — Actual Size when already at 100%, ⌘+ at the top rung —
|
||||||
|
/// and re-run the whole strip to draw exactly what it was drawing. A board nobody zoomed must not
|
||||||
|
/// pay for the feature existing.
|
||||||
|
///
|
||||||
|
/// The *announcement* is deliberately not gated with it (`step(_:)`): a user who pressed ⌘+ at the
|
||||||
|
/// top rung is still owed the answer.
|
||||||
|
public func setLevel(_ newValue: CGFloat) {
|
||||||
|
let normalized = BoardZoom.normalize(Double(newValue))
|
||||||
|
guard normalized != level else { return }
|
||||||
|
level = normalized
|
||||||
|
defaults.set(Double(normalized), forKey: AppPreferences.boardZoomLevelKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One rung up; a fixed point at the top.
|
||||||
|
public func zoomIn() { setLevel(BoardZoom.stepIn(from: level)) }
|
||||||
|
|
||||||
|
/// One rung down; a fixed point at the bottom.
|
||||||
|
public func zoomOut() { setLevel(BoardZoom.stepOut(from: level)) }
|
||||||
|
|
||||||
|
/// Back to 100%, where the strip renders exactly what it rendered before zoom existed.
|
||||||
|
public func actualSize() { setLevel(BoardZoom.actualSize) }
|
||||||
|
|
||||||
|
// MARK: - Reading
|
||||||
|
|
||||||
|
public var canZoomIn: Bool { BoardZoom.canZoomIn(level) }
|
||||||
|
|
||||||
|
public var canZoomOut: Bool { BoardZoom.canZoomOut(level) }
|
||||||
|
|
||||||
|
public var isActualSize: Bool { BoardZoom.isActualSize(level) }
|
||||||
|
|
||||||
|
/// The level as the strip's views take it.
|
||||||
|
///
|
||||||
|
/// Internal rather than `public` like its neighbours, and the asymmetry is the type's, not an
|
||||||
|
/// oversight: `BoardZoomContext` is an environment value, which is a UI-layer concern the way
|
||||||
|
/// `dragSession` is — nothing outside this module has any business rendering a board.
|
||||||
|
var context: BoardZoomContext { BoardZoomContext(level: level) }
|
||||||
|
|
||||||
|
/// "125%" — the announcement's value (10-accessibility.md ▸ Text scaling).
|
||||||
|
public var percentLabel: String { BoardZoom.percentLabel(level) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// **The write side of `background`** — the read side is `FrontmatterDocument.background` and
|
||||||
|
/// `.backgroundImage` (FrontmatterFields.swift).
|
||||||
|
///
|
||||||
|
/// `background` is a mapping and only a mapping (01-storage-format.md § Frontmatter, ruled
|
||||||
|
/// 2026-08-06), so the app writes one: `{color: "#112233", image: sunset.jpg}`. That one key holds
|
||||||
|
/// two independent values and the app has a control for exactly one of them — the colour grid
|
||||||
|
/// (03-board-ui.md § Styling ▸ Controls). There is no image picker and none is planned; the path is
|
||||||
|
/// hand-written, "the raw file is the escape hatch" applied one field over. So a style write edits
|
||||||
|
/// the **subkey, not the key**: choosing a well on a board that carries an image leaves the image
|
||||||
|
/// where it is, and the None well removes the colour alone.
|
||||||
|
///
|
||||||
|
/// A key that is *not* a mapping — a retired scalar somebody hand-wrote, a sequence — has no
|
||||||
|
/// subkeys to preserve and is simply replaced by the mapping the app writes. That is the
|
||||||
|
/// malformed-value-cleared posture `icon` already has ("choosing any well replaces it",
|
||||||
|
/// § Styling ▸ Controls), which is exactly right here: the reader could not make a colour of it
|
||||||
|
/// either.
|
||||||
|
///
|
||||||
|
/// ### Preservation is per subkey, not per byte
|
||||||
|
///
|
||||||
|
/// The document's surgical editor rewrites a key's whole value lines, and `FrontmatterValue` has no
|
||||||
|
/// mapping case to rewrite them with — the engine emits scalars and has never round-tripped a
|
||||||
|
/// collection. So the merged value is re-emitted as a flow mapping through the `.raw` escape, built
|
||||||
|
/// from the *parsed* subvalues: every other subkey survives as a value and in its original position,
|
||||||
|
/// while its spelling does not — a block mapping collapses to flow form, quoting is normalized, and
|
||||||
|
/// a subvalue's own inline comment is lost with the lines it sat on.
|
||||||
|
///
|
||||||
|
/// That is the narrowest place in the app where 01-storage-format.md's verbatim promise yields, and
|
||||||
|
/// it yields only on the one key the write was already rewriting: a board with no `background` key,
|
||||||
|
/// or one written as a plain scalar, takes exactly the path it always took. The alternative — a
|
||||||
|
/// mapping-aware span editor — is a great deal of machinery for a field with two subkeys, one of
|
||||||
|
/// which the app writes.
|
||||||
|
extension FrontmatterDocument {
|
||||||
|
|
||||||
|
/// Writes a lenient string style field — `title`, `background`, `icon` — or removes the key
|
||||||
|
/// when `value` is `nil`, which is what "before" means for a field that was not there and what
|
||||||
|
/// the None well leaves behind.
|
||||||
|
///
|
||||||
|
/// `title` and `icon` are a plain scalar `set`/`remove`, unchanged and unchangeable: their
|
||||||
|
/// values *are* strings.
|
||||||
|
///
|
||||||
|
/// **`background` is always written as a mapping**, whatever it held before. One already written
|
||||||
|
/// as one keeps it, with the `color` subkey replaced in place, appended when it was absent, or
|
||||||
|
/// dropped — every other subkey carried through either way. Anything else starts from no subkeys
|
||||||
|
/// at all, so a colour lands as `{color: "…"}` and a removal simply takes the key. A mapping the
|
||||||
|
/// removal empties takes the key with it too, because `background: {}` is a key that says nothing
|
||||||
|
/// and the removal's contract is that the field is gone.
|
||||||
|
///
|
||||||
|
/// Deliberately keyed on `background` rather than on "whatever is mapping-shaped": `icon` has no
|
||||||
|
/// subkey vocabulary at all, so a hand-written `icon: {a: 1}` — a malformed value the forward
|
||||||
|
/// write exists to clear — must be *replaced* by the chosen symbol, never merged into.
|
||||||
|
public mutating func setStyleValue(_ value: String?, for key: String) {
|
||||||
|
guard key == FrontmatterKeys.background else {
|
||||||
|
if let value {
|
||||||
|
set(key, to: .string(value))
|
||||||
|
} else {
|
||||||
|
remove(key)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Only a mapping has subkeys worth carrying; every other shape — absent, the retired scalar,
|
||||||
|
// a sequence — starts empty and is replaced outright by what the app writes.
|
||||||
|
var existing: [YAMLValue.Pair] = []
|
||||||
|
if case let .mapping(pairs)? = self.value(for: key) { existing = pairs }
|
||||||
|
|
||||||
|
let merged = Self.merged(existing, subkey: FrontmatterKeys.Background.color, value: value)
|
||||||
|
if merged.isEmpty {
|
||||||
|
remove(key)
|
||||||
|
} else {
|
||||||
|
set(key, to: .raw(Self.flowMapping(merged)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pairs` with `subkey` set to `value`, or removed when it is `nil` — **in place**: a subkey
|
||||||
|
/// that was already there comes back at the index it occupied, so the author's own key order
|
||||||
|
/// survives a colour change. One that was not there is appended, which is the only position that
|
||||||
|
/// says nothing about what the author intended.
|
||||||
|
private static func merged(
|
||||||
|
_ pairs: [YAMLValue.Pair],
|
||||||
|
subkey: String,
|
||||||
|
value: String?
|
||||||
|
) -> [YAMLValue.Pair] {
|
||||||
|
var merged = pairs.filter { $0.key != .string(subkey) }
|
||||||
|
guard let value else { return merged }
|
||||||
|
let pair = YAMLValue.Pair(key: .string(subkey), value: .string(value))
|
||||||
|
guard let index = pairs.firstIndex(where: { $0.key == .string(subkey) }) else {
|
||||||
|
merged.append(pair)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
// Every survivor ahead of the old occurrence kept its index, so the old index is still the
|
||||||
|
// right hole; the clamp is belt-and-braces against a shape the parser cannot actually produce
|
||||||
|
// (a nested duplicate key is `unparseableYAML`, so at most one pair was filtered out).
|
||||||
|
merged.insert(pair, at: min(index, merged.count))
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pairs as a single-line YAML flow mapping — the one form the span editor can write, since
|
||||||
|
/// it replaces a key's value with one line's worth of text.
|
||||||
|
private static func flowMapping(_ pairs: [YAMLValue.Pair]) -> String {
|
||||||
|
"{" + pairs.map { "\(flowKey($0.key)): \(flowText($0.value))" }.joined(separator: ", ") + "}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mapping key in flow context: plain when it is a bare word — a letter or `_` first, then
|
||||||
|
/// letters, digits, `-`, `_`, `.` — and emitted as a value otherwise.
|
||||||
|
///
|
||||||
|
/// The pretty case is the only one that occurs (`color`, `image`, an agent's own subkey) and is
|
||||||
|
/// worth keeping pretty: this text is read by hand. The fallback is what stops a key nobody
|
||||||
|
/// anticipated from breaking the collection it is written into.
|
||||||
|
private static func flowKey(_ value: YAMLValue) -> String {
|
||||||
|
guard case let .string(text) = value, let first = text.unicodeScalars.first,
|
||||||
|
CharacterSet.letters.contains(first) || first == "_",
|
||||||
|
text.unicodeScalars.allSatisfy({
|
||||||
|
CharacterSet.alphanumerics.contains($0) || $0 == "-" || $0 == "_" || $0 == "."
|
||||||
|
})
|
||||||
|
else { return flowText(value) }
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One value inside a flow collection.
|
||||||
|
///
|
||||||
|
/// **Strings are always double-quoted**, which is the rule that makes this safe without a YAML
|
||||||
|
/// emitter: `FrontmatterValue.emitScalar`'s round-trip check asks whether a value survives in
|
||||||
|
/// *block* context, and flow context ends a plain scalar at `,`, `]`, `}` and `: ` too — so a
|
||||||
|
/// colour or a path that round-trips fine on its own line could still break the mapping it is
|
||||||
|
/// written into. Quoting costs two characters on a hex that would not have needed them, and a
|
||||||
|
/// hex is what this almost always writes.
|
||||||
|
///
|
||||||
|
/// The scalar cases route through `FrontmatterValue` rather than re-deriving their text, so a
|
||||||
|
/// preserved subkey's number or timestamp is emitted by the same code that writes `order` and
|
||||||
|
/// `created`. Nested collections recurse, which keeps an unknown subkey holding a list from
|
||||||
|
/// being flattened into its `description`.
|
||||||
|
private static func flowText(_ value: YAMLValue) -> String {
|
||||||
|
switch value {
|
||||||
|
case .null: "null"
|
||||||
|
case let .bool(value): FrontmatterValue.bool(value).yamlText
|
||||||
|
case let .int(value): FrontmatterValue.int(value).yamlText
|
||||||
|
case let .double(value): FrontmatterValue.double(value).yamlText
|
||||||
|
case let .date(value): FrontmatterValue.date(value).yamlText
|
||||||
|
case let .string(value): FrontmatterValue.emitQuoted(value)
|
||||||
|
case let .sequence(values): "[" + values.map(flowText).joined(separator: ", ") + "]"
|
||||||
|
case let .mapping(pairs):
|
||||||
|
"{" + pairs.map { "\(flowKey($0.key)): \(flowText($0.value))" }.joined(separator: ", ") + "}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -898,6 +898,7 @@ public enum BoardLoader: Sendable {
|
|||||||
modifiedBy: boardDocument.modifiedBy,
|
modifiedBy: boardDocument.modifiedBy,
|
||||||
deleted: boardDocument.deleted,
|
deleted: boardDocument.deleted,
|
||||||
background: boardDocument.background,
|
background: boardDocument.background,
|
||||||
|
backgroundImage: boardDocument.backgroundImage,
|
||||||
icon: boardDocument.icon,
|
icon: boardDocument.icon,
|
||||||
iconColor: boardDocument.iconColor,
|
iconColor: boardDocument.iconColor,
|
||||||
template: boardDocument.value(for: templateKey),
|
template: boardDocument.value(for: templateKey),
|
||||||
|
|||||||
@@ -88,6 +88,21 @@ public struct BoardModel: Sendable, Equatable {
|
|||||||
public let deleted: FieldValue<Date>
|
public let deleted: FieldValue<Date>
|
||||||
|
|
||||||
public let background: FieldValue<String>
|
public let background: FieldValue<String>
|
||||||
|
|
||||||
|
/// The `background` mapping's `image` subkey — a path **relative to `rootURL`**
|
||||||
|
/// (01-storage-format.md § Frontmatter; 03-board-ui.md § Styling ▸ Capabilities).
|
||||||
|
///
|
||||||
|
/// **Board-level only**, which is why `Lane` and `Card` carry no twin: a lane's and a card's
|
||||||
|
/// colour are edge accents, and there is nothing at those levels an image could fill. The
|
||||||
|
/// shared reader still accepts the mapping at every level for the colour's sake — one key, one
|
||||||
|
/// reading — but this half has exactly one consumer, the board window's backdrop.
|
||||||
|
///
|
||||||
|
/// A **reading, not a location**: the path is resolved (and required to stay inside the board)
|
||||||
|
/// where it is drawn, `BoardBackdrop.imageURL(named:inBoardRoot:)`, so a value that leads
|
||||||
|
/// nowhere paints nothing and stays on disk exactly as written — the same lenient degrade an
|
||||||
|
/// unrecognized colour gets.
|
||||||
|
public let backgroundImage: FieldValue<String>
|
||||||
|
|
||||||
public let icon: FieldValue<String>
|
public let icon: FieldValue<String>
|
||||||
public let iconColor: FieldValue<String>
|
public let iconColor: FieldValue<String>
|
||||||
|
|
||||||
|
|||||||
@@ -568,6 +568,19 @@ public enum FrontmatterKeys {
|
|||||||
public static let icon = "icon"
|
public static let icon = "icon"
|
||||||
public static let iconColor = "iconColor"
|
public static let iconColor = "iconColor"
|
||||||
|
|
||||||
|
/// **The `background` mapping's subkeys** (01-storage-format.md § Frontmatter — the field is a
|
||||||
|
/// mapping and nothing else, ruled 2026-08-06): `{color: "#112233", image: sunset.jpg}`, either
|
||||||
|
/// half absent, and any other subkey an author writes tolerated and carried through
|
||||||
|
/// (`FrontmatterDocument.setStyleValue`). A bare scalar has no reading at all.
|
||||||
|
///
|
||||||
|
/// **Named here without joining `schemaOwned`**, and for a plainer reason than `remote`'s: that
|
||||||
|
/// set is what `unknownFields` subtracts from the document's *top-level* keys, and these two are
|
||||||
|
/// inside one. A top-level `color:` or `image:` is somebody else's key and stays an unknown one.
|
||||||
|
public enum Background {
|
||||||
|
public static let color = "color"
|
||||||
|
public static let image = "image"
|
||||||
|
}
|
||||||
|
|
||||||
/// The object's kind — `board`, `lane`, `card` (01-storage-format.md § Frontmatter ▸ Common to
|
/// The object's kind — `board`, `lane`, `card` (01-storage-format.md § Frontmatter ▸ Common to
|
||||||
/// all levels, re-ruled 2026-07-29). Written at creation of every object, backfilled on touch
|
/// all levels, re-ruled 2026-07-29). Written at creation of every object, backfilled on touch
|
||||||
/// when absent (`IntegrityRules.healOnTouch`), and never stripped.
|
/// when absent (`IntegrityRules.healOnTouch`), and never stripped.
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ extension FrontmatterDocument {
|
|||||||
record(FrontmatterKeys.modifiedBy, modifiedBy)
|
record(FrontmatterKeys.modifiedBy, modifiedBy)
|
||||||
record(FrontmatterKeys.author, author)
|
record(FrontmatterKeys.author, author)
|
||||||
record(FrontmatterKeys.background, background)
|
record(FrontmatterKeys.background, background)
|
||||||
|
// **`background` can contribute two entries**, because the one key holds two readings once
|
||||||
|
// it is written as a mapping (`FrontmatterDocument.backgroundImage`). Both are filed under
|
||||||
|
// the key the schema spells, which is the key an author would go and fix; they are told
|
||||||
|
// apart by their raw text, since each quotes the subvalue that could not be read. A mapping
|
||||||
|
// whose colour reads fine and whose image does not still leaves a trace, which is the whole
|
||||||
|
// contract here.
|
||||||
|
record(FrontmatterKeys.background, backgroundImage)
|
||||||
record(FrontmatterKeys.icon, icon)
|
record(FrontmatterKeys.icon, icon)
|
||||||
record(FrontmatterKeys.iconColor, iconColor)
|
record(FrontmatterKeys.iconColor, iconColor)
|
||||||
record(FrontmatterKeys.kind, kind)
|
record(FrontmatterKeys.kind, kind)
|
||||||
@@ -143,10 +150,54 @@ extension FrontmatterDocument {
|
|||||||
/// (`title: 2048` reads as `"2048"`). Only a sequence or mapping — no scalar reading exists
|
/// (`title: 2048` reads as `"2048"`). Only a sequence or mapping — no scalar reading exists
|
||||||
/// — is malformed.
|
/// — is malformed.
|
||||||
public var title: FieldValue<String> { read(FrontmatterKeys.title, Self.string) }
|
public var title: FieldValue<String> { read(FrontmatterKeys.title, Self.string) }
|
||||||
public var background: FieldValue<String> { read(FrontmatterKeys.background, Self.string) }
|
|
||||||
public var icon: FieldValue<String> { read(FrontmatterKeys.icon, Self.string) }
|
public var icon: FieldValue<String> { read(FrontmatterKeys.icon, Self.string) }
|
||||||
public var iconColor: FieldValue<String> { read(FrontmatterKeys.iconColor, Self.string) }
|
public var iconColor: FieldValue<String> { read(FrontmatterKeys.iconColor, Self.string) }
|
||||||
|
|
||||||
|
/// The `background` mapping's **colour** — a palette name or a `#RRGGBB[AA]` hex, read through
|
||||||
|
/// the same scalar coercion every other string field uses.
|
||||||
|
///
|
||||||
|
/// **`background` is a mapping, and only a mapping** (01-storage-format.md § Frontmatter, ruled
|
||||||
|
/// 2026-08-06): `{color: "#112233", image: sunset.jpg}`, either subkey absent, unknown subkeys
|
||||||
|
/// tolerated. A bare scalar — `background: green` — has **no reading at all** and is
|
||||||
|
/// `.malformed`: it renders as no colour, files a coerce-tier trace, and stays on disk exactly as
|
||||||
|
/// written, which is the same lenient degrade a colour nobody can resolve already gets.
|
||||||
|
///
|
||||||
|
/// That is a ruling about the *schema*, not a migration: nothing had shipped when it was made, so
|
||||||
|
/// there is no legacy spelling to keep alive, no version bump, and no healing machinery. One key,
|
||||||
|
/// one shape, and a field whose type does not depend on which subkeys the author happened to
|
||||||
|
/// want.
|
||||||
|
///
|
||||||
|
/// **One reader for all three levels, deliberately.** A lane's and a card's `background` mean
|
||||||
|
/// colour and nothing else — they are edge accents, and only the board consumes an image
|
||||||
|
/// (03-board-ui.md § Styling ▸ Capabilities) — but the *shape* is uniform, so a lane writes
|
||||||
|
/// `{color: fern}` exactly as the board does and nothing below has to know which level it is
|
||||||
|
/// reading.
|
||||||
|
///
|
||||||
|
/// A mapping carrying no `color` — an image-only background, or an explicit `color: null` —
|
||||||
|
/// reads `.missing`, which is exactly "no colour" and renders the level's default; that is an
|
||||||
|
/// absence, not a failure, and it files no trace. A `color` that is itself a sequence or mapping
|
||||||
|
/// has no scalar reading and is `.malformed` like any other.
|
||||||
|
public var background: FieldValue<String> {
|
||||||
|
backgroundReading(FrontmatterKeys.Background.color, reportsShape: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `background` mapping's **image** — a path relative to the board root, board-only in
|
||||||
|
/// meaning (`BoardModel` carries it; `Lane` and `Card` deliberately do not).
|
||||||
|
///
|
||||||
|
/// `.missing` for every shape that is not a mapping, including the retired scalar: a value the
|
||||||
|
/// schema cannot read is **one** unreadable value, and the colour reading above already reports
|
||||||
|
/// it. Two `.malformed`s off one key would file the same defect twice and say the file named an
|
||||||
|
/// image when it did nothing of the kind.
|
||||||
|
///
|
||||||
|
/// **Where the path leads is not this layer's question.** Whether it resolves inside the board
|
||||||
|
/// root, and whether the bytes are an image at all, belongs to the renderer
|
||||||
|
/// (`BoardBackdrop.imageURL(named:inBoardRoot:)`); this is the document's reading of what was
|
||||||
|
/// written, and an unresolvable path degrades exactly like an unrecognized colour — paint
|
||||||
|
/// nothing, change nothing on disk.
|
||||||
|
public var backgroundImage: FieldValue<String> {
|
||||||
|
backgroundReading(FrontmatterKeys.Background.image, reportsShape: false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Width multiplier. An exact-integer reading — from an int, a double, or a numeric string —
|
/// Width multiplier. An exact-integer reading — from an int, a double, or a numeric string —
|
||||||
/// always coerces: at or above 1 to itself (`"2"`, `2.0` → `2`), below 1 to 1 (**ranges are
|
/// always coerces: at or above 1 to itself (`"2"`, `2.0` → `2`), below 1 to 1 (**ranges are
|
||||||
/// part of the sensible reading**, 01-storage-format.md § Frontmatter, settled — the table's
|
/// part of the sensible reading**, 01-storage-format.md § Frontmatter, settled — the table's
|
||||||
@@ -190,6 +241,36 @@ extension FrontmatterDocument {
|
|||||||
|
|
||||||
// MARK: -
|
// MARK: -
|
||||||
|
|
||||||
|
/// One subkey's reading out of the `background` mapping. It is `read(_:_:)`'s shape with one
|
||||||
|
/// extra step, and it cannot *be* `read(_:_:)`: that helper's transform answers `nil` for "no
|
||||||
|
/// sensible reading", where a mapping with no such subkey has to answer `.missing` — an absent
|
||||||
|
/// subkey is an absent value, not an unreadable one, and reporting it as a coerce-tier fallback
|
||||||
|
/// would file a defect against every image-only background in existence.
|
||||||
|
///
|
||||||
|
/// `reportsShape` is the whole difference between the two readers above, and it is about the
|
||||||
|
/// **key's** shape rather than the subkey's: a value that is not a mapping at all — the retired
|
||||||
|
/// scalar, a sequence — is one unreadable value, so exactly one reader reports it. The colour is
|
||||||
|
/// that reader because the colour is what the key means when it has no subkeys to speak of; the
|
||||||
|
/// image stays `.missing`, since a file that never wrote a mapping never claimed to name a
|
||||||
|
/// picture.
|
||||||
|
///
|
||||||
|
/// **A subvalue's malformed raw is the parse's rendering, not a source span.** `rawValue(for:)`
|
||||||
|
/// addresses top-level keys, so a subkey has no span to quote; the coerce record takes what the
|
||||||
|
/// parse retained (`YAMLValue.description`), which is the most this shape can honestly offer and
|
||||||
|
/// still names what could not be read. The key's *own* malformed raw is the span, as always.
|
||||||
|
private func backgroundReading(_ subkey: String, reportsShape: Bool) -> FieldValue<String> {
|
||||||
|
guard let value = value(for: FrontmatterKeys.background) else { return .missing }
|
||||||
|
if case .null = value { return .missing }
|
||||||
|
guard case let .mapping(pairs) = value else {
|
||||||
|
guard reportsShape else { return .missing }
|
||||||
|
return .malformed(raw: rawValue(for: FrontmatterKeys.background) ?? value.description)
|
||||||
|
}
|
||||||
|
guard let subvalue = pairs.first(where: { $0.key == .string(subkey) })?.value else { return .missing }
|
||||||
|
if case .null = subvalue { return .missing }
|
||||||
|
let raw = subvalue.description
|
||||||
|
return Self.string(subvalue, raw: raw).map(FieldValue.valid) ?? .malformed(raw: raw)
|
||||||
|
}
|
||||||
|
|
||||||
private func read<Value>(_ key: String, _ transform: (YAMLValue, String) -> Value?) -> FieldValue<Value> {
|
private func read<Value>(_ key: String, _ transform: (YAMLValue, String) -> Value?) -> FieldValue<Value> {
|
||||||
guard let value = value(for: key) else { return .missing }
|
guard let value = value(for: key) else { return .missing }
|
||||||
if case .null = value { return .missing }
|
if case .null = value { return .missing }
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ extension FrontmatterValue {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The double-quoted form unconditionally — what a value inside a **flow collection** takes
|
||||||
|
/// (`FrontmatterDocument.setStyleValue`), where `emitScalar`'s block-context round trip is not
|
||||||
|
/// the right question: `,`, `]`, `}` and `: ` end a plain scalar in flow context and not on a
|
||||||
|
/// line of its own.
|
||||||
|
static func emitQuoted(_ value: String) -> String { quoted(value) }
|
||||||
|
|
||||||
private static func quoted(_ value: String) -> String {
|
private static func quoted(_ value: String) -> String {
|
||||||
var out = "\""
|
var out = "\""
|
||||||
for scalar in value.unicodeScalars {
|
for scalar in value.unicodeScalars {
|
||||||
|
|||||||
@@ -177,6 +177,18 @@ enum AccessibilityPhrases {
|
|||||||
shown ? "Trash shown" : "Trash hidden"
|
shown ? "Trash shown" : "Trash hidden"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What View ▸ Zoom In / Zoom Out / Actual Size announces — "a zoom change announces its new
|
||||||
|
/// level" (10-accessibility.md ▸ Text scaling).
|
||||||
|
///
|
||||||
|
/// `trashVisibility`'s rule exactly, and for its reason: the resulting *state* rather than the
|
||||||
|
/// action, because three commands and two toolbar buttons all land on one ladder and what a user
|
||||||
|
/// needs to hear is which rung they are on — not that something moved. It is also the only signal
|
||||||
|
/// there is: nothing gains or loses focus, no element's label or value changes, and the whole
|
||||||
|
/// effect is a redraw a VoiceOver user cannot see.
|
||||||
|
static func zoomLevel(_ percent: String) -> String {
|
||||||
|
"Zoom \(percent)"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Live board announcements
|
// MARK: - Live board announcements
|
||||||
|
|
||||||
/// "3 lanes", "1 lane" — `cardCount`'s twin, and the second half of the digest's plural folding.
|
/// "3 lanes", "1 lane" — `cardCount`'s twin, and the second half of the digest's plural folding.
|
||||||
|
|||||||
@@ -75,6 +75,20 @@ enum Accommodations {
|
|||||||
contrast == .increased ? 1 : base
|
contrast == .increased ? 1 : base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a selected lane draws its **halo** — the soft accent glow bleeding outward from the
|
||||||
|
/// plate's hairline edge (`LaneView.selectionStroke`), the system focus ring's vocabulary at
|
||||||
|
/// selection strength.
|
||||||
|
///
|
||||||
|
/// Under Increase Contrast the answer is no, and that is the setting's own logic rather than a
|
||||||
|
/// retreat: "strengthens borders and the selection indicator" means *crisper*, and a blur is
|
||||||
|
/// the one thing a border cannot become. The edge instead goes full-alpha and heavier
|
||||||
|
/// (`accentOpacity`, `borderWidth` at the solid ring's base), so the setting sees the same
|
||||||
|
/// unambiguous solid ring it always has — the halo is the resting treatment's softness, not
|
||||||
|
/// the indicator itself.
|
||||||
|
static func drawsSelectionHalo(contrast: ColorSchemeContrast) -> Bool {
|
||||||
|
contrast != .increased
|
||||||
|
}
|
||||||
|
|
||||||
/// Increase Contrast, asked of AppKit rather than of the SwiftUI environment — for callers built
|
/// Increase Contrast, asked of AppKit rather than of the SwiftUI environment — for callers built
|
||||||
/// outside a rendered hierarchy, where the environment's accessibility values are not reliably
|
/// outside a rendered hierarchy, where the environment's accessibility values are not reliably
|
||||||
/// populated (`Motion.prefersReducedMotion`'s constituency).
|
/// populated (`Motion.prefersReducedMotion`'s constituency).
|
||||||
@@ -90,19 +104,28 @@ enum Accommodations {
|
|||||||
/// appear").
|
/// appear").
|
||||||
///
|
///
|
||||||
/// The design's own example (the card face carousel's page dots) died with the carousel
|
/// The design's own example (the card face carousel's page dots) died with the carousel
|
||||||
/// (03-board-ui.md § Card face's no-carousel resettlement), so the rule's one surviving subject
|
/// (03-board-ui.md § Card face's no-carousel resettlement), so the rule's subjects on the board
|
||||||
/// on the board is the transient search bar's `.bar` material. It is stated as a type anyway
|
/// are the transient search bar's `.bar` material and the backdrop's title-bar frost — the
|
||||||
/// rather than inlined at that one call site, because "wherever they appear" is a standing rule
|
/// "next material to arrive" this type was stated for, and it found the answer already written.
|
||||||
/// and the next material to arrive should find the answer already written.
|
|
||||||
enum Underlay: Equatable {
|
enum Underlay: Equatable {
|
||||||
/// `Material.bar` — the find-bar's own backdrop, translucent over the board beneath it.
|
/// `Material.bar` — the find-bar's own backdrop, translucent over the board beneath it.
|
||||||
case glass
|
case glass
|
||||||
|
/// `Material.thin` — the title-bar frost over a custom board backdrop
|
||||||
|
/// (`BoardView.boardBackground`). Deliberately not `.bar` — the find-bar sits over lanes
|
||||||
|
/// the board's own plates have already calmed, where the frost sits directly on an image
|
||||||
|
/// the author may well have chosen *for* its busyness — and deliberately not the heavier
|
||||||
|
/// notches either, tried and retired: `.ultraThick` read as a cloudy plate where a backdrop
|
||||||
|
/// should still show through, and `.regular` still veiled it more than the chrome needs.
|
||||||
|
/// The thin weight carries the chrome, and the dissolve below it (`BoardView.frostStrip`)
|
||||||
|
/// is what keeps the strip from reading as a bar.
|
||||||
|
case frost
|
||||||
/// The window's own background colour, opaque.
|
/// The window's own background colour, opaque.
|
||||||
case solid
|
case solid
|
||||||
|
|
||||||
var style: AnyShapeStyle {
|
var style: AnyShapeStyle {
|
||||||
switch self {
|
switch self {
|
||||||
case .glass: AnyShapeStyle(.bar)
|
case .glass: AnyShapeStyle(.bar)
|
||||||
|
case .frost: AnyShapeStyle(.thinMaterial)
|
||||||
case .solid: AnyShapeStyle(Color(nsColor: .windowBackgroundColor))
|
case .solid: AnyShapeStyle(Color(nsColor: .windowBackgroundColor))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +135,12 @@ enum Accommodations {
|
|||||||
reduceTransparency ? .solid : .glass
|
reduceTransparency ? .solid : .glass
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The title-bar frost's own reading of the same rule — heavier glass, identical accommodation:
|
||||||
|
/// under Reduce Transparency both underlays take the one solid.
|
||||||
|
static func frost(reduceTransparency: Bool) -> Underlay {
|
||||||
|
reduceTransparency ? .solid : .frost
|
||||||
|
}
|
||||||
|
|
||||||
/// A **translucent wash** — a tint laid over whatever happens to be behind it, and the shape
|
/// A **translucent wash** — a tint laid over whatever happens to be behind it, and the shape
|
||||||
/// every non-material translucency on the board takes: every lane's plate, the trash column's
|
/// every non-material translucency on the board takes: every lane's plate, the trash column's
|
||||||
/// plate and hatched header, and the drag shadow's fill.
|
/// plate and hatched header, and the drag shadow's fill.
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import ImageIO
|
||||||
|
import SwiftUI
|
||||||
|
import os
|
||||||
|
|
||||||
|
// MARK: - BoardBackdrop
|
||||||
|
|
||||||
|
/// **The board background's image half** (03-board-ui.md § Styling ▸ Capabilities; the `background`
|
||||||
|
/// mapping's `image` subkey, 01-storage-format.md § Frontmatter).
|
||||||
|
///
|
||||||
|
/// ### The path is relative, and it stays inside the board
|
||||||
|
///
|
||||||
|
/// `image: sunset.jpg` names a file in the board folder; `image: art/sunset.jpg` names one in a
|
||||||
|
/// subfolder of it. An absolute path, or any path that climbs out with `..`, resolves to **nothing**
|
||||||
|
/// — the same lenient degrade as an unrecognized colour, and for the same two reasons. A `.kanban`
|
||||||
|
/// folder is a document: it is what gets copied, zipped, synced and handed to somebody else, and a
|
||||||
|
/// background pointing at `/Users/someone/Pictures` would silently stop working the moment it left
|
||||||
|
/// this Mac. And the sandbox would refuse the read anyway — the board's own security-scoped access
|
||||||
|
/// is the only thing this app holds — so the rule the containment check states is the rule the
|
||||||
|
/// system would enforce one layer down, stated where it can be explained instead of failing.
|
||||||
|
///
|
||||||
|
/// The check is **lexical**, which is what makes it testable without a filesystem, and it is not the
|
||||||
|
/// security boundary: a symlink inside the board pointing anywhere at all still resolves here and is
|
||||||
|
/// still refused by the sandbox when the bytes are asked for. That is the correct division — 01's
|
||||||
|
/// "symlinks are never traversed" governs what the *loader* renders as items, and this reads bytes
|
||||||
|
/// nobody has an identity claim on.
|
||||||
|
///
|
||||||
|
/// ### Nothing here decides whether the file is any good
|
||||||
|
///
|
||||||
|
/// A path that resolves, a file that is missing, and a file that is not an image all end the same
|
||||||
|
/// way: no image, no banner, no defect, bytes untouched. There is no editing UI for the field at all
|
||||||
|
/// (Controls: "the raw file is the escape hatch"), so the one person who can be wrong about it is
|
||||||
|
/// the one person looking at the folder.
|
||||||
|
enum BoardBackdrop {
|
||||||
|
|
||||||
|
/// The longest edge, in pixels, the backdrop is ever decoded at.
|
||||||
|
///
|
||||||
|
/// Generous enough for a 6K display's short side and for the Retina backing of any window a
|
||||||
|
/// board is realistically shown in, and small enough that a 60-megapixel photo dropped in the
|
||||||
|
/// folder never becomes a 240 MB decode on a window resize. ImageIO does the reduction while it
|
||||||
|
/// reads (`decode`), so the full-size bitmap is never materialized at all.
|
||||||
|
static let maximumPixelSize = 3072
|
||||||
|
|
||||||
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-backdrop")
|
||||||
|
|
||||||
|
/// Where `path` lands inside `root`, or `nil` when it lands nowhere this board may read.
|
||||||
|
///
|
||||||
|
/// Standardized before the comparison so `art/../sunset.jpg` is recognized as the file it names
|
||||||
|
/// — the check is about where the path *ends up*, not how it is spelled. The trailing separator
|
||||||
|
/// on the root is what keeps a sibling board named `Boards/Work.kanban.backup` from passing a
|
||||||
|
/// prefix test against `Boards/Work.kanban`.
|
||||||
|
///
|
||||||
|
/// `~` is not expanded and is not special: a file honestly named `~notes.png` sitting in the
|
||||||
|
/// board folder resolves, because only the shell ever meant anything else by that character.
|
||||||
|
static func imageURL(named path: String, inBoardRoot root: URL) -> URL? {
|
||||||
|
// An absolute path has to be rejected before it is appended, not after: appending `/etc/x`
|
||||||
|
// to a root yields `<root>/etc/x`, which *passes* containment while naming a file the author
|
||||||
|
// plainly did not mean.
|
||||||
|
guard !path.isEmpty, !path.hasPrefix("/") else { return nil }
|
||||||
|
let root = root.standardizedFileURL
|
||||||
|
let candidate = root.appendingPathComponent(path).standardizedFileURL
|
||||||
|
guard candidate.path.hasPrefix(root.path + "/") else { return nil }
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This board's backdrop image, where it has a readable one to name.
|
||||||
|
static func imageURL(for board: BoardModel, root: URL) -> URL? {
|
||||||
|
guard let path = board.backgroundImage.value else { return nil }
|
||||||
|
return imageURL(named: path, inBoardRoot: root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this board paints a background of its own — **the window-chrome predicate**
|
||||||
|
/// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one
|
||||||
|
/// runs its content under a transparent title bar, and a board without one keeps the standard
|
||||||
|
/// chrome exactly as it has always looked.
|
||||||
|
///
|
||||||
|
/// It asks the *resolved* image URL rather than merely whether the key reads, so a path that
|
||||||
|
/// could never paint anything — absolute, or climbing out of the board — leaves the chrome alone
|
||||||
|
/// instead of producing a transparent title bar over the standard background. It does **not**
|
||||||
|
/// ask whether the file exists: that is a disk touch, this is read on every board render, and a
|
||||||
|
/// declared-but-missing image renders as the frosted strip alone — which is the honest picture of
|
||||||
|
/// a board that asked for a backdrop it has not got.
|
||||||
|
static func isCustom(_ board: BoardModel, root: URL) -> Bool {
|
||||||
|
Palette.color(for: board.background) != nil || imageURL(for: board, root: root) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Reading the bytes
|
||||||
|
|
||||||
|
/// The file's identity as far as reloading is concerned — modification date and size.
|
||||||
|
///
|
||||||
|
/// Both, because either alone is forgeable by an ordinary copy: a file replaced within the
|
||||||
|
/// timestamp's resolution keeps its date, and a re-export at the same instant rarely keeps its
|
||||||
|
/// byte count too. Missing values (a file that is not there) compare equal to each other, which
|
||||||
|
/// is what stops a board naming a missing image from re-decoding on every reload.
|
||||||
|
struct Stamp: Equatable, Sendable {
|
||||||
|
var modified: Date?
|
||||||
|
var size: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stamp(of url: URL) -> Stamp {
|
||||||
|
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
|
||||||
|
return Stamp(modified: values?.contentModificationDate, size: values?.fileSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes the file at `url`, downsampled to `maximumPixelSize` on its longest edge — or `nil`
|
||||||
|
/// for anything that is not a readable image.
|
||||||
|
///
|
||||||
|
/// **ImageIO's thumbnail path, not a full decode plus a resize**: `CGImageSourceCreateThumbnail
|
||||||
|
/// AtIndex` reads at a reduced scale, so the peak allocation is the *output* size rather than
|
||||||
|
/// the file's. `FromImageAlways` is what makes it a downsample rather than a lottery — without
|
||||||
|
/// it a JPEG carrying its own small embedded thumbnail would answer with that instead of the
|
||||||
|
/// picture. `WithTransform` applies the EXIF orientation, so a photo shot in portrait is not
|
||||||
|
/// laid on its side.
|
||||||
|
///
|
||||||
|
/// Never call this on the main actor; see `BoardBackdropImage`'s task.
|
||||||
|
static func decode(_ url: URL) -> CGImage? {
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
|
kCGImageSourceShouldCacheImmediately: true,
|
||||||
|
kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize,
|
||||||
|
]
|
||||||
|
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
||||||
|
logger.debug("board backdrop image could not be decoded")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BoardBackdropImage
|
||||||
|
|
||||||
|
/// The decoded backdrop, drawn to fill (03-board-ui.md § Styling ▸ Capabilities).
|
||||||
|
///
|
||||||
|
/// **Fill, cropped — never letterboxed and never stretched.** A background is a surface, so it
|
||||||
|
/// covers the window whatever its aspect ratio; the alternative would put bars of the underlying
|
||||||
|
/// colour along two edges and make the board look broken rather than styled.
|
||||||
|
///
|
||||||
|
/// ### The load is asynchronous, and that is the whole design of this view
|
||||||
|
///
|
||||||
|
/// A board is opened by double-clicking a folder, and the folder may contain a 60-megapixel
|
||||||
|
/// photograph. Decoding that on the main actor during a body evaluation is a visible hitch on open
|
||||||
|
/// and a worse one on every subsequent reload, so the work happens off it and the view simply has
|
||||||
|
/// nothing to draw until it lands — under the board's colour, which is already painted beneath.
|
||||||
|
///
|
||||||
|
/// The task is keyed on the URL and on the store's landed-reload count, which is the board's
|
||||||
|
/// FSEvents pulse: replacing `sunset.jpg` in Finder changes no *model* value, so the snapshot comes
|
||||||
|
/// back equal and `snapshotGeneration` deliberately does not move (`BoardStore.landedReloads`) —
|
||||||
|
/// keying on the generation would mean an edited image never reloaded. Every re-key costs one
|
||||||
|
/// `stat`; only a file that actually changed costs a decode.
|
||||||
|
struct BoardBackdropImage: View {
|
||||||
|
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
/// The board's landed-reload count — see the type's note. Not read from a store here because
|
||||||
|
/// this view has no other reason to hold one.
|
||||||
|
let reloads: Int
|
||||||
|
|
||||||
|
/// What is on screen, and what it was decoded from. One value rather than three `@State`s so a
|
||||||
|
/// URL, its stamp and its bitmap can never disagree about which file is being shown.
|
||||||
|
@State private var loaded: Loaded?
|
||||||
|
|
||||||
|
private struct Loaded {
|
||||||
|
let url: URL
|
||||||
|
let stamp: BoardBackdrop.Stamp
|
||||||
|
let image: CGImage
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// `Color.clear` establishes the frame the image fills and is what `clipped` trims against;
|
||||||
|
// the overlay is what overflows it. Decorative, because a board background is decoration in
|
||||||
|
// the precise sense 10-accessibility.md means — it carries no information VoiceOver could
|
||||||
|
// usefully say, and the ink rule keeps the text on it legible on its own.
|
||||||
|
Color.clear
|
||||||
|
.overlay {
|
||||||
|
if let loaded {
|
||||||
|
Image(decorative: loaded.image, scale: 1)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipped()
|
||||||
|
.task(id: Key(url: url, reloads: reloads)) { await reload() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `.task` identity: the file, and the board's pulse.
|
||||||
|
private struct Key: Equatable {
|
||||||
|
let url: URL
|
||||||
|
let reloads: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-decodes when the bytes have changed, and only then.
|
||||||
|
///
|
||||||
|
/// `Task.detached` rather than a bare `await` on a `nonisolated` function, so the hop off this
|
||||||
|
/// view's actor is stated rather than inferred from whatever the language mode currently makes
|
||||||
|
/// of an async call. Cancellation is checked on the way back instead of forwarded into it: both
|
||||||
|
/// halves are short, and a stale bitmap assigned to a view that has gone away is the failure
|
||||||
|
/// worth preventing.
|
||||||
|
private func reload() async {
|
||||||
|
let url = url
|
||||||
|
let stamp = await Task.detached(priority: .utility) { BoardBackdrop.stamp(of: url) }.value
|
||||||
|
if let loaded, loaded.url == url, loaded.stamp == stamp { return }
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
let decoded = await Task.detached(priority: .userInitiated) { BoardBackdrop.decode(url) }.value
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
// A failure clears what was there: the file the board names is the file it shows, and
|
||||||
|
// holding the previous picture would make a broken path look like a working one.
|
||||||
|
loaded = decoded.map { Loaded(url: url, stamp: stamp, image: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ final class CardOpener {
|
|||||||
|
|
||||||
/// `nil` until the window's board has loaded, which is also exactly when Open Card has nothing
|
/// `nil` until the window's board has loaded, which is also exactly when Open Card has nothing
|
||||||
/// to act on.
|
/// to act on.
|
||||||
var open: ((ItemID) -> Void)?
|
var open: (@MainActor (ItemID) -> Void)?
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ final class LaneDropRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The board's current ruler, written by `BoardView` from the zoom environment (03-board-ui.md
|
||||||
|
/// ▸ Layout — zoom).
|
||||||
|
///
|
||||||
|
/// It lives here for `stripFrame`'s reason: the drop delegates run at event time, outside any
|
||||||
|
/// body evaluation, so a number they need has to be *on* the registry rather than captured from a
|
||||||
|
/// view's last render. The default is the system size, which is what an un-zoomed board and every
|
||||||
|
/// test that never sets it get.
|
||||||
|
var bodyPointSize: CGFloat = BoardMetrics.bodyPointSize
|
||||||
|
|
||||||
/// The height a card with no registered measurement is assumed to have — a lane whose faces have
|
/// The height a card with no registered measurement is assumed to have — a lane whose faces have
|
||||||
/// not laid out yet. Nominal rather than zero, so the resting rows still tile.
|
/// not laid out yet. Nominal rather than zero, so the resting rows still tile.
|
||||||
///
|
///
|
||||||
@@ -73,9 +82,12 @@ final class LaneDropRegistry {
|
|||||||
/// every un-measured slot boundary in the wrong place at a large system text size — and the drop
|
/// every un-measured slot boundary in the wrong place at a large system text size — and the drop
|
||||||
/// model is forbidden from reading measured frames mid-flight (03-board-ui.md § Motion), so this
|
/// model is forbidden from reading measured frames mid-flight (03-board-ui.md § Motion), so this
|
||||||
/// guess is all it has until the lane lays out.
|
/// guess is all it has until the lane lays out.
|
||||||
@MainActor
|
///
|
||||||
static var nominalCardHeight: CGFloat {
|
/// An instance property rather than a static for exactly that reason extended to zoom: the guess
|
||||||
BoardMetrics.nominalCardHeight(bodyPointSize: BoardMetrics.bodyPointSize)
|
/// has to be made on the ruler the board is *currently* drawing with, and the level is a property
|
||||||
|
/// of the window this registry belongs to.
|
||||||
|
var nominalCardHeight: CGFloat {
|
||||||
|
BoardMetrics.nominalCardHeight(bodyPointSize: bodyPointSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The board strip's own frame in the window's SwiftUI global space — the origin the strip
|
/// The board strip's own frame in the window's SwiftUI global space — the origin the strip
|
||||||
@@ -246,7 +258,7 @@ final class RestingLayoutCache {
|
|||||||
heights.reserveCapacity(lane.cards.count)
|
heights.reserveCapacity(lane.cards.count)
|
||||||
for card in lane.cards where !hidden.contains(card.id) {
|
for card in lane.cards where !hidden.contains(card.id) {
|
||||||
cardIDs.append(card.id)
|
cardIDs.append(card.id)
|
||||||
heights.append(registry.heights[card.id] ?? LaneDropRegistry.nominalCardHeight)
|
heights.append(registry.heights[card.id] ?? registry.nominalCardHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
let layout = LaneRestingLayout(cardIDs: cardIDs, heights: heights)
|
let layout = LaneRestingLayout(cardIDs: cardIDs, heights: heights)
|
||||||
@@ -449,7 +461,7 @@ struct BoardDropContext {
|
|||||||
heights: resting.heights,
|
heights: resting.heights,
|
||||||
// The run's footprint at the landing spot: the first dragged card's frozen height, which
|
// The run's footprint at the landing spot: the first dragged card's frozen height, which
|
||||||
// is the trigger rect the cursor is over (the rest stack below it).
|
// is the trigger rect the cursor is over (the rest stack below it).
|
||||||
draggedHeight: session.cardHeights.first ?? LaneDropRegistry.nominalCardHeight,
|
draggedHeight: session.cardHeights.first ?? registry.nominalCardHeight,
|
||||||
current: session.laneProposal(onBoardRooted: store.rootKey, laneID: laneID)
|
current: session.laneProposal(onBoardRooted: store.rootKey, laneID: laneID)
|
||||||
)
|
)
|
||||||
guard let slot else { return } // a dead region: hold
|
guard let slot else { return } // a dead region: hold
|
||||||
@@ -661,7 +673,7 @@ struct BoardDropContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let rendered = lane.cards
|
let rendered = lane.cards
|
||||||
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
let heights = rendered.map { registry.heights[$0.id] ?? registry.nominalCardHeight }
|
||||||
let count = FinderDrop.shadowCount(info.itemProviders(for: [.fileURL]))
|
let count = FinderDrop.shadowCount(info.itemProviders(for: [.fileURL]))
|
||||||
|
|
||||||
let landing = FileDropZones.landing(
|
let landing = FileDropZones.landing(
|
||||||
@@ -669,7 +681,7 @@ struct BoardDropContext {
|
|||||||
headerBottom: registry.headers[laneID]?.maxY,
|
headerBottom: registry.headers[laneID]?.maxY,
|
||||||
placement: grid.placement,
|
placement: grid.placement,
|
||||||
heights: heights,
|
heights: heights,
|
||||||
nominalHeight: LaneDropRegistry.nominalCardHeight,
|
nominalHeight: registry.nominalCardHeight,
|
||||||
current: session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: laneID)?.index
|
current: session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: laneID)?.index
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -309,8 +309,27 @@ struct BoardInfoView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
sectionHeader("Title")
|
HStack(spacing: 6) {
|
||||||
BoardRenameField(store: store)
|
// 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)
|
.padding(inset)
|
||||||
|
|
||||||
@@ -324,7 +343,9 @@ struct BoardInfoView: View {
|
|||||||
// ("nothing selected = the board"); this embed is the surface that exists *because*
|
// ("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 ▸
|
// the board is a style target, so it can have no other target (§ Styling ▸
|
||||||
// Controls: "the board popover's target is the board itself").
|
// 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
|
// 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 store: BoardStore
|
||||||
let presentation: BoardSearchPresentation
|
let presentation: BoardSearchPresentation
|
||||||
|
|
||||||
/// Reduce Transparency — **this bar is the board's one glass underlay** ("glass underlays go
|
/// Reduce Transparency — this bar is one of the board's two glass underlays, beside the
|
||||||
/// solid, wherever they appear", 10-accessibility.md; the design's own example, the card face
|
/// backdrop's title-bar frost ("glass underlays go solid, wherever they appear",
|
||||||
/// carousel's page dots, died with the carousel). `.bar` is a material, so under the setting it
|
/// 10-accessibility.md; the design's own example, the card face carousel's page dots, died with
|
||||||
/// becomes the opaque window background (`Accommodations.Underlay`).
|
/// the carousel). `.bar` is a material, so under the setting it becomes the opaque window
|
||||||
|
/// background (`Accommodations.Underlay`).
|
||||||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||||
|
|
||||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||||
|
|||||||
@@ -9,19 +9,23 @@ extension NSToolbarItem.Identifier {
|
|||||||
static let boardUndo = Self("board.undo")
|
static let boardUndo = Self("board.undo")
|
||||||
static let boardRedo = Self("board.redo")
|
static let boardRedo = Self("board.redo")
|
||||||
static let boardShowTrash = Self("board.showTrash")
|
static let boardShowTrash = Self("board.showTrash")
|
||||||
|
static let boardZoomIn = Self("board.zoomIn")
|
||||||
|
static let boardZoomOut = Self("board.zoomOut")
|
||||||
|
static let boardAppearance = Self("board.appearance")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The board window's toolbar
|
// MARK: - The board window's toolbar
|
||||||
|
|
||||||
/// The board window's toolbar (03-board-ui.md ▸ Toolbar).
|
/// The board window's toolbar (03-board-ui.md ▸ Toolbar).
|
||||||
///
|
///
|
||||||
/// ### The default is one item, and the catalog is five more
|
/// ### The default is the search field and Appearance, and the catalog is the rest
|
||||||
///
|
///
|
||||||
/// "**Board window default: the search field, nothing else** — trailing, the one default item; the
|
/// "**Board window default: the search field and Appearance** — both **centered**, the titlebar's
|
||||||
/// titlebar stays clean." The flexible space ahead of it is what "trailing" means to `NSToolbar`.
|
/// view-controls cluster." The flexible space ahead of the pair is what keeps them off the leading
|
||||||
|
/// edge before `centeredItemIdentifiers` takes over their placement.
|
||||||
///
|
///
|
||||||
/// "**Catalog** (available via Customize): New Card, New Lane, Undo, Redo …, Show Trash (toggle
|
/// "**Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out …, Undo, Redo …,
|
||||||
/// state matching the View menu checkmark)." Every one of them is the *same command* as its menu row
|
/// Show Trash (toggle state matching the View menu checkmark)." Every one of them is the *same command* as its menu row
|
||||||
/// — the predicates below are the rows' own (`BoardStore.newCardTarget`, `acceptsBoardMutations`),
|
/// — the predicates below are the rows' own (`BoardStore.newCardTarget`, `acceptsBoardMutations`),
|
||||||
/// and the two actions with consequences call the rows' own functions (`beginNewCard`,
|
/// and the two actions with consequences call the rows' own functions (`beginNewCard`,
|
||||||
/// `setTrashVisible`) rather than restating them. That is what makes "toolbars are pure enhancement"
|
/// `setTrashVisible`) rather than restating them. That is what makes "toolbars are pure enhancement"
|
||||||
@@ -52,10 +56,33 @@ enum BoardToolbar {
|
|||||||
/// than one window's — Finder's behaviour, and the reason the identifier is a constant.
|
/// than one window's — Finder's behaviour, and the reason the identifier is a constant.
|
||||||
static let identifier = "dev.rzen.indie.Kanban.board"
|
static let identifier = "dev.rzen.indie.Kanban.board"
|
||||||
|
|
||||||
/// "The search field, nothing else — trailing, the one default item."
|
/// "The search field and Appearance — both centered, immediately after the field
|
||||||
static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch]
|
/// (03-board-ui.md ▸ Toolbar, the view-controls cluster)."
|
||||||
|
static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch, .boardAppearance]
|
||||||
|
|
||||||
static func specs(store: BoardStore, search: BoardSearchPresentation) -> [ToolbarItemSpec] {
|
/// The Appearance picker's rows, in menu order — the one place index and meaning are joined, so
|
||||||
|
/// `specs(...)`'s `selected`/`select` closures and this array can never name two different
|
||||||
|
/// orderings of the same three choices.
|
||||||
|
private static let appearanceOptions: [AppAppearance?] = [nil, .light, .dark]
|
||||||
|
|
||||||
|
/// - Parameters:
|
||||||
|
/// - zoom: the app-wide zoom level, in the catalog order the palette shows. It is not the
|
||||||
|
/// store's, unlike every other predicate here, because the level is not a board's
|
||||||
|
/// (03-board-ui.md ▸ Layout — zoom) — and it must be `@Observable` rather than read from
|
||||||
|
/// `UserDefaults` at build time, since `WindowToolbarController.trackValidationState` re-arms
|
||||||
|
/// observation over each spec's `isEnabled` and a plain scalar would leave Zoom In looking live
|
||||||
|
/// at the top rung.
|
||||||
|
/// - appearance: the app-wide appearance override, `zoom`'s reason exactly — not the store's,
|
||||||
|
/// and `@Observable` so the picker's checkmark, read when its menu opens, is never stale.
|
||||||
|
/// - session: the app's drag session, for the same guard the menu rows carry
|
||||||
|
/// (`ZoomCommands.isEnabled`).
|
||||||
|
static func specs(
|
||||||
|
store: BoardStore,
|
||||||
|
search: BoardSearchPresentation,
|
||||||
|
zoom: BoardZoomStore,
|
||||||
|
appearance: AppearanceStore,
|
||||||
|
session: DragSession
|
||||||
|
) -> [ToolbarItemSpec] {
|
||||||
[
|
[
|
||||||
.mirroring(
|
.mirroring(
|
||||||
menuTitle: "New Card",
|
menuTitle: "New Card",
|
||||||
@@ -75,6 +102,34 @@ enum BoardToolbar {
|
|||||||
perform: { [weak store] in store?.createLane() }
|
perform: { [weak store] in store?.createLane() }
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
// The zoom pair — plain buttons, because that is what the menu rows are. There is no
|
||||||
|
// percentage readout and no popup: a control that *displays* the level would need a
|
||||||
|
// custom view and a new `ToolbarItemSpec.Behavior` case, and the level already has a
|
||||||
|
// spoken voice (`AccessibilityPhrases.zoomLevel`) and a visible one (the board itself).
|
||||||
|
.mirroring(
|
||||||
|
menuTitle: "Zoom In",
|
||||||
|
identifier: .boardZoomIn,
|
||||||
|
symbol: "plus.magnifyingglass",
|
||||||
|
behavior: .button(
|
||||||
|
isEnabled: { [weak store, weak zoom, weak session] in
|
||||||
|
guard let zoom, let session else { return false }
|
||||||
|
return ZoomCommands.isEnabled(store: store, session: session) && zoom.canZoomIn
|
||||||
|
},
|
||||||
|
perform: { [weak zoom] in zoom?.step(.in) }
|
||||||
|
)
|
||||||
|
),
|
||||||
|
.mirroring(
|
||||||
|
menuTitle: "Zoom Out",
|
||||||
|
identifier: .boardZoomOut,
|
||||||
|
symbol: "minus.magnifyingglass",
|
||||||
|
behavior: .button(
|
||||||
|
isEnabled: { [weak store, weak zoom, weak session] in
|
||||||
|
guard let zoom, let session else { return false }
|
||||||
|
return ZoomCommands.isEnabled(store: store, session: session) && zoom.canZoomOut
|
||||||
|
},
|
||||||
|
perform: { [weak zoom] in zoom?.step(.out) }
|
||||||
|
)
|
||||||
|
),
|
||||||
.staticLabel(
|
.staticLabel(
|
||||||
"Undo",
|
"Undo",
|
||||||
identifier: .boardUndo,
|
identifier: .boardUndo,
|
||||||
@@ -97,6 +152,31 @@ enum BoardToolbar {
|
|||||||
setOn: { [weak store] shown in store?.setTrashVisible(shown) }
|
setOn: { [weak store] shown in store?.setTrashVisible(shown) }
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
// A pull-down rather than a toggle: Auto/Light/Dark is a three-way exclusive choice, not
|
||||||
|
// an on/off bit. The one default (and centered) catalog item beside the field
|
||||||
|
// (`defaultItems`, `controller(...)`'s `centeredItemIdentifiers`), always enabled — an
|
||||||
|
// appearance override needs no board state, exactly as the View-menu row needs no board
|
||||||
|
// window (`AppearanceCommands`).
|
||||||
|
.mirroring(
|
||||||
|
menuTitle: "Appearance",
|
||||||
|
identifier: .boardAppearance,
|
||||||
|
symbol: "circle.lefthalf.filled",
|
||||||
|
behavior: .picker(
|
||||||
|
options: [
|
||||||
|
(title: "Auto", symbol: nil),
|
||||||
|
(title: "Light", symbol: nil),
|
||||||
|
(title: "Dark", symbol: nil),
|
||||||
|
],
|
||||||
|
selected: { [weak appearance] in
|
||||||
|
guard let appearance else { return nil }
|
||||||
|
return appearanceOptions.firstIndex(of: appearance.override)
|
||||||
|
},
|
||||||
|
select: { [weak appearance] index in
|
||||||
|
guard appearanceOptions.indices.contains(index) else { return }
|
||||||
|
appearance?.setOverride(appearanceOptions[index])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
),
|
||||||
.staticLabel(
|
.staticLabel(
|
||||||
"Search",
|
"Search",
|
||||||
identifier: .boardSearch,
|
identifier: .boardSearch,
|
||||||
@@ -132,12 +212,25 @@ enum BoardToolbar {
|
|||||||
/// The window's toolbar, wired to tell the search presentation where its field currently lives —
|
/// The window's toolbar, wired to tell the search presentation where its field currently lives —
|
||||||
/// which is the whole input to ⌘F's transient fallback (03: "with the field removed from the
|
/// which is the whole input to ⌘F's transient fallback (03: "with the field removed from the
|
||||||
/// toolbar, invoking it surfaces the field transiently until the search clears").
|
/// toolbar, invoking it surfaces the field transiently until the search clears").
|
||||||
static func controller(store: BoardStore, search: BoardSearchPresentation) -> WindowToolbarController {
|
static func controller(
|
||||||
|
store: BoardStore,
|
||||||
|
search: BoardSearchPresentation,
|
||||||
|
zoom: BoardZoomStore,
|
||||||
|
appearance: AppearanceStore,
|
||||||
|
session: DragSession
|
||||||
|
) -> WindowToolbarController {
|
||||||
let controller = WindowToolbarController(
|
let controller = WindowToolbarController(
|
||||||
identifier: identifier,
|
identifier: identifier,
|
||||||
specs: specs(store: store, search: search),
|
specs: specs(store: store, search: search, zoom: zoom, appearance: appearance, session: session),
|
||||||
defaults: defaultItems
|
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. Appearance joined
|
||||||
|
// the cluster after search (`defaultItems`'s own order), so the two center together as one
|
||||||
|
// group — search first, Appearance beside it — rather than as two independently-placed items.
|
||||||
|
controller.toolbar.centeredItemIdentifiers = [.boardSearch, .boardAppearance]
|
||||||
controller.onInstalledItemsChanged = { [weak search] identifiers in
|
controller.onInstalledItemsChanged = { [weak search] identifiers in
|
||||||
search?.isInstalledInToolbar = identifiers.contains(.boardSearch)
|
search?.isInstalledInToolbar = identifiers.contains(.boardSearch)
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-12
@@ -67,7 +67,7 @@ struct BoardView: View {
|
|||||||
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar). A closure from
|
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar). A closure from
|
||||||
/// `BoardWindowHost` rather than an `openWindow` call here, because building a `CardWindowRef`
|
/// `BoardWindowHost` rather than an `openWindow` call here, because building a `CardWindowRef`
|
||||||
/// needs the board's own window ref, which is the host's identity and not the board's.
|
/// needs the board's own window ref, which is the host's identity and not the board's.
|
||||||
let openCard: (ItemID) -> Void
|
let openCard: @MainActor (ItemID) -> Void
|
||||||
|
|
||||||
/// The toolbar search field's handle (`BoardSearchPresentation`), threaded down so the strip can
|
/// The toolbar search field's handle (`BoardSearchPresentation`), threaded down so the strip can
|
||||||
/// fill in `focusBoard` — Escape's "in an empty field it returns focus to the board" needs the
|
/// fill in `focusBoard` — Escape's "in an empty field it returns focus to the board" needs the
|
||||||
@@ -106,6 +106,16 @@ struct BoardView: View {
|
|||||||
/// Increase Contrast, for the marquee band's border below (10-accessibility.md; `Accommodations`).
|
/// Increase Contrast, for the marquee band's border below (10-accessibility.md; `Accommodations`).
|
||||||
@Environment(\.colorSchemeContrast) private var contrast
|
@Environment(\.colorSchemeContrast) private var contrast
|
||||||
|
|
||||||
|
/// Reduce Transparency, for the backdrop's title-bar frost — the one glass underlay this view
|
||||||
|
/// draws (10-accessibility.md: "glass underlays go solid, wherever they appear";
|
||||||
|
/// `Accommodations.frost`).
|
||||||
|
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||||
|
|
||||||
|
/// **The board's ruler** (03-board-ui.md ▸ Layout — zoom; `BoardZoom`). Injected on this view by
|
||||||
|
/// `BoardWindowHost` and read all the way down the strip; every `BoardMetrics` figure below takes
|
||||||
|
/// `zoom.bodyPointSize` rather than the system's, which is the whole of what View ▸ Zoom In does.
|
||||||
|
@Environment(\.boardZoom) private var zoom
|
||||||
|
|
||||||
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
|
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
|
||||||
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
|
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
|
||||||
/// must go back to meaning create/rename rather than nothing at all.
|
/// must go back to meaning create/rename rather than nothing at all.
|
||||||
@@ -119,7 +129,7 @@ struct BoardView: View {
|
|||||||
/// size widens the gap and therefore *narrows* every lane, since the window's width still divides
|
/// size widens the gap and therefore *narrows* every lane, since the window's width still divides
|
||||||
/// across `units + 1` gaps. The strip never grows and never scrolls; the lanes compress, "the
|
/// across `units + 1` gaps. The strip never grows and never scrolls; the lanes compress, "the
|
||||||
/// degenerate case accepted, not floored" (03-board-ui.md § Layout — full visibility).
|
/// degenerate case accepted, not floored" (03-board-ui.md § Layout — full visibility).
|
||||||
private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: BoardMetrics.bodyPointSize) }
|
private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: zoom.bodyPointSize) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// The strip's own body count (`BoardRenderMetrics`) — DEBUG only, and the discriminator
|
// The strip's own body count (`BoardRenderMetrics`) — DEBUG only, and the discriminator
|
||||||
@@ -155,6 +165,22 @@ struct BoardView: View {
|
|||||||
// rather than into `@State` so the drop delegates read it live at event time rather than
|
// rather than into `@State` so the drop delegates read it live at event time rather than
|
||||||
// as of the last body evaluation.
|
// as of the last body evaluation.
|
||||||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { laneDrops.stripFrame = $0 }
|
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { laneDrops.stripFrame = $0 }
|
||||||
|
// The board's ruler, into the registry beside the strip's frame and for its reason: the
|
||||||
|
// drop delegates run at event time, outside any body, and `nominalCardHeight` — the
|
||||||
|
// stand-in they tile un-measured rows with — has to be computed on the zoom the board is
|
||||||
|
// actually drawing at (03-board-ui.md ▸ Layout — zoom). `initial: true` because the first
|
||||||
|
// render is already a level, not a change.
|
||||||
|
.onChange(of: zoom.bodyPointSize, initial: true) {
|
||||||
|
laneDrops.bodyPointSize = zoom.bodyPointSize
|
||||||
|
// And the resting layouts built on the old ruler go with it. `RestingLayoutCache`'s
|
||||||
|
// entry key is snapshot generation, heights generation, board root and hidden set —
|
||||||
|
// deliberately not the point size, because until zoom existed the point size could
|
||||||
|
// not move. This is what keeps that key honest rather than adding a fifth term to it:
|
||||||
|
// a level change is rare, a cache miss costs one rebuild, and `ZoomCommands` already
|
||||||
|
// holds the rows shut while a drag is in flight, so in practice there is nothing
|
||||||
|
// standing here to clear.
|
||||||
|
dropContext.session.restingLayouts.clear()
|
||||||
|
}
|
||||||
// **The strip's drop target** — the backdrop, the gaps, the outer margin, and the trash
|
// **The strip's drop target** — the backdrop, the gaps, the outer margin, and the trash
|
||||||
// column's footprint, which is never a landing spot of its own (04-interactions.md ▸ The
|
// column's footprint, which is never a landing spot of its own (04-interactions.md ▸ The
|
||||||
// trash) and so simply falls through to here. It accepts *every* session type — ours and
|
// trash) and so simply falls through to here. It accepts *every* session type — ours and
|
||||||
@@ -319,7 +345,7 @@ struct BoardView: View {
|
|||||||
// One of the drag's N contiguous shadows, at the exact width the arriving lane
|
// One of the drag's N contiguous shadows, at the exact width the arriving lane
|
||||||
// will occupy — its units measured against *this* strip's standard, which is
|
// will occupy — its units measured against *this* strip's standard, which is
|
||||||
// what makes the drop land precisely where the shadow shows.
|
// what makes the drop land precisely where the shadow shows.
|
||||||
DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize))
|
DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: zoom.bodyPointSize))
|
||||||
.frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing))
|
.frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing))
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
@@ -418,13 +444,42 @@ struct BoardView: View {
|
|||||||
/// is what the design asks for — and it is why the board is the level 10-accessibility.md binds
|
/// is what the design asks for — and it is why the board is the level 10-accessibility.md binds
|
||||||
/// its ≥ 4.5:1 rule to: text does sit on it.
|
/// its ≥ 4.5:1 rule to: text does sit on it.
|
||||||
///
|
///
|
||||||
|
/// ### Three layers, and the window is the frame
|
||||||
|
///
|
||||||
|
/// The colour is the underlay, the image draws over it, and a frosted strip sits on top of both
|
||||||
|
/// under the title bar. All of it runs the **full height of the window** — `ignoresSafeArea`
|
||||||
|
/// here is what the window's own `fullSizeContentView` flip is for (`HostedWindowController
|
||||||
|
/// .setExtendsContentUnderTitlebar`, driven from `BoardWindowHost`), and the two only ever move
|
||||||
|
/// together: a board with no background of its own draws none of this and keeps the standard
|
||||||
|
/// chrome exactly as it has always looked.
|
||||||
|
///
|
||||||
|
/// The **colour is painted even while an image is loading**, and stays painted underneath it: a
|
||||||
|
/// decode is asynchronous (`BoardBackdropImage`) and a window that flashed the system background
|
||||||
|
/// on open would be the hitch that work exists to avoid. It is also what a failed or missing
|
||||||
|
/// image degrades to, with nothing said about it.
|
||||||
|
///
|
||||||
|
/// The **frost** is the price of the extended chrome: the title bar's own material is gone, so
|
||||||
|
/// the traffic lights, the board-name widget and the toolbar would otherwise sit directly on a
|
||||||
|
/// saturated colour or a photograph. It is a glass underlay (`Accommodations.frost` — the
|
||||||
|
/// ladder's thin weight, both ends tried and retired: `.bar` reads as barely-there over a
|
||||||
|
/// busy image, `.regular` and up as a veil the backdrop shouldn't pay for) and takes the
|
||||||
|
/// standard accommodation: solid
|
||||||
|
/// under Reduce Transparency, "wherever they appear". Its geometry is `frostStrip`'s — full
|
||||||
|
/// strength through the top safe-area inset, dissolving over a short tail below it — with the
|
||||||
|
/// inset read from a `GeometryReader` that is itself inside the `ignoresSafeArea`: the proxy
|
||||||
|
/// still reports the inset it was told to ignore, which is exactly the title-bar-plus-toolbar
|
||||||
|
/// band and moves on its own when the toolbar's size class changes. Nothing here hit-tests, so
|
||||||
|
/// the widget and the toolbar above it are untouched.
|
||||||
|
///
|
||||||
|
/// ### The contrast rule is the colour's, and the image is outside it
|
||||||
|
///
|
||||||
/// The rule is enforced from the *text* side rather than here, because this view paints the
|
/// The rule is enforced from the *text* side rather than here, because this view paints the
|
||||||
/// surface and draws none of the glyphs on it. Whatever colour lands below — a palette name or a
|
/// surface and draws none of the glyphs on it. Whatever colour lands below — a palette name, a
|
||||||
/// hand-written hex, they reach the same place — has its text colour computed against the
|
/// hand-written hex, or the `color` subkey of the mapping form, they reach the same place — has
|
||||||
/// threshold by `BoardTextInk`, composited over the window background in the active appearance
|
/// its text colour computed against the threshold by `BoardTextInk`, composited over the window
|
||||||
/// and recomputed on an appearance flip; the two subtrees that sit on this fill
|
/// background in the active appearance and recomputed on an appearance flip; the two subtrees
|
||||||
/// (`LaneView.header` and `TrashLaneView.header` — their plates are translucent washes the
|
/// that sit on this fill (`LaneView.header` and `TrashLaneView.header` — their plates are
|
||||||
/// colour shows through, where every card carries its own opaque plate,
|
/// translucent washes the colour shows through, where every card carries its own opaque plate,
|
||||||
/// `BoardSurface.cardPlate`) take the answer as a `\.colorScheme` override.
|
/// `BoardSurface.cardPlate`) take the answer as a `\.colorScheme` override.
|
||||||
///
|
///
|
||||||
/// **One path, two verification stories** (`ContrastMath`): the twelve palette pairs are checked
|
/// **One path, two verification stories** (`ContrastMath`): the twelve palette pairs are checked
|
||||||
@@ -433,12 +488,69 @@ struct BoardView: View {
|
|||||||
/// cannot be settled by a table of colours alone); an arbitrary hex is checked only as it
|
/// cannot be settled by a table of colours alone); an arbitrary hex is checked only as it
|
||||||
/// renders, because its value arrives from a file.
|
/// renders, because its value arrives from a file.
|
||||||
///
|
///
|
||||||
|
/// **An image makes no AA claim at all**, and the ink does not try to derive one from it. Ink
|
||||||
|
/// still follows the `color` reading — the colour the author chose to sit under the picture, or
|
||||||
|
/// the default when they chose none — which is the same bytes-from-a-file posture an arbitrary
|
||||||
|
/// hex already has, one step further out: a photograph has no single luminance to threshold
|
||||||
|
/// against, the field has no in-app control that could warn about one, and a per-pixel answer
|
||||||
|
/// would change as the window resized. An author who lays text over a busy picture is doing what
|
||||||
|
/// the raw file exists to let them do.
|
||||||
|
///
|
||||||
/// A value that resolves to nothing paints nothing, so the window keeps the standard background:
|
/// A value that resolves to nothing paints nothing, so the window keeps the standard background:
|
||||||
/// the same lenient degrade as the other two levels, and the bytes stay as written.
|
/// the same lenient degrade as the other two levels, and the bytes stay as written.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var boardBackground: some View {
|
private var boardBackground: some View {
|
||||||
if let color = Palette.color(for: store.snapshot.background) {
|
let color = Palette.color(for: store.snapshot.background)
|
||||||
color
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,7 +589,7 @@ struct BoardView: View {
|
|||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
if resizing {
|
if resizing {
|
||||||
DragShadow(
|
DragShadow(
|
||||||
cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize),
|
cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: zoom.bodyPointSize),
|
||||||
dashed: false
|
dashed: false
|
||||||
)
|
)
|
||||||
.frame(width: slotWidth)
|
.frame(width: slotWidth)
|
||||||
|
|||||||
@@ -30,11 +30,14 @@ import SwiftUI
|
|||||||
/// **Only the trash side carries the confirmation host** (settled): the board side's Delete is the
|
/// **Only the trash side carries the confirmation host** (settled): the board side's Delete is the
|
||||||
/// ordinary staged move into `.trash/` and never stands an alert, so `board` needs nothing beyond the
|
/// ordinary staged move into `.trash/` and never stands an alert, so `board` needs nothing beyond the
|
||||||
/// card opener.
|
/// card opener.
|
||||||
enum CardFaceRole {
|
/// `Sendable` because `CardFaceView.==` is nonisolated and a nonisolated context may only read a
|
||||||
|
/// main-actor `let` of Sendable type — which is why `openCard` is typed `@MainActor` (an isolated
|
||||||
|
/// function type is Sendable; a bare one is not, and would sink the whole enum).
|
||||||
|
enum CardFaceRole: Sendable {
|
||||||
|
|
||||||
/// A card in a lane. Carries the board window's card opener — ⌘↩'s pointer twin
|
/// A card in a lane. Carries the board window's card opener — ⌘↩'s pointer twin
|
||||||
/// (04-interactions.md ▸ Selection).
|
/// (04-interactions.md ▸ Selection).
|
||||||
case board(openCard: (ItemID) -> Void)
|
case board(openCard: @MainActor (ItemID) -> Void)
|
||||||
|
|
||||||
/// A card in `<root>/.trash/`. Carries the window's purge-alert host, because the trash's Delete
|
/// A card in `<root>/.trash/`. Carries the window's purge-alert host, because the trash's Delete
|
||||||
/// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
|
/// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
|
||||||
@@ -146,10 +149,16 @@ struct CardFaceView: View, Equatable {
|
|||||||
/// `Accommodations`, which owns what "increased" does to a stroke.
|
/// `Accommodations`, which owns what "increased" does to a stroke.
|
||||||
@Environment(\.colorSchemeContrast) private var contrast
|
@Environment(\.colorSchemeContrast) private var contrast
|
||||||
|
|
||||||
|
/// The board's ruler (03-board-ui.md ▸ Layout — zoom; `BoardZoom`). **The environment is what
|
||||||
|
/// makes zoom reach a card face at all**: this view is `.equatable()`, and the gate above compares
|
||||||
|
/// nothing that moves with the level — but it does not compare environment values either, because
|
||||||
|
/// SwiftUI invalidates on those itself. A level threaded any other way would be swallowed here.
|
||||||
|
@Environment(\.boardZoom) private var zoom
|
||||||
|
|
||||||
/// The live body metric — every figure this face lays out on is a multiple of it
|
/// The live body metric — every figure this face lays out on is a multiple of it
|
||||||
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule). Read here rather than
|
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule). Read here rather than
|
||||||
/// passed in, which is `CardAttachmentsSection`'s pattern on the card-window side.
|
/// passed in, which is `CardAttachmentsSection`'s pattern on the card-window side.
|
||||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||||||
|
|
||||||
/// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
|
/// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
|
||||||
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
|
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
|
||||||
@@ -400,13 +409,41 @@ struct CardFaceView: View, Equatable {
|
|||||||
// The dragged items' sizes, frozen at drag start — the pickup transition scales the
|
// The dragged items' sizes, frozen at drag start — the pickup transition scales the
|
||||||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||||||
// span-cap (03-board-ui.md § Motion).
|
// span-cap (03-board-ui.md § Motion).
|
||||||
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
|
heights: ordered.map { drops.registry.heights[$0] ?? drops.registry.nominalCardHeight },
|
||||||
container: .board,
|
container: .board,
|
||||||
source: store
|
source: store,
|
||||||
|
seed: ownSlotSeed(dragging: ids, laneID: lanesByCard[card.id], in: snapshot)
|
||||||
)
|
)
|
||||||
return payload.itemProvider()
|
return payload.itemProvider()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pickup's own-slot proposal (`DragSession.begin`): the grabbed card's position among its
|
||||||
|
/// lane's rendered cards with the dragged run lifted out — the same resting-layout index space
|
||||||
|
/// the retargets and `moveCards` count in, filtered by the same `LaneView.rendered` rules, so
|
||||||
|
/// the seeded shadow run stands exactly where the lifted cards stood and pickup moves nothing.
|
||||||
|
///
|
||||||
|
/// **A ⌥-pickup seeds nothing.** A copy's resting layout keeps the originals in place, so there
|
||||||
|
/// is no vacated space for a shadow to hold; the first `dropUpdated` resolves the copy and opens
|
||||||
|
/// the run beside the originals, exactly as it does today (DRAG-REORDER.md § Resting-layout
|
||||||
|
/// zones — the modifier reflow is the feedback being asked for).
|
||||||
|
private func ownSlotSeed(
|
||||||
|
dragging ids: Set<ItemID>,
|
||||||
|
laneID: ItemID?,
|
||||||
|
in snapshot: BoardModel
|
||||||
|
) -> DropTarget? {
|
||||||
|
guard !NSEvent.modifierFlags.contains(.option),
|
||||||
|
let laneID,
|
||||||
|
let laneCards = snapshot.lanes.first(where: { $0.id == laneID })?.cards
|
||||||
|
else { return nil }
|
||||||
|
let index = LaneView.rendered(
|
||||||
|
Array(laneCards.prefix { $0.id != card.id }),
|
||||||
|
hiddenByDrag: ids,
|
||||||
|
filter: store.searchFilter,
|
||||||
|
renaming: store.transient.renameEditor?.targetID
|
||||||
|
).count
|
||||||
|
return DropTarget(boardRoot: store.rootKey, container: .lane(laneID), index: index)
|
||||||
|
}
|
||||||
|
|
||||||
/// A trash card's drag out — **the restore**, and deliberately not special: an ordinary `.cards`
|
/// A trash card's drag out — **the restore**, and deliberately not special: an ordinary `.cards`
|
||||||
/// session in the `.trash` container, which `BoardDropContext.commitDrop` hands to the same
|
/// session in the `.trash` container, which `BoardDropContext.commitDrop` hands to the same
|
||||||
/// `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an ordinary move
|
/// `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an ordinary move
|
||||||
@@ -447,7 +484,7 @@ struct CardFaceView: View, Equatable {
|
|||||||
drops.session.beginCards(
|
drops.session.beginCards(
|
||||||
rows.map(\.id),
|
rows.map(\.id),
|
||||||
folders: payload.folders,
|
folders: payload.folders,
|
||||||
heights: rows.map { drops.registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight },
|
heights: rows.map { drops.registry.heights[$0.id] ?? drops.registry.nominalCardHeight },
|
||||||
container: .trash,
|
container: .trash,
|
||||||
source: store,
|
source: store,
|
||||||
mixesKinds: mixesKinds
|
mixesKinds: mixesKinds
|
||||||
@@ -492,7 +529,7 @@ struct CardFaceView: View, Equatable {
|
|||||||
.foregroundStyle(iconTint)
|
.foregroundStyle(iconTint)
|
||||||
.imageScale(.medium)
|
.imageScale(.medium)
|
||||||
Text(card.title.value ?? "Untitled")
|
Text(card.title.value ?? "Untitled")
|
||||||
.font(.body)
|
.boardFont(.body)
|
||||||
.lineLimit(4)
|
.lineLimit(4)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
attachmentsIndicator
|
attachmentsIndicator
|
||||||
@@ -512,7 +549,7 @@ struct CardFaceView: View, Equatable {
|
|||||||
)
|
)
|
||||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate))
|
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate))
|
||||||
.overlay(alignment: .leading) { accentStripe }
|
.overlay(alignment: .leading) { accentStripe }
|
||||||
.dragReplicaShadow()
|
.dragReplicaShadow(zoom: zoom)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Context menus
|
// MARK: - Context menus
|
||||||
@@ -682,10 +719,10 @@ struct CardFaceView: View, Equatable {
|
|||||||
if case let .board(openCard) = role { openCard(id) }
|
if case let .board(openCard) = role { openCard(id) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.font(.body)
|
.boardFont(.body)
|
||||||
} else {
|
} else {
|
||||||
Text(card.title.value ?? "Untitled")
|
Text(card.title.value ?? "Untitled")
|
||||||
.font(.body)
|
.boardFont(.body)
|
||||||
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
||||||
.lineLimit(4)
|
.lineLimit(4)
|
||||||
}
|
}
|
||||||
@@ -715,7 +752,7 @@ struct CardFaceView: View, Equatable {
|
|||||||
private var attachmentsIndicator: some View {
|
private var attachmentsIndicator: some View {
|
||||||
if !card.attachments.isEmpty {
|
if !card.attachments.isEmpty {
|
||||||
Image(systemName: "paperclip")
|
Image(systemName: "paperclip")
|
||||||
.font(.caption)
|
.boardFont(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,22 +50,47 @@ enum DragAutoScrollMath {
|
|||||||
|
|
||||||
/// How far above the visible area the pointer may sit and still drive it — enough to cover the
|
/// How far above the visible area the pointer may sit and still drive it — enough to cover the
|
||||||
/// lane's header, which is where a drag naturally goes to scroll up.
|
/// lane's header, which is where a drag naturally goes to scroll up.
|
||||||
static let reachAbove: CGFloat = 48
|
///
|
||||||
|
/// **Font-derived, unlike the three figures above it, and the split is the point.** `band`,
|
||||||
|
/// `minSpeed` and `maxSpeed` describe the *cursor's* relationship to an edge — how near counts as
|
||||||
|
/// near, how fast the content should answer — and none of that changes because the board is drawn
|
||||||
|
/// larger; they stay fixed for `LaneResizeSession.reentry`'s reason. The three reaches describe
|
||||||
|
/// **where the lane's furniture is**, and that furniture moves: this one is specified as "enough
|
||||||
|
/// to cover the lane's header", and a header at 200% zoom is twice as tall as the 48 points that
|
||||||
|
/// covered it at 13pt (03-board-ui.md ▸ Layout — zoom).
|
||||||
|
///
|
||||||
|
/// 3.7 em: 48 points at the standard body size, which is what the reach has always been.
|
||||||
|
static func reachAbove(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
|
BoardMetrics.em(3.7, bodyPointSize: bodyPointSize)
|
||||||
|
}
|
||||||
|
|
||||||
/// The same below, covering the lane's bottom padding.
|
/// The same below, covering the lane's bottom padding — 1.85 em, the standard size's 24 points.
|
||||||
static let reachBelow: CGFloat = 24
|
static func reachBelow(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
|
BoardMetrics.em(1.85, bodyPointSize: bodyPointSize)
|
||||||
|
}
|
||||||
|
|
||||||
/// The sideways reach — kept under half the distance between two lanes' scroll areas so only
|
/// The sideways reach — kept under half the distance between two lanes' scroll areas so only
|
||||||
/// one lane ever engages.
|
/// one lane ever engages.
|
||||||
static let reachSide: CGFloat = 12
|
///
|
||||||
|
/// **It is the inter-lane gap, named as itself** rather than as a coincidentally equal number:
|
||||||
|
/// the distance between two lanes' scroll areas is the gap plus a plate padding on each side
|
||||||
|
/// (0.9 + 2 × 0.45 em), so half of it is exactly `stripGap`. Writing it that way is what keeps
|
||||||
|
/// the sentence above true at every zoom level instead of only at 13pt, where 12 happened to be
|
||||||
|
/// the answer.
|
||||||
|
static func reachSide(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
|
BoardMetrics.stripGap(bodyPointSize: bodyPointSize)
|
||||||
|
}
|
||||||
|
|
||||||
/// The region — in the visible area's own coordinates, `(0, 0)` at its top-left — a pointer
|
/// The region — in the visible area's own coordinates, `(0, 0)` at its top-left — a pointer
|
||||||
/// must be in to drive this scroller at all.
|
/// must be in to drive this scroller at all.
|
||||||
static func engagementRect(viewport: CGSize) -> CGRect {
|
static func engagementRect(viewport: CGSize, bodyPointSize: CGFloat) -> CGRect {
|
||||||
CGRect(x: -reachSide,
|
let above = reachAbove(bodyPointSize: bodyPointSize)
|
||||||
y: -reachAbove,
|
let below = reachBelow(bodyPointSize: bodyPointSize)
|
||||||
width: viewport.width + reachSide * 2,
|
let side = reachSide(bodyPointSize: bodyPointSize)
|
||||||
height: viewport.height + reachAbove + reachBelow)
|
return CGRect(x: -side,
|
||||||
|
y: -above,
|
||||||
|
width: viewport.width + side * 2,
|
||||||
|
height: viewport.height + above + below)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signed scroll velocity in points/second for a pointer at `position` along an axis whose
|
/// Signed scroll velocity in points/second for a pointer at `position` along an axis whose
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ final class DragAutoScroller {
|
|||||||
/// Invoked after every scroll step. Re-resolves the drop proposal; see the type's note.
|
/// Invoked after every scroll step. Re-resolves the drop proposal; see the type's note.
|
||||||
fileprivate var didScroll: (() -> Void)?
|
fileprivate var didScroll: (() -> Void)?
|
||||||
|
|
||||||
|
/// The board's ruler, for the engagement rect's three reaches — they are distances to the lane's
|
||||||
|
/// own furniture, which moves with the zoom level (`DragAutoScrollMath.reachAbove`;
|
||||||
|
/// 03-board-ui.md ▸ Layout — zoom).
|
||||||
|
///
|
||||||
|
/// Set by the lane at the head of each drag rather than observed, because that is exactly when it
|
||||||
|
/// can change: zoom is inert while a session is in flight (`ZoomCommands.isEnabled`), so one read
|
||||||
|
/// per drag is one read per level.
|
||||||
|
///
|
||||||
|
/// Optional, and resolved at use rather than defaulted in `init`, because the initialiser is
|
||||||
|
/// `nonisolated` (the tick-source seam is constructed off the main actor in tests) and the system
|
||||||
|
/// body size is a main-actor read. Un-set means "nobody has told me", which resolves to the
|
||||||
|
/// system's ruler — an un-zoomed board's answer.
|
||||||
|
var bodyPointSize: CGFloat?
|
||||||
|
|
||||||
/// Where frames come from. One implementation ships (`DisplayLinkTickSource`); the seam exists
|
/// Where frames come from. One implementation ships (`DisplayLinkTickSource`); the seam exists
|
||||||
/// because a `CADisplayLink` needs a screen and a run loop and so cannot tick in a test bundle
|
/// because a `CADisplayLink` needs a screen and a run loop and so cannot tick in a test bundle
|
||||||
/// (`DragAutoScrollDriverTests`).
|
/// (`DragAutoScrollDriverTests`).
|
||||||
@@ -81,7 +95,9 @@ final class DragAutoScroller {
|
|||||||
let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
|
let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
|
||||||
let inClip = clip.convert(inWindow, from: nil)
|
let inClip = clip.convert(inWindow, from: nil)
|
||||||
let pointer = CGPoint(x: inClip.x - visible.minX, y: inClip.y - visible.minY)
|
let pointer = CGPoint(x: inClip.x - visible.minX, y: inClip.y - visible.minY)
|
||||||
guard DragAutoScrollMath.engagementRect(viewport: visible.size).contains(pointer) else { return }
|
let ruler = bodyPointSize ?? BoardMetrics.bodyPointSize
|
||||||
|
guard DragAutoScrollMath.engagementRect(viewport: visible.size, bodyPointSize: ruler)
|
||||||
|
.contains(pointer) else { return }
|
||||||
|
|
||||||
let velocity = DragAutoScrollMath.velocity(pointer: pointer, viewport: visible.size)
|
let velocity = DragAutoScrollMath.velocity(pointer: pointer, viewport: visible.size)
|
||||||
guard velocity.dx != 0 || velocity.dy != 0 else { return }
|
guard velocity.dx != 0 || velocity.dy != 0 else { return }
|
||||||
|
|||||||
@@ -611,17 +611,19 @@ final class DragSession {
|
|||||||
/// - members: the dragged cards in flatten order (`SelectionGrammar.boardCards`, or the
|
/// - members: the dragged cards in flatten order (`SelectionGrammar.boardCards`, or the
|
||||||
/// trash's own order for a trash-card drag).
|
/// trash's own order for a trash-card drag).
|
||||||
/// - heights: their measured heights, captured **before** the pickup transition starts.
|
/// - heights: their measured heights, captured **before** the pickup transition starts.
|
||||||
|
/// - seed: the run's own resting slot, proposed from the first frame — see `begin`.
|
||||||
func beginCards(
|
func beginCards(
|
||||||
_ members: [ItemID],
|
_ members: [ItemID],
|
||||||
folders: [URL],
|
folders: [URL],
|
||||||
heights: [CGFloat],
|
heights: [CGFloat],
|
||||||
container: ItemContainer,
|
container: ItemContainer,
|
||||||
source: BoardStore,
|
source: BoardStore,
|
||||||
mixesKinds: Bool = false
|
mixesKinds: Bool = false,
|
||||||
|
seed: DropTarget? = nil
|
||||||
) {
|
) {
|
||||||
begin(
|
begin(
|
||||||
kind: .cards, members: members, folders: folders,
|
kind: .cards, members: members, folders: folders,
|
||||||
container: container, source: source, mixesKinds: mixesKinds
|
container: container, source: source, mixesKinds: mixesKinds, seed: seed
|
||||||
)
|
)
|
||||||
cardHeights = heights
|
cardHeights = heights
|
||||||
laneUnits = []
|
laneUnits = []
|
||||||
@@ -637,23 +639,37 @@ final class DragSession {
|
|||||||
units: [Int],
|
units: [Int],
|
||||||
container: ItemContainer = .board,
|
container: ItemContainer = .board,
|
||||||
source: BoardStore,
|
source: BoardStore,
|
||||||
mixesKinds: Bool = false
|
mixesKinds: Bool = false,
|
||||||
|
seed: DropTarget? = nil
|
||||||
) {
|
) {
|
||||||
begin(
|
begin(
|
||||||
kind: .lanes, members: members, folders: folders,
|
kind: .lanes, members: members, folders: folders,
|
||||||
container: container, source: source, mixesKinds: mixesKinds
|
container: container, source: source, mixesKinds: mixesKinds, seed: seed
|
||||||
)
|
)
|
||||||
laneUnits = units
|
laneUnits = units
|
||||||
cardHeights = []
|
cardHeights = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `seed` is the pickup's own-slot proposal — "at drag start it replaces the item's original
|
||||||
|
/// space" (DRAG-REORDER.md § The pieces), made true from the very first frame rather than from
|
||||||
|
/// the first `dropUpdated`. Without it the lift-out and the shadow's arrival land in different
|
||||||
|
/// transactions: the masonry closes the vacated gap (un-animated — `shadowRun` hasn't moved) and
|
||||||
|
/// then springs it back open when the first hover sample proposes the own slot, a shuffle for no
|
||||||
|
/// information. Seeded, the lift and the shadow are one transaction with identical geometry —
|
||||||
|
/// the run's frozen sizes at the run's own resting position — so nothing on the board moves at
|
||||||
|
/// pickup, which is what the own-slot fixed point always promised.
|
||||||
|
///
|
||||||
|
/// Set directly rather than through `propose`, because a pickup is not a *new landing spot*:
|
||||||
|
/// the alignment tick stays reserved for genuine retargets. The first real `dropUpdated`
|
||||||
|
/// re-proposes the same slot and `propose`'s early-out makes it a silent no-op.
|
||||||
private func begin(
|
private func begin(
|
||||||
kind: DragKind,
|
kind: DragKind,
|
||||||
members: [ItemID],
|
members: [ItemID],
|
||||||
folders: [URL],
|
folders: [URL],
|
||||||
container: ItemContainer,
|
container: ItemContainer,
|
||||||
source: BoardStore,
|
source: BoardStore,
|
||||||
mixesKinds: Bool
|
mixesKinds: Bool,
|
||||||
|
seed: DropTarget?
|
||||||
) {
|
) {
|
||||||
// A new drag's first `sinceLastMs` must not be the gap since the previous drag's last sample.
|
// A new drag's first `sinceLastMs` must not be the gap since the previous drag's last sample.
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -669,7 +685,7 @@ final class DragSession {
|
|||||||
self.mixesKinds = mixesKinds
|
self.mixesKinds = mixesKinds
|
||||||
self.sourceStore = source
|
self.sourceStore = source
|
||||||
self.sourceRoot = source.rootKey
|
self.sourceRoot = source.rootKey
|
||||||
self.proposal = nil
|
self.proposal = seed
|
||||||
self.operation = .move
|
self.operation = .move
|
||||||
// The reload-resolved drag set: vanished members leave it silently, which is what
|
// The reload-resolved drag set: vanished members leave it silently, which is what
|
||||||
// `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
|
// `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
|
||||||
|
|||||||
@@ -57,15 +57,19 @@ struct DragCountBadge: View {
|
|||||||
|
|
||||||
let count: Int
|
let count: Int
|
||||||
|
|
||||||
|
/// The board's ruler (`BoardZoom`) — the badge sits over the shadow of a card drawn at this zoom,
|
||||||
|
/// so it has to be sized on the same one.
|
||||||
|
@Environment(\.boardZoom) private var zoom
|
||||||
|
|
||||||
/// The badge is a disc around a numeral, so every figure in it follows the numeral's font
|
/// The badge is a disc around a numeral, so every figure in it follows the numeral's font
|
||||||
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule) — at the standard body size
|
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule) — at the standard body size
|
||||||
/// they are the 6, 3 and 10 points it has always drawn.
|
/// they are the 6, 3 and 10 points it has always drawn.
|
||||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if count > 1 {
|
if count > 1 {
|
||||||
Text("\(count)")
|
Text("\(count)")
|
||||||
.font(.caption2.bold())
|
.boardFont(.caption2, weight: .bold)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, BoardMetrics.em(0.45, bodyPointSize: pointSize))
|
.padding(.horizontal, BoardMetrics.em(0.45, bodyPointSize: pointSize))
|
||||||
@@ -94,7 +98,19 @@ enum DragReplicaStyle {
|
|||||||
extension View {
|
extension View {
|
||||||
/// The pickup lift's shadow (`DragReplicaStyle`), for a drag replica's face — shared by the
|
/// The pickup lift's shadow (`DragReplicaStyle`), for a drag replica's face — shared by the
|
||||||
/// card, lane, and trashed-lane replicas so the figures are spelled once.
|
/// card, lane, and trashed-lane replicas so the figures are spelled once.
|
||||||
func dragReplicaShadow() -> some View {
|
///
|
||||||
shadow(color: DragReplicaStyle.shadowColor, radius: DragReplicaStyle.shadowRadius, y: DragReplicaStyle.shadowY)
|
/// **It re-asserts the board's zoom, and that is not decoration.** A `.onDrag(_:preview:)`
|
||||||
|
/// preview is hosted for the drag *image*, outside the strip's view hierarchy, so the environment
|
||||||
|
/// the replica's own `.boardFont(_:)` modifiers resolve against is not reliably the board's. The
|
||||||
|
/// replica must be drawn on the same ruler as the face it was lifted from — otherwise the image
|
||||||
|
/// under the cursor is a card the board does not contain, which is the exact failure
|
||||||
|
/// `BoardMetrics.cardReplicaWidth(measured:)` exists to prevent on the width axis. Passing the
|
||||||
|
/// level in explicitly closes the type axis the same way.
|
||||||
|
///
|
||||||
|
/// It rides on this modifier rather than being a fourth line in each replica for the reason the
|
||||||
|
/// shadow does: three replicas, one rule, spelled once.
|
||||||
|
func dragReplicaShadow(zoom: BoardZoomContext) -> some View {
|
||||||
|
environment(\.boardZoom, zoom)
|
||||||
|
.shadow(color: DragReplicaStyle.shadowColor, radius: DragReplicaStyle.shadowRadius, y: DragReplicaStyle.shadowY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,14 +38,18 @@ struct LaneResizeHandle: View {
|
|||||||
/// cursor.
|
/// cursor.
|
||||||
@State private var cursorPushed = false
|
@State private var cursorPushed = false
|
||||||
|
|
||||||
|
/// The board's ruler (`BoardZoom`) — the gap this strip is proportioned against moves with the
|
||||||
|
/// level, so the grab target has to move with it too or it drifts off the gap it lives in.
|
||||||
|
@Environment(\.boardZoom) private var zoom
|
||||||
|
|
||||||
/// The grab strip's width and its rightward shift — both font-derived, because the inter-lane
|
/// The grab strip's width and its rightward shift — both font-derived, because the inter-lane
|
||||||
/// gap they are proportioned against is (`BoardMetrics.stripGap`, 10-accessibility.md's
|
/// gap they are proportioned against is (`BoardMetrics.stripGap`, 10-accessibility.md's
|
||||||
/// full-relative-scaling rule). At the standard body size they are the 12pt and 8pt the strip
|
/// full-relative-scaling rule). At the standard body size they are the 12pt and 8pt the strip
|
||||||
/// has always used: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt
|
/// has always used: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt
|
||||||
/// into the gap (clear of the lane's own scrollbar).
|
/// into the gap (clear of the lane's own scrollbar).
|
||||||
private var handleWidth: CGFloat { BoardMetrics.resizeHandleWidth(bodyPointSize: BoardMetrics.bodyPointSize) }
|
private var handleWidth: CGFloat { BoardMetrics.resizeHandleWidth(bodyPointSize: zoom.bodyPointSize) }
|
||||||
|
|
||||||
private var overhang: CGFloat { BoardMetrics.resizeHandleOverhang(bodyPointSize: BoardMetrics.bodyPointSize) }
|
private var overhang: CGFloat { BoardMetrics.resizeHandleOverhang(bodyPointSize: zoom.bodyPointSize) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Color.clear
|
Color.clear
|
||||||
|
|||||||
+156
-53
@@ -71,7 +71,7 @@ struct LaneView: View, Equatable {
|
|||||||
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar, "commits and opens
|
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar, "commits and opens
|
||||||
/// the card window"). Supplied by the strip, which is supplied by the host: a lane has no
|
/// the card window"). Supplied by the strip, which is supplied by the host: a lane has no
|
||||||
/// business knowing about `WindowGroup` keys.
|
/// business knowing about `WindowGroup` keys.
|
||||||
let openCard: (ItemID) -> Void
|
let openCard: @MainActor (ItemID) -> Void
|
||||||
|
|
||||||
/// Reduce Motion, for the card transition below (10-accessibility.md). Read from the environment
|
/// Reduce Motion, for the card transition below (10-accessibility.md). Read from the environment
|
||||||
/// and handed to `Motion`, which owns what "reduced" means.
|
/// and handed to `Motion`, which owns what "reduced" means.
|
||||||
@@ -94,9 +94,14 @@ struct LaneView: View, Equatable {
|
|||||||
/// against the colours of the appearance the window is now in.
|
/// against the colours of the appearance the window is now in.
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
/// The board's ruler (03-board-ui.md ▸ Layout — zoom; `BoardZoom`), injected on the strip by
|
||||||
|
/// `BoardWindowHost`. An `@Environment` read rather than a value passed down deliberately: this
|
||||||
|
/// view is `.equatable()`, and environment values are the one input the gate cannot suppress.
|
||||||
|
@Environment(\.boardZoom) private var zoom
|
||||||
|
|
||||||
/// The live body metric — every figure this lane lays out on is a multiple of it
|
/// The live body metric — every figure this lane lays out on is a multiple of it
|
||||||
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule).
|
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule), at the board's zoom.
|
||||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||||||
|
|
||||||
/// Spacing between cards, and between the interior columns.
|
/// Spacing between cards, and between the interior columns.
|
||||||
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
|
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
|
||||||
@@ -117,6 +122,11 @@ struct LaneView: View, Equatable {
|
|||||||
/// is set to.
|
/// is set to.
|
||||||
@State private var measuredHeaderHeight: CGFloat = 0
|
@State private var measuredHeaderHeight: CGFloat = 0
|
||||||
|
|
||||||
|
/// The card stack's viewport height — the masonry's height *floor* (`scrollableCards`).
|
||||||
|
/// Measured because a `ScrollView` proposes nothing along its scroll axis, so no frame maximum
|
||||||
|
/// can stretch the content to fill it; only an explicit minimum can.
|
||||||
|
@State private var scrollViewportHeight: CGFloat = 0
|
||||||
|
|
||||||
/// This lane's edge-autoscroll driver — one per lane, ticking only while a card session is in
|
/// This lane's edge-autoscroll driver — one per lane, ticking only while a card session is in
|
||||||
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
|
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
|
||||||
@State private var autoScroller = DragAutoScroller()
|
@State private var autoScroller = DragAutoScroller()
|
||||||
@@ -165,10 +175,6 @@ struct LaneView: View, Equatable {
|
|||||||
}
|
}
|
||||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||||
}
|
}
|
||||||
.background(selectionBackground)
|
|
||||||
// Behind the selection wash, not composed into it: the plate is the lane's resting
|
|
||||||
// surface, the wash above it is the selection's own layer, and stacked `.background`s
|
|
||||||
// put the later one further back.
|
|
||||||
.background(lanePlate)
|
.background(lanePlate)
|
||||||
.overlay(selectionStroke)
|
.overlay(selectionStroke)
|
||||||
// The deferred cut's dim (04-interactions.md ▸ Clipboard) — on the whole lane, because a cut
|
// The deferred cut's dim (04-interactions.md ▸ Clipboard) — on the whole lane, because a cut
|
||||||
@@ -476,10 +482,10 @@ struct LaneView: View, Equatable {
|
|||||||
// and open[s] the card window", and only a card has one to open).
|
// and open[s] the card window", and only a card has one to open).
|
||||||
onCommitAndOpen: { store.commitRename() }
|
onCommitAndOpen: { store.commitRename() }
|
||||||
)
|
)
|
||||||
.font(.headline)
|
.boardFont(.headline)
|
||||||
} else {
|
} else {
|
||||||
Text(lane.title.value ?? "Untitled")
|
Text(lane.title.value ?? "Untitled")
|
||||||
.font(.headline)
|
.boardFont(.headline)
|
||||||
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
|
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
@@ -502,7 +508,7 @@ struct LaneView: View, Equatable {
|
|||||||
/// body renders (see `renderedCards`).
|
/// body renders (see `renderedCards`).
|
||||||
private var countBadge: some View {
|
private var countBadge: some View {
|
||||||
Text("\(renderedCards.count)")
|
Text("\(renderedCards.count)")
|
||||||
.font(.caption)
|
.boardFont(.caption)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
|
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
|
||||||
@@ -577,7 +583,17 @@ struct LaneView: View, Equatable {
|
|||||||
// The dragged items' own sizes, frozen at drag start — the one thing that is
|
// The dragged items' own sizes, frozen at drag start — the one thing that is
|
||||||
// (03-board-ui.md § Motion).
|
// (03-board-ui.md § Motion).
|
||||||
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
|
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||||
source: store
|
source: store,
|
||||||
|
// The strip's own-slot seed (`DragSession.begin`): the grabbed lane's position among
|
||||||
|
// the lanes with the dragged run removed — the strip proposal's own index space. A
|
||||||
|
// within-board lane drag never resolves to `.copy` (⌥ is ignored there), so unlike the
|
||||||
|
// card seed this one carries no modifier clause.
|
||||||
|
seed: DropTarget(
|
||||||
|
boardRoot: store.rootKey,
|
||||||
|
container: .strip,
|
||||||
|
index: store.snapshot.lanes.prefix { $0.id != lane.id }
|
||||||
|
.count { !ids.contains($0.id) }
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return payload.itemProvider()
|
return payload.itemProvider()
|
||||||
}
|
}
|
||||||
@@ -640,7 +656,7 @@ struct LaneView: View, Equatable {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.imageScale(.medium)
|
.imageScale(.medium)
|
||||||
Text(card.title.value ?? "Untitled")
|
Text(card.title.value ?? "Untitled")
|
||||||
.font(.body)
|
.boardFont(.body)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
@@ -663,7 +679,7 @@ struct LaneView: View, Equatable {
|
|||||||
.background(lanePlate)
|
.background(lanePlate)
|
||||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
.dragReplicaShadow()
|
.dragReplicaShadow(zoom: zoom)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The replica's size — **the lane's own**, floored for a lane that has not measured itself yet
|
/// The replica's size — **the lane's own**, floored for a lane that has not measured itself yet
|
||||||
@@ -785,7 +801,14 @@ struct LaneView: View, Equatable {
|
|||||||
.id(slot.id)
|
.id(slot.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
// **The measured viewport is the masonry's height floor.** A `ScrollView` proposes
|
||||||
|
// nothing along its scroll axis, so `maxHeight: .infinity` cannot stretch this view —
|
||||||
|
// sized to fit its cards, it left the blank space beneath them (the whole body, in an
|
||||||
|
// empty lane) outside the content shape below, and every surface attached to it dead
|
||||||
|
// there: the lane-select tap, the double-click create, the context menu, and the band.
|
||||||
|
// The explicit minimum is what makes "click-drag rubber-bands across lanes" arm from a
|
||||||
|
// lane's own empty space (04-interactions.md § Selection; `TrashLaneView` is the twin).
|
||||||
|
.frame(maxWidth: .infinity, minHeight: scrollViewportHeight, maxHeight: .infinity, alignment: .topLeading)
|
||||||
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's shadow run**
|
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's shadow run**
|
||||||
// and nothing broader (03-board-ui.md § Motion). `MasonryLayout` is a `Layout` over one
|
// and nothing broader (03-board-ui.md § Motion). `MasonryLayout` is a `Layout` over one
|
||||||
// `ForEach` precisely so the round-robin reshuffle animates as positional slides rather
|
// `ForEach` precisely so the round-robin reshuffle animates as positional slides rather
|
||||||
@@ -801,34 +824,92 @@ struct LaneView: View, Equatable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onDisappear { drops.registry.removeGrid(lane.id) }
|
.onDisappear { drops.registry.removeGrid(lane.id) }
|
||||||
|
// **The empty-space click surfaces live on a background layer, not on the container**
|
||||||
|
// (the 2026-08-06 click-latency fix). The pathfinder shape — `.onTapGesture(count: 2)`
|
||||||
|
// stacked over `.onTapGesture` on the container that wraps the masonry — made SwiftUI
|
||||||
|
// hold *every* single click in the lane, its own empty space and every card face
|
||||||
|
// alike, hostage to the system double-click interval while the sequential two-tap
|
||||||
|
// recogniser disambiguated (measured ~475 ms click-to-selection on a hosted board;
|
||||||
|
// `PointerLatencyTests` pins the recovery).
|
||||||
|
//
|
||||||
|
// The background layer is the structural answer to both halves of that defect. A card
|
||||||
|
// click hit-tests to the card, and the card is **not** a descendant of this layer, so
|
||||||
|
// no recogniser here can ever hold a card's click — where a container gesture always
|
||||||
|
// shares the card's gesture path, an ancestor's simultaneity notwithstanding. And a
|
||||||
|
// click that does land here *is* empty space by construction — the double-click create
|
||||||
|
// needs no geometric guard against the cards, the way the rubber band's begin does
|
||||||
|
// (`MarqueeControl`), because the masonry above already consumed everything that was
|
||||||
|
// not empty.
|
||||||
|
.background {
|
||||||
|
Rectangle()
|
||||||
|
.fill(.clear)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
// **No drag source on this layer, deliberately** (measured on real events,
|
||||||
|
// 2026-08-07): an `.onDrag` here — even one whose provider is empty — claims
|
||||||
|
// the mouse-drag the moment the cursor crosses the drag threshold, and the
|
||||||
|
// rubber band's simultaneous `DragGesture` on the container gets one sample
|
||||||
|
// and then silence: the band begins and freezes, and even the mouseUp never
|
||||||
|
// reaches the app. Dragless, the same sweep tracks every sample. And the drag
|
||||||
|
// source buys nothing in return: a bare count-1 tap on this layer fires in
|
||||||
|
// ~1–3 ms in a real event stream, identical to the with-`.onDrag` shape — the
|
||||||
|
// hold that made an empty provider look load-bearing (2026-08-06, "a lone
|
||||||
|
// click never fired") was the hosted harness's sterile `NSApp.postEvent`
|
||||||
|
// stream, which over-holds; real streams have nothing to disambiguate here.
|
||||||
|
// So: drags from empty space belong wholly to the band (`MarqueeControl`),
|
||||||
|
// whose begin guard keeps card-face drags out by geometry.
|
||||||
|
// **One recogniser, both meanings** — a single `.onTapGesture` that branches
|
||||||
|
// on `PointerClick.count`, AppKit's own `mouseDown` idiom. Not a second
|
||||||
|
// two-tap recogniser in *either* form: sequential stacking is the bug this
|
||||||
|
// fix removes, and a *multi-click* recogniser — sequential or simultaneous —
|
||||||
|
// is what makes macOS hold a dragless subtree's primary clicks for the whole
|
||||||
|
// double-click interval (see `PointerClick`; the count-1 tap alone triggers
|
||||||
|
// no such hold).
|
||||||
|
// A lone tap fires once; a double fires it once per click, so the branch is
|
||||||
|
// Finder's cadence exactly: the first click selects, the second creates.
|
||||||
|
//
|
||||||
|
// "Single click selects the lane (click again to unselect)" — the toggle the
|
||||||
|
// header shares (04-interactions.md § Selection), with the modifier grammar
|
||||||
|
// on top. "Double click creates a card at the bottom" — **plain only**: ⌘ and
|
||||||
|
// ⇧ double-clicks are selection gestures that happened twice, the card face's
|
||||||
|
// settled reading, so their second click re-enters the grammar instead of
|
||||||
|
// creating.
|
||||||
|
.onTapGesture {
|
||||||
|
let modifier = ClickModifier.current
|
||||||
|
if modifier == .plain, PointerClick.count > 1 {
|
||||||
|
// The second click of a plain double: the create. Never also the
|
||||||
|
// toggle — it would unselect the lane the first click just selected,
|
||||||
|
// under the placeholder this opens. A third click of a triple lands
|
||||||
|
// here too and no-ops on `isEditingInline`: the placeholder is open.
|
||||||
|
guard !store.isReadOnly, !store.isEditingInline else { return }
|
||||||
|
store.transient.beginPlaceholder(inLane: lane.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.click(
|
||||||
|
SelectionTarget(id: lane.id, kind: .lane, container: .board),
|
||||||
|
modifier: modifier,
|
||||||
|
togglesOnRepeat: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
// The edge-autoscroll anchor, **inside** the scroll view's content so
|
// The edge-autoscroll anchor, **inside** the scroll view's content so
|
||||||
// `enclosingScrollView` resolves (`DragAutoScrollAnchor`). Every scroll step re-resolves
|
// `enclosingScrollView` resolves (`DragAutoScrollAnchor`). Every scroll step re-resolves
|
||||||
// the proposal through the same shared retarget the drop delegate uses, because the
|
// the proposal through the same shared retarget the drop delegate uses, because the
|
||||||
// cursor is stationary while the content moves under it.
|
// cursor is stationary while the content moves under it.
|
||||||
|
//
|
||||||
|
// **Behind the click layer above** (a later `.background` stacks further back): the
|
||||||
|
// anchor is a plain `NSView`, hit-testable by default, and in front of the click layer
|
||||||
|
// it would swallow every empty-space click before the layer's recognisers saw one. It
|
||||||
|
// needs no hits itself — it exists to sit in the hierarchy and resolve its enclosing
|
||||||
|
// scroll view.
|
||||||
.background {
|
.background {
|
||||||
DragAutoScrollAnchor(scroller: autoScroller) {
|
DragAutoScrollAnchor(scroller: autoScroller) {
|
||||||
drops.retargetCards(inLane: lane.id)
|
drops.retargetCards(inLane: lane.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
// The rubber band's first surface — "click-drag rubber-bands across lanes". On the
|
||||||
// Order matters: the two-tap recogniser must be attached first so a double click is not
|
// container, not the layer above: ancestry means a drag over cards and empty space
|
||||||
// consumed as two singles.
|
// alike reaches it, and the band's own begin guard is what keeps a drag that started
|
||||||
.onTapGesture(count: 2) {
|
// on a card face out of it (`MarqueeControl`). Simultaneous, so the taps stay instant.
|
||||||
guard !store.isReadOnly, !store.isEditingInline else { return }
|
|
||||||
store.transient.beginPlaceholder(inLane: lane.id)
|
|
||||||
}
|
|
||||||
// "Single click selects the lane (click again to unselect)" — the toggle the header
|
|
||||||
// shares (04-interactions.md § Selection), and the modifier grammar on top of it.
|
|
||||||
.onTapGesture {
|
|
||||||
store.click(
|
|
||||||
SelectionTarget(id: lane.id, kind: .lane, container: .board),
|
|
||||||
modifier: .current,
|
|
||||||
togglesOnRepeat: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// The rubber band's first surface — "click-drag rubber-bands across lanes". Simultaneous
|
|
||||||
// so the taps above stay instant; the band's own begin guard is what keeps a drag that
|
|
||||||
// started on a card face out of it (`MarqueeControl`).
|
|
||||||
.simultaneousGesture(marquee.gesture(in: .board))
|
.simultaneousGesture(marquee.gesture(in: .board))
|
||||||
// The same menu the header carries — "one menu, invoked on the header or lane empty
|
// The same menu the header carries — "one menu, invoked on the header or lane empty
|
||||||
// space alike" (03-board-ui.md § Lane, settled).
|
// space alike" (03-board-ui.md § Lane, settled).
|
||||||
@@ -841,8 +922,16 @@ struct LaneView: View, Equatable {
|
|||||||
// engagement rect simply scrolls nothing.
|
// engagement rect simply scrolls nothing.
|
||||||
.task(id: drops.session.isDraggingCards) {
|
.task(id: drops.session.isDraggingCards) {
|
||||||
guard drops.session.isDraggingCards else { return }
|
guard drops.session.isDraggingCards else { return }
|
||||||
|
// The engagement rect's reaches are distances to this lane's own furniture, so they are
|
||||||
|
// measured on the board's ruler (03-board-ui.md ▸ Layout — zoom). Read once at the head
|
||||||
|
// of the drag, which is once per level: zoom is inert while a session is in flight.
|
||||||
|
autoScroller.bodyPointSize = pointSize
|
||||||
await autoScroller.run()
|
await autoScroller.run()
|
||||||
}
|
}
|
||||||
|
// The floor's measurement — the scroll view's own height, which is exactly the space the
|
||||||
|
// masonry must cover for the empty-surface gestures above. No feedback loop: the lane's
|
||||||
|
// height is the strip's to give, so the content growing to the floor never moves the floor.
|
||||||
|
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { scrollViewportHeight = $0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere.
|
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere.
|
||||||
@@ -867,7 +956,7 @@ struct LaneView: View, Equatable {
|
|||||||
if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: lane.id) {
|
if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: lane.id) {
|
||||||
return ShadowRun(
|
return ShadowRun(
|
||||||
position: proposal.index,
|
position: proposal.index,
|
||||||
heights: Array(repeating: LaneDropRegistry.nominalCardHeight, count: proposal.count)
|
heights: Array(repeating: drops.registry.nominalCardHeight, count: proposal.count)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -988,28 +1077,35 @@ struct LaneView: View, Equatable {
|
|||||||
.fill(Accommodations.lanePlateWash(reduceTransparency: reduceTransparency).style)
|
.fill(Accommodations.lanePlateWash(reduceTransparency: reduceTransparency).style)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet —
|
/// The selection treatment: a hairline accent edge with a soft accent glow bleeding outward —
|
||||||
/// 03-board-ui.md gives lane *colour* to the top-edge accent band, so selection must not read as
|
/// the system focus ring's vocabulary at selection strength (resettled 2026-08-06, replacing
|
||||||
/// a fill that would compete with it once that lands.
|
/// the whole-lane wash-and-solid-ring, whose emphasis scaled with the lane's area: what read as
|
||||||
private var selectionBackground: some View {
|
/// a ring on a card read as a slab on a column). No wash at all now — nothing tints the cards'
|
||||||
RoundedRectangle(cornerRadius: cornerRadius)
|
/// backdrop, and lane *colour* stays the accent band's alone (03-board-ui.md § Styling).
|
||||||
.fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.08)) : AnyShapeStyle(.clear))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The selection ring — and, under Increase Contrast, the plate's resting edge as well
|
|
||||||
/// (10-accessibility.md: "Increase Contrast strengthens borders and the selection indicator";
|
|
||||||
/// `Accommodations`, and `CardFaceView.plateStroke` for the same three-way branch on a card).
|
|
||||||
///
|
///
|
||||||
/// A lane is otherwise bounded by its quiet wash and the gap between it and its neighbour,
|
/// The glow rides the stroke shape, never the plate: the plate is translucent
|
||||||
/// which is the distinction the setting most needs to restore here.
|
/// (`Accommodations.lanePlateWash`), and a shadow drawn behind it would bleed through as murk.
|
||||||
|
/// Under Increase Contrast the halo yields to the solid full-alpha ring — the two width bases
|
||||||
|
/// below — so the setting's selection indicator is exactly what it was before the resettlement
|
||||||
|
/// (`Accommodations.drawsSelectionHalo`; the trash's lane rows and `CardFaceView` are
|
||||||
|
/// card-scale and keep the solid ring at every contrast: one selection vocabulary, intensity
|
||||||
|
/// inverse to area).
|
||||||
private var selectionStroke: some View {
|
private var selectionStroke: some View {
|
||||||
RoundedRectangle(cornerRadius: cornerRadius)
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
.strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
|
.strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
|
||||||
|
.shadow(color: haloColor, radius: 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The halo's glow — clear whenever the halo doesn't draw, because a conditional modifier
|
||||||
|
/// would change the overlay's identity where a clear shadow just paints nothing.
|
||||||
|
private var haloColor: Color {
|
||||||
|
guard isSelected, Accommodations.drawsSelectionHalo(contrast: contrast) else { return .clear }
|
||||||
|
return Color.accentColor.opacity(0.3)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var plateStroke: AnyShapeStyle {
|
private var plateStroke: AnyShapeStyle {
|
||||||
if isSelected {
|
if isSelected {
|
||||||
AnyShapeStyle(Color.accentColor)
|
AnyShapeStyle(Color.accentColor.opacity(Accommodations.accentOpacity(0.55, contrast: contrast)))
|
||||||
} else if Accommodations.drawsRestingBorder(contrast: contrast) {
|
} else if Accommodations.drawsRestingBorder(contrast: contrast) {
|
||||||
AnyShapeStyle(.separator)
|
AnyShapeStyle(.separator)
|
||||||
} else {
|
} else {
|
||||||
@@ -1018,7 +1114,10 @@ struct LaneView: View, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var plateStrokeWidth: CGFloat {
|
private var plateStrokeWidth: CGFloat {
|
||||||
Accommodations.borderWidth(isSelected ? 1.5 : 1, contrast: contrast)
|
// The halo's edge is a hairline; with the halo off (Increase Contrast) the selected edge
|
||||||
|
// returns to the solid ring's 1.5 base, which `borderWidth` then strengthens as ever.
|
||||||
|
let base: CGFloat = isSelected && !Accommodations.drawsSelectionHalo(contrast: contrast) ? 1.5 : 1
|
||||||
|
return Accommodations.borderWidth(base, contrast: contrast)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Rename plumbing
|
// MARK: - Rename plumbing
|
||||||
@@ -1157,14 +1256,18 @@ private struct NewCardStubView: View {
|
|||||||
/// the face this view draws and the identity the slot is keyed by are the same answer.
|
/// the face this view draws and the identity the slot is keyed by are the same answer.
|
||||||
let phase: NewCardPlaceholder.Phase
|
let phase: NewCardPlaceholder.Phase
|
||||||
|
|
||||||
let openCard: (ItemID) -> Void
|
let openCard: @MainActor (ItemID) -> Void
|
||||||
|
|
||||||
/// Increase Contrast, for the editor well's stroke below (10-accessibility.md; `Accommodations`).
|
/// Increase Contrast, for the editor well's stroke below (10-accessibility.md; `Accommodations`).
|
||||||
@Environment(\.colorSchemeContrast) private var contrast
|
@Environment(\.colorSchemeContrast) private var contrast
|
||||||
|
|
||||||
|
/// The board's ruler (`BoardZoom`) — the same environment the real face reads, so the placeholder
|
||||||
|
/// and the card that replaces it are drawn at one zoom.
|
||||||
|
@Environment(\.boardZoom) private var zoom
|
||||||
|
|
||||||
/// The live body metric — the same one the real face reads, which is what makes "the numbers are
|
/// The live body metric — the same one the real face reads, which is what makes "the numbers are
|
||||||
/// the same numbers rather than equal ones" survive the move to font-derived metrics.
|
/// the same numbers rather than equal ones" survive the move to font-derived metrics.
|
||||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch phase {
|
switch phase {
|
||||||
@@ -1189,7 +1292,7 @@ private struct NewCardStubView: View {
|
|||||||
if let id = commit() { openCard(id) }
|
if let id = commit() { openCard(id) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.font(.body)
|
.boardFont(.body)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||||
.background(
|
.background(
|
||||||
@@ -1221,7 +1324,7 @@ private struct NewCardStubView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.imageScale(.medium)
|
.imageScale(.medium)
|
||||||
Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
|
Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
|
||||||
.font(.body)
|
.boardFont(.body)
|
||||||
.lineLimit(4)
|
.lineLimit(4)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,45 @@ extension ClickModifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The click a handler is riding
|
||||||
|
|
||||||
|
/// Which click of a multi-click run the current gesture handler is riding — `NSEvent.clickCount`
|
||||||
|
/// off the event being dispatched, read the way `ClickModifier.current` reads the keyboard:
|
||||||
|
/// SwiftUI's `TapGesture` hands its handler nothing about the event.
|
||||||
|
///
|
||||||
|
/// **This is how a surface without a drag source gets a double-click meaning** (the 2026-08-06
|
||||||
|
/// click-latency fix). A second tap recogniser is never the way: a *multi-click* recogniser on a
|
||||||
|
/// dragless subtree — a sequential `.onTapGesture(count: 2)` or even a simultaneous two-tap —
|
||||||
|
/// makes macOS hold every primary click on that subtree pending disambiguation for the system
|
||||||
|
/// double-click interval. (A real `.onDrag` forces immediate delivery, which is why the card
|
||||||
|
/// faces, the lane header and the trash rows — `CardFaceView`'s simultaneous arrangement — can
|
||||||
|
/// carry one and stay instant. A lone count-1 tap needs no such help: measured on real events
|
||||||
|
/// 2026-08-07, `LaneView`'s dragless empty-space layer fires in ~1–3 ms — there is nothing to
|
||||||
|
/// disambiguate. And an `.onDrag` must never be added there *for* delivery: even an
|
||||||
|
/// empty-provider drag source claims drags outright and kills the rubber band's simultaneous
|
||||||
|
/// `DragGesture`.) A single `.onTapGesture` fires once per click of a run, so branching on this
|
||||||
|
/// count expresses "first click selects, second creates" — Finder's cadence — with exactly one
|
||||||
|
/// recogniser and nothing to disambiguate.
|
||||||
|
enum PointerClick {
|
||||||
|
|
||||||
|
/// The `clickCount` of the click being handled: 1 for a lone click or a run's first, 2 for
|
||||||
|
/// the second click of a double, and so on.
|
||||||
|
///
|
||||||
|
/// `NSApp.currentEvent` rather than a stored flag: the event being dispatched *is* the click,
|
||||||
|
/// and AppKit's `clickCount` already embodies the system double-click interval and the
|
||||||
|
/// spatial-proximity rule, so no timer here could disagree with the event stream's own
|
||||||
|
/// pairing. A current event that is not a mouse click (or is absent — a synthetic call) reads
|
||||||
|
/// as a first click, which fails toward the single-click action: selection stays reachable.
|
||||||
|
@MainActor
|
||||||
|
static var count: Int {
|
||||||
|
guard let event = NSApp.currentEvent else { return 1 }
|
||||||
|
switch event.type {
|
||||||
|
case .leftMouseDown, .leftMouseUp: return max(1, event.clickCount)
|
||||||
|
default: return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - The rubber band's gesture
|
// MARK: - The rubber band's gesture
|
||||||
|
|
||||||
/// What a board window lends its empty surfaces so each can be a rubber band: the one session, the
|
/// What a board window lends its empty surfaces so each can be a rubber band: the one session, the
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ struct TrashLaneRowView: View {
|
|||||||
/// selected card wear the same ring at the same strength.
|
/// selected card wear the same ring at the same strength.
|
||||||
@Environment(\.colorSchemeContrast) private var contrast
|
@Environment(\.colorSchemeContrast) private var contrast
|
||||||
|
|
||||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
/// The board's ruler (`BoardZoom`) — `CardFaceView`'s rule again, so a trash row and a card face
|
||||||
|
/// are drawn on one scale.
|
||||||
|
@Environment(\.boardZoom) private var zoom
|
||||||
|
|
||||||
|
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||||||
|
|
||||||
/// The card plate's radius: the rows sit in one column and a row with a different corner would
|
/// The card plate's radius: the rows sit in one column and a row with a different corner would
|
||||||
/// read as a different *kind of surface* rather than as a different kind of row. What
|
/// read as a different *kind of surface* rather than as a different kind of row. What
|
||||||
@@ -95,7 +99,7 @@ struct TrashLaneRowView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.imageScale(.medium)
|
.imageScale(.medium)
|
||||||
Text(lane.title.value ?? AccessibilityPhrases.untitled)
|
Text(lane.title.value ?? AccessibilityPhrases.untitled)
|
||||||
.font(.body)
|
.boardFont(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
@@ -159,7 +163,7 @@ struct TrashLaneRowView: View {
|
|||||||
/// furniture, and this row is deliberately not one.
|
/// furniture, and this row is deliberately not one.
|
||||||
private var heldCount: some View {
|
private var heldCount: some View {
|
||||||
Text(AccessibilityPhrases.cardCount(lane.heldCards))
|
Text(AccessibilityPhrases.cardCount(lane.heldCards))
|
||||||
.font(.caption)
|
.boardFont(.caption)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
// Folded into the flattened element's label above, like the card face's chips.
|
// Folded into the flattened element's label above, like the card face's chips.
|
||||||
@@ -264,12 +268,12 @@ struct TrashLaneRowView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.imageScale(.medium)
|
.imageScale(.medium)
|
||||||
Text(lane.title.value ?? AccessibilityPhrases.untitled)
|
Text(lane.title.value ?? AccessibilityPhrases.untitled)
|
||||||
.font(.body)
|
.boardFont(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Text(AccessibilityPhrases.cardCount(lane.heldCards))
|
Text(AccessibilityPhrases.cardCount(lane.heldCards))
|
||||||
.font(.caption)
|
.boardFont(.caption)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
@@ -279,7 +283,7 @@ struct TrashLaneRowView: View {
|
|||||||
alignment: .leading
|
alignment: .leading
|
||||||
)
|
)
|
||||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.tertiary))
|
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.tertiary))
|
||||||
.dragReplicaShadow()
|
.dragReplicaShadow(zoom: zoom)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The row's two rows of menu
|
// MARK: - The row's two rows of menu
|
||||||
|
|||||||
@@ -113,10 +113,14 @@ struct TrashLaneView: View {
|
|||||||
/// light/dark flip recomputes the header's ink (`LaneView.colorScheme`'s twin, for its reason).
|
/// light/dark flip recomputes the header's ink (`LaneView.colorScheme`'s twin, for its reason).
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
/// The board's ruler (`BoardZoom`) — the same one the live lanes read, since the trash column has
|
||||||
|
/// to stay their sibling at every zoom level as well as at every text size.
|
||||||
|
@Environment(\.boardZoom) private var zoom
|
||||||
|
|
||||||
/// The live body metric — this column's geometry is `LaneView`'s, derived from the same font
|
/// The live body metric — this column's geometry is `LaneView`'s, derived from the same font
|
||||||
/// (`BoardMetrics`), because these are the same cards in a column that must read as their
|
/// (`BoardMetrics`), because these are the same cards in a column that must read as their
|
||||||
/// sibling.
|
/// sibling.
|
||||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||||||
|
|
||||||
/// The lane plate's corner radius — matched to `LaneView`'s so the column reads as a sibling of
|
/// The lane plate's corner radius — matched to `LaneView`'s so the column reads as a sibling of
|
||||||
/// the lanes rather than as a different kind of object.
|
/// the lanes rather than as a different kind of object.
|
||||||
@@ -125,6 +129,11 @@ struct TrashLaneView: View {
|
|||||||
/// Between the cards — `LaneView.cardSpacing`, because these are the same cards.
|
/// Between the cards — `LaneView.cardSpacing`, because these are the same cards.
|
||||||
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
|
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
|
||||||
|
|
||||||
|
/// The column's scroll viewport height — the rows' height *floor* (`scrollableCards`), measured
|
||||||
|
/// for `LaneView.scrollViewportHeight`'s reason: a `ScrollView` proposes nothing along its
|
||||||
|
/// scroll axis, so only an explicit minimum can stretch the content to fill it.
|
||||||
|
@State private var scrollViewportHeight: CGFloat = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// The strip's third body level, observed (`BoardRenderMetrics`) — DEBUG only, and a
|
// The strip's third body level, observed (`BoardRenderMetrics`) — DEBUG only, and a
|
||||||
// `let _` because `body` is a `@ViewBuilder` and a bare `Void` call is not a view.
|
// `let _` because `body` is a `@ViewBuilder` and a bare `Void` call is not a view.
|
||||||
@@ -235,7 +244,7 @@ struct TrashLaneView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.imageScale(.medium)
|
.imageScale(.medium)
|
||||||
Text("Trash")
|
Text("Trash")
|
||||||
.font(.headline)
|
.boardFont(.headline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
@@ -292,7 +301,7 @@ struct TrashLaneView: View {
|
|||||||
/// the difference between a card and a lane's freight actually matters.
|
/// the difference between a card and a lane's freight actually matters.
|
||||||
private var countBadge: some View {
|
private var countBadge: some View {
|
||||||
Text("\(renderedRows.count)")
|
Text("\(renderedRows.count)")
|
||||||
.font(.caption)
|
.boardFont(.caption)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
|
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
|
||||||
@@ -360,7 +369,7 @@ struct TrashLaneView: View {
|
|||||||
// being proposed have no face here yet to be measured.
|
// being proposed have no face here yet to be measured.
|
||||||
DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: LaneDropRegistry.nominalCardHeight)
|
.frame(height: drops.registry.nominalCardHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A slot is a row in this column, so it arrives and leaves in the card's dialect
|
// A slot is a row in this column, so it arrives and leaves in the card's dialect
|
||||||
@@ -384,14 +393,22 @@ struct TrashLaneView: View {
|
|||||||
// nothing else** (03-board-ui.md § Motion's narrow keys) — the trash column's own copy of
|
// nothing else** (03-board-ui.md § Motion's narrow keys) — the trash column's own copy of
|
||||||
// the rule `BoardView` applies to the strip and `LaneView` to its masonry.
|
// the rule `BoardView` applies to the strip and `LaneView` to its masonry.
|
||||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: proposal)
|
.animation(Motion.dragReflow(reduced: reduceMotion), value: proposal)
|
||||||
// `maxHeight: .infinity` here, not just `maxWidth`, is what makes the gesture surface
|
// **The measured viewport is the rows' height floor** — this is what makes the gesture
|
||||||
// below reach the column's full height rather than stopping where the last card ends —
|
// surface below reach the column's full height rather than stopping where the last card
|
||||||
// the same fix `LaneView.scrollableCards` applies to its masonry, and for the identical
|
// ends. `maxHeight: .infinity` alone could not: a `ScrollView` proposes *nothing* along
|
||||||
// reason: a `ScrollView` proposes its content only the height that content asks for, so a
|
// its scroll axis, so no frame maximum stretches the content, and a view sized to fit
|
||||||
// view sized to fit its cards leaves the blank space beneath them un-hit-testable. "The
|
// its cards leaves the blank space beneath them un-hit-testable. "The column's gesture
|
||||||
// column's gesture surface is full height" (04-interactions.md ▸ The trash, settled)
|
// surface is full height" (04-interactions.md ▸ The trash, settled) needs that blank
|
||||||
// needs that blank space to actually belong to the view the gesture below is on.
|
// space to actually belong to the view the gesture below is on, so the measured floor
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
// supplies it — less the plate padding, which sits inside the scroll content here and
|
||||||
|
// would otherwise make an empty column scrollable by its own inset
|
||||||
|
// (`LaneView.scrollableCards` is the twin, with its padding outside).
|
||||||
|
.frame(
|
||||||
|
maxWidth: .infinity,
|
||||||
|
minHeight: max(0, scrollViewportHeight - 2 * BoardMetrics.lanePlatePadding(bodyPointSize: pointSize)),
|
||||||
|
maxHeight: .infinity,
|
||||||
|
alignment: .topLeading
|
||||||
|
)
|
||||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
// The band's trash-side surface. It arms from the column's empty space, full height
|
// The band's trash-side surface. It arms from the column's empty space, full height
|
||||||
@@ -401,6 +418,10 @@ struct TrashLaneView: View {
|
|||||||
// gesture priority (`MarqueeControl`).
|
// gesture priority (`MarqueeControl`).
|
||||||
.simultaneousGesture(marquee.gesture(in: .trash))
|
.simultaneousGesture(marquee.gesture(in: .trash))
|
||||||
}
|
}
|
||||||
|
// The floor's measurement — `LaneView`'s, on the trash side: the scroll view's own height
|
||||||
|
// is the space the rows must cover for the empty-surface gesture above, and the content
|
||||||
|
// growing to the floor never moves the floor.
|
||||||
|
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { scrollViewportHeight = $0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - View ▸ Zoom In / Zoom Out / Actual Size
|
||||||
|
|
||||||
|
/// The board's three zoom rows (11-command-nexus.md ▸ View; behaviour in 03-board-ui.md ▸ Layout —
|
||||||
|
/// zoom).
|
||||||
|
///
|
||||||
|
/// They step one app-wide level (`BoardZoomStore`), which every open board window reads through the
|
||||||
|
/// environment, so this is deliberately *not* a per-window command: ⌘+ in one window zooms the board
|
||||||
|
/// in the other too, the way Show Comments checks in every card window at once.
|
||||||
|
///
|
||||||
|
/// ### Why they are scoped to a board window anyway
|
||||||
|
///
|
||||||
|
/// The level is app-wide but the *surface* it moves is not: only board windows zoom (the card window
|
||||||
|
/// is a later milestone). A ⌘+ that changed nothing visible while a card window was frontmost would
|
||||||
|
/// be a command that silently missed, so `@FocusedValue(\.boardStore)` scopes the rows the way every
|
||||||
|
/// other board command is scoped — present and live over a board, disabled everywhere else.
|
||||||
|
///
|
||||||
|
/// ### The three disabled states
|
||||||
|
///
|
||||||
|
/// Each end of the ladder disables its own direction and Actual Size disables at 100%: "an item whose
|
||||||
|
/// only outcome is a no-op reads better disabled than dead" (`LaneWidthCommands.canDecrease`).
|
||||||
|
///
|
||||||
|
/// **All three also disable while a drag session is in flight**, which is an invariant stated rather
|
||||||
|
/// than a defence. A card or lane drag freezes geometry the level feeds — the drag's frozen card
|
||||||
|
/// heights, and `RestingLayoutCache`, whose entry key does not include the point size — so a level
|
||||||
|
/// that moved underneath one would leave the proposal resolving against a layout the board is no
|
||||||
|
/// longer drawing. In practice an AppKit drag loop swallows key equivalents and no toolbar button can
|
||||||
|
/// be clicked with the mouse already down, so the guard should never fire; `BoardView` clears the
|
||||||
|
/// resting layouts on a level change regardless, which is what actually makes the case safe. This is
|
||||||
|
/// the honest statement of the rule, and the thing a test can hold
|
||||||
|
/// (`ShowCommentsCommand.isEnabled`'s pattern).
|
||||||
|
///
|
||||||
|
/// The read-only lock is deliberately absent, for `ShowTrashCommand`'s reason: zooming is a view
|
||||||
|
/// change, not a mutation, and a locked board is exactly when a user wants to read it more
|
||||||
|
/// comfortably.
|
||||||
|
struct ZoomCommands: View {
|
||||||
|
|
||||||
|
/// The app-wide level. A plain `let` rather than an `@Environment` read because menu commands live
|
||||||
|
/// in the menu bar, outside every scene's environment; it re-renders on a level change because
|
||||||
|
/// `AppModel` is `@Observable` (`NewBoardCommand`'s pattern).
|
||||||
|
let appModel: AppModel
|
||||||
|
|
||||||
|
@FocusedValue(\.boardStore) private var store
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button("Zoom In") { appModel.zoom.step(.in) }
|
||||||
|
.keyboardShortcut("+", modifiers: .command)
|
||||||
|
.disabled(!Self.isEnabled(store: store, session: appModel.dragSession) || !appModel.zoom.canZoomIn)
|
||||||
|
|
||||||
|
Button("Zoom Out") { appModel.zoom.step(.out) }
|
||||||
|
.keyboardShortcut("-", modifiers: .command)
|
||||||
|
.disabled(!Self.isEnabled(store: store, session: appModel.dragSession) || !appModel.zoom.canZoomOut)
|
||||||
|
|
||||||
|
Button("Actual Size") { appModel.zoom.step(.actualSize) }
|
||||||
|
.keyboardShortcut("0", modifiers: .command)
|
||||||
|
.disabled(!Self.isEnabled(store: store, session: appModel.dragSession) || appModel.zoom.isActualSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the rows apply at all — a board window in front, and no drag in flight.
|
||||||
|
///
|
||||||
|
/// Extracted as a static function so the rule is assertable without driving a menu
|
||||||
|
/// (`ShowCommentsCommand.isEnabled`, `SaveAsTemplateCommand.allowsSave`). It deliberately does
|
||||||
|
/// *not* fold in the per-direction ladder ends: those are properties of the level, already stated
|
||||||
|
/// on `BoardZoom`, and duplicating them here would be two answers to one question.
|
||||||
|
static func isEnabled(store: BoardStore?, session: DragSession) -> Bool {
|
||||||
|
store != nil && !session.isActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The move, and the one place it happens
|
||||||
|
|
||||||
|
/// Which way a zoom command goes — the three rows' whole difference from one another.
|
||||||
|
///
|
||||||
|
/// An enum rather than three methods so `BoardZoomStore.step(_:)` can be the single write path the
|
||||||
|
/// menu rows *and* the toolbar buttons share, the way `BoardStore.setTrashVisible` is Show Trash's
|
||||||
|
/// (03-board-ui.md ▸ Toolbar: a toolbar item is a menu command with a different face, never a second
|
||||||
|
/// implementation of it).
|
||||||
|
enum ZoomMove {
|
||||||
|
case `in`
|
||||||
|
case out
|
||||||
|
case actualSize
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BoardZoomStore {
|
||||||
|
|
||||||
|
/// A zoom command's whole behaviour — move the level, animate the board under it, say the new
|
||||||
|
/// level out loud.
|
||||||
|
func step(_ move: ZoomMove) {
|
||||||
|
// A user-initiated structural change, so it animates in the structural voice and goes instant
|
||||||
|
// under Reduce Motion (03-board-ui.md § Motion: "everything the user does through the app …
|
||||||
|
// lands in an animated transaction regardless of entry point").
|
||||||
|
//
|
||||||
|
// Reduce Motion read from AppKit rather than from `@Environment` for `setTrashVisible`'s
|
||||||
|
// reason: a menu command's content is built outside any rendered hierarchy, and a toolbar item
|
||||||
|
// has no environment at all.
|
||||||
|
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
|
||||||
|
switch move {
|
||||||
|
case .in: zoomIn()
|
||||||
|
case .out: zoomOut()
|
||||||
|
case .actualSize: actualSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Announced from here rather than from each row, so the two toolbar buttons say the same
|
||||||
|
// sentence as the menu (10-accessibility.md ▸ Text scaling). Unconditional even when the level
|
||||||
|
// did not move: a user who pressed ⌘+ at the top rung is owed the answer "still 200%", and a
|
||||||
|
// silent no-op is the one response that reads as a broken command.
|
||||||
|
AccessibilityAnnouncer.post(AccessibilityPhrases.zoomLevel(percentLabel))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import AppKit
|
||||||
|
import CoreGraphics
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The board's **zoom ladder** — the multiplier macOS never supplies, applied to the one scalar the
|
||||||
|
/// whole strip is already derived from (03-board-ui.md ▸ Layout — "Zoom scales the ruler, never the
|
||||||
|
/// strip"; 10-accessibility.md ▸ Text scaling).
|
||||||
|
///
|
||||||
|
/// `Motion` and `Accommodations`' sibling, and deliberately the same shape: pure functions over a
|
||||||
|
/// value, nothing rendered, nothing stored. `BoardZoomStore` owns the level and persists it; this
|
||||||
|
/// owns what a level *means*, so every claim below is assertable without driving a view
|
||||||
|
/// (`BoardZoomTests`).
|
||||||
|
///
|
||||||
|
/// ### Why this feature is arithmetic rather than a magnification
|
||||||
|
///
|
||||||
|
/// `BoardMetrics` is twenty-two `em(multiple, bodyPointSize:)` functions and one impure read of what
|
||||||
|
/// the body point size is, because 10-accessibility.md committed the board to full relative scaling:
|
||||||
|
/// "relative text styles everywhere, no fixed point sizes … so layout survives the largest system
|
||||||
|
/// text sizes". On macOS that commitment has never once been exercised — there is no system text-size
|
||||||
|
/// setting, so `NSFont.preferredFont(forTextStyle: .body).pointSize` is 13 forever. Zoom is that
|
||||||
|
/// machinery's first consumer: a rung on the ladder multiplies the point size, and the twenty-two
|
||||||
|
/// functions carry it to every gap, radius, inset and stripe on the board.
|
||||||
|
///
|
||||||
|
/// A `scaleEffect` or an `NSScrollView.magnification` was the other candidate and is rejected on two
|
||||||
|
/// counts. It would have to widen the strip, which means horizontal scroll — refused by
|
||||||
|
/// 03-board-ui.md ▸ Layout, and refused in the pathfinder before it. And it would scale the
|
||||||
|
/// coordinate space that `onGeometryChange` reports frames in while leaving `NSEvent.mouseLocation`
|
||||||
|
/// alone, silently desynchronising every drop registry from the cursor.
|
||||||
|
///
|
||||||
|
/// ### What the level does not touch
|
||||||
|
///
|
||||||
|
/// **Lane width.** The window's width divides across the lanes' width units and that is the whole of
|
||||||
|
/// the resting layout (`LaneLayoutMath.standardWidth`), so a rung moves the inter-lane gap — an em
|
||||||
|
/// multiple like everything else — and nothing more. Lanes narrow by a few percent across the
|
||||||
|
/// ladder's whole range; cards inside them grow. That asymmetry *is* the feature on a board that
|
||||||
|
/// refuses horizontal scroll: zoom in for bigger, more legible cards and fewer per screen.
|
||||||
|
///
|
||||||
|
/// **The window.** `BoardMetrics.windowMinimumSize` stays pinned to the system body size, because
|
||||||
|
/// 03-board-ui.md ▸ Lane rules that moving the window belongs to the right-edge drag alone.
|
||||||
|
enum BoardZoom {
|
||||||
|
|
||||||
|
// MARK: - The ladder
|
||||||
|
|
||||||
|
/// The rungs, ascending. Zoom In and Zoom Out step between adjacent entries; there is no
|
||||||
|
/// continuous level, and nothing off this list is reachable through the UI.
|
||||||
|
///
|
||||||
|
/// Discrete rather than a percentage field for the reason every zoom control on the platform is:
|
||||||
|
/// the interesting question is "a bit bigger", not "117%", and a ladder makes ⌘+ ⌘+ ⌘− land back
|
||||||
|
/// exactly where it started. The spacing widens as it climbs — 0.10 near the default, 0.25 at the
|
||||||
|
/// top — because a fixed step reads as too coarse small and too fine large.
|
||||||
|
///
|
||||||
|
/// The floor is 0.75 (≈10pt body text) rather than something smaller: below that the card face's
|
||||||
|
/// title stops being readable at arm's length and the board stops being a board. The ceiling is
|
||||||
|
/// 2.0, where a standard lane still fits a card with room for its title.
|
||||||
|
static let levels: [CGFloat] = [0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0]
|
||||||
|
|
||||||
|
/// The rung Actual Size returns to, and the one every metric on the board was tuned against.
|
||||||
|
///
|
||||||
|
/// **The default renders pixel-for-pixel what it rendered before zoom existed** — `bodyPointSize`
|
||||||
|
/// below multiplies by exactly 1 and `font` hands back the relative style untouched. That is the
|
||||||
|
/// same guarantee `BoardMetrics`' em multiples were chosen under ("this milestone is meant to make
|
||||||
|
/// the board *scale*, not to redesign it"), extended to the control that moves them.
|
||||||
|
static let actualSize: CGFloat = 1.0
|
||||||
|
|
||||||
|
/// The next rung up, or `level` itself at the top — a fixed point rather than a wrap, so a held
|
||||||
|
/// ⌘+ settles instead of cycling.
|
||||||
|
static func stepIn(from level: CGFloat) -> CGFloat {
|
||||||
|
levels.first { $0 > level } ?? levels[levels.count - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next rung down, or `level` itself at the bottom.
|
||||||
|
static func stepOut(from level: CGFloat) -> CGFloat {
|
||||||
|
levels.last { $0 < level } ?? levels[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func canZoomIn(_ level: CGFloat) -> Bool { level < levels[levels.count - 1] }
|
||||||
|
|
||||||
|
static func canZoomOut(_ level: CGFloat) -> Bool { level > levels[0] }
|
||||||
|
|
||||||
|
/// Whether Actual Size would change anything.
|
||||||
|
static func isActualSize(_ level: CGFloat) -> Bool { level == actualSize }
|
||||||
|
|
||||||
|
// MARK: - Reading a stored level
|
||||||
|
|
||||||
|
/// An arbitrary stored `Double` mapped onto a legal rung — clamped to the ladder's ends and
|
||||||
|
/// snapped to the nearest entry.
|
||||||
|
///
|
||||||
|
/// Every read of the persisted level goes through this, and it is not defensive decoration. The
|
||||||
|
/// preference is a plain `UserDefaults` key: a user can `defaults write` it to anything, a future
|
||||||
|
/// build can shorten the ladder under a value written by an older one, and `double(forKey:)`
|
||||||
|
/// answers **0 for a key that was never set** — which, unfiltered, would drive every em multiple
|
||||||
|
/// to its 1pt floor and draw a board of hairlines. Nothing downstream should have to ask whether
|
||||||
|
/// its level is real.
|
||||||
|
///
|
||||||
|
/// Non-finite input (NaN, infinity) resolves to `actualSize` rather than to an end of the ladder:
|
||||||
|
/// there is no honest nearest rung to a value that is not a number, and the default is the only
|
||||||
|
/// answer that cannot surprise.
|
||||||
|
static func normalize(_ stored: Double) -> CGFloat {
|
||||||
|
guard stored.isFinite else { return actualSize }
|
||||||
|
let value = CGFloat(stored)
|
||||||
|
guard let nearest = levels.min(by: { abs($0 - value) < abs($1 - value) }) else { return actualSize }
|
||||||
|
return nearest
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - What a level means
|
||||||
|
|
||||||
|
/// The body point size the board draws at: the system's, times the level.
|
||||||
|
///
|
||||||
|
/// **Deliberately not rounded.** `BoardMetrics.em` already rounds every figure it produces to a
|
||||||
|
/// whole point and floors it at one, so rounding here would round twice — and the two roundings
|
||||||
|
/// disagree (`round(round(13 × 1.15) × 0.9)` is not `round(13 × 1.15 × 0.9)`). One rule, applied
|
||||||
|
/// where it already lives: the level scales, `em` rounds.
|
||||||
|
///
|
||||||
|
/// Floored at 1 for the same reason `em` is: no proposal downstream may be zero or negative, and
|
||||||
|
/// a level cannot be trusted to be positive until `normalize` has seen it.
|
||||||
|
static func bodyPointSize(system: CGFloat, level: CGFloat) -> CGFloat {
|
||||||
|
max(1, system * level)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A text style at this level — **the relative style itself at Actual Size**, a fixed size scaled
|
||||||
|
/// off it anywhere else.
|
||||||
|
///
|
||||||
|
/// The early return is the whole reason this function exists rather than an unconditional
|
||||||
|
/// `.system(size:)`. A relative style carries more than a number — the system's own leading and
|
||||||
|
/// its accessibility traits ride along with it — and a user who never touches zoom should not pay
|
||||||
|
/// for the feature by having every label on the board silently swapped for a point size. So the
|
||||||
|
/// default rung keeps the semantic style verbatim and only a deliberate zoom trades it away.
|
||||||
|
///
|
||||||
|
/// Off the default, every style scales by the same factor, so the type hierarchy the board
|
||||||
|
/// encodes — `.headline` over `.body` over `.caption` — survives the trip intact.
|
||||||
|
@MainActor
|
||||||
|
static func font(_ style: Font.TextStyle, level: CGFloat, weight: Font.Weight? = nil) -> Font {
|
||||||
|
guard level != actualSize else { return .system(style, weight: weight) }
|
||||||
|
let system = NSFont.preferredFont(forTextStyle: style.appKitStyle).pointSize
|
||||||
|
return .system(size: system * level, weight: weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The level as a percentage, for the announcement a zoom owes VoiceOver
|
||||||
|
/// (10-accessibility.md ▸ Text scaling).
|
||||||
|
static func percentLabel(_ level: CGFloat) -> String {
|
||||||
|
"\(Int((level * 100).rounded()))%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The environment
|
||||||
|
|
||||||
|
/// The zoom level as the strip's views see it — **the app's one custom environment value**.
|
||||||
|
///
|
||||||
|
/// The environment rather than a read of the store inside each `body`, and the reason is specific
|
||||||
|
/// enough to be worth stating: `CardFaceView` and `LaneView` are `.equatable()`, and
|
||||||
|
/// `CardFaceView`'s own note says `@Environment` values are "deliberately NOT compared" because
|
||||||
|
/// "SwiftUI invalidates on those itself". A level threaded through the environment therefore
|
||||||
|
/// propagates *through* the render gates for free. A level read from a singleton or mirrored into
|
||||||
|
/// `@State` would be swallowed by them on every card face — the board would zoom everywhere except
|
||||||
|
/// the cards.
|
||||||
|
///
|
||||||
|
/// The value carries the level rather than the multiplied point size so it stays `Sendable` and
|
||||||
|
/// non-isolated (the environment's default must be constructible without touching AppKit); the
|
||||||
|
/// system half is read on demand below, exactly as `BoardMetrics.bodyPointSize` reads it.
|
||||||
|
struct BoardZoomContext: Equatable, Sendable {
|
||||||
|
|
||||||
|
var level: CGFloat
|
||||||
|
|
||||||
|
/// The unzoomed board — the environment's default, and what any view rendered outside a board
|
||||||
|
/// window's injection gets. Unzoomed is the only honest fallback: it is what the board drew
|
||||||
|
/// before this file existed.
|
||||||
|
static let actualSize = BoardZoomContext(level: BoardZoom.actualSize)
|
||||||
|
|
||||||
|
/// The number every `BoardMetrics` function takes.
|
||||||
|
///
|
||||||
|
/// **This, not `BoardMetrics.bodyPointSize`, is what the strip asks.** The two are deliberately
|
||||||
|
/// different readings and the distinction is load-bearing: `BoardMetrics.bodyPointSize` is the
|
||||||
|
/// *system's* body size, which is still what the window minimum, the banner strip, the sheets and
|
||||||
|
/// popovers, the welcome window and the whole card window want — none of them zoom (03-board-ui.md
|
||||||
|
/// ▸ Layout: the level is the board's, and a sheet is a form, not the board).
|
||||||
|
@MainActor
|
||||||
|
var bodyPointSize: CGFloat {
|
||||||
|
BoardZoom.bodyPointSize(system: BoardMetrics.bodyPointSize, level: level)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func font(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> Font {
|
||||||
|
BoardZoom.font(style, level: level, weight: weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
/// Injected once, on the board window's content (`BoardWindowHost`). Everything under it — lanes,
|
||||||
|
/// card faces, the trash column, drag shadows, the resize handle — reads its ruler from here.
|
||||||
|
@Entry var boardZoom: BoardZoomContext = .actualSize
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
/// A text style at the board's current zoom — the strip's replacement for `.font(_:)`.
|
||||||
|
///
|
||||||
|
/// Every `Text` inside the strip wears this instead of a bare relative style, because the style
|
||||||
|
/// alone does not move: SwiftUI resolves `.caption` against the system, which zoom does not
|
||||||
|
/// change. Chrome outside the strip keeps using `.font(_:)` and stays at system size on purpose.
|
||||||
|
///
|
||||||
|
/// SF Symbols need no equivalent — `Image(systemName:)` under `.imageScale(_:)` sizes off the
|
||||||
|
/// current font, which is why `BoardMetrics.newCardButtonReserve` is 1.7 em rather than 22pt.
|
||||||
|
func boardFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View {
|
||||||
|
modifier(BoardFontModifier(style: style, weight: weight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BoardFontModifier: ViewModifier {
|
||||||
|
|
||||||
|
@Environment(\.boardZoom) private var zoom
|
||||||
|
|
||||||
|
let style: Font.TextStyle
|
||||||
|
let weight: Font.Weight?
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.font(zoom.font(style, weight: weight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The style tables
|
||||||
|
|
||||||
|
private extension Font.TextStyle {
|
||||||
|
|
||||||
|
/// SwiftUI's text style as AppKit names it, so `NSFont.preferredFont(forTextStyle:)` can be asked
|
||||||
|
/// what point size it resolves to.
|
||||||
|
///
|
||||||
|
/// A table rather than a rawValue bridge because there is no bridge: the two enumerations agree on
|
||||||
|
/// most names and disagree on three — SwiftUI's `.title` is AppKit's `.title1`, its `.caption` is
|
||||||
|
/// `.caption1`, and the sizes SwiftUI adds beyond AppKit's list have nowhere to land. Those fall
|
||||||
|
/// back to `.body`, which is the closest AppKit *has*; none of them appears on the board.
|
||||||
|
var appKitStyle: NSFont.TextStyle {
|
||||||
|
switch self {
|
||||||
|
case .largeTitle: .largeTitle
|
||||||
|
case .title: .title1
|
||||||
|
case .title2: .title2
|
||||||
|
case .title3: .title3
|
||||||
|
case .headline: .headline
|
||||||
|
case .subheadline: .subheadline
|
||||||
|
case .body: .body
|
||||||
|
case .callout: .callout
|
||||||
|
case .footnote: .footnote
|
||||||
|
case .caption: .caption1
|
||||||
|
case .caption2: .caption2
|
||||||
|
@unknown default: .body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,17 +166,23 @@ struct CardAttachmentsSection: View {
|
|||||||
)
|
)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
// Double-click opens, a single click selects (05 ▸ Attachments; the window's click grammar,
|
// Double-click opens, a single click selects (05 ▸ Attachments; the window's click grammar,
|
||||||
// where clicking selects and never edits). The two-count gesture is declared first so
|
// where clicking selects and never edits). The two-count gesture rides `simultaneousGesture`
|
||||||
// SwiftUI gives it the chance to claim the second click.
|
// rather than stacking as a second `.onTapGesture` — a sequential pair makes the single
|
||||||
.onTapGesture(count: 2) {
|
// click wait out the double-click interval before selecting (`CardFaceView`'s arrangement;
|
||||||
isFocused = true
|
// the 2026-08-06 latency fix). Simultaneity stays instant *here* because the row carries
|
||||||
attachments.selected = name
|
// `.onDrag` below, which forces immediate click delivery — a surface without a drag source
|
||||||
attachments.open(name)
|
// must branch one recogniser on `PointerClick.count` instead (`LaneView`'s empty space).
|
||||||
}
|
// The first click of a pair selects, the second opens; the open re-asserting focus and
|
||||||
|
// selection is idempotent.
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
isFocused = true
|
isFocused = true
|
||||||
attachments.selected = name
|
attachments.selected = name
|
||||||
}
|
}
|
||||||
|
.simultaneousGesture(TapGesture(count: 2).onEnded {
|
||||||
|
isFocused = true
|
||||||
|
attachments.selected = name
|
||||||
|
attachments.open(name)
|
||||||
|
})
|
||||||
// **Rows drag out their file URL** (05 ▸ Attachments; 11-command-nexus.md ▸ Pointer-only
|
// **Rows drag out their file URL** (05 ▸ Attachments; 11-command-nexus.md ▸ Pointer-only
|
||||||
// affordances) — which is what makes drag-to-Finder and drag-into-another-app work with no
|
// affordances) — which is what makes drag-to-Finder and drag-into-another-app work with no
|
||||||
// export path of this app's own. An empty provider for a row whose file has gone refuses the
|
// export path of this app's own. An empty provider for a row whose file has gone refuses the
|
||||||
|
|||||||
@@ -35,9 +35,16 @@ struct CardStyleSection: View {
|
|||||||
/// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels): a colour or symbol
|
/// **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
|
/// 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
|
/// 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
|
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,
|
/// 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.
|
/// so every section in this sidebar derives its geometry the same way.
|
||||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||||
@@ -56,6 +63,10 @@ struct CardStyleSection: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
|
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
|
||||||
CardSidebarSectionHeader(title: "Style")
|
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(
|
StyleEditorView(
|
||||||
store: store,
|
store: store,
|
||||||
recents: recents,
|
recents: recents,
|
||||||
@@ -64,11 +75,82 @@ struct CardStyleSection: View {
|
|||||||
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize),
|
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize),
|
||||||
bodyPointSize: pointSize
|
bodyPointSize: pointSize
|
||||||
),
|
),
|
||||||
undo: undo
|
undo: undo,
|
||||||
|
showsBackground: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.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
|
// MARK: - Actions
|
||||||
|
|||||||
@@ -0,0 +1,620 @@
|
|||||||
|
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's padding inside its zone, asymmetric and user-tuned: a wider berth at the sides
|
||||||
|
/// than above and below, so the colour reads as a bar sitting in the field rather than filling
|
||||||
|
/// it wall to wall. The space comes out of the swatch — the control's overall size is untouched.
|
||||||
|
private static let swatchPaddingH: CGFloat = 7
|
||||||
|
private static let swatchPaddingV: CGFloat = 4
|
||||||
|
/// The trigger square's own inset from the zone's height — kept at the old ring width rather
|
||||||
|
/// than the swatch's larger padding, so the indicator stays a legible ~10pt square instead of
|
||||||
|
/// shrinking with every padding tweak the swatch takes.
|
||||||
|
private static let triggerInset: 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. `controlColor` fill — the push-button neutral grey, not
|
||||||
|
/// `controlBackgroundColor`, whose near-black dark-mode reading drowned the padding ring —
|
||||||
|
/// `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.controlColor.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.swatchPaddingH, dy: Self.swatchPaddingV)
|
||||||
|
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.triggerInset
|
||||||
|
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:)`)
|
// The pathfinder's panel round-trip helpers, ported below (`NSColor.paletteHexString`,
|
||||||
// stay unported: they exist to turn a colour the *system picker* returned back into a palette name,
|
// `Palette.name(forHex:in:)`): the reusable colour-picker combo (`ColorComboView`, ColorCombo.swift)
|
||||||
// and this app has no colour picker — "custom hex is not pickable in-app" (03 § Styling ▸ Controls)
|
// is the surface that finally needs them — a colour the *system picker* returns has to become a
|
||||||
// makes the whole round trip a surface that doesn't exist. Its swatch drawing, on the other hand, is
|
// stored value the same way a palette pick already does: the palette NAME when the colour lands
|
||||||
// below: a menu can only render `Image`/`Text`, so the quick-style row's dots have to be pictures.
|
// 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
|
// MARK: - Menu swatches
|
||||||
|
|
||||||
@@ -102,6 +104,28 @@ enum PaletteSwatch {
|
|||||||
return true
|
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 {
|
extension Palette {
|
||||||
@@ -134,9 +158,18 @@ extension Palette {
|
|||||||
guard let value = field.value else { return nil }
|
guard let value = field.value else { return nil }
|
||||||
return color(named: value)
|
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 {
|
extension NSColor {
|
||||||
/// `#RRGGBB` or `#RRGGBBAA` → `NSColor` in **sRGB** — the colour space the hex digits name,
|
/// `#RRGGBB` or `#RRGGBBAA` → `NSColor` in **sRGB** — the colour space the hex digits name,
|
||||||
@@ -160,4 +193,20 @@ extension NSColor {
|
|||||||
alpha: alpha
|
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
|
// 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.
|
/// followed by its grid, with the target set's current value stated beside the section title.
|
||||||
///
|
///
|
||||||
/// ### What it shows for a batch
|
/// ### 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.
|
/// a colour chosen in a card window is one of that window's session gestures.
|
||||||
var undo: CardWindowUndo?
|
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
|
/// 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
|
/// every anchor derives its geometry the same way (10-accessibility.md's full-relative-scaling
|
||||||
/// rule).
|
/// rule).
|
||||||
@@ -263,9 +276,15 @@ struct StyleEditorView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: StyleEditorLayout.sectionSpacing(bodyPointSize: pointSize)) {
|
VStack(alignment: .leading, spacing: StyleEditorLayout.sectionSpacing(bodyPointSize: pointSize)) {
|
||||||
targetCaption(count: subjects.count)
|
targetCaption(count: subjects.count)
|
||||||
backgroundSection(background, layout: layout)
|
if showsBackground {
|
||||||
Divider()
|
backgroundSection(background, layout: layout)
|
||||||
symbolSection(icon, layout: layout)
|
}
|
||||||
|
if showsBackground && showsSymbols {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
if showsSymbols {
|
||||||
|
symbolSection(icon, layout: layout)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(layout.padding)
|
.padding(layout.padding)
|
||||||
.frame(width: layout.width)
|
.frame(width: layout.width)
|
||||||
|
|||||||
@@ -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,166 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// An appearance store on a scratch defaults domain — the override is app-wide and persisted, so a
|
||||||
|
/// suite that used `.standard` would touch the developer's own appearance
|
||||||
|
/// (`BoardZoomTests.makeStore`'s reason). The apply seam defaults to a no-op so a test that is not
|
||||||
|
/// exercising it never touches `NSApp`.
|
||||||
|
@MainActor
|
||||||
|
private func makeStore(
|
||||||
|
seeding stored: String? = nil,
|
||||||
|
apply: @escaping (NSAppearance.Name?) -> Void = { _ in }
|
||||||
|
) -> (AppearanceStore, UserDefaults, () -> Void) {
|
||||||
|
let name = "dev.rzen.indie.Kanban.appearance-tests.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: name)!
|
||||||
|
if let stored { defaults.set(stored, forKey: AppPreferences.appearanceKey) }
|
||||||
|
return (AppearanceStore(defaults: defaults, apply: apply), defaults,
|
||||||
|
{ UserDefaults.standard.removePersistentDomain(forName: name) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The pure resolver
|
||||||
|
|
||||||
|
/// `AppearanceStore.appearanceName(for:)` — no `NSApp`, no live application, provable with nothing but
|
||||||
|
/// the enum (`BoardZoomLadderTests`'s reason for testing `BoardZoom`'s pure functions on their own).
|
||||||
|
@Suite("Appearance ▸ the pure resolver")
|
||||||
|
struct AppearanceResolverTests {
|
||||||
|
|
||||||
|
@Test("Light resolves to aqua, dark to darkAqua, and Auto to nothing at all")
|
||||||
|
func resolvesToTheRightName() {
|
||||||
|
#expect(AppearanceStore.appearanceName(for: .light) == .aqua)
|
||||||
|
#expect(AppearanceStore.appearanceName(for: .dark) == .darkAqua)
|
||||||
|
#expect(AppearanceStore.appearanceName(for: nil) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The persisted override
|
||||||
|
|
||||||
|
@Suite("Appearance ▸ the persisted override")
|
||||||
|
@MainActor
|
||||||
|
struct AppearanceStorePersistenceTests {
|
||||||
|
|
||||||
|
@Test("A fresh domain opens on Auto")
|
||||||
|
func freshDomainIsAuto() {
|
||||||
|
let (store, _, tearDown) = makeStore()
|
||||||
|
defer { tearDown() }
|
||||||
|
#expect(store.override == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Light and Dark survive the trip through defaults")
|
||||||
|
func overrideRoundTrips() {
|
||||||
|
let (store, defaults, tearDown) = makeStore()
|
||||||
|
defer { tearDown() }
|
||||||
|
|
||||||
|
store.setOverride(.light)
|
||||||
|
#expect(AppearanceStore(defaults: defaults).override == .light)
|
||||||
|
|
||||||
|
store.setOverride(.dark)
|
||||||
|
#expect(AppearanceStore(defaults: defaults).override == .dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The remove-at-default idiom, `BoardZoomStore`'s neighbours already keep it: Auto is meant to
|
||||||
|
/// read as "no override on file" to anyone who inspects the domain, not as a third stored spelling
|
||||||
|
/// of the same thing.
|
||||||
|
@Test("Setting Auto removes the key rather than writing a third spelling of it")
|
||||||
|
func autoRemovesTheKey() {
|
||||||
|
let (store, defaults, tearDown) = makeStore(seeding: "light")
|
||||||
|
defer { tearDown() }
|
||||||
|
#expect(defaults.string(forKey: AppPreferences.appearanceKey) == "light")
|
||||||
|
|
||||||
|
store.setOverride(nil)
|
||||||
|
|
||||||
|
#expect(defaults.string(forKey: AppPreferences.appearanceKey) == nil)
|
||||||
|
#expect(store.override == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trap this exists for: a hand edit, or a future build's spelling read by an older one, must
|
||||||
|
/// degrade rather than crash or silently misapply an override nobody asked for.
|
||||||
|
@Test("A stored string that is neither light nor dark degrades to Auto")
|
||||||
|
func unknownStringIsAuto() {
|
||||||
|
for stored in ["sepia", "", "Light", "LIGHT", "light "] {
|
||||||
|
let (store, _, tearDown) = makeStore(seeding: stored)
|
||||||
|
defer { tearDown() }
|
||||||
|
#expect(store.override == nil, "\"\(stored)\" must not resolve to an override")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every case survives its own round trip through the raw value")
|
||||||
|
func everyCaseRoundTrips() {
|
||||||
|
for override in AppAppearance.allCases {
|
||||||
|
let (store, defaults, tearDown) = makeStore()
|
||||||
|
defer { tearDown() }
|
||||||
|
store.setOverride(override)
|
||||||
|
#expect(AppearanceStore(defaults: defaults).override == override)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The apply seam
|
||||||
|
|
||||||
|
/// The one write path — `setOverride` and `applyCurrent` — and the seam it hands its answer to,
|
||||||
|
/// proven with a recording closure rather than `NSApp` (`AppearanceStore.init`'s own reason for taking
|
||||||
|
/// one).
|
||||||
|
@Suite("Appearance ▸ the apply seam")
|
||||||
|
@MainActor
|
||||||
|
struct AppearanceApplySeamTests {
|
||||||
|
|
||||||
|
@Test("Setting an override invokes the apply seam with the resolved name, in order")
|
||||||
|
func setInvokesApply() {
|
||||||
|
var applied: [NSAppearance.Name?] = []
|
||||||
|
let (store, _, tearDown) = makeStore(apply: { applied.append($0) })
|
||||||
|
defer { tearDown() }
|
||||||
|
|
||||||
|
store.setOverride(.light)
|
||||||
|
store.setOverride(.dark)
|
||||||
|
store.setOverride(nil)
|
||||||
|
|
||||||
|
#expect(applied == [.aqua, .darkAqua, nil])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `BoardZoomStore.setLevel`'s own guard, load-bearing for the same reason: `@Observable` notifies
|
||||||
|
/// on every assignment, equal or not, so an ungated write would hand the apply seam a repeat call
|
||||||
|
/// for a selection that never moved.
|
||||||
|
@Test("An unchanged value writes and applies nothing")
|
||||||
|
func unchangedValueIsANoOp() {
|
||||||
|
var applyCount = 0
|
||||||
|
let (store, defaults, tearDown) = makeStore(apply: { _ in applyCount += 1 })
|
||||||
|
defer { tearDown() }
|
||||||
|
|
||||||
|
store.setOverride(.light)
|
||||||
|
#expect(applyCount == 1)
|
||||||
|
store.setOverride(.light)
|
||||||
|
#expect(applyCount == 1, "the same value again must not re-apply")
|
||||||
|
#expect(defaults.string(forKey: AppPreferences.appearanceKey) == "light")
|
||||||
|
|
||||||
|
store.setOverride(nil)
|
||||||
|
#expect(applyCount == 2)
|
||||||
|
store.setOverride(nil)
|
||||||
|
#expect(applyCount == 2, "Auto set twice must not re-apply either")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch's whole job: `init` only reads, so nothing has applied yet until this is called.
|
||||||
|
@Test("applyCurrent re-applies the stored value, and reads nothing new")
|
||||||
|
func applyCurrentReappliesTheStoredValue() {
|
||||||
|
var applied: [NSAppearance.Name?] = []
|
||||||
|
let (store, _, tearDown) = makeStore(seeding: "dark", apply: { applied.append($0) })
|
||||||
|
defer { tearDown() }
|
||||||
|
#expect(applied.isEmpty, "construction alone must not touch the apply seam")
|
||||||
|
|
||||||
|
store.applyCurrent()
|
||||||
|
|
||||||
|
#expect(applied == [.darkAqua])
|
||||||
|
#expect(store.override == .dark, "applyCurrent hands out what init already resolved")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("applyCurrent on a fresh Auto domain applies nil")
|
||||||
|
func applyCurrentOnAutoAppliesNil() {
|
||||||
|
var applied: [NSAppearance.Name?] = []
|
||||||
|
let (store, _, tearDown) = makeStore(apply: { applied.append($0) })
|
||||||
|
defer { tearDown() }
|
||||||
|
|
||||||
|
store.applyCurrent()
|
||||||
|
|
||||||
|
#expect(applied == [nil])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// The board background's **mapping form** — the write side that has to preserve what the app has no
|
||||||
|
/// control over, and the path rule that decides where an `image:` may point (01-storage-format.md §
|
||||||
|
/// Frontmatter; 03-board-ui.md § Styling ▸ Capabilities).
|
||||||
|
///
|
||||||
|
/// The *readings* live with their siblings in `FrontmatterTests`; what is here is everything that is
|
||||||
|
/// new machinery rather than a new value: `FrontmatterDocument.setStyleValue` (BackgroundField.swift)
|
||||||
|
/// and `BoardBackdrop.imageURL(named:inBoardRoot:)`.
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
private func document(_ frontmatter: String) throws -> FrontmatterDocument {
|
||||||
|
try FrontmatterDocument.parse("---\n\(frontmatter)\n---\nbody\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `background:` line as it now reads on disk, or `nil` when the key is gone.
|
||||||
|
private func backgroundLine(_ document: FrontmatterDocument) -> String? {
|
||||||
|
document.serialized()
|
||||||
|
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||||
|
.first { $0.hasPrefix("background:") }
|
||||||
|
.map(String.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Writing into the mapping
|
||||||
|
|
||||||
|
@Suite("Board background ▸ the write preserves the mapping")
|
||||||
|
struct BackgroundWriteTests {
|
||||||
|
|
||||||
|
/// The whole point of the field: the app owns the colour well and nothing else, so a colour
|
||||||
|
/// change on a board carrying an image has to come back still carrying it.
|
||||||
|
@Test("Setting a colour replaces the subkey and keeps the image")
|
||||||
|
func setKeepsTheImage() throws {
|
||||||
|
var document = try document("background: {color: fern, image: sunset.jpg}")
|
||||||
|
document.setStyleValue("dark-teal", for: FrontmatterKeys.background)
|
||||||
|
|
||||||
|
#expect(document.background == .valid("dark-teal"))
|
||||||
|
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
||||||
|
#expect(backgroundLine(document) == "background: {color: \"dark-teal\", image: \"sunset.jpg\"}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A colour written into a mapping that had none joins it rather than replacing it — the
|
||||||
|
/// image-only board is exactly the board the style editor is most likely to be opened on.
|
||||||
|
@Test("Setting a colour on an image-only background adds the subkey")
|
||||||
|
func setAddsTheSubkeyToAnImageOnlyMapping() throws {
|
||||||
|
var document = try document("background: {image: sunset.jpg}")
|
||||||
|
document.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||||
|
|
||||||
|
#expect(document.background == .valid("chalk"))
|
||||||
|
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unknown subkeys ride along like unknown keys do — nothing in the app knows what `blend:`
|
||||||
|
/// means and nothing in the app is entitled to drop it.
|
||||||
|
@Test("Unknown subkeys survive a colour change, in their own positions")
|
||||||
|
func setPreservesUnknownSubkeys() throws {
|
||||||
|
var document = try document("background: {blend: multiply, color: fern, opacity: 0.5, tags: [a, b]}")
|
||||||
|
document.setStyleValue("obsidian", for: FrontmatterKeys.background)
|
||||||
|
|
||||||
|
#expect(backgroundLine(document)
|
||||||
|
== "background: {blend: \"multiply\", color: \"obsidian\", opacity: 0.5, tags: [\"a\", \"b\"]}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The None well removes the *colour*, not the background: an image the user never chose in the
|
||||||
|
/// app must not disappear because they cleared a colour (03-board-ui.md § Styling ▸ Controls).
|
||||||
|
@Test("The None well drops the colour subkey alone")
|
||||||
|
func removeDropsOnlyTheColour() throws {
|
||||||
|
var document = try document("background: {color: fern, image: sunset.jpg}")
|
||||||
|
document.setStyleValue(nil, for: FrontmatterKeys.background)
|
||||||
|
|
||||||
|
#expect(document.background == .missing)
|
||||||
|
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
||||||
|
#expect(backgroundLine(document) == "background: {image: \"sunset.jpg\"}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mapping the removal empties takes the key with it — `background: {}` is a key that says
|
||||||
|
/// nothing, and the removal's contract is that the field is gone.
|
||||||
|
@Test("A mapping emptied by the removal takes the key with it")
|
||||||
|
func removeDropsAnEmptiedKey() throws {
|
||||||
|
var document = try document("background: {color: fern}")
|
||||||
|
document.setStyleValue(nil, for: FrontmatterKeys.background)
|
||||||
|
|
||||||
|
#expect(document.background == .missing)
|
||||||
|
#expect(!document.contains(FrontmatterKeys.background))
|
||||||
|
#expect(backgroundLine(document) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A block mapping is the same value as a flow one and is merged the same way. The spelling is
|
||||||
|
/// what does not survive — the span editor rewrites a key's value as one line — which is the
|
||||||
|
/// documented limit of the verbatim promise on the one key being written.
|
||||||
|
@Test("A block mapping merges, collapsing to flow form")
|
||||||
|
func blockMappingCollapsesToFlow() throws {
|
||||||
|
var document = try FrontmatterDocument.parse(
|
||||||
|
"---\nschema: 1\nbackground:\n color: fern\n image: sunset.jpg\nicon: tray\n---\nbody\n"
|
||||||
|
)
|
||||||
|
document.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||||
|
|
||||||
|
#expect(document.background == .valid("chalk"))
|
||||||
|
#expect(document.backgroundImage == .valid("sunset.jpg"))
|
||||||
|
#expect(document.serialized() == """
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
background: {color: "chalk", image: "sunset.jpg"}
|
||||||
|
icon: tray
|
||||||
|
---
|
||||||
|
body
|
||||||
|
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The app always writes the mapping** (01-storage-format.md § Frontmatter): a key that was
|
||||||
|
/// absent gets one, and a key holding a shape the schema cannot read — the retired scalar, a
|
||||||
|
/// sequence — is *replaced* by one, which is the malformed-value-cleared posture ("choosing any
|
||||||
|
/// well replaces it").
|
||||||
|
@Test("A colour written onto an absent or unreadable key lands as a mapping")
|
||||||
|
func alwaysWritesTheMapping() throws {
|
||||||
|
var absent = try document("schema: 1")
|
||||||
|
absent.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||||
|
#expect(backgroundLine(absent) == "background: {color: \"chalk\"}")
|
||||||
|
#expect(absent.background == .valid("chalk"))
|
||||||
|
|
||||||
|
var scalar = try document("background: fern")
|
||||||
|
scalar.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||||
|
#expect(backgroundLine(scalar) == "background: {color: \"chalk\"}")
|
||||||
|
|
||||||
|
var sequence = try document("background: [a, b]")
|
||||||
|
sequence.setStyleValue("chalk", for: FrontmatterKeys.background)
|
||||||
|
#expect(backgroundLine(sequence) == "background: {color: \"chalk\"}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A removal over a shape with no subkeys to keep is simply a removal — there is no mapping to
|
||||||
|
/// preserve half of, and nothing was readable to begin with.
|
||||||
|
@Test("A removal over a scalar or an absent key just removes it")
|
||||||
|
func removalOverANonMappingRemovesTheKey() throws {
|
||||||
|
var scalar = try document("background: fern")
|
||||||
|
scalar.setStyleValue(nil, for: FrontmatterKeys.background)
|
||||||
|
#expect(!scalar.contains(FrontmatterKeys.background))
|
||||||
|
|
||||||
|
var absent = try document("schema: 1")
|
||||||
|
absent.setStyleValue(nil, for: FrontmatterKeys.background)
|
||||||
|
#expect(!absent.contains(FrontmatterKeys.background))
|
||||||
|
#expect(absent.serialized() == "---\nschema: 1\n---\nbody\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other style keys are untouched by any of this: their values *are* strings, and a
|
||||||
|
/// hand-written `icon: {a: 1}` is a malformed value the write exists to clear — never something
|
||||||
|
/// to merge a `color:` subkey into.
|
||||||
|
@Test("Title and icon keep the plain scalar path")
|
||||||
|
func otherKeysStayScalar() throws {
|
||||||
|
var titled = try document("title: Old")
|
||||||
|
titled.setStyleValue("New", for: FrontmatterKeys.title)
|
||||||
|
#expect(titled.title == .valid("New"))
|
||||||
|
#expect(titled.serialized() == "---\ntitle: New\n---\nbody\n")
|
||||||
|
|
||||||
|
var mappedIcon = try document("icon: {a: 1}")
|
||||||
|
mappedIcon.setStyleValue("tray", for: FrontmatterKeys.icon)
|
||||||
|
#expect(mappedIcon.icon == .valid("tray"))
|
||||||
|
#expect(mappedIcon.serialized() == "---\nicon: tray\n---\nbody\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The emitted mapping is read back by the very reader the app uses, values with YAML-significant
|
||||||
|
/// characters included — the reason strings are always quoted in flow context.
|
||||||
|
@Test("An awkward colour and path round-trip through the emitted mapping")
|
||||||
|
func awkwardValuesRoundTrip() throws {
|
||||||
|
var document = try document("background: {image: \"a, b}.jpg\"}")
|
||||||
|
document.setStyleValue("#ff8800", for: FrontmatterKeys.background)
|
||||||
|
|
||||||
|
let reparsed = try FrontmatterDocument.parse(document.serialized())
|
||||||
|
#expect(reparsed.background == .valid("#ff8800"))
|
||||||
|
#expect(reparsed.backgroundImage == .valid("a, b}.jpg"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Where an image may point
|
||||||
|
|
||||||
|
@Suite("Board background ▸ the image path stays inside the board")
|
||||||
|
struct BackgroundImagePathTests {
|
||||||
|
|
||||||
|
private let root = URL(fileURLWithPath: "/Users/someone/Boards/Work.kanban", isDirectory: true)
|
||||||
|
|
||||||
|
@Test("A plain name and a nested path resolve inside the board")
|
||||||
|
func resolvesRelativePaths() {
|
||||||
|
#expect(BoardBackdrop.imageURL(named: "sunset.jpg", inBoardRoot: root)?.path
|
||||||
|
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
||||||
|
#expect(BoardBackdrop.imageURL(named: "art/backdrops/sunset.png", inBoardRoot: root)?.path
|
||||||
|
== "/Users/someone/Boards/Work.kanban/art/backdrops/sunset.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The check is about where the path *ends up*, not how it is spelled: a climb that lands back
|
||||||
|
/// inside the board is an ordinary file in it.
|
||||||
|
@Test("A path that climbs and returns is still inside")
|
||||||
|
func resolvesPathsThatStandardizeBackInside() {
|
||||||
|
#expect(BoardBackdrop.imageURL(named: "art/../sunset.jpg", inBoardRoot: root)?.path
|
||||||
|
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
||||||
|
#expect(BoardBackdrop.imageURL(named: "./sunset.jpg", inBoardRoot: root)?.path
|
||||||
|
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `.kanban` folder is a document — it gets copied, zipped and handed to somebody else — so a
|
||||||
|
/// background that only works on the Mac it was written on resolves to nothing at all.
|
||||||
|
@Test("Escapes and absolute paths resolve to nothing")
|
||||||
|
func rejectsEscapes() {
|
||||||
|
#expect(BoardBackdrop.imageURL(named: "../escape.jpg", inBoardRoot: root) == nil)
|
||||||
|
#expect(BoardBackdrop.imageURL(named: "art/../../escape.jpg", inBoardRoot: root) == nil)
|
||||||
|
#expect(BoardBackdrop.imageURL(named: "/etc/passwd", inBoardRoot: root) == nil)
|
||||||
|
// Appended rather than rejected, an absolute path would land as `<root>/etc/passwd` and pass
|
||||||
|
// containment while naming a file the author plainly did not mean.
|
||||||
|
#expect(BoardBackdrop.imageURL(named: "/sunset.jpg", inBoardRoot: root) == nil)
|
||||||
|
#expect(BoardBackdrop.imageURL(named: "", inBoardRoot: root) == nil)
|
||||||
|
#expect(BoardBackdrop.imageURL(named: ".", inBoardRoot: root) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trailing separator in the containment test, doing its job: a sibling whose name merely
|
||||||
|
/// starts with this board's is a different board.
|
||||||
|
@Test("A sibling folder with a prefixed name is not inside")
|
||||||
|
func rejectsAPrefixedSibling() {
|
||||||
|
#expect(BoardBackdrop.imageURL(named: "../Work.kanban.backup/sunset.jpg", inBoardRoot: root) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `~` is not expanded and is not special: only a shell ever meant a home folder by it, and a
|
||||||
|
/// file honestly named that way sits in the board like any other.
|
||||||
|
@Test("A tilde is an ordinary character in a file name")
|
||||||
|
func treatsTildeAsAnOrdinaryCharacter() {
|
||||||
|
#expect(BoardBackdrop.imageURL(named: "~notes.png", inBoardRoot: root)?.path
|
||||||
|
== "/Users/someone/Boards/Work.kanban/~notes.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole-model reading, which is what the view and the window chrome ask: no key, an
|
||||||
|
/// unresolvable one, and a good one.
|
||||||
|
@Test("The board-level reading follows the field and the path rule")
|
||||||
|
func readsTheBoardsOwnField() throws {
|
||||||
|
func board(_ frontmatter: String) throws -> BoardModel {
|
||||||
|
BoardModel(
|
||||||
|
rootURL: root,
|
||||||
|
schema: 1,
|
||||||
|
title: .missing,
|
||||||
|
created: .missing,
|
||||||
|
modified: .missing,
|
||||||
|
modifiedBy: .missing,
|
||||||
|
deleted: .missing,
|
||||||
|
background: .missing,
|
||||||
|
backgroundImage: try document(frontmatter).backgroundImage,
|
||||||
|
icon: .missing,
|
||||||
|
iconColor: .missing,
|
||||||
|
template: nil,
|
||||||
|
lanes: [],
|
||||||
|
document: try document(frontmatter)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(BoardBackdrop.imageURL(for: try board("background: {color: fern}"), root: root) == nil)
|
||||||
|
#expect(BoardBackdrop.imageURL(for: try board("background: {image: ../x.jpg}"), root: root) == nil)
|
||||||
|
#expect(BoardBackdrop.imageURL(for: try board("background: {image: sunset.jpg}"), root: root)?.path
|
||||||
|
== "/Users/someone/Boards/Work.kanban/sunset.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The window-chrome predicate** (`BoardWindowHost`): a board paints a background of its own
|
||||||
|
/// when a colour resolves or an image path lands inside the board — a path that could never
|
||||||
|
/// paint anything leaves the standard chrome alone.
|
||||||
|
@Test("The chrome predicate answers for colour, image, both and neither")
|
||||||
|
func answersTheChromePredicate() throws {
|
||||||
|
func board(_ frontmatter: String) throws -> BoardModel {
|
||||||
|
let parsed = try document(frontmatter)
|
||||||
|
return BoardModel(
|
||||||
|
rootURL: root,
|
||||||
|
schema: 1,
|
||||||
|
title: .missing,
|
||||||
|
created: .missing,
|
||||||
|
modified: .missing,
|
||||||
|
modifiedBy: .missing,
|
||||||
|
deleted: .missing,
|
||||||
|
background: parsed.background,
|
||||||
|
backgroundImage: parsed.backgroundImage,
|
||||||
|
icon: .missing,
|
||||||
|
iconColor: .missing,
|
||||||
|
template: nil,
|
||||||
|
lanes: [],
|
||||||
|
document: parsed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(BoardBackdrop.isCustom(try board("schema: 1"), root: root) == false)
|
||||||
|
#expect(BoardBackdrop.isCustom(try board("background: {color: fern}"), root: root))
|
||||||
|
#expect(BoardBackdrop.isCustom(try board("background: {image: sunset.jpg}"), root: root))
|
||||||
|
#expect(BoardBackdrop.isCustom(try board("background: {color: fern, image: sunset.jpg}"), root: root))
|
||||||
|
// Neither half resolves: an unrecognized colour name and a path that leaves the board.
|
||||||
|
#expect(BoardBackdrop.isCustom(try board("background: {color: mauve, image: /tmp/x.jpg}"), root: root) == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -541,7 +541,7 @@ struct ShownTrashDiffTests {
|
|||||||
let retitled = try fixture.snapshot()
|
let retitled = try fixture.snapshot()
|
||||||
try fixture.item(
|
try fixture.item(
|
||||||
".trash/\(trashedLaneID)",
|
".trash/\(trashedLaneID)",
|
||||||
"---\nschema: 1\ntitle: Shipped\norder: 1024\nkind: lane\nbackground: blue\n---\n\n"
|
"---\nschema: 1\ntitle: Shipped\norder: 1024\nkind: lane\nbackground: {color: blue}\n---\n\n"
|
||||||
)
|
)
|
||||||
let styled = BoardDiff.between(retitled, try fixture.snapshot(), includingTrash: true)
|
let styled = BoardDiff.between(retitled, try fixture.snapshot(), includingTrash: true)
|
||||||
#expect(styled.lanes.isEmpty, "no accent is rendered, so nothing visible changed")
|
#expect(styled.lanes.isEmpty, "no accent is rendered, so nothing visible changed")
|
||||||
|
|||||||
@@ -87,6 +87,36 @@ private func makeFixture(lanes laneCount: Int = laneCount, cards cardsPerLane: I
|
|||||||
|
|
||||||
// MARK: - Hosting
|
// MARK: - Hosting
|
||||||
|
|
||||||
|
/// `BoardWindowHost`'s one relevant job, reproduced: read the app-wide zoom level and inject it as
|
||||||
|
/// the strip's ruler (03-board-ui.md ▸ Layout — zoom).
|
||||||
|
///
|
||||||
|
/// A wrapper rather than a `.environment(\.boardZoom, …)` on the hosted root, because the modifier's
|
||||||
|
/// argument is evaluated once when the root value is built and an `NSHostingView`'s root is a stored
|
||||||
|
/// value. Reading `appModel.zoom` inside a `body` is what makes a level change re-run this view and
|
||||||
|
/// therefore re-publish the environment — which is exactly the propagation path the real window uses,
|
||||||
|
/// and exactly what the zoom invariant below is a claim about.
|
||||||
|
private struct ZoomedBoard: View {
|
||||||
|
|
||||||
|
let store: BoardStore
|
||||||
|
let window: @MainActor () -> NSWindow?
|
||||||
|
let confirmations: TrashConfirmations
|
||||||
|
let openCard: @MainActor (ItemID) -> Void
|
||||||
|
let search: BoardSearchPresentation
|
||||||
|
|
||||||
|
@Environment(AppModel.self) private var appModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
BoardView(
|
||||||
|
store: store,
|
||||||
|
window: window,
|
||||||
|
confirmations: confirmations,
|
||||||
|
openCard: openCard,
|
||||||
|
search: search
|
||||||
|
)
|
||||||
|
.environment(\.boardZoom, appModel.zoom.context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything the hosted board needs to stay alive for the length of a test — an `NSHostingView`
|
/// Everything the hosted board needs to stay alive for the length of a test — an `NSHostingView`
|
||||||
/// whose window is released the moment nothing holds it would stop rendering mid-measurement.
|
/// whose window is released the moment nothing holds it would stop rendering mid-measurement.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -97,19 +127,26 @@ private final class HostedBoard {
|
|||||||
let view: NSView
|
let view: NSView
|
||||||
private let scratch: URL
|
private let scratch: URL
|
||||||
|
|
||||||
|
/// The preferences domain the model's app-wide state persists into — redirected for
|
||||||
|
/// `registryStorageURL`'s reason. The zoom level lives here, and a suite that used `.standard`
|
||||||
|
/// would leave the developer's own boards zoomed.
|
||||||
|
private let preferencesDomain: String
|
||||||
|
|
||||||
init(store: BoardStore, scratch: URL) {
|
init(store: BoardStore, scratch: URL) {
|
||||||
self.store = store
|
self.store = store
|
||||||
self.scratch = scratch
|
self.scratch = scratch
|
||||||
|
preferencesDomain = "dev.rzen.indie.Kanban.render-perf.\(UUID().uuidString)"
|
||||||
appModel = AppModel(
|
appModel = AppModel(
|
||||||
registryStorageURL: scratch.appendingPathComponent("board-registry.json"),
|
registryStorageURL: scratch.appendingPathComponent("board-registry.json"),
|
||||||
clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true)
|
clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true),
|
||||||
|
preferences: UserDefaults(suiteName: preferencesDomain)!
|
||||||
)
|
)
|
||||||
let window = NSWindow(
|
let window = NSWindow(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 1600, height: 1000),
|
contentRect: NSRect(x: 0, y: 0, width: 1600, height: 1000),
|
||||||
styleMask: [.titled], backing: .buffered, defer: false
|
styleMask: [.titled], backing: .buffered, defer: false
|
||||||
)
|
)
|
||||||
self.window = window
|
self.window = window
|
||||||
let root = BoardView(
|
let root = ZoomedBoard(
|
||||||
store: store,
|
store: store,
|
||||||
window: { [weak window] in window },
|
window: { [weak window] in window },
|
||||||
confirmations: TrashConfirmations(),
|
confirmations: TrashConfirmations(),
|
||||||
@@ -131,6 +168,7 @@ private final class HostedBoard {
|
|||||||
window.orderOut(nil)
|
window.orderOut(nil)
|
||||||
window.contentView = nil
|
window.contentView = nil
|
||||||
try? FileManager.default.removeItem(at: scratch)
|
try? FileManager.default.removeItem(at: scratch)
|
||||||
|
UserDefaults.standard.removePersistentDomain(forName: preferencesDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pumps the main run loop until SwiftUI has flushed its pending updates and laid the tree out.
|
/// Pumps the main run loop until SwiftUI has flushed its pending updates and laid the tree out.
|
||||||
@@ -359,4 +397,63 @@ struct BoardRenderPerformanceTests {
|
|||||||
// this card's. See RENDER-INSTRUMENTATION.md ▸ What the first run found.
|
// this card's. See RENDER-INSTRUMENTATION.md ▸ What the first run found.
|
||||||
#expect(selected.strips >= 1, "the strip did not re-run for a selection change")
|
#expect(selected.strips >= 1, "the strip did not re-run for a selection change")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The zoom feature's load-bearing render claim** (03-board-ui.md ▸ Layout — zoom).
|
||||||
|
///
|
||||||
|
/// `CardFaceView` is `.equatable()` and its `==` compares nothing that moves with the zoom level:
|
||||||
|
/// the card, its role, its store, the marquee and the drop context — all identical across a
|
||||||
|
/// ⌘+. The level reaches the faces *only* because it travels in the environment, which
|
||||||
|
/// `CardFaceView`'s own note says the gate deliberately does not compare ("SwiftUI invalidates on
|
||||||
|
/// those itself").
|
||||||
|
///
|
||||||
|
/// That makes this the one assertion standing between the feature and a board that zooms its
|
||||||
|
/// lanes while every card face stays 13pt — a failure that would look like a rendering glitch and
|
||||||
|
/// actually be an architecture decision quietly coming undone. A zero here is the whole bug.
|
||||||
|
@Test("A zoom change repaints the card faces, through the equality gate")
|
||||||
|
func zoomRepaintsTheCardFaces() throws {
|
||||||
|
let fixture = try makeFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let board = try host(fixture)
|
||||||
|
|
||||||
|
BoardRenderMetrics.reset()
|
||||||
|
board.appModel.zoom.step(.in)
|
||||||
|
board.settle()
|
||||||
|
let zoomed = Cost()
|
||||||
|
|
||||||
|
print("── zoom in one rung — \(zoomed.summary)")
|
||||||
|
|
||||||
|
#expect(board.appModel.zoom.level != BoardZoom.actualSize, "the level did not actually move")
|
||||||
|
#expect(zoomed.cards > 0, "a zoom change repainted no card faces — the equality gate swallowed it")
|
||||||
|
#expect(zoomed.containers > 0, "a zoom change repainted no lanes")
|
||||||
|
#expect(zoomed.strips >= 1, "the strip did not re-run for a zoom change")
|
||||||
|
// The masonry's own cache is keyed on column width, which moves with the card spacing, so a
|
||||||
|
// level change must also cost a re-measure rather than replay stale heights.
|
||||||
|
#expect(zoomed.measures > 0, "the masonry replayed heights measured on the old ruler")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The counter-invariant: Actual Size costs what it always did. A board nobody zooms must not pay
|
||||||
|
/// for the feature existing — the steady state after a reload is still the numbers the two
|
||||||
|
/// invariants above pin, with the ruler sitting on the system's own body size.
|
||||||
|
@Test("At Actual Size the strip renders on the system's own ruler")
|
||||||
|
func actualSizeIsTheUntouchedBoard() throws {
|
||||||
|
let fixture = try makeFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let board = try host(fixture)
|
||||||
|
|
||||||
|
#expect(board.appModel.zoom.isActualSize, "a fresh domain must open unzoomed")
|
||||||
|
#expect(board.appModel.zoom.context.bodyPointSize == BoardMetrics.bodyPointSize)
|
||||||
|
|
||||||
|
BoardRenderMetrics.reset()
|
||||||
|
board.appModel.zoom.step(.actualSize)
|
||||||
|
board.settle()
|
||||||
|
let resettled = Cost()
|
||||||
|
|
||||||
|
print("── Actual Size when already there — \(resettled.summary)")
|
||||||
|
|
||||||
|
// `BoardZoomStore.setLevel` refuses an unchanged level outright — `@Observable` notifies on
|
||||||
|
// every set, equal or not, so without that guard re-asserting the level the board is already
|
||||||
|
// at would re-run the whole strip to draw exactly what it was drawing.
|
||||||
|
#expect(resettled.cards == 0, "a no-op Actual Size repainted \(resettled.cards) card faces")
|
||||||
|
#expect(resettled.containers == 0, "a no-op Actual Size repainted \(resettled.containers) lanes")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// A zoom store on a scratch defaults domain — the level is app-wide and persisted, so a suite that
|
||||||
|
/// used `.standard` would zoom the developer's own boards.
|
||||||
|
@MainActor
|
||||||
|
private func makeStore(seeding stored: Double? = nil) -> (BoardZoomStore, UserDefaults, () -> Void) {
|
||||||
|
let name = "dev.rzen.indie.Kanban.zoom-tests.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: name)!
|
||||||
|
if let stored { defaults.set(stored, forKey: AppPreferences.boardZoomLevelKey) }
|
||||||
|
return (BoardZoomStore(defaults: defaults), defaults,
|
||||||
|
{ UserDefaults.standard.removePersistentDomain(forName: name) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The standard system body size. Written down rather than read, so every claim below is a fact about
|
||||||
|
/// the arithmetic and not about the machine the suite happens to run on.
|
||||||
|
private let standardBody: CGFloat = 13
|
||||||
|
|
||||||
|
// MARK: - The ladder
|
||||||
|
|
||||||
|
@Suite("Zoom ▸ the ladder")
|
||||||
|
struct BoardZoomLadderTests {
|
||||||
|
|
||||||
|
@Test("The rungs ascend, and 100% is one of them")
|
||||||
|
func rungsAscend() {
|
||||||
|
#expect(BoardZoom.levels == BoardZoom.levels.sorted())
|
||||||
|
#expect(Set(BoardZoom.levels).count == BoardZoom.levels.count, "no rung appears twice")
|
||||||
|
#expect(BoardZoom.levels.contains(BoardZoom.actualSize), "Actual Size must be reachable by stepping")
|
||||||
|
#expect(BoardZoom.levels.allSatisfy { $0 > 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A held ⌘+ must settle rather than cycle, so both ends are fixed points.
|
||||||
|
@Test("Stepping walks the ladder and stops at each end")
|
||||||
|
func steppingTerminates() {
|
||||||
|
var level = BoardZoom.levels.first!
|
||||||
|
for expected in BoardZoom.levels.dropFirst() {
|
||||||
|
level = BoardZoom.stepIn(from: level)
|
||||||
|
#expect(level == expected)
|
||||||
|
}
|
||||||
|
#expect(BoardZoom.stepIn(from: level) == level, "the top rung is a fixed point")
|
||||||
|
|
||||||
|
for expected in BoardZoom.levels.dropLast().reversed() {
|
||||||
|
level = BoardZoom.stepOut(from: level)
|
||||||
|
#expect(level == expected)
|
||||||
|
}
|
||||||
|
#expect(BoardZoom.stepOut(from: level) == level, "the bottom rung is a fixed point")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⌘+ then ⌘− lands back exactly where it started — the whole reason the ladder is discrete
|
||||||
|
/// rather than a percentage field.
|
||||||
|
@Test("In then out is a round trip from every interior rung")
|
||||||
|
func inThenOutRoundTrips() {
|
||||||
|
for level in BoardZoom.levels.dropFirst().dropLast() {
|
||||||
|
#expect(BoardZoom.stepOut(from: BoardZoom.stepIn(from: level)) == level)
|
||||||
|
#expect(BoardZoom.stepIn(from: BoardZoom.stepOut(from: level)) == level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The can-step predicates agree with what stepping actually does")
|
||||||
|
func predicatesMatchStepping() {
|
||||||
|
for level in BoardZoom.levels {
|
||||||
|
#expect(BoardZoom.canZoomIn(level) == (BoardZoom.stepIn(from: level) != level))
|
||||||
|
#expect(BoardZoom.canZoomOut(level) == (BoardZoom.stepOut(from: level) != level))
|
||||||
|
}
|
||||||
|
#expect(!BoardZoom.canZoomIn(BoardZoom.levels.last!))
|
||||||
|
#expect(!BoardZoom.canZoomOut(BoardZoom.levels.first!))
|
||||||
|
#expect(BoardZoom.isActualSize(BoardZoom.actualSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reading a stored level
|
||||||
|
|
||||||
|
/// `normalize` is the one gate every persisted level passes through, and the reason it exists is that
|
||||||
|
/// the preference is a plain `UserDefaults` scalar a user can `defaults write` to anything.
|
||||||
|
@Suite("Zoom ▸ normalising a stored level")
|
||||||
|
struct BoardZoomNormalizeTests {
|
||||||
|
|
||||||
|
@Test("Every rung survives a round trip")
|
||||||
|
func rungsAreFixedPoints() {
|
||||||
|
for level in BoardZoom.levels {
|
||||||
|
#expect(BoardZoom.normalize(Double(level)) == level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trap this exists for: `double(forKey:)` answers 0 for a key nobody ever set, and an
|
||||||
|
/// unfiltered 0 would drive every `BoardMetrics.em` multiple to its 1pt floor.
|
||||||
|
@Test("Zero is not a level, and never becomes one")
|
||||||
|
func zeroIsClamped() {
|
||||||
|
let level = BoardZoom.normalize(0)
|
||||||
|
#expect(BoardZoom.levels.contains(level))
|
||||||
|
#expect(level == BoardZoom.levels.first!)
|
||||||
|
#expect(BoardZoom.bodyPointSize(system: standardBody, level: level) > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Garbage lands on a legal rung")
|
||||||
|
func garbageIsClamped() {
|
||||||
|
#expect(BoardZoom.levels.contains(BoardZoom.normalize(-4)))
|
||||||
|
#expect(BoardZoom.levels.contains(BoardZoom.normalize(99)))
|
||||||
|
#expect(BoardZoom.normalize(99) == BoardZoom.levels.last!)
|
||||||
|
#expect(BoardZoom.normalize(-4) == BoardZoom.levels.first!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not a number has no honest nearest rung, so it resolves to the one answer that cannot
|
||||||
|
/// surprise.
|
||||||
|
@Test("Non-finite input resolves to Actual Size")
|
||||||
|
func nonFiniteIsActualSize() {
|
||||||
|
#expect(BoardZoom.normalize(.nan) == BoardZoom.actualSize)
|
||||||
|
#expect(BoardZoom.normalize(.infinity) == BoardZoom.actualSize)
|
||||||
|
#expect(BoardZoom.normalize(-.infinity) == BoardZoom.actualSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A build that shortened the ladder under a value an older one wrote — snapped, not refused.
|
||||||
|
@Test("An off-ladder value snaps to its nearest rung")
|
||||||
|
func offLadderSnaps() {
|
||||||
|
#expect(BoardZoom.normalize(1.07) == 1.0)
|
||||||
|
#expect(BoardZoom.normalize(1.12) == 1.15)
|
||||||
|
#expect(BoardZoom.normalize(1.9) == 2.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - What a level means
|
||||||
|
|
||||||
|
@Suite("Zoom ▸ what a level means")
|
||||||
|
struct BoardZoomMeaningTests {
|
||||||
|
|
||||||
|
/// The founding guarantee: Actual Size draws pixel-for-pixel what the board drew before zoom
|
||||||
|
/// existed.
|
||||||
|
@Test("Actual Size is the system's own ruler, exactly")
|
||||||
|
func actualSizeIsTheSystemRuler() {
|
||||||
|
#expect(BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.actualSize) == standardBody)
|
||||||
|
#expect(BoardZoom.bodyPointSize(system: 18, level: BoardZoom.actualSize) == 18)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other half of that guarantee: a relative text style stays a relative text style, carrying
|
||||||
|
/// the system's own leading and traits, until a deliberate zoom trades it away.
|
||||||
|
@Test("Actual Size hands back the relative style itself")
|
||||||
|
@MainActor
|
||||||
|
func actualSizeKeepsRelativeStyles() {
|
||||||
|
#expect(BoardZoom.font(.body, level: BoardZoom.actualSize) == .body)
|
||||||
|
#expect(BoardZoom.font(.caption, level: BoardZoom.actualSize) == .caption)
|
||||||
|
#expect(BoardZoom.font(.headline, level: BoardZoom.actualSize) == .headline)
|
||||||
|
#expect(BoardZoom.font(.caption2, level: BoardZoom.actualSize) == .caption2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Off the default rung, the style is scaled rather than kept")
|
||||||
|
@MainActor
|
||||||
|
func zoomedStylesAreScaled() {
|
||||||
|
#expect(BoardZoom.font(.body, level: 2.0) != .body)
|
||||||
|
#expect(BoardZoom.font(.caption, level: 0.75) != .caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The level scales the ruler monotonically")
|
||||||
|
func levelScalesTheRuler() {
|
||||||
|
let sizes = BoardZoom.levels.map { BoardZoom.bodyPointSize(system: standardBody, level: $0) }
|
||||||
|
#expect(sizes == sizes.sorted())
|
||||||
|
#expect(sizes.first! < standardBody)
|
||||||
|
#expect(sizes.last! > standardBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deliberately unrounded: `BoardMetrics.em` already rounds every figure it produces, and
|
||||||
|
/// rounding here would round twice on two different rulers.
|
||||||
|
@Test("The ruler is not pre-rounded")
|
||||||
|
func rulerIsNotRounded() {
|
||||||
|
#expect(BoardZoom.bodyPointSize(system: 13, level: 1.15) == 13 * 1.15)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A level cannot be trusted to be positive until `normalize` has seen it, and no proposal
|
||||||
|
/// downstream may be zero or negative.
|
||||||
|
@Test("The ruler is floored at one point")
|
||||||
|
func rulerIsFloored() {
|
||||||
|
#expect(BoardZoom.bodyPointSize(system: 13, level: 0) == 1)
|
||||||
|
#expect(BoardZoom.bodyPointSize(system: 13, level: -2) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The percent label is the announcement's value")
|
||||||
|
func percentLabel() {
|
||||||
|
#expect(BoardZoom.percentLabel(1.0) == "100%")
|
||||||
|
#expect(BoardZoom.percentLabel(1.15) == "115%")
|
||||||
|
#expect(BoardZoom.percentLabel(0.75) == "75%")
|
||||||
|
#expect(BoardZoom.percentLabel(2.0) == "200%")
|
||||||
|
#expect(AccessibilityPhrases.zoomLevel(BoardZoom.percentLabel(1.3)) == "Zoom 130%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Every board metric follows the ruler
|
||||||
|
|
||||||
|
/// The point of the whole feature: `BoardMetrics` is already parameterised on the body point size, so
|
||||||
|
/// moving the level moves every figure the strip draws. Asserted over the table rather than per
|
||||||
|
/// figure, so a metric added later is covered the day it lands.
|
||||||
|
@Suite("Zoom ▸ the metrics follow the level")
|
||||||
|
struct BoardZoomMetricsTests {
|
||||||
|
|
||||||
|
/// Every em-derived figure on the board, by name — `VisualAccommodationsTests`' own inventory
|
||||||
|
/// shape, which is how a new metric gets caught by this suite without anyone remembering to add
|
||||||
|
/// it to two lists.
|
||||||
|
private var figures: [(String, (CGFloat) -> CGFloat)] { [
|
||||||
|
("stripGap", { BoardMetrics.stripGap(bodyPointSize: $0) }),
|
||||||
|
("laneCornerRadius", { BoardMetrics.laneCornerRadius(bodyPointSize: $0) }),
|
||||||
|
("lanePlatePadding", { BoardMetrics.lanePlatePadding(bodyPointSize: $0) }),
|
||||||
|
("laneStackSpacing", { BoardMetrics.laneStackSpacing(bodyPointSize: $0) }),
|
||||||
|
("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }),
|
||||||
|
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
|
||||||
|
("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }),
|
||||||
|
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
||||||
|
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
|
||||||
|
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
|
||||||
|
("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
|
||||||
|
("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
|
||||||
|
("resizeHandleWidth", { BoardMetrics.resizeHandleWidth(bodyPointSize: $0) }),
|
||||||
|
("trashHatchSpacing", { BoardMetrics.trashHatchSpacing(bodyPointSize: $0) }),
|
||||||
|
] }
|
||||||
|
|
||||||
|
@Test("Zooming in grows every figure; zooming out shrinks every figure")
|
||||||
|
func figuresFollowTheLevel() {
|
||||||
|
let resting = BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.actualSize)
|
||||||
|
let zoomedIn = BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.levels.last!)
|
||||||
|
let zoomedOut = BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.levels.first!)
|
||||||
|
|
||||||
|
for (name, figure) in figures {
|
||||||
|
#expect(figure(zoomedIn) > figure(resting), "\(name) must grow when the board zooms in")
|
||||||
|
#expect(figure(zoomedOut) < figure(resting), "\(name) must shrink when the board zooms out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every figure stays a whole point, and at least one, at every rung")
|
||||||
|
func figuresStayLegalAtEveryRung() {
|
||||||
|
for level in BoardZoom.levels {
|
||||||
|
let size = BoardZoom.bodyPointSize(system: standardBody, level: level)
|
||||||
|
for (name, figure) in figures {
|
||||||
|
let value = figure(size)
|
||||||
|
#expect(value >= 1, "\(name) collapsed at \(BoardZoom.percentLabel(level))")
|
||||||
|
#expect(value == value.rounded(), "\(name) is not a whole point at \(BoardZoom.percentLabel(level))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The no-horizontal-scroll invariant survives every rung** (03-board-ui.md ▸ Layout). Zoom
|
||||||
|
/// moves the gap, so the lanes narrow; what it must never do is make the strip want more room
|
||||||
|
/// than the window has.
|
||||||
|
@Test("The strip still exactly fills its window at every rung")
|
||||||
|
func stripStillFillsAtEveryRung() {
|
||||||
|
let stripWidth: CGFloat = 1400
|
||||||
|
for level in BoardZoom.levels {
|
||||||
|
let gap = BoardMetrics.stripGap(
|
||||||
|
bodyPointSize: BoardZoom.bodyPointSize(system: standardBody, level: level))
|
||||||
|
for units in 1...8 {
|
||||||
|
let standard = LaneLayoutMath.standardWidth(
|
||||||
|
stripWidth: stripWidth, totalUnits: units, gap: gap)
|
||||||
|
#expect(standard > 0)
|
||||||
|
let drawn = standard * CGFloat(units) + gap * CGFloat(units + 1)
|
||||||
|
#expect(abs(drawn - stripWidth) < 0.001,
|
||||||
|
"\(units) units at \(BoardZoom.percentLabel(level)) does not fill the strip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zoom in and the lanes narrow — the honest consequence of a board that refuses horizontal
|
||||||
|
/// scroll, and the one thing about this feature a user could be surprised by. Pinned so it stays
|
||||||
|
/// a decision rather than becoming a bug report.
|
||||||
|
@Test("Zooming in narrows the lanes rather than widening the strip")
|
||||||
|
func zoomingInNarrowsLanes() {
|
||||||
|
let stripWidth: CGFloat = 1400
|
||||||
|
let resting = LaneLayoutMath.standardWidth(
|
||||||
|
stripWidth: stripWidth, totalUnits: 4,
|
||||||
|
gap: BoardMetrics.stripGap(bodyPointSize: standardBody))
|
||||||
|
let zoomed = LaneLayoutMath.standardWidth(
|
||||||
|
stripWidth: stripWidth, totalUnits: 4,
|
||||||
|
gap: BoardMetrics.stripGap(
|
||||||
|
bodyPointSize: BoardZoom.bodyPointSize(system: standardBody, level: 2.0)))
|
||||||
|
|
||||||
|
#expect(zoomed < resting)
|
||||||
|
// …but only by the gap's growth, which is a few percent — not by the level.
|
||||||
|
#expect(zoomed > resting * 0.9, "the narrowing is the gap's, not the level's")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The persisted level
|
||||||
|
|
||||||
|
@Suite("Zoom ▸ the persisted level")
|
||||||
|
@MainActor
|
||||||
|
struct BoardZoomStoreTests {
|
||||||
|
|
||||||
|
@Test("A fresh domain opens at Actual Size")
|
||||||
|
func freshDomainIsActualSize() {
|
||||||
|
let (store, _, tearDown) = makeStore()
|
||||||
|
defer { tearDown() }
|
||||||
|
|
||||||
|
#expect(store.level == BoardZoom.actualSize)
|
||||||
|
#expect(store.isActualSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A level survives the trip through defaults")
|
||||||
|
func levelPersists() {
|
||||||
|
let (store, defaults, tearDown) = makeStore()
|
||||||
|
defer { tearDown() }
|
||||||
|
|
||||||
|
store.zoomIn()
|
||||||
|
let level = store.level
|
||||||
|
#expect(level != BoardZoom.actualSize)
|
||||||
|
|
||||||
|
let reopened = BoardZoomStore(defaults: defaults)
|
||||||
|
#expect(reopened.level == level)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored scalar is a plain preference a user can hand-edit; whatever is in there, the store
|
||||||
|
/// opens on a rung.
|
||||||
|
@Test("A hand-edited preference still opens on a rung")
|
||||||
|
func handEditedPreferenceIsNormalised() {
|
||||||
|
for stored in [0, -1, 1.07, 42] as [Double] {
|
||||||
|
let (store, _, tearDown) = makeStore(seeding: stored)
|
||||||
|
defer { tearDown() }
|
||||||
|
#expect(BoardZoom.levels.contains(store.level), "\(stored) opened off the ladder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The three moves walk the ladder and hold at its ends")
|
||||||
|
func movesWalkTheLadder() {
|
||||||
|
let (store, _, tearDown) = makeStore()
|
||||||
|
defer { tearDown() }
|
||||||
|
|
||||||
|
for _ in BoardZoom.levels { store.zoomIn() }
|
||||||
|
#expect(store.level == BoardZoom.levels.last!)
|
||||||
|
#expect(!store.canZoomIn)
|
||||||
|
|
||||||
|
for _ in BoardZoom.levels { store.zoomOut() }
|
||||||
|
#expect(store.level == BoardZoom.levels.first!)
|
||||||
|
#expect(!store.canZoomOut)
|
||||||
|
|
||||||
|
store.actualSize()
|
||||||
|
#expect(store.level == BoardZoom.actualSize)
|
||||||
|
#expect(store.isActualSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single write path the menu rows and the toolbar buttons share, so the two faces of one
|
||||||
|
/// command can never drift (`BoardStore.setTrashVisible`'s rule).
|
||||||
|
@Test("The shared step is the same walk")
|
||||||
|
func sharedStepIsTheSameWalk() {
|
||||||
|
let (store, _, tearDown) = makeStore()
|
||||||
|
defer { tearDown() }
|
||||||
|
|
||||||
|
store.step(.in)
|
||||||
|
#expect(store.level == BoardZoom.stepIn(from: BoardZoom.actualSize))
|
||||||
|
store.step(.out)
|
||||||
|
#expect(store.level == BoardZoom.actualSize)
|
||||||
|
store.step(.in)
|
||||||
|
store.step(.actualSize)
|
||||||
|
#expect(store.level == BoardZoom.actualSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The context carries the level to the strip")
|
||||||
|
func contextCarriesTheLevel() {
|
||||||
|
let (store, _, tearDown) = makeStore()
|
||||||
|
defer { tearDown() }
|
||||||
|
|
||||||
|
store.zoomIn()
|
||||||
|
#expect(store.context.level == store.level)
|
||||||
|
#expect(store.context == BoardZoomContext(level: store.level))
|
||||||
|
#expect(BoardZoomContext.actualSize.level == BoardZoom.actualSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The commands' scope
|
||||||
|
|
||||||
|
@Suite("Zoom ▸ the commands' scope")
|
||||||
|
@MainActor
|
||||||
|
struct ZoomCommandScopeTests {
|
||||||
|
|
||||||
|
private func makeBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rows are board-scoped: the level is app-wide, but only board windows draw with it, so a
|
||||||
|
/// ⌘+ over a card window or the welcome screen would be a command that silently missed.
|
||||||
|
@Test("No board window, no zoom")
|
||||||
|
func withoutABoardTheRowsAreDead() {
|
||||||
|
#expect(!ZoomCommands.isEnabled(store: nil, session: DragSession()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A board window in front, and nothing in flight, is live")
|
||||||
|
func withABoardTheRowsAreLive() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
#expect(ZoomCommands.isEnabled(store: store, session: DragSession()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A drag freezes geometry the level feeds — the run's heights, and `RestingLayoutCache`, whose
|
||||||
|
/// entry key does not include the point size. The guard states that rather than relying on the
|
||||||
|
/// AppKit drag loop happening to swallow the chord.
|
||||||
|
@Test("A drag in flight holds all three rows shut")
|
||||||
|
func aDragHoldsTheRowsShut() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true)
|
||||||
|
|
||||||
|
let cards = DragSession()
|
||||||
|
cards.beginCards([ItemID(rawValue: Ident.card1)], folders: [folder], heights: [44],
|
||||||
|
container: .board, source: store)
|
||||||
|
#expect(!ZoomCommands.isEnabled(store: store, session: cards))
|
||||||
|
|
||||||
|
let lanes = DragSession()
|
||||||
|
lanes.beginLanes([ItemID(rawValue: Ident.lane1)], folders: [folder], units: [1], source: store)
|
||||||
|
#expect(!ZoomCommands.isEnabled(store: store, session: lanes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zooming is a view change, not a mutation — `ShowTrashCommand`'s rule. So the rows are not
|
||||||
|
/// gated on `acceptsBoardMutations`, which an open inline editor is enough to falsify: a user
|
||||||
|
/// naming a new card can still make the board bigger to read it.
|
||||||
|
@Test("A board that refuses mutations still zooms")
|
||||||
|
func mutationRefusalDoesNotCloseTheRows() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.transient.beginPlaceholder(inLane: ItemID(rawValue: Ident.lane1))
|
||||||
|
#expect(!store.acceptsBoardMutations, "the fixture must actually be refusing mutations")
|
||||||
|
#expect(ZoomCommands.isEnabled(store: store, session: DragSession()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -483,7 +483,7 @@ struct CardSessionStalenessTests {
|
|||||||
// A foreign styling of the same card: the session wrote the body and nothing else, so the
|
// A foreign styling of the same card: the session wrote the body and nothing else, so the
|
||||||
// step names no style field to be stale against (13 ▸ Rules, the field-level predicate).
|
// step names no style field to be stale against (13 ▸ Rules, the field-level predicate).
|
||||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(cardPath), operation: .style(title: nil)) {
|
try BoardWriter.updateIndex(inItemFolder: fixture.url(cardPath), operation: .style(title: nil)) {
|
||||||
$0.set(FrontmatterKeys.background, to: .string("blue"))
|
$0.setStyleValue("blue", for: FrontmatterKeys.background)
|
||||||
}
|
}
|
||||||
|
|
||||||
window.board.undo()
|
window.board.undo()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ struct CardDetailsKeyTests {
|
|||||||
created: 2026-01-01T09:00:00Z
|
created: 2026-01-01T09:00:00Z
|
||||||
modified: 2026-02-02T09:00:00Z
|
modified: 2026-02-02T09:00:00Z
|
||||||
modified-by: claude
|
modified-by: claude
|
||||||
background: mint
|
background: {color: mint}
|
||||||
icon: flag
|
icon: flag
|
||||||
iconColor: carnation
|
iconColor: carnation
|
||||||
project: overlay-rewritten
|
project: overlay-rewritten
|
||||||
@@ -99,7 +99,7 @@ struct CardDetailsKeyTests {
|
|||||||
schema: 1
|
schema: 1
|
||||||
title: Styled
|
title: Styled
|
||||||
order: 1024
|
order: 1024
|
||||||
background: mint
|
background: {color: mint}
|
||||||
icon: flag
|
icon: flag
|
||||||
iconColor: carnation
|
iconColor: carnation
|
||||||
created: 2026-01-01T09:00:00Z
|
created: 2026-01-01T09:00:00Z
|
||||||
|
|||||||
@@ -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
|
let restyled = try compose { fixture in
|
||||||
try fixture.item(
|
try fixture.item(
|
||||||
"\(Ident.lane1)/\(Ident.card1)",
|
"\(Ident.lane1)/\(Ident.card1)",
|
||||||
"---\nschema: 1\ntitle: Fix login\norder: 1024\nbackground: blue\n---\n\n"
|
"---\nschema: 1\ntitle: Fix login\norder: 1024\nbackground: {color: blue}\n---\n\n"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
#expect(restyled == "Restyle card 'Fix login'")
|
#expect(restyled == "Restyle card 'Fix login'")
|
||||||
|
|||||||
@@ -121,27 +121,64 @@ struct DragAutoScrollMathTests {
|
|||||||
|
|
||||||
// MARK: Engagement reach
|
// MARK: Engagement reach
|
||||||
|
|
||||||
|
/// The standard system body size — the ruler every figure below was tuned against.
|
||||||
|
private static let standardBody: CGFloat = 13
|
||||||
|
|
||||||
@Test("Engagement reaches over the header but barely sideways")
|
@Test("Engagement reaches over the header but barely sideways")
|
||||||
func engagementReach() {
|
func engagementReach() {
|
||||||
let viewport = CGSize(width: 240, height: 400)
|
let viewport = CGSize(width: 240, height: 400)
|
||||||
let reach = DragAutoScrollMath.engagementRect(viewport: viewport)
|
let size = Self.standardBody
|
||||||
|
let above = DragAutoScrollMath.reachAbove(bodyPointSize: size)
|
||||||
|
let below = DragAutoScrollMath.reachBelow(bodyPointSize: size)
|
||||||
|
let side = DragAutoScrollMath.reachSide(bodyPointSize: size)
|
||||||
|
let reach = DragAutoScrollMath.engagementRect(viewport: viewport, bodyPointSize: size)
|
||||||
|
|
||||||
#expect(reach.contains(CGPoint(x: 120, y: 200)), "inside the visible area, always")
|
#expect(reach.contains(CGPoint(x: 120, y: 200)), "inside the visible area, always")
|
||||||
|
|
||||||
// Above it (the lane header) and below it (the strip's padding).
|
// Above it (the lane header) and below it (the strip's padding).
|
||||||
#expect(reach.contains(CGPoint(x: 120, y: -DragAutoScrollMath.reachAbove + 1)))
|
#expect(reach.contains(CGPoint(x: 120, y: -above + 1)))
|
||||||
#expect(reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow - 1)))
|
#expect(reach.contains(CGPoint(x: 120, y: viewport.height + below - 1)))
|
||||||
#expect(!reach.contains(CGPoint(x: 120, y: -DragAutoScrollMath.reachAbove - 1)))
|
#expect(!reach.contains(CGPoint(x: 120, y: -above - 1)))
|
||||||
#expect(!reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow + 1)))
|
#expect(!reach.contains(CGPoint(x: 120, y: viewport.height + below + 1)))
|
||||||
|
|
||||||
// Sideways: only a sliver, so the neighbouring lane never engages.
|
// Sideways: only a sliver, so the neighbouring lane never engages.
|
||||||
#expect(reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide + 1, y: 200)))
|
#expect(reach.contains(CGPoint(x: -side + 1, y: 200)))
|
||||||
#expect(!reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide - 1, y: 200)))
|
#expect(!reach.contains(CGPoint(x: -side - 1, y: 200)))
|
||||||
#expect(!reach.contains(CGPoint(x: viewport.width + DragAutoScrollMath.reachSide + 1, y: 200)))
|
#expect(!reach.contains(CGPoint(x: viewport.width + side + 1, y: 200)))
|
||||||
|
|
||||||
// The sideways reach must stay under half the distance between two lanes' scroll areas, or
|
// The sideways reach must stay under half the distance between two lanes' scroll areas, or
|
||||||
// two lanes would scroll at once.
|
// two lanes would scroll at once.
|
||||||
#expect(DragAutoScrollMath.reachSide < 28 / 2)
|
#expect(side < 28 / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The standard body size draws the reaches it always drew")
|
||||||
|
func reachesAtStandardBodySize() {
|
||||||
|
let size = Self.standardBody
|
||||||
|
#expect(DragAutoScrollMath.reachAbove(bodyPointSize: size) == 48)
|
||||||
|
#expect(DragAutoScrollMath.reachBelow(bodyPointSize: size) == 24)
|
||||||
|
#expect(DragAutoScrollMath.reachSide(bodyPointSize: size) == 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reaches are distances to the lane's own furniture — a header, a padding, a gap — and all
|
||||||
|
/// three of those grow with the board's zoom (03-board-ui.md ▸ Layout — zoom). A reach fixed in
|
||||||
|
/// points would stop covering the header it is specified against.
|
||||||
|
@Test("Every reach grows with the board's ruler")
|
||||||
|
func reachesFollowTheRuler() {
|
||||||
|
let standard = Self.standardBody
|
||||||
|
let zoomed = BoardZoom.bodyPointSize(system: standard, level: 2.0)
|
||||||
|
|
||||||
|
#expect(DragAutoScrollMath.reachAbove(bodyPointSize: zoomed)
|
||||||
|
> DragAutoScrollMath.reachAbove(bodyPointSize: standard))
|
||||||
|
#expect(DragAutoScrollMath.reachBelow(bodyPointSize: zoomed)
|
||||||
|
> DragAutoScrollMath.reachBelow(bodyPointSize: standard))
|
||||||
|
#expect(DragAutoScrollMath.reachSide(bodyPointSize: zoomed)
|
||||||
|
> DragAutoScrollMath.reachSide(bodyPointSize: standard))
|
||||||
|
|
||||||
|
// And the sideways rule holds on the zoomed ruler too: half the distance between two lanes'
|
||||||
|
// scroll areas is the gap plus a plate padding on each side.
|
||||||
|
let gap = BoardMetrics.stripGap(bodyPointSize: zoomed)
|
||||||
|
let padding = BoardMetrics.lanePlatePadding(bodyPointSize: zoomed)
|
||||||
|
#expect(DragAutoScrollMath.reachSide(bodyPointSize: zoomed) <= (gap + 2 * padding) / 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Stepping the offset
|
// MARK: Stepping the offset
|
||||||
|
|||||||
@@ -1047,3 +1047,116 @@ struct HapticTickTests {
|
|||||||
#expect(ticks() == afterFirstLanding + 1, "slot A to slot B is one new landing spot")
|
#expect(ticks() == afterFirstLanding + 1, "slot A to slot B is one new landing spot")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The seeded pickup
|
||||||
|
|
||||||
|
/// **The shadow replaces the item's original space at drag start** (DRAG-REORDER.md § The pieces):
|
||||||
|
/// `begin` takes the pickup's own slot as a seed, so the lift-out and the shadow's arrival are one
|
||||||
|
/// transaction with identical geometry instead of a gap that closes un-animated and springs back
|
||||||
|
/// open at the first `dropUpdated`.
|
||||||
|
///
|
||||||
|
/// The seed bypasses `propose` deliberately — a pickup is not a *new landing spot*, so the
|
||||||
|
/// alignment tick stays reserved for genuine retargets, and the first real sample's re-propose of
|
||||||
|
/// the same slot is `propose`'s ordinary silent early-out.
|
||||||
|
@MainActor
|
||||||
|
@Suite("The seeded pickup")
|
||||||
|
struct SeededPickupTests {
|
||||||
|
|
||||||
|
private static let lane1 = ItemID(rawValue: Ident.lane1)
|
||||||
|
private static let card1 = ItemID(rawValue: Ident.card1)
|
||||||
|
|
||||||
|
private func makeBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cardFolder(_ store: BoardStore) -> URL {
|
||||||
|
store.rootURL
|
||||||
|
.appendingPathComponent(Ident.lane1, isDirectory: true)
|
||||||
|
.appendingPathComponent(Ident.card1, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A seeded pickup proposes the own slot from the first frame")
|
||||||
|
func seedIsTheProposal() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = DragSession()
|
||||||
|
let seed = DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0)
|
||||||
|
|
||||||
|
session.beginCards(
|
||||||
|
[Self.card1], folders: [cardFolder(store)], heights: [44],
|
||||||
|
container: .board, source: store, seed: seed
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(session.proposal == seed, "the shadow holds the vacated space before any sample")
|
||||||
|
#expect(
|
||||||
|
session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 0,
|
||||||
|
"the lane reads the seed through the same accessor every retargeted proposal uses"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The seed is silent, and the first sample's re-propose stays silent")
|
||||||
|
func seedDoesNotTick() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = DragSession()
|
||||||
|
var count = 0
|
||||||
|
session.hapticTick = { count += 1 }
|
||||||
|
let seed = DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0)
|
||||||
|
|
||||||
|
session.beginCards(
|
||||||
|
[Self.card1], folders: [cardFolder(store)], heights: [44],
|
||||||
|
container: .board, source: store, seed: seed
|
||||||
|
)
|
||||||
|
#expect(count == 0, "a pickup is not a new landing spot")
|
||||||
|
|
||||||
|
session.propose(seed)
|
||||||
|
#expect(count == 0, "the first dropUpdated re-proposes the seeded slot — the early-out holds")
|
||||||
|
|
||||||
|
session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 1))
|
||||||
|
#expect(count == 1, "a genuine retarget still ticks exactly as before")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An unseeded pickup still begins with no proposal")
|
||||||
|
func noSeedIsTheOldContract() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = DragSession()
|
||||||
|
let seed = DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0)
|
||||||
|
|
||||||
|
session.beginCards(
|
||||||
|
[Self.card1], folders: [cardFolder(store)], heights: [44],
|
||||||
|
container: .board, source: store, seed: seed
|
||||||
|
)
|
||||||
|
// A second begin without a seed — the ⌥-pickup's shape, and every trash pickup's — must not
|
||||||
|
// inherit the previous session's proposal.
|
||||||
|
session.beginCards(
|
||||||
|
[Self.card1], folders: [cardFolder(store)], heights: [44],
|
||||||
|
container: .trash, source: store
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(session.proposal == nil, "a fresh session before the first sample, exactly as documented")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A lane pickup seeds its strip slot the same way")
|
||||||
|
func laneSeedNamesTheStrip() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = DragSession()
|
||||||
|
let seed = DropTarget(boardRoot: store.rootKey, container: .strip, index: 0)
|
||||||
|
|
||||||
|
session.beginLanes(
|
||||||
|
[Self.lane1], folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
|
||||||
|
units: [1], source: store, seed: seed
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(session.stripProposal(onBoardRooted: store.rootKey) == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -661,7 +661,7 @@ struct CardSlotTests {
|
|||||||
struct FileDropZoneTests {
|
struct FileDropZoneTests {
|
||||||
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
|
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
|
||||||
private let heights: [CGFloat] = [40, 60, 30, 20, 50]
|
private let heights: [CGFloat] = [40, 60, 30, 20, 50]
|
||||||
private let nominal = LaneDropRegistry.nominalCardHeight
|
private let nominal = LaneDropRegistry().nominalCardHeight
|
||||||
|
|
||||||
private func landing(
|
private func landing(
|
||||||
_ x: CGFloat, _ y: CGFloat, headerBottom: CGFloat? = nil, current: Int? = nil,
|
_ x: CGFloat, _ y: CGFloat, headerBottom: CGFloat? = nil, current: Int? = nil,
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ struct FixtureCoercionTests {
|
|||||||
let cardTitleSeq = "40000000-0000-4000-8000-000000000004"
|
let cardTitleSeq = "40000000-0000-4000-8000-000000000004"
|
||||||
let cardIconColorInt = "50000000-0000-4000-8000-000000000005"
|
let cardIconColorInt = "50000000-0000-4000-8000-000000000005"
|
||||||
let cardBackgroundMap = "60000000-0000-4000-8000-000000000006"
|
let cardBackgroundMap = "60000000-0000-4000-8000-000000000006"
|
||||||
let cardBackgroundInt = "70000000-0000-4000-8000-000000000007"
|
let cardBackgroundScalar = "70000000-0000-4000-8000-000000000007"
|
||||||
let cardDeletedBad = "80000000-0000-4000-8000-000000000008"
|
let cardDeletedBad = "80000000-0000-4000-8000-000000000008"
|
||||||
|
|
||||||
let result = try loadFixture("Valid/coercion.kanban")
|
let result = try loadFixture("Valid/coercion.kanban")
|
||||||
@@ -371,8 +371,14 @@ struct FixtureCoercionTests {
|
|||||||
#expect(try card(cardTitleInt).title == .valid("2048"))
|
#expect(try card(cardTitleInt).title == .valid("2048"))
|
||||||
#expect(try card(cardTitleSeq).title == .malformed(raw: "[a, b]"))
|
#expect(try card(cardTitleSeq).title == .malformed(raw: "[a, b]"))
|
||||||
#expect(try card(cardIconColorInt).iconColor == .valid("42"))
|
#expect(try card(cardIconColorInt).iconColor == .valid("42"))
|
||||||
#expect(try card(cardBackgroundMap).background == .malformed(raw: "{x: 1}"))
|
// **`background` is a mapping and only a mapping** (01-storage-format.md § Frontmatter,
|
||||||
#expect(try card(cardBackgroundInt).background == .valid("12345"))
|
// ruled 2026-08-06). So the two background cards say opposite things about one key:
|
||||||
|
// `{x: 1}` is a perfectly legal mapping that simply names neither subkey — no colour, no
|
||||||
|
// trace, the unknown subkey riding along like any unknown key — while the bare scalar
|
||||||
|
// `12345` has no reading at all, which is the golden pin on the retired shape.
|
||||||
|
#expect(try card(cardBackgroundMap).background == .missing)
|
||||||
|
#expect(try card(cardBackgroundScalar).background == .malformed(raw: "12345"))
|
||||||
|
#expect(try card(cardBackgroundScalar).background.rawText == "12345")
|
||||||
|
|
||||||
let deletedBad = try card(cardDeletedBad)
|
let deletedBad = try card(cardDeletedBad)
|
||||||
#expect(deletedBad.deleted == .malformed(raw: "definitely-not-a-date"))
|
#expect(deletedBad.deleted == .malformed(raw: "definitely-not-a-date"))
|
||||||
|
|||||||
@@ -551,8 +551,8 @@ struct FrontmatterLenientFieldTests {
|
|||||||
|
|
||||||
@Test func wellFormedLenientValues() throws {
|
@Test func wellFormedLenientValues() throws {
|
||||||
#expect(try document("title: My Board").title == .valid("My Board"))
|
#expect(try document("title: My Board").title == .valid("My Board"))
|
||||||
#expect(try document("background: \"#ff8800\"").background == .valid("#ff8800"))
|
#expect(try document("background: {color: \"#ff8800\"}").background == .valid("#ff8800"))
|
||||||
#expect(try document("background: slate").background == .valid("slate"))
|
#expect(try document("background: {color: slate}").background == .valid("slate"))
|
||||||
#expect(try document("icon: tray.full").icon == .valid("tray.full"))
|
#expect(try document("icon: tray.full").icon == .valid("tray.full"))
|
||||||
#expect(try document("iconColor: teal").iconColor == .valid("teal"))
|
#expect(try document("iconColor: teal").iconColor == .valid("teal"))
|
||||||
#expect(try document("width: 3").width == .valid(3))
|
#expect(try document("width: 3").width == .valid(3))
|
||||||
@@ -565,7 +565,7 @@ struct FrontmatterLenientFieldTests {
|
|||||||
@Test func scalarsOfTheWrongTypeCoerceToTheirSourceText() throws {
|
@Test func scalarsOfTheWrongTypeCoerceToTheirSourceText() throws {
|
||||||
#expect(try document("title: 2048").title == .valid("2048"))
|
#expect(try document("title: 2048").title == .valid("2048"))
|
||||||
#expect(try document("title: true").title == .valid("true"))
|
#expect(try document("title: true").title == .valid("true"))
|
||||||
#expect(try document("background: 42").background == .valid("42"))
|
#expect(try document("background: {color: 42}").background == .valid("42"))
|
||||||
#expect(try document("iconColor: true").iconColor == .valid("true"))
|
#expect(try document("iconColor: true").iconColor == .valid("true"))
|
||||||
#expect(try document("icon: 2026-07-26T16:41:38Z").icon == .valid("2026-07-26T16:41:38Z"))
|
#expect(try document("icon: 2026-07-26T16:41:38Z").icon == .valid("2026-07-26T16:41:38Z"))
|
||||||
}
|
}
|
||||||
@@ -575,7 +575,7 @@ struct FrontmatterLenientFieldTests {
|
|||||||
@Test func aTrailingCommentIsNotPartOfACoercedValue() throws {
|
@Test func aTrailingCommentIsNotPartOfACoercedValue() throws {
|
||||||
#expect(try document("title: 2048 # note").title == .valid("2048"))
|
#expect(try document("title: 2048 # note").title == .valid("2048"))
|
||||||
#expect(try document("title: true # note").title == .valid("true"))
|
#expect(try document("title: true # note").title == .valid("true"))
|
||||||
#expect(try document("background: 42\t# tabbed").background == .valid("42"))
|
#expect(try document("background: {color: 42}\t# tabbed").background == .valid("42"))
|
||||||
#expect(try document("icon: 2026-07-26T16:41:38Z # when").icon == .valid("2026-07-26T16:41:38Z"))
|
#expect(try document("icon: 2026-07-26T16:41:38Z # when").icon == .valid("2026-07-26T16:41:38Z"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,8 +583,8 @@ struct FrontmatterLenientFieldTests {
|
|||||||
/// at all, so a read must not treat it as a comment.
|
/// at all, so a read must not treat it as a comment.
|
||||||
@Test func aHashInsideAQuotedValueIsNotTrimmed() throws {
|
@Test func aHashInsideAQuotedValueIsNotTrimmed() throws {
|
||||||
#expect(try document("title: \"2048 # note\"").title == .valid("2048 # note"))
|
#expect(try document("title: \"2048 # note\"").title == .valid("2048 # note"))
|
||||||
#expect(try document("background: \"#ff8800\"").background == .valid("#ff8800"))
|
#expect(try document("background: {color: \"#ff8800\"}").background == .valid("#ff8800"))
|
||||||
#expect(try document("background: \"#ff8800\" # brand orange").background == .valid("#ff8800"))
|
#expect(try document("background: {color: \"#ff8800\"} # brand orange").background == .valid("#ff8800"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A sequence or mapping has no scalar reading at all; the raw text it falls back to stops at
|
/// A sequence or mapping has no scalar reading at all; the raw text it falls back to stops at
|
||||||
@@ -608,6 +608,115 @@ struct FrontmatterLenientFieldTests {
|
|||||||
#expect(try document("title: [a, b]").title == .malformed(raw: "[a, b]"))
|
#expect(try document("title: [a, b]").title == .malformed(raw: "[a, b]"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: `background` is a mapping and only a mapping
|
||||||
|
|
||||||
|
/// **The retired scalar** (01-storage-format.md § Frontmatter, ruled 2026-08-06 before anything
|
||||||
|
/// shipped — one shape, no legacy spelling, no migration): `background: green` has no reading at
|
||||||
|
/// all. It is `.malformed` like any other unreadable value, which renders as no colour and
|
||||||
|
/// leaves the bytes exactly as written.
|
||||||
|
@Test func aScalarBackgroundHasNoReading() throws {
|
||||||
|
#expect(try document("background: green").background == .malformed(raw: "green"))
|
||||||
|
#expect(try document("background: \"#ff8800\"").background == .malformed(raw: "\"#ff8800\""))
|
||||||
|
#expect(try document("background: 42").background == .malformed(raw: "42"))
|
||||||
|
// The raw stops at the comment like every other read, since a comment is the line's.
|
||||||
|
#expect(try document("background: green # my colour").background == .malformed(raw: "green"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One unreadable value is reported **once**: the colour reading owns the key's shape, and the
|
||||||
|
/// image stays silent about a file that never wrote a mapping to name a picture in.
|
||||||
|
@Test func onlyTheColourReportsANonMappingShape() throws {
|
||||||
|
#expect(try document("background: green").backgroundImage == .missing)
|
||||||
|
#expect(try document("background: [red, blue]").backgroundImage == .missing)
|
||||||
|
#expect(try document("background: 42").backgroundImage == .missing)
|
||||||
|
#expect(try document("schema: 1").backgroundImage == .missing)
|
||||||
|
#expect(try document("background: green").coercedFields
|
||||||
|
== [CoercedField(key: "background", raw: "green")])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mapping form, both halves present — flow and block spellings are one YAML value and
|
||||||
|
/// therefore one reading.
|
||||||
|
@Test func aMappingBackgroundReadsBothSubkeys() throws {
|
||||||
|
let flow = try document("background: {color: \"#112233\", image: sunset.jpg}")
|
||||||
|
#expect(flow.background == .valid("#112233"))
|
||||||
|
#expect(flow.backgroundImage == .valid("sunset.jpg"))
|
||||||
|
|
||||||
|
let block = try FrontmatterDocument.parse(
|
||||||
|
"---\nbackground:\n color: fern\n image: art/sunset.jpg\n---\nbody\n"
|
||||||
|
)
|
||||||
|
#expect(block.background == .valid("fern"))
|
||||||
|
#expect(block.backgroundImage == .valid("art/sunset.jpg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Either half may be absent, and an absent half is `.missing` — not malformed. An image-only
|
||||||
|
/// background is a board with no colour, which is the level's default and not a fallback.
|
||||||
|
@Test func eitherSubkeyMayBeAbsent() throws {
|
||||||
|
let colorOnly = try document("background: {color: chalk}")
|
||||||
|
#expect(colorOnly.background == .valid("chalk"))
|
||||||
|
#expect(colorOnly.backgroundImage == .missing)
|
||||||
|
|
||||||
|
let imageOnly = try document("background: {image: sunset.jpg}")
|
||||||
|
#expect(imageOnly.background == .missing)
|
||||||
|
#expect(imageOnly.backgroundImage == .valid("sunset.jpg"))
|
||||||
|
|
||||||
|
let empty = try document("background: {}")
|
||||||
|
#expect(empty.background == .missing)
|
||||||
|
#expect(empty.backgroundImage == .missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An explicit null subkey reads exactly like an absent one — `FieldValue.missing` already
|
||||||
|
/// treats `background: null` that way, and a subkey is no different.
|
||||||
|
@Test func nullSubkeysReadAsMissing() throws {
|
||||||
|
let nulls = try document("background: {color: null, image: ~}")
|
||||||
|
#expect(nulls.background == .missing)
|
||||||
|
#expect(nulls.backgroundImage == .missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unknown subkeys are tolerated on the way in exactly as unknown *keys* are — they mean
|
||||||
|
/// nothing to either reading and cost it nothing.
|
||||||
|
@Test func unknownSubkeysAreTolerated() throws {
|
||||||
|
let extra = try document("background: {opacity: 0.5, color: fern, blend: multiply}")
|
||||||
|
#expect(extra.background == .valid("fern"))
|
||||||
|
#expect(extra.backgroundImage == .missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inside the mapping the subvalues coerce like any other scalar, quoted or not; a subvalue with
|
||||||
|
/// no scalar reading at all is malformed, quoting the subvalue rather than the whole span —
|
||||||
|
/// a subkey has no source span of its own to quote.
|
||||||
|
@Test func subkeyScalarsCoerceAndCollectionsAreMalformed() throws {
|
||||||
|
#expect(try document("background: {color: 42}").background == .valid("42"))
|
||||||
|
#expect(try document("background: {image: \"sun set.jpg\"}").backgroundImage == .valid("sun set.jpg"))
|
||||||
|
#expect(try document("background: {color: [a, b]}").background == .malformed(raw: "[a, b]"))
|
||||||
|
#expect(try document("background: {image: {a: 1}}").backgroundImage == .malformed(raw: "{a: 1}"))
|
||||||
|
// The other half of a mapping with one bad subkey still reads perfectly well.
|
||||||
|
#expect(try document("background: {color: [a, b], image: sunset.jpg}").backgroundImage == .valid("sunset.jpg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The coerce tier's trace covers both readings** (01-storage-format.md § Frontmatter: "every
|
||||||
|
/// silent recovery leaves a trace"). Both are filed under the key the schema spells, and are told
|
||||||
|
/// apart by the subvalue each quotes — so a mapping whose colour reads fine and whose image does
|
||||||
|
/// not is still visible.
|
||||||
|
@Test func bothBackgroundReadingsReachTheCoerceRecord() throws {
|
||||||
|
#expect(try document("background: {color: fern, image: [a, b]}").coercedFields
|
||||||
|
== [CoercedField(key: "background", raw: "[a, b]")])
|
||||||
|
#expect(try document("background: {color: [a], image: [b]}").coercedFields
|
||||||
|
== [CoercedField(key: "background", raw: "[a]"), CoercedField(key: "background", raw: "[b]")])
|
||||||
|
// A sequence is one unreadable value and is reported once — the image reading stays silent
|
||||||
|
// about a shape that never claimed to name one.
|
||||||
|
#expect(try document("background: [red, blue]").coercedFields
|
||||||
|
== [CoercedField(key: "background", raw: "[red, blue]")])
|
||||||
|
#expect(try document("background: {image: sunset.jpg}").coercedFields.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine reads the shape; it never rewrites it. A mapping background round-trips
|
||||||
|
/// byte-identically like every other value the app did not touch.
|
||||||
|
@Test func aMappingBackgroundRoundTripsVerbatim() throws {
|
||||||
|
let text = "---\nschema: 1\nbackground:\n color: fern\n image: art/sunset.jpg\n blend: multiply\n---\nbody\n"
|
||||||
|
let document = try FrontmatterDocument.parse(text)
|
||||||
|
#expect(document.serialized() == text)
|
||||||
|
#expect(document.background == .valid("fern"))
|
||||||
|
#expect(document.backgroundImage == .valid("art/sunset.jpg"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Only a fractional or non-numeric reading has no sensible width at all — a sequence,
|
/// Only a fractional or non-numeric reading has no sensible width at all — a sequence,
|
||||||
/// mapping, or scalar with no integer reading whatsoever stays malformed and renders as the
|
/// mapping, or scalar with no integer reading whatsoever stays malformed and renders as the
|
||||||
/// default 1.
|
/// default 1.
|
||||||
|
|||||||
@@ -837,7 +837,13 @@ struct UndoCommandSurfaceTests {
|
|||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation())
|
let controller = BoardToolbar.controller(
|
||||||
|
store: store,
|
||||||
|
search: BoardSearchPresentation(),
|
||||||
|
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!),
|
||||||
|
appearance: AppearanceStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!, apply: { _ in }),
|
||||||
|
session: DragSession()
|
||||||
|
)
|
||||||
let provider = FakeHistoryProvider()
|
let provider = FakeHistoryProvider()
|
||||||
let manager = BoardUndoManager(history: provider)
|
let manager = BoardUndoManager(history: provider)
|
||||||
let (window, hosted) = hostedWindow(manager)
|
let (window, hosted) = hostedWindow(manager)
|
||||||
@@ -888,7 +894,15 @@ struct UndoCommandSurfaceTests {
|
|||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let toolbar = BoardToolbar.controller(store: store, search: BoardSearchPresentation())
|
let zoomDomain = "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)"
|
||||||
|
defer { UserDefaults.standard.removePersistentDomain(forName: zoomDomain) }
|
||||||
|
let toolbar = BoardToolbar.controller(
|
||||||
|
store: store,
|
||||||
|
search: BoardSearchPresentation(),
|
||||||
|
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: zoomDomain)!),
|
||||||
|
appearance: AppearanceStore(defaults: UserDefaults(suiteName: zoomDomain + ".appearance")!, apply: { _ in }),
|
||||||
|
session: DragSession()
|
||||||
|
)
|
||||||
let provider = FakeHistoryProvider()
|
let provider = FakeHistoryProvider()
|
||||||
let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn })
|
let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn })
|
||||||
let (window, hosted) = hostedWindow(manager)
|
let (window, hosted) = hostedWindow(manager)
|
||||||
@@ -928,7 +942,13 @@ struct UndoCommandSurfaceTests {
|
|||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
let specs = BoardToolbar.specs(
|
||||||
|
store: store,
|
||||||
|
search: BoardSearchPresentation(),
|
||||||
|
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!),
|
||||||
|
appearance: AppearanceStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!, apply: { _ in }),
|
||||||
|
session: DragSession()
|
||||||
|
)
|
||||||
|
|
||||||
// Every other item mirrors its menu row's predicate; these two mirror the *mechanism*. A
|
// Every other item mirrors its menu row's predicate; these two mirror the *mechanism*. A
|
||||||
// spec-level `isEnabled` here would be a second answer able to disagree with the responder
|
// spec-level `isEnabled` here would be a second answer able to disagree with the responder
|
||||||
|
|||||||
@@ -460,11 +460,11 @@ struct ObjectKindWriteTests {
|
|||||||
try BoardWriter.updateIndex(
|
try BoardWriter.updateIndex(
|
||||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||||
operation: .style(title: nil)
|
operation: .style(title: nil)
|
||||||
) { $0.set(FrontmatterKeys.background, to: .string("fern")) }
|
) { $0.setStyleValue("fern", for: FrontmatterKeys.background) }
|
||||||
try BoardWriter.updateIndex(
|
try BoardWriter.updateIndex(
|
||||||
inItemFolder: fixture.url(Ident.lane1),
|
inItemFolder: fixture.url(Ident.lane1),
|
||||||
operation: .style(title: nil)
|
operation: .style(title: nil)
|
||||||
) { $0.set(FrontmatterKeys.background, to: .string("fern")) }
|
) { $0.setStyleValue("fern", for: FrontmatterKeys.background) }
|
||||||
|
|
||||||
#expect(try kind(of: "\(Ident.lane1)/\(Ident.card1)", in: fixture) == .valid("card"))
|
#expect(try kind(of: "\(Ident.lane1)/\(Ident.card1)", in: fixture) == .valid("card"))
|
||||||
#expect(try kind(of: Ident.lane1, in: fixture) == .valid("lane"))
|
#expect(try kind(of: Ident.lane1, in: fixture) == .valid("lane"))
|
||||||
@@ -498,7 +498,7 @@ struct ObjectKindWriteTests {
|
|||||||
let folder = try fixture.item("notes", Item.rich(order: "1024", title: "Hand-made"))
|
let folder = try fixture.item("notes", Item.rich(order: "1024", title: "Hand-made"))
|
||||||
|
|
||||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) {
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) {
|
||||||
$0.set(FrontmatterKeys.background, to: .string("fern"))
|
$0.setStyleValue("fern", for: FrontmatterKeys.background)
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(try kind(of: "notes", in: fixture) == .missing)
|
#expect(try kind(of: "notes", in: fixture) == .missing)
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// **Regression pins for the 2026-08-06 click-latency fix**, measured on a real hosted `BoardView`
|
||||||
|
/// with synthetic pointer events.
|
||||||
|
///
|
||||||
|
/// The defect: `LaneView`'s empty-space double-click was a second `.onTapGesture(count: 2)` stacked
|
||||||
|
/// over the single tap, and a sequential two-tap recogniser holds every single click on that
|
||||||
|
/// container — its own empty space and every card face it wraps — hostage to the system
|
||||||
|
/// double-click interval (500 ms here) while it disambiguates. Measured before the fix: ~475 ms
|
||||||
|
/// from click to selection, and ~464 ms from right-click to menu when the right-click followed a
|
||||||
|
/// left click. After: both a handful of milliseconds.
|
||||||
|
///
|
||||||
|
/// The fix moved the empty-space surfaces to a background layer behind the masonry (cards no
|
||||||
|
/// longer share a gesture path with any lane recogniser), replaced the two-tap recogniser with one
|
||||||
|
/// `.onTapGesture` branching on `PointerClick.count`, and gave the layer an empty-provider
|
||||||
|
/// `.onDrag` — without a drag source, macOS holds a subtree's primary clicks pending multi-click
|
||||||
|
/// disambiguation (the lone-click pin below is the tripwire for that regressing). The behavioral
|
||||||
|
/// halves are pinned alongside the latency:
|
||||||
|
/// - a card double-click opens the card window and creates **no** placeholder (the layer is not
|
||||||
|
/// the card's ancestor, so its create can never fire for a card's clicks),
|
||||||
|
/// - an empty-space double-click opens the placeholder **and keeps the lane selected** — the
|
||||||
|
/// pair's second click is the create alone, never also the toggle (`PointerClick.count`).
|
||||||
|
///
|
||||||
|
/// Events go through `NSApp.postEvent` and are drained via `NSApp.nextEvent` rather than
|
||||||
|
/// `window.sendEvent`, because `PointerClick` reads `NSApp.currentEvent` — which only the real
|
||||||
|
/// dequeue path populates — and because the dequeue path is the one real clicks take through the
|
||||||
|
/// hold-and-release machinery this suite exists to pin.
|
||||||
|
|
||||||
|
// MARK: - Fixture
|
||||||
|
|
||||||
|
private let laneCount = 6
|
||||||
|
private let cardsPerLane = 30
|
||||||
|
/// Lane 0 stays short so it has visible empty space to double-click.
|
||||||
|
private let shortLaneCards = 3
|
||||||
|
|
||||||
|
private func laneName(_ lane: Int) -> String {
|
||||||
|
String(format: "1%07d-1111-4111-8111-111111111111", lane)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cardName(_ lane: Int, _ card: Int) -> String {
|
||||||
|
String(format: "2%03d%04d-2222-4222-8222-222222222222", lane, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makeFixture() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.board(title: "Latency Board")
|
||||||
|
for lane in 0..<laneCount {
|
||||||
|
try fixture.lane(laneName(lane), order: "\((lane + 1) * 1024)", title: "Lane \(lane)")
|
||||||
|
let cards = lane == 0 ? shortLaneCards : cardsPerLane
|
||||||
|
for card in 0..<cards {
|
||||||
|
try fixture.card(
|
||||||
|
cardName(lane, card),
|
||||||
|
in: laneName(lane),
|
||||||
|
order: "\((card + 1) * 1024)",
|
||||||
|
title: "Card \(lane)-\(card)",
|
||||||
|
body: "Body text for card \(lane)-\(card)."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hosting (BoardRenderPerformanceTests' harness, with an openCard capture)
|
||||||
|
|
||||||
|
private struct ZoomedBoard: View {
|
||||||
|
let store: BoardStore
|
||||||
|
let window: @MainActor () -> NSWindow?
|
||||||
|
let confirmations: TrashConfirmations
|
||||||
|
let openCard: @MainActor (ItemID) -> Void
|
||||||
|
let search: BoardSearchPresentation
|
||||||
|
|
||||||
|
@Environment(AppModel.self) private var appModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
BoardView(
|
||||||
|
store: store,
|
||||||
|
window: window,
|
||||||
|
confirmations: confirmations,
|
||||||
|
openCard: openCard,
|
||||||
|
search: search
|
||||||
|
)
|
||||||
|
.environment(\.boardZoom, appModel.zoom.context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class HostedBoard {
|
||||||
|
let store: BoardStore
|
||||||
|
let appModel: AppModel
|
||||||
|
let window: NSWindow
|
||||||
|
let view: NSView
|
||||||
|
private(set) var openedCards: [ItemID] = []
|
||||||
|
private let scratch: URL
|
||||||
|
private let preferencesDomain: String
|
||||||
|
|
||||||
|
init(store: BoardStore, scratch: URL) {
|
||||||
|
self.store = store
|
||||||
|
self.scratch = scratch
|
||||||
|
preferencesDomain = "dev.rzen.indie.Kanban.pointer-latency.\(UUID().uuidString)"
|
||||||
|
appModel = AppModel(
|
||||||
|
registryStorageURL: scratch.appendingPathComponent("board-registry.json"),
|
||||||
|
clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true),
|
||||||
|
preferences: UserDefaults(suiteName: preferencesDomain)!
|
||||||
|
)
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 1600, height: 1000),
|
||||||
|
styleMask: [.titled], backing: .buffered, defer: false
|
||||||
|
)
|
||||||
|
self.window = window
|
||||||
|
var recordOpen: (ItemID) -> Void = { _ in }
|
||||||
|
let root = ZoomedBoard(
|
||||||
|
store: store,
|
||||||
|
window: { [weak window] in window },
|
||||||
|
confirmations: TrashConfirmations(),
|
||||||
|
openCard: { recordOpen($0) },
|
||||||
|
search: BoardSearchPresentation()
|
||||||
|
)
|
||||||
|
.environment(appModel)
|
||||||
|
let hosting = NSHostingView(rootView: root)
|
||||||
|
hosting.frame = NSRect(x: 0, y: 0, width: 1600, height: 1000)
|
||||||
|
view = hosting
|
||||||
|
window.contentView = hosting
|
||||||
|
window.orderBack(nil)
|
||||||
|
window.makeKey()
|
||||||
|
recordOpen = { [weak self] id in
|
||||||
|
MainActor.assumeIsolated { self?.openedCards.append(id) }
|
||||||
|
}
|
||||||
|
settle()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
window.orderOut(nil)
|
||||||
|
window.contentView = nil
|
||||||
|
try? FileManager.default.removeItem(at: scratch)
|
||||||
|
UserDefaults.standard.removePersistentDomain(forName: preferencesDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func settle(turns: Int = 6) {
|
||||||
|
for _ in 0..<turns {
|
||||||
|
RunLoop.main.run(until: Date().addingTimeInterval(0.02))
|
||||||
|
view.layoutSubtreeIfNeeded()
|
||||||
|
window.displayIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func host(_ fixture: WriterFixture) throws -> HostedBoard {
|
||||||
|
let scratch = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("PointerLatency-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)
|
||||||
|
return HostedBoard(store: try BoardStore(rootURL: fixture.root), scratch: scratch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event synthesis
|
||||||
|
|
||||||
|
/// Posts one mouse event into the application's queue — the queue, not `window.sendEvent`, so the
|
||||||
|
/// dequeue below stamps it as `NSApp.currentEvent` the way a real click is.
|
||||||
|
@MainActor
|
||||||
|
private func post(_ type: NSEvent.EventType, at p: NSPoint, in window: NSWindow, clicks: Int) {
|
||||||
|
let event = NSEvent.mouseEvent(
|
||||||
|
with: type, location: p, modifierFlags: [],
|
||||||
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||||
|
windowNumber: window.windowNumber, context: nil,
|
||||||
|
eventNumber: Int.random(in: 1...999_999), clickCount: clicks, pressure: 1
|
||||||
|
)!
|
||||||
|
NSApp.postEvent(event, atStart: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drains the queue through the real dequeue-and-dispatch path, then gives SwiftUI a turn.
|
||||||
|
@MainActor
|
||||||
|
private func pump(_ seconds: TimeInterval) {
|
||||||
|
let deadline = Date().addingTimeInterval(seconds)
|
||||||
|
repeat {
|
||||||
|
while let event = NSApp.nextEvent(matching: .any, until: .distantPast, inMode: .default, dequeue: true) {
|
||||||
|
NSApp.sendEvent(event)
|
||||||
|
}
|
||||||
|
RunLoop.main.run(until: min(Date().addingTimeInterval(0.004), deadline))
|
||||||
|
} while Date() < deadline
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One full click — down then up, drained after each half.
|
||||||
|
@MainActor
|
||||||
|
private func click(at p: NSPoint, in window: NSWindow, clicks: Int = 1) {
|
||||||
|
post(.leftMouseDown, at: p, in: window, clicks: clicks)
|
||||||
|
pump(0.02)
|
||||||
|
post(.leftMouseUp, at: p, in: window, clicks: clicks)
|
||||||
|
pump(0.02)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pumps until `condition` holds; returns elapsed ms, or nil on timeout.
|
||||||
|
@MainActor
|
||||||
|
private func waitFor(_ timeout: TimeInterval, condition: () -> Bool) -> Double? {
|
||||||
|
let t0 = CACurrentMediaTime()
|
||||||
|
while CACurrentMediaTime() - t0 < timeout {
|
||||||
|
pump(0.004)
|
||||||
|
if condition() { return (CACurrentMediaTime() - t0) * 1000 }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `waitFor`, with the pointer resting live near `p`: posts a `.mouseMoved` with 1 pt of jitter
|
||||||
|
/// every ~30 ms, the micro-motion a real pointer always emits. AppKit's held-event machinery
|
||||||
|
/// resolves pending click disambiguation off the *timestamps of subsequent events* — a perfectly
|
||||||
|
/// sterile queue can defer a held click forever, which no real event stream ever does.
|
||||||
|
@MainActor
|
||||||
|
private func waitForWithMotion(
|
||||||
|
at p: NSPoint, in window: NSWindow, timeout: TimeInterval, condition: () -> Bool
|
||||||
|
) -> Double? {
|
||||||
|
let t0 = CACurrentMediaTime()
|
||||||
|
var lastMove = t0
|
||||||
|
var jitter = false
|
||||||
|
while CACurrentMediaTime() - t0 < timeout {
|
||||||
|
pump(0.004)
|
||||||
|
if condition() { return (CACurrentMediaTime() - t0) * 1000 }
|
||||||
|
if CACurrentMediaTime() - lastMove > 0.03 {
|
||||||
|
lastMove = CACurrentMediaTime()
|
||||||
|
jitter.toggle()
|
||||||
|
let moved = NSPoint(x: p.x + (jitter ? 1 : 0), y: p.y)
|
||||||
|
let event = NSEvent.mouseEvent(
|
||||||
|
with: .mouseMoved, location: moved, modifierFlags: [],
|
||||||
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||||
|
windowNumber: window.windowNumber, context: nil,
|
||||||
|
eventNumber: Int.random(in: 1...999_999), clickCount: 0, pressure: 0
|
||||||
|
)!
|
||||||
|
NSApp.postEvent(event, atStart: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The menu-tracking observer's mailbox — statics because the notification closure is @Sendable.
|
||||||
|
private enum MenuProbe {
|
||||||
|
nonisolated(unsafe) static var beganAt: CFTimeInterval?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Probe points
|
||||||
|
|
||||||
|
/// Lane 0's first card sits near the strip's top-leading corner; found empirically by the
|
||||||
|
/// investigation probe and pinned by `#require` on the selection it produces.
|
||||||
|
private let cardPoint = NSPoint(x: 90, y: 1000 - 90)
|
||||||
|
|
||||||
|
/// Finds a point whose click selects lane 0 itself — its empty space. Probed rather than
|
||||||
|
/// hard-coded, so the pin does not depend on how far the lane's click surface happens to extend
|
||||||
|
/// below its cards on any given layout.
|
||||||
|
@MainActor
|
||||||
|
private func findEmptySpacePoint(on board: HostedBoard) -> NSPoint? {
|
||||||
|
let laneID = ItemID(rawValue: laneName(0))
|
||||||
|
for x in stride(from: 60, through: 240, by: 60) {
|
||||||
|
for yTop in stride(from: 500, through: 120, by: -60) {
|
||||||
|
let p = NSPoint(x: CGFloat(x), y: 1000 - CGFloat(yTop))
|
||||||
|
click(at: p, in: board.window, clicks: 1)
|
||||||
|
_ = waitFor(0.4) { !board.store.selection.isEmpty }
|
||||||
|
let hit = board.store.selection.ids == [laneID]
|
||||||
|
board.store.clearSelection()
|
||||||
|
pump(0.2)
|
||||||
|
if hit { return p }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The pins
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Pointer latency on a hosted board", .serialized)
|
||||||
|
struct PointerLatencyTests {
|
||||||
|
|
||||||
|
@Test("A click on a card selects it without the double-click wait")
|
||||||
|
func cardClickSelectsWithoutTheDoubleClickWait() throws {
|
||||||
|
let fixture = try makeFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let board = try host(fixture)
|
||||||
|
let store = board.store
|
||||||
|
|
||||||
|
post(.leftMouseDown, at: cardPoint, in: board.window, clicks: 1)
|
||||||
|
pump(0.02)
|
||||||
|
post(.leftMouseUp, at: cardPoint, in: board.window, clicks: 1)
|
||||||
|
let latency = try #require(
|
||||||
|
waitFor(2.0) { !store.selection.isEmpty },
|
||||||
|
"the click never selected anything — did the layout move under the probe point?"
|
||||||
|
)
|
||||||
|
// Which card the point lands on is the masonry's business (column-major, two columns at
|
||||||
|
// standard width) — what matters is that it is *a card*, instantly. Fixture card ids all
|
||||||
|
// start with "2", lanes with "1".
|
||||||
|
let selected = try #require(store.selection.ids.first)
|
||||||
|
#expect(selected.rawValue.hasPrefix("2"),
|
||||||
|
"the probe point should land on a card, selected \(store.selection.ids)")
|
||||||
|
|
||||||
|
// The defect measured ~475 ms here — the system double-click interval leaking into every
|
||||||
|
// single click. The bound is generous headroom over the healthy ~5 ms, far under the
|
||||||
|
// interval it must never re-approach.
|
||||||
|
#expect(latency < 250, "click → selection took \(Int(latency)) ms")
|
||||||
|
print(String(format: "── click → selection: %.0f ms", latency))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A card double-click opens the card window and creates no placeholder")
|
||||||
|
func cardDoubleClickOpensTheWindow() throws {
|
||||||
|
let fixture = try makeFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let board = try host(fixture)
|
||||||
|
let store = board.store
|
||||||
|
|
||||||
|
click(at: cardPoint, in: board.window, clicks: 1)
|
||||||
|
_ = waitFor(1.0) { !store.selection.isEmpty }
|
||||||
|
let target = try #require(store.selection.ids.first,
|
||||||
|
"the pair's first click should select the card under the point")
|
||||||
|
click(at: cardPoint, in: board.window, clicks: 2)
|
||||||
|
_ = waitFor(1.0) { !board.openedCards.isEmpty }
|
||||||
|
board.settle(turns: 2)
|
||||||
|
|
||||||
|
#expect(board.openedCards == [target],
|
||||||
|
"the double-click should open exactly the clicked card, opened \(board.openedCards)")
|
||||||
|
// The empty-space layer is a background sibling of the masonry, not the card's ancestor —
|
||||||
|
// structurally, a card's clicks can never reach its create. This pins that structure.
|
||||||
|
#expect(store.transient.newCardPlaceholder == nil,
|
||||||
|
"a double-click on a card must not open the lane's placeholder")
|
||||||
|
#expect(store.selection.ids.contains(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An empty-space double-click opens the placeholder and keeps the lane selected")
|
||||||
|
func emptySpaceDoubleClickCreatesThePlaceholder() throws {
|
||||||
|
let fixture = try makeFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let board = try host(fixture)
|
||||||
|
let store = board.store
|
||||||
|
let emptySpacePoint = try #require(findEmptySpacePoint(on: board),
|
||||||
|
"no probe point selected lane 0's empty space")
|
||||||
|
|
||||||
|
// A lone empty-space click first: the lane's own selection latency, the defect's original
|
||||||
|
// surface. Measured ~475 ms before the fix — and unbounded on a surface that carries a
|
||||||
|
// two-tap recogniser without a drag source, which is why the layer branches one tap on
|
||||||
|
// `PointerClick.count` instead. The pointer rests live near the click, as a real one does.
|
||||||
|
post(.leftMouseDown, at: emptySpacePoint, in: board.window, clicks: 1)
|
||||||
|
pump(0.02)
|
||||||
|
post(.leftMouseUp, at: emptySpacePoint, in: board.window, clicks: 1)
|
||||||
|
let lone = try #require(
|
||||||
|
waitForWithMotion(at: emptySpacePoint, in: board.window, timeout: 2.0) { !store.selection.isEmpty },
|
||||||
|
"a lone empty-space click never selected the lane"
|
||||||
|
)
|
||||||
|
print(String(format: "── lone empty-space click → lane selected: %.0f ms", lone))
|
||||||
|
#expect(lone < 250, "empty-space click → selection took \(Int(lone)) ms")
|
||||||
|
#expect(store.selection.ids == [ItemID(rawValue: laneName(0))])
|
||||||
|
store.clearSelection()
|
||||||
|
pump(0.8)
|
||||||
|
|
||||||
|
// The pair: first click selects, second creates — and the first click's selection
|
||||||
|
// survives, because the second click is the create alone, never also the toggle.
|
||||||
|
click(at: emptySpacePoint, in: board.window, clicks: 1)
|
||||||
|
let firstClick = try #require(waitFor(0.5) { !store.selection.isEmpty },
|
||||||
|
"the pair's first click should select the lane")
|
||||||
|
print(String(format: "── pair's first click → lane selected: %.0f ms", firstClick))
|
||||||
|
click(at: emptySpacePoint, in: board.window, clicks: 2)
|
||||||
|
_ = waitFor(1.0) { store.transient.newCardPlaceholder != nil }
|
||||||
|
board.settle(turns: 2)
|
||||||
|
|
||||||
|
let placeholder = try #require(store.transient.newCardPlaceholder,
|
||||||
|
"the empty-space double-click should open the placeholder")
|
||||||
|
#expect(placeholder.laneID == ItemID(rawValue: laneName(0)))
|
||||||
|
// The first click of the pair selected the lane; the second is the create alone
|
||||||
|
// (`PointerClick.count` branches it away from the toggle), so the selection survives.
|
||||||
|
#expect(store.selection.ids == [ItemID(rawValue: laneName(0))],
|
||||||
|
"the pair's first click's selection should survive, selection \(store.selection.ids)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A right-click on the heels of a left click reaches its menu without the wait")
|
||||||
|
func rightClickMenuAfterAClick() throws {
|
||||||
|
let fixture = try makeFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let board = try host(fixture)
|
||||||
|
let store = board.store
|
||||||
|
|
||||||
|
let observer = NotificationCenter.default.addObserver(
|
||||||
|
forName: NSMenu.didBeginTrackingNotification, object: nil, queue: nil
|
||||||
|
) { note in
|
||||||
|
if MenuProbe.beganAt == nil { MenuProbe.beganAt = CACurrentMediaTime() }
|
||||||
|
guard let menu = note.object as? NSMenu else { return }
|
||||||
|
nonisolated(unsafe) let pending = menu
|
||||||
|
// .common fires during menu tracking, which is what ends it — without this the
|
||||||
|
// dispatch below never returns.
|
||||||
|
let timer = Timer(timeInterval: 0.1, repeats: false) { _ in
|
||||||
|
pending.cancelTrackingWithoutAnimation()
|
||||||
|
}
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
}
|
||||||
|
defer { NotificationCenter.default.removeObserver(observer) }
|
||||||
|
|
||||||
|
func rightClick(_ label: String) -> Double? {
|
||||||
|
MenuProbe.beganAt = nil
|
||||||
|
let armed = CACurrentMediaTime()
|
||||||
|
post(.rightMouseDown, at: cardPoint, in: board.window, clicks: 1)
|
||||||
|
pump(0.02)
|
||||||
|
post(.rightMouseUp, at: cardPoint, in: board.window, clicks: 1)
|
||||||
|
_ = waitFor(2.0) { MenuProbe.beganAt != nil }
|
||||||
|
let latency = MenuProbe.beganAt.map { ($0 - armed) * 1000 }
|
||||||
|
print(latency.map { String(format: "── right-click → menu (%@): %.0f ms", label, $0) }
|
||||||
|
?? "── right-click → menu (\(label)): NEVER")
|
||||||
|
return latency
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first menu open in a process pays a one-time AppKit warmup (~350 ms) — spent here,
|
||||||
|
// unasserted, so the pinned figures below measure the steady state users live in.
|
||||||
|
_ = rightClick("cold, first menu in the process")
|
||||||
|
pump(1.5)
|
||||||
|
for gap in [0.1, 0.4, 0.8] {
|
||||||
|
click(at: cardPoint, in: board.window, clicks: 1)
|
||||||
|
_ = waitFor(1.0) { !store.selection.isEmpty }
|
||||||
|
pump(gap)
|
||||||
|
let label = String(format: "%.1f s after a left click", gap)
|
||||||
|
let latency = try #require(rightClick(label), "the context menu never began tracking")
|
||||||
|
// ~464 ms before the fix: pending click disambiguation deferred the menu too.
|
||||||
|
#expect(latency < 250, "right-click → menu (\(label)) took \(Int(latency)) ms")
|
||||||
|
store.clearSelection()
|
||||||
|
pump(1.2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,7 +96,7 @@ struct RestingLayoutCacheTests {
|
|||||||
|
|
||||||
let layout = cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])
|
let layout = cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])
|
||||||
|
|
||||||
let nominal = LaneDropRegistry.nominalCardHeight
|
let nominal = registry.nominalCardHeight
|
||||||
#expect(layout?.heights == [40, nominal, nominal])
|
#expect(layout?.heights == [40, nominal, nominal])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ struct RestingLayoutRegroundingTests {
|
|||||||
// uncached path left it.
|
// uncached path left it.
|
||||||
let reground = cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])
|
let reground = cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])
|
||||||
#expect(reground?.cardIDs == [card1, foreignCard, card2, card3])
|
#expect(reground?.cardIDs == [card1, foreignCard, card2, card3])
|
||||||
#expect(reground?.heights == [40, LaneDropRegistry.nominalCardHeight, 60, 80])
|
#expect(reground?.heights == [40, registry.nominalCardHeight, 60, 80])
|
||||||
#expect(cache.builds == 2)
|
#expect(cache.builds == 2)
|
||||||
#expect(cache.reuses == 0)
|
#expect(cache.reuses == 0)
|
||||||
}
|
}
|
||||||
@@ -288,7 +288,7 @@ struct RestingLayoutMeasurementTests {
|
|||||||
|
|
||||||
registry.removeHeight(card3)
|
registry.removeHeight(card3)
|
||||||
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])?.heights
|
#expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])?.heights
|
||||||
== [40, 60, LaneDropRegistry.nominalCardHeight])
|
== [40, 60, registry.nominalCardHeight])
|
||||||
#expect(cache.builds == 2)
|
#expect(cache.builds == 2)
|
||||||
|
|
||||||
// A second removal of the same card is not a change, and must not cost a rebuild.
|
// A second removal of the same card is not a change, and must not cost a rebuild.
|
||||||
@@ -312,7 +312,7 @@ struct RestingLayoutMeasurementTests {
|
|||||||
|
|
||||||
#expect(cache.layout(inLane: lane1, of: store, registry: settled, hidden: [])?.heights
|
#expect(cache.layout(inLane: lane1, of: store, registry: settled, hidden: [])?.heights
|
||||||
== [40, 60, 80])
|
== [40, 60, 80])
|
||||||
let nominal = LaneDropRegistry.nominalCardHeight
|
let nominal = opening.nominalCardHeight
|
||||||
#expect(cache.layout(inLane: lane1, of: store, registry: opening, hidden: [])?.heights
|
#expect(cache.layout(inLane: lane1, of: store, registry: opening, hidden: [])?.heights
|
||||||
== [nominal, nominal, nominal])
|
== [nominal, nominal, nominal])
|
||||||
#expect(cache.builds == 2)
|
#expect(cache.builds == 2)
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ private func makeBoard() throws -> WriterFixture {
|
|||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
try fixture.item("", boardIndex)
|
try fixture.item("", boardIndex)
|
||||||
try fixture.item(Ident.lane1, styled(order: "1024", title: "Todo"))
|
try fixture.item(Ident.lane1, styled(order: "1024", title: "Todo"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: fern", "iconColor: chalk"]))
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: {color: fern}", "iconColor: chalk"]))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second"))
|
||||||
try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: chalk", "icon: tray"]))
|
try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: {color: chalk}", "icon: tray"]))
|
||||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third"))
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third"))
|
||||||
try fixture.item(Ident.lane3, Item.uneditable)
|
try fixture.item(Ident.lane3, Item.uneditable)
|
||||||
try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone"))
|
try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone"))
|
||||||
@@ -120,7 +120,7 @@ struct StyleWriteTests {
|
|||||||
store.applyStyle(to: .items([card2]), background: .set("smokey-ocean"))
|
store.applyStyle(to: .items([card2]), background: .set("smokey-ocean"))
|
||||||
|
|
||||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
||||||
#expect(after.contains("background: smokey-ocean"))
|
#expect(after.contains("background: {color: \"smokey-ocean\"}"))
|
||||||
#expect(!after.contains("icon:"), "the untouched dimension writes no key at all")
|
#expect(!after.contains("icon:"), "the untouched dimension writes no key at all")
|
||||||
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
|
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
|
||||||
#expect(untouchedLines(after) == untouchedLines(before))
|
#expect(untouchedLines(after) == untouchedLines(before))
|
||||||
@@ -141,13 +141,13 @@ struct StyleWriteTests {
|
|||||||
store.applyStyle(to: .items([card1]), background: .set("dark-teal"), icon: .set("flag"))
|
store.applyStyle(to: .items([card1]), background: .set("dark-teal"), icon: .set("flag"))
|
||||||
|
|
||||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||||
#expect(after.contains("background: dark-teal"))
|
#expect(after.contains("background: {color: \"dark-teal\"}"))
|
||||||
#expect(after.contains("icon: flag"))
|
#expect(after.contains("icon: flag"))
|
||||||
// "iconColor: resolved — schema yes, control no" (03 § Styling ▸ Capabilities): the field
|
// "iconColor: resolved — schema yes, control no" (03 § Styling ▸ Capabilities): the field
|
||||||
// renders when hand-written and the app offers no control for it, so a style write must
|
// renders when hand-written and the app offers no control for it, so a style write must
|
||||||
// carry it through untouched like any unknown key.
|
// carry it through untouched like any unknown key.
|
||||||
#expect(after.contains("iconColor: chalk"))
|
#expect(after.contains("iconColor: chalk"))
|
||||||
#expect(!after.contains("background: fern"), "the old value is replaced, not duplicated")
|
#expect(!after.contains("fern"), "the old value is replaced, not duplicated")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("The None and default wells remove their key rather than writing a blank value")
|
@Test("The None and default wells remove their key rather than writing a blank value")
|
||||||
@@ -182,8 +182,8 @@ struct StyleWriteTests {
|
|||||||
|
|
||||||
#expect(log.begins == 1)
|
#expect(log.begins == 1)
|
||||||
#expect(log.ends == 1)
|
#expect(log.ends == 1)
|
||||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("background: light-cayenne"))
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("background: {color: \"light-cayenne\"}"))
|
||||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: light-cayenne"))
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: {color: \"light-cayenne\"}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A value a target already carries writes nothing — per target and per dimension")
|
@Test("A value a target already carries writes nothing — per target and per dimension")
|
||||||
@@ -195,13 +195,13 @@ struct StyleWriteTests {
|
|||||||
log.attach(to: store)
|
log.attach(to: store)
|
||||||
let untouchedCard = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
let untouchedCard = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
|
||||||
// `card1` is already `fern` and `card2` has no background at all: only the second file may
|
// `card1` is already `{color: fern}` and `card2` has no background at all: only the second file may
|
||||||
// move. A well clicked twice must not stamp `modified` or mint a commit on what was already
|
// move. A well clicked twice must not stamp `modified` or mint a commit on what was already
|
||||||
// right (`setLaneWidth`'s rule).
|
// right (`setLaneWidth`'s rule).
|
||||||
store.applyStyle(to: .items([card1, card2]), background: .set("fern"))
|
store.applyStyle(to: .items([card1, card2]), background: .set("fern"))
|
||||||
|
|
||||||
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == untouchedCard)
|
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == untouchedCard)
|
||||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: fern"))
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: {color: \"fern\"}"))
|
||||||
#expect(log.begins == 1, "the batch still opens exactly one bracket for the target that moved")
|
#expect(log.begins == 1, "the batch still opens exactly one bracket for the target that moved")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ struct StyleWriteTests {
|
|||||||
log.attach(to: store)
|
log.attach(to: store)
|
||||||
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
|
||||||
// Both dimensions already read this way: `background: fern` is set and `icon` is absent, so
|
// Both dimensions already read this way: the colour is already `fern` and `icon` is absent, so
|
||||||
// the removal is a no-op too.
|
// the removal is a no-op too.
|
||||||
store.applyStyle(to: .items([card1]), background: .set("fern"), icon: .remove)
|
store.applyStyle(to: .items([card1]), background: .set("fern"), icon: .remove)
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ struct StyleWriteTests {
|
|||||||
store.applyStyle(to: .items([card2]), background: .set("shale"))
|
store.applyStyle(to: .items([card2]), background: .set("shale"))
|
||||||
|
|
||||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
||||||
#expect(after.contains("background: shale"))
|
#expect(after.contains("background: {color: \"shale\"}"))
|
||||||
#expect(!after.contains("[a, b]"))
|
#expect(!after.contains("[a, b]"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@ struct StyleWriteTests {
|
|||||||
store.applyStyle(to: .board, background: .set("intense-cool-shale"), icon: .set("square.stack"))
|
store.applyStyle(to: .board, background: .set("intense-cool-shale"), icon: .set("square.stack"))
|
||||||
|
|
||||||
let after = try fixture.indexText("")
|
let after = try fixture.indexText("")
|
||||||
#expect(after.contains("background: intense-cool-shale"))
|
#expect(after.contains("background: {color: \"intense-cool-shale\"}"))
|
||||||
#expect(after.contains("icon: square.stack"))
|
#expect(after.contains("icon: square.stack"))
|
||||||
#expect(after.contains("iconColor: carnation"))
|
#expect(after.contains("iconColor: carnation"))
|
||||||
#expect(after.contains("Board description."))
|
#expect(after.contains("Board description."))
|
||||||
@@ -258,6 +258,38 @@ struct StyleWriteTests {
|
|||||||
#expect(lane(lane1, in: model)?.background.isMissing == true)
|
#expect(lane(lane1, in: model)?.background.isMissing == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The mapping form, through the real writer** (03-board-ui.md § Styling ▸ Capabilities; the
|
||||||
|
/// unit-level claims are `BackgroundWriteTests`'). The app has a control for the colour and none
|
||||||
|
/// for the image, so the whole gesture — well, then None — has to leave the image standing.
|
||||||
|
@Test("A colour change on a board carrying an image preserves the image, and None drops only the colour")
|
||||||
|
func preservesABackgroundImage() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", """
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: Board
|
||||||
|
background: {color: fern, image: art/sunset.jpg}
|
||||||
|
---
|
||||||
|
Board description.
|
||||||
|
|
||||||
|
""")
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.applyStyle(to: .board, background: .set("dark-teal"))
|
||||||
|
|
||||||
|
var model = try load(fixture)
|
||||||
|
#expect(model.background == .valid("dark-teal"))
|
||||||
|
#expect(model.backgroundImage == .valid("art/sunset.jpg"))
|
||||||
|
#expect(try fixture.indexText("").contains("Board description."))
|
||||||
|
|
||||||
|
store.applyStyle(to: .board, background: .remove)
|
||||||
|
|
||||||
|
model = try load(fixture)
|
||||||
|
#expect(model.background.isMissing)
|
||||||
|
#expect(model.backgroundImage == .valid("art/sunset.jpg"), "the None well removes the colour, not the picture")
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Vanished and trashed targets are skipped silently")
|
@Test("Vanished and trashed targets are skipped silently")
|
||||||
func skipsTargetsThatRenderNowhere() throws {
|
func skipsTargetsThatRenderNowhere() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+185
-23
@@ -31,6 +31,44 @@ private extension Array where Element == ToolbarItemSpec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A zoom store on a scratch defaults domain — the level is app-wide and persisted, so a suite that
|
||||||
|
/// used `.standard` would zoom the developer's own boards (`StyleRecents`' injection, for its reason).
|
||||||
|
@MainActor
|
||||||
|
private func makeZoom(level: CGFloat = BoardZoom.actualSize) -> BoardZoomStore {
|
||||||
|
let name = "dev.rzen.indie.Kanban.toolbar-tests.\(UUID().uuidString)"
|
||||||
|
let store = BoardZoomStore(defaults: UserDefaults(suiteName: name)!)
|
||||||
|
store.setLevel(level)
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An appearance store on a scratch defaults domain, apply seam stubbed out — this suite's subject is
|
||||||
|
/// the toolbar item, not the store itself (`AppearanceTests.swift` owns that), so nothing here should
|
||||||
|
/// touch `NSApp` (`makeZoom`'s reason, turned toward AppKit).
|
||||||
|
@MainActor
|
||||||
|
private func makeAppearance() -> AppearanceStore {
|
||||||
|
let name = "dev.rzen.indie.Kanban.toolbar-tests.appearance.\(UUID().uuidString)"
|
||||||
|
return AppearanceStore(defaults: UserDefaults(suiteName: name)!, apply: { _ in })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The board catalog, with the three collaborators every test here supplies the same way: a fresh
|
||||||
|
/// zoom store, a fresh appearance store, and a drag session with nothing in flight.
|
||||||
|
@MainActor
|
||||||
|
private func boardSpecs(
|
||||||
|
store: BoardStore,
|
||||||
|
search: BoardSearchPresentation = BoardSearchPresentation(),
|
||||||
|
zoom: BoardZoomStore? = nil,
|
||||||
|
appearance: AppearanceStore? = nil,
|
||||||
|
session: DragSession = DragSession()
|
||||||
|
) -> [ToolbarItemSpec] {
|
||||||
|
BoardToolbar.specs(
|
||||||
|
store: store,
|
||||||
|
search: search,
|
||||||
|
zoom: zoom ?? makeZoom(),
|
||||||
|
appearance: appearance ?? makeAppearance(),
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - The vocabulary
|
// MARK: - The vocabulary
|
||||||
|
|
||||||
/// **Toolbar item labels are menu titles minus a trailing ellipsis** (03-board-ui.md ▸ Toolbar) —
|
/// **Toolbar item labels are menu titles minus a trailing ellipsis** (03-board-ui.md ▸ Toolbar) —
|
||||||
@@ -77,36 +115,119 @@ struct ToolbarVocabularyTests {
|
|||||||
@Suite("Toolbar ▸ the board window")
|
@Suite("Toolbar ▸ the board window")
|
||||||
struct BoardToolbarTests {
|
struct BoardToolbarTests {
|
||||||
|
|
||||||
@Test("The default set is the search field, trailing, and nothing else")
|
@Test("The default set is the search field and Appearance, trailing, and nothing else")
|
||||||
func defaultsAreTheSearchFieldAlone() {
|
func defaultsAreTheSearchFieldAndAppearance() {
|
||||||
// "Board window default: the search field, nothing else — trailing, the one default item;
|
// "Board window default: the search field and Appearance — both centered, immediately after
|
||||||
// the titlebar stays clean." The flexible space ahead of it is what "trailing" means to
|
// the field." The flexible space ahead of them is what keeps the pair off the leading edge, so
|
||||||
// NSToolbar, so the *items* in the default set are exactly one.
|
// the *items* in the default set are exactly two.
|
||||||
#expect(BoardToolbar.defaultItems == [.flexibleSpace, .boardSearch])
|
#expect(BoardToolbar.defaultItems == [.flexibleSpace, .boardSearch, .boardAppearance])
|
||||||
#expect(BoardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [.boardSearch])
|
#expect(BoardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [.boardSearch, .boardAppearance])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("The catalog is 03's five commands plus the field — and the board popover is not in it")
|
@Test("The catalog is 03's eight commands plus the field — and the board popover is not in it")
|
||||||
func catalogIsTheDesignsInventory() throws {
|
func catalogIsTheDesignsInventory() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
let specs = boardSpecs(store: store)
|
||||||
|
|
||||||
// "Catalog (available via Customize): New Card, New Lane, Undo, Redo …, Show Trash" — plus
|
// "Catalog (available via Customize): New Card, New Lane, Zoom In, Zoom Out …, Undo, Redo …,
|
||||||
// the search field, which is a catalog item too (a user who removes it can put it back).
|
// Show Trash, Appearance" — plus the search field, which is a catalog item too (a user who
|
||||||
|
// removes it can put it back).
|
||||||
#expect(specs.map(\.identifier) == [
|
#expect(specs.map(\.identifier) == [
|
||||||
.boardNewCard,
|
.boardNewCard,
|
||||||
.boardNewLane,
|
.boardNewLane,
|
||||||
|
.boardZoomIn,
|
||||||
|
.boardZoomOut,
|
||||||
.boardUndo,
|
.boardUndo,
|
||||||
.boardRedo,
|
.boardRedo,
|
||||||
.boardShowTrash,
|
.boardShowTrash,
|
||||||
|
.boardAppearance,
|
||||||
.boardSearch,
|
.boardSearch,
|
||||||
])
|
])
|
||||||
// "The board popover deliberately has no toolbar item — the window-title widget is its
|
// "The board popover deliberately has no toolbar item — the window-title widget is its
|
||||||
// committed home, and a second entry would muddy it." Absence is a settlement, so it is
|
// committed home, and a second entry would muddy it." Absence is a settlement, so it is
|
||||||
// pinned by the exact-inventory assertion above and stated again here.
|
// pinned by the exact-inventory assertion above and stated again here.
|
||||||
#expect(specs.count == 6)
|
#expect(specs.count == 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The zoom pair is catalog-only — "the titlebar's default stays the search field alone"
|
||||||
|
/// (03-board-ui.md ▸ Toolbar). Stated separately from the default-set test because this is the
|
||||||
|
/// claim a future item is most likely to break by helpfully adding itself.
|
||||||
|
@Test("Zoom In and Zoom Out are available but never default")
|
||||||
|
func zoomIsCatalogOnly() {
|
||||||
|
#expect(!BoardToolbar.defaultItems.contains(.boardZoomIn))
|
||||||
|
#expect(!BoardToolbar.defaultItems.contains(.boardZoomOut))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// There is deliberately no Actual Size item: it is a menu row only. Two buttons are the whole
|
||||||
|
/// of what a toolbar can usefully offer for a ladder with no readout — a third that resets is
|
||||||
|
/// titlebar clutter for a chord.
|
||||||
|
@Test("Actual Size has no toolbar item")
|
||||||
|
func actualSizeIsMenuOnly() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
#expect(!boardSpecs(store: store).contains { $0.label == "Actual Size" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each button mirrors its menu row's ladder end, which is the same predicate `ZoomCommands`
|
||||||
|
/// disables on — one answer, two faces (03-board-ui.md ▸ Toolbar).
|
||||||
|
@Test("The zoom buttons disable at their own end of the ladder")
|
||||||
|
func zoomButtonsMirrorTheLadderEnds() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
// Every collaborator a spec's `isEnabled` interrogates lives in a local for the whole
|
||||||
|
// test, exactly as `store` already does: the closures capture them weakly — production
|
||||||
|
// hands them app-lived objects — so a temporary reads as uniformly disabled.
|
||||||
|
let session = DragSession()
|
||||||
|
let topZoom = makeZoom(level: BoardZoom.levels.last!)
|
||||||
|
let top = boardSpecs(store: store, zoom: topZoom, session: session)
|
||||||
|
#expect(try !#require(top.spec(.boardZoomIn)).isEnabled)
|
||||||
|
#expect(try #require(top.spec(.boardZoomOut)).isEnabled)
|
||||||
|
|
||||||
|
let bottomZoom = makeZoom(level: BoardZoom.levels.first!)
|
||||||
|
let bottom = boardSpecs(store: store, zoom: bottomZoom, session: session)
|
||||||
|
#expect(try #require(bottom.spec(.boardZoomIn)).isEnabled)
|
||||||
|
#expect(try !#require(bottom.spec(.boardZoomOut)).isEnabled)
|
||||||
|
|
||||||
|
let middleZoom = makeZoom()
|
||||||
|
let middle = boardSpecs(store: store, zoom: middleZoom, session: session)
|
||||||
|
#expect(try #require(middle.spec(.boardZoomIn)).isEnabled)
|
||||||
|
#expect(try #require(middle.spec(.boardZoomOut)).isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both are push buttons, and both go dead while a drag is in flight — the menu rows' guard,
|
||||||
|
/// reached through the very same predicate (`ZoomCommands.isEnabled`).
|
||||||
|
@Test("The zoom buttons are push buttons, and hold shut mid-drag")
|
||||||
|
func zoomButtonsHoldShutMidDrag() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
// Held in locals for the reason `zoomButtonsMirrorTheLadderEnds` spells out — and the
|
||||||
|
// dragging half asks with the same live zoom, so its disabled answer is the drag guard's
|
||||||
|
// rather than a dead weak reference's.
|
||||||
|
let zoom = makeZoom()
|
||||||
|
let restingSession = DragSession()
|
||||||
|
let resting = boardSpecs(store: store, zoom: zoom, session: restingSession)
|
||||||
|
for identifier in [NSToolbarItem.Identifier.boardZoomIn, .boardZoomOut] {
|
||||||
|
let spec = try #require(resting.spec(identifier))
|
||||||
|
#expect(spec.isOn == nil, "\(spec.label) is a push button, not a toggle")
|
||||||
|
#expect(spec.isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = DragSession()
|
||||||
|
let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true)
|
||||||
|
session.beginCards([ItemID(rawValue: Ident.card1)], folders: [folder], heights: [44],
|
||||||
|
container: .board, source: store)
|
||||||
|
let dragging = boardSpecs(store: store, zoom: zoom, session: session)
|
||||||
|
for identifier in [NSToolbarItem.Identifier.boardZoomIn, .boardZoomOut] {
|
||||||
|
#expect(try !#require(dragging.spec(identifier)).isEnabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Every label is its menu row's title, and Undo/Redo keep static ones")
|
@Test("Every label is its menu row's title, and Undo/Redo keep static ones")
|
||||||
@@ -114,9 +235,11 @@ struct BoardToolbarTests {
|
|||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
let specs = boardSpecs(store: store)
|
||||||
|
|
||||||
#expect(specs.map(\.label) == ["New Card", "New Lane", "Undo", "Redo", "Show Trash", "Search"])
|
#expect(specs.map(\.label) == [
|
||||||
|
"New Card", "New Lane", "Zoom In", "Zoom Out", "Undo", "Redo", "Show Trash", "Appearance", "Search",
|
||||||
|
])
|
||||||
// The one exception 03 names: "the Undo/Redo toolbar items keep static labels —
|
// The one exception 03 names: "the Undo/Redo toolbar items keep static labels —
|
||||||
// NSUndoManager rewrites their menu titles dynamically ('Undo Move Card…'), which a toolbar
|
// NSUndoManager rewrites their menu titles dynamically ('Undo Move Card…'), which a toolbar
|
||||||
// label doesn't track". They are also the two items with no action of their own: nil target,
|
// label doesn't track". They are also the two items with no action of their own: nil target,
|
||||||
@@ -137,7 +260,7 @@ struct BoardToolbarTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
for spec in BoardToolbar.specs(store: store, search: BoardSearchPresentation()) {
|
for spec in boardSpecs(store: store) {
|
||||||
guard let symbol = spec.symbol else { continue }
|
guard let symbol = spec.symbol else { continue }
|
||||||
#expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have")
|
#expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have")
|
||||||
}
|
}
|
||||||
@@ -151,13 +274,13 @@ struct BoardToolbarTests {
|
|||||||
defer { empty.tearDown() }
|
defer { empty.tearDown() }
|
||||||
|
|
||||||
let store = try BoardStore(rootURL: populated.root)
|
let store = try BoardStore(rootURL: populated.root)
|
||||||
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
let specs = boardSpecs(store: store)
|
||||||
let newCard = try #require(specs.spec(.boardNewCard))
|
let newCard = try #require(specs.spec(.boardNewCard))
|
||||||
#expect(newCard.isEnabled)
|
#expect(newCard.isEnabled)
|
||||||
#expect(newCard.isOn == nil, "New Card is a push button, not a toggle")
|
#expect(newCard.isOn == nil, "New Card is a push button, not a toggle")
|
||||||
|
|
||||||
let emptyStore = try BoardStore(rootURL: empty.root)
|
let emptyStore = try BoardStore(rootURL: empty.root)
|
||||||
let emptySpecs = BoardToolbar.specs(store: emptyStore, search: BoardSearchPresentation())
|
let emptySpecs = boardSpecs(store: emptyStore)
|
||||||
let disabled = try #require(emptySpecs.spec(.boardNewCard))
|
let disabled = try #require(emptySpecs.spec(.boardNewCard))
|
||||||
#expect(!disabled.isEnabled, "the zero-lane board disables the row, so it disables the item")
|
#expect(!disabled.isEnabled, "the zero-lane board disables the row, so it disables the item")
|
||||||
#expect(emptyStore.newCardTarget == nil, "one predicate, read by both")
|
#expect(emptyStore.newCardTarget == nil, "one predicate, read by both")
|
||||||
@@ -169,7 +292,7 @@ struct BoardToolbarTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let search = BoardSearchPresentation()
|
let search = BoardSearchPresentation()
|
||||||
let controller = BoardToolbar.controller(store: store, search: search)
|
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
|
||||||
|
|
||||||
// 03's three customization sentences: "right-click ▸ Customize Toolbar…, drag to rearrange,
|
// 03's three customization sentences: "right-click ▸ Customize Toolbar…, drag to rearrange,
|
||||||
// system overflow and icon/text display options".
|
// system overflow and icon/text display options".
|
||||||
@@ -181,7 +304,7 @@ struct BoardToolbarTests {
|
|||||||
#expect(allowed.contains(.flexibleSpace) && allowed.contains(.space), "the palette's spacers")
|
#expect(allowed.contains(.flexibleSpace) && allowed.contains(.space), "the palette's spacers")
|
||||||
#expect(controller.toolbarDefaultItemIdentifiers(controller.toolbar) == BoardToolbar.defaultItems)
|
#expect(controller.toolbarDefaultItemIdentifiers(controller.toolbar) == BoardToolbar.defaultItems)
|
||||||
|
|
||||||
for spec in BoardToolbar.specs(store: store, search: BoardSearchPresentation()) {
|
for spec in boardSpecs(store: store) {
|
||||||
let item = try #require(controller.toolbar(
|
let item = try #require(controller.toolbar(
|
||||||
controller.toolbar,
|
controller.toolbar,
|
||||||
itemForItemIdentifier: spec.identifier,
|
itemForItemIdentifier: spec.identifier,
|
||||||
@@ -198,7 +321,7 @@ struct BoardToolbarTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let search = BoardSearchPresentation()
|
let search = BoardSearchPresentation()
|
||||||
let controller = BoardToolbar.controller(store: store, search: search)
|
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
|
||||||
|
|
||||||
#expect(search.focusField == nil, "nothing to focus until the item exists")
|
#expect(search.focusField == nil, "nothing to focus until the item exists")
|
||||||
|
|
||||||
@@ -233,7 +356,7 @@ struct BoardToolbarTests {
|
|||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation())
|
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation(), zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
|
||||||
|
|
||||||
let item = try #require(controller.toolbar(
|
let item = try #require(controller.toolbar(
|
||||||
controller.toolbar,
|
controller.toolbar,
|
||||||
@@ -267,7 +390,7 @@ struct BoardToolbarTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let search = BoardSearchPresentation()
|
let search = BoardSearchPresentation()
|
||||||
let controller = BoardToolbar.controller(store: store, search: search)
|
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
|
||||||
|
|
||||||
_ = controller.toolbar(
|
_ = controller.toolbar(
|
||||||
controller.toolbar,
|
controller.toolbar,
|
||||||
@@ -290,7 +413,7 @@ struct BoardToolbarTests {
|
|||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
let specs = boardSpecs(store: store)
|
||||||
let showTrash = try #require(specs.spec(.boardShowTrash))
|
let showTrash = try #require(specs.spec(.boardShowTrash))
|
||||||
|
|
||||||
#expect(showTrash.isOn == false, "hidden by default, like the menu row's checkmark")
|
#expect(showTrash.isOn == false, "hidden by default, like the menu row's checkmark")
|
||||||
@@ -303,6 +426,45 @@ struct BoardToolbarTests {
|
|||||||
#expect(!store.transient.isTrashVisible)
|
#expect(!store.transient.isTrashVisible)
|
||||||
#expect(showTrash.isOn == false)
|
#expect(showTrash.isOn == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The picker's whole state contract, `showTrashTogglesTheQuasiLane`'s shape turned toward
|
||||||
|
/// `.picker`: no single `isOn`, so the checkmark and the write path are the item's own
|
||||||
|
/// `selected`/`select` closures rather than `.isOn`/`.activate()`.
|
||||||
|
@Test("Appearance is a picker whose selected row tracks the store and whose rows write it")
|
||||||
|
func appearanceTracksTheStore() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let appearance = makeAppearance()
|
||||||
|
let specs = boardSpecs(store: store, appearance: appearance)
|
||||||
|
let item = try #require(specs.spec(.boardAppearance))
|
||||||
|
|
||||||
|
#expect(item.isOn == nil, "a picker has no single on-state")
|
||||||
|
#expect(item.isEnabled, "always enabled — no board state gates an appearance override")
|
||||||
|
|
||||||
|
guard case let .picker(options, selected, select) = item.behavior else {
|
||||||
|
Issue.record("Appearance is not a picker")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(options.map(\.title) == ["Auto", "Light", "Dark"])
|
||||||
|
#expect(selected() == 0, "Auto by default, like the store's own nil override")
|
||||||
|
|
||||||
|
select(1)
|
||||||
|
#expect(appearance.override == .light, "the row drives the store's own setter")
|
||||||
|
#expect(selected() == 1)
|
||||||
|
|
||||||
|
select(2)
|
||||||
|
#expect(appearance.override == .dark)
|
||||||
|
#expect(selected() == 2)
|
||||||
|
|
||||||
|
select(0)
|
||||||
|
#expect(appearance.override == nil)
|
||||||
|
#expect(selected() == 0)
|
||||||
|
|
||||||
|
// `.activate()` is a no-op for a picker — firing lives in the dropdown's own rows.
|
||||||
|
item.activate()
|
||||||
|
#expect(appearance.override == nil, "activate() does not move the selection")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The card window's toolbar
|
// MARK: - The card window's toolbar
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ schema: 1
|
|||||||
title: Styled
|
title: Styled
|
||||||
order: 3072
|
order: 3072
|
||||||
project: lanework # agent overlay
|
project: lanework # agent overlay
|
||||||
background: blue
|
background: {color: blue}
|
||||||
icon: star
|
icon: star
|
||||||
created: 2026-01-01T09:00:00Z
|
created: 2026-01-01T09:00:00Z
|
||||||
---
|
---
|
||||||
@@ -991,7 +991,7 @@ private enum Foreign {
|
|||||||
|
|
||||||
static func restyle(_ fixture: WriterFixture, _ path: String, background: String) throws {
|
static func restyle(_ fixture: WriterFixture, _ path: String, background: String) throws {
|
||||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .style(title: nil)) { document in
|
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .style(title: nil)) { document in
|
||||||
document.set(FrontmatterKeys.background, to: .string(background))
|
document.setStyleValue(background, for: FrontmatterKeys.background)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ private func makeLane(
|
|||||||
slotWidth: CGFloat = 260,
|
slotWidth: CGFloat = 260,
|
||||||
drops: BoardDropContext,
|
drops: BoardDropContext,
|
||||||
marquee: MarqueeControl,
|
marquee: MarqueeControl,
|
||||||
openCard: @escaping (ItemID) -> Void = { _ in }
|
openCard: @escaping @MainActor (ItemID) -> Void = { _ in }
|
||||||
) -> LaneView {
|
) -> LaneView {
|
||||||
LaneView(
|
LaneView(
|
||||||
store: store,
|
store: store,
|
||||||
|
|||||||
@@ -255,6 +255,15 @@ struct IncreaseContrastTests {
|
|||||||
#expect(Accommodations.accentOpacity(base, contrast: .increased) == 1)
|
#expect(Accommodations.accentOpacity(base, contrast: .increased) == 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The selected lane's glow is the resting treatment's softness, not the indicator itself:
|
||||||
|
/// under Increase Contrast it yields to the solid full-alpha ring, because "strengthens
|
||||||
|
/// borders" means crisper and a blur is the one thing a border cannot become.
|
||||||
|
@Test("The selection halo yields to Increase Contrast")
|
||||||
|
func theHaloYieldsToIncreaseContrast() {
|
||||||
|
#expect(Accommodations.drawsSelectionHalo(contrast: .standard))
|
||||||
|
#expect(!Accommodations.drawsSelectionHalo(contrast: .increased))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Reduce Transparency
|
// MARK: - Reduce Transparency
|
||||||
@@ -262,13 +271,15 @@ struct IncreaseContrastTests {
|
|||||||
@Suite("Accommodations ▸ Reduce Transparency")
|
@Suite("Accommodations ▸ Reduce Transparency")
|
||||||
struct ReduceTransparencyTests {
|
struct ReduceTransparencyTests {
|
||||||
|
|
||||||
/// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board's one
|
/// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board carries
|
||||||
/// surviving material is the transient search bar's `.bar` — the design's own example, the card
|
/// two materials — the transient search bar's `.bar` and the backdrop's title-bar frost — and
|
||||||
/// face carousel's page dots, died with the carousel (03-board-ui.md § Card face).
|
/// the rule is one rule: both take the same solid, whatever their weights without it.
|
||||||
@Test("The one glass underlay goes solid")
|
@Test("Both glass underlays go solid")
|
||||||
func glassGoesSolid() {
|
func glassGoesSolid() {
|
||||||
#expect(Accommodations.underlay(reduceTransparency: false) == .glass)
|
#expect(Accommodations.underlay(reduceTransparency: false) == .glass)
|
||||||
#expect(Accommodations.underlay(reduceTransparency: true) == .solid)
|
#expect(Accommodations.underlay(reduceTransparency: true) == .solid)
|
||||||
|
#expect(Accommodations.frost(reduceTransparency: false) == .frost)
|
||||||
|
#expect(Accommodations.frost(reduceTransparency: true) == .solid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The washes are not glass — they composite at an alpha rather than sampling a backdrop — but
|
/// The washes are not glass — they composite at an alpha rather than sampling a backdrop — but
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ struct WriteFidelityMinimalTouchTests {
|
|||||||
try step("style write", targeting: ["\(Ident.lane1)/\(Ident.card2)"]) {
|
try step("style write", targeting: ["\(Ident.lane1)/\(Ident.card2)"]) {
|
||||||
try BoardWriter.updateIndex(
|
try BoardWriter.updateIndex(
|
||||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card2)"), operation: .style(title: nil)
|
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card2)"), operation: .style(title: nil)
|
||||||
) { $0.set(FrontmatterKeys.background, to: .string("blue")) }
|
) { $0.setStyleValue("blue", for: FrontmatterKeys.background) }
|
||||||
}
|
}
|
||||||
try step("rename", targeting: ["\(Ident.lane2)/\(Ident.card3)"]) {
|
try step("rename", targeting: ["\(Ident.lane2)/\(Ident.card3)"]) {
|
||||||
try BoardWriter.updateIndex(
|
try BoardWriter.updateIndex(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
|
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
|
||||||
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. Every open passes through a real loading window — it appears immediately at its saved frame under the registry's cached name, its content a centered spinner behind a ~200 ms grace, while the tree walk runs off the main actor (one walk per board however many windows ask at once), and ⌘W during the walk genuinely cancels it. When an **attended** open refuses, that same content area transforms in place into the **decision surface** — never a sheet, never a chained dialog: the walk's defects grouped by class, each class stating the problem once and listing the affected files with Reveal in Finder and Open in Editor, one class-level choice preselected to its default and a per-file override behind a disclosure. Only honest choices are offered — unparseable frontmatter gets Open in Editor and Re-check (or Skip below the root), a file from a newer Lanework gets Skip alone (and blocks the whole board at the root), a board root with no `index.md` gets a minted index, a root with no `schema` gets a `schema: 1` stamp. Repair and Open applies every chosen fix in one write bracket — ordinary app writes, and on Pro boards a separate heal-authored repair commit — then re-runs the whole walk, opening on a clean result and re-aggregating into the *same* surface otherwise; Re-check re-walks without writing; Cancel (and ⌘W) retires to welcome's row. Skips are per-open consent that rides the session and is never persisted, and the opened board carries a warning-tone notice naming what was left out, each with its own Reveal in Finder. Restoration failures keep the retire-to-welcome-row landing — repair is an attended act, and launch never chains dialogs.
|
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. Every open passes through a real loading window — it appears immediately at its saved frame under the registry's cached name, its content a centered spinner behind a ~200 ms grace, while the tree walk runs off the main actor (one walk per board however many windows ask at once), and ⌘W during the walk genuinely cancels it. When an **attended** open refuses, that same content area transforms in place into the **decision surface** — never a sheet, never a chained dialog: the walk's defects grouped by class, each class stating the problem once and listing the affected files with Reveal in Finder and Open in Editor, one class-level choice preselected to its default and a per-file override behind a disclosure. Only honest choices are offered — unparseable frontmatter gets Open in Editor and Re-check (or Skip below the root), a file from a newer Lanework gets Skip alone (and blocks the whole board at the root), a board root with no `index.md` gets a minted index, a root with no `schema` gets a `schema: 1` stamp. Repair and Open applies every chosen fix in one write bracket — ordinary app writes, and on Pro boards a separate heal-authored repair commit — then re-runs the whole walk, opening on a clean result and re-aggregating into the *same* surface otherwise; Re-check re-walks without writing; Cancel (and ⌘W) retires to welcome's row. Skips are per-open consent that rides the session and is never persisted, and the opened board carries a warning-tone notice naming what was left out, each with its own Reveal in Finder. Restoration failures keep the retire-to-welcome-row landing — repair is an attended act, and launch never chains dialogs.
|
||||||
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
|
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
|
||||||
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job.
|
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, the colour written either way as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex — `background` as a mapping, `{color: fern}`, which is the one shape that field takes. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job.
|
||||||
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a target that is trashed or deleted discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
|
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a target that is trashed or deleted discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
|
||||||
|
|
||||||
- **Drag & drop** — cards, in either container, and lanes all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane carries exactly its cards either way, since the trash is board-level and there is nothing lane-nested to strip. Dragging a trash card onto a lane restores it at the drop position — an ordinary move — while dropping it on another board follows the same copy default every cross-board drag does, ⌘ forcing the true restore-move. The same gesture runs the other way: dropping a live card on the shown trash deletes it, exactly as ⌫ would, with the shadow always taking the topmost row — which the rank minting makes honest rather than arbitrary: every arrival really does land above the current top. A lane drag proposes the same delete over the column, and a trashed lane row drags back out to a strip slot the way a trash card drags back into a lane; a foreign board's item isn't deliverable there and neither is ⌥, since copying into the trash isn't a thing. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size. Files dragged in from Finder join the same dispatch: dropped on a card they copy into its `attachments/` (any type, multi-file, Finder-style renames on collision, the card highlighting while hovered), dropped on lane empty space they become one card per file — titled with the filename minus its extension, that file attached, landing at the drop position with a shadow per card. The trash column and its cards are inert to them, and a read-only board or an open inline editor refuses them outright.
|
- **Drag & drop** — cards, in either container, and lanes all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane carries exactly its cards either way, since the trash is board-level and there is nothing lane-nested to strip. Dragging a trash card onto a lane restores it at the drop position — an ordinary move — while dropping it on another board follows the same copy default every cross-board drag does, ⌘ forcing the true restore-move. The same gesture runs the other way: dropping a live card on the shown trash deletes it, exactly as ⌫ would, with the shadow always taking the topmost row — which the rank minting makes honest rather than arbitrary: every arrival really does land above the current top. A lane drag proposes the same delete over the column, and a trashed lane row drags back out to a strip slot the way a trash card drags back into a lane; a foreign board's item isn't deliverable there and neither is ⌥, since copying into the trash isn't a thing. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size. Files dragged in from Finder join the same dispatch: dropped on a card they copy into its `attachments/` (any type, multi-file, Finder-style renames on collision, the card highlighting while hovered), dropped on lane empty space they become one card per file — titled with the filename minus its extension, that file attached, landing at the drop position with a shadow per card. The trash column and its cards are inert to them, and a read-only board or an open inline editor refuses them outright.
|
||||||
@@ -23,7 +23,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
|
|
||||||
- **The clipboard** — ⌘X/⌘C/⌘V move cards *and* lanes, within a board and across boards, so structure transfers without a mouse. It's a hybrid: the pasteboard carries a small manifest plus the titles as plain text, while the real content — whole folders, attachments and strays and all — is snapshotted into Application Support the instant you press ⌘C, so a copy captures the item as it was at that moment and survives the original being deleted, its volume unmounting, or the app quitting and relaunching. The store keeps exactly one snapshot: every copy and every launch sweeps whatever the pasteboard no longer points at. If a snapshot has gone missing by the time you paste, the manifest still carries each item's full `index.md`, so the paste lands with its content intact — and says so out loud, naming exactly what was left behind ("Pasted 'Fix login' without its 2 attachments") rather than leaving you to find an empty `attachments/` later. Cut is Finder-style deferred: the items dim in place and stay put until a paste moves them, voiding if another app takes the pasteboard or the source board closes (the paste then quietly becomes a copy), and voiding *per item* if one is deleted in the meantime — so a paste moves whatever survived, and a cut emptied down to nothing simply does nothing. Paste lands after the anchor card, at a selected lane's bottom, or at the last member of a multi-selection in flatten order — the same anchor ⌘N uses — and a lane payload lands after the anchor lane or at the board's right end, which is one of the two ways out of a board with no lanes at all. Copies keep `created` and take fresh identities throughout; a lane carries exactly its cards, copied or moved, because the trash is board-level and there is nothing lane-nested to strip; pasting a lane back into its own board is the within-board duplicate the drag deliberately doesn't offer. The clipboard works on trash cards like on any card — ⌘C yields a live copy wherever you paste it, and ⌘X in the trash followed by ⌘V is the keyboard-native restore, a card into a lane and a trashed lane row after the anchor lane — while paste never targets the trash itself, and the read-only lock blocks cut without ever blocking copy, because copying out is a read.
|
- **The clipboard** — ⌘X/⌘C/⌘V move cards *and* lanes, within a board and across boards, so structure transfers without a mouse. It's a hybrid: the pasteboard carries a small manifest plus the titles as plain text, while the real content — whole folders, attachments and strays and all — is snapshotted into Application Support the instant you press ⌘C, so a copy captures the item as it was at that moment and survives the original being deleted, its volume unmounting, or the app quitting and relaunching. The store keeps exactly one snapshot: every copy and every launch sweeps whatever the pasteboard no longer points at. If a snapshot has gone missing by the time you paste, the manifest still carries each item's full `index.md`, so the paste lands with its content intact — and says so out loud, naming exactly what was left behind ("Pasted 'Fix login' without its 2 attachments") rather than leaving you to find an empty `attachments/` later. Cut is Finder-style deferred: the items dim in place and stay put until a paste moves them, voiding if another app takes the pasteboard or the source board closes (the paste then quietly becomes a copy), and voiding *per item* if one is deleted in the meantime — so a paste moves whatever survived, and a cut emptied down to nothing simply does nothing. Paste lands after the anchor card, at a selected lane's bottom, or at the last member of a multi-selection in flatten order — the same anchor ⌘N uses — and a lane payload lands after the anchor lane or at the board's right end, which is one of the two ways out of a board with no lanes at all. Copies keep `created` and take fresh identities throughout; a lane carries exactly its cards, copied or moved, because the trash is board-level and there is nothing lane-nested to strip; pasting a lane back into its own board is the within-board duplicate the drag deliberately doesn't offer. The clipboard works on trash cards like on any card — ⌘C yields a live copy wherever you paste it, and ⌘X in the trash followed by ⌘V is the keyboard-native restore, a card into a lane and a trashed lane row after the anchor lane — while paste never targets the trash itself, and the read-only lock blocks cut without ever blocking copy, because copying out is a read.
|
||||||
|
|
||||||
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched.
|
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched. **A board can also wear a picture.** `background` is a mapping at every level — `{color: fern}` on a card or a lane — and a board's may name an image beside its colour: `background: {color: "#112233", image: art/sunset.jpg}` paints that image over that colour, scaled to fill, across the **whole window**: the content runs under a transparent title bar with a frosted strip keeping the toolbar and the board-name widget legible on top of it. The path is relative to the board folder, so the picture travels with the document when it is copied, zipped or synced (an absolute path, or one climbing out of the folder, simply paints nothing). Either half may stand alone, the colour shows through while a large photograph decodes off the main thread, and replacing the file in Finder swaps the backdrop live. There is no picker for it — like a hand-written hex, the raw file is the escape hatch — and a board with no background of its own keeps the standard window chrome exactly as before.
|
||||||
|
|
||||||
- **The trash** — deleting a card **moves** it: its folder travels into the board's reserved `.trash/`, always landing at the top, and View ▸ Show Trash reveals a trailing column where those cards live. A trashed card is an ordinary card in a special place — the same card face, the same colour stripe, the same attachments chip, the same search, the same selection, the same clipboard — so `.trash/` is self-describing in Finder and to agents, and there is no tombstone flag anywhere. **Lanes delete into the trash too**: the folder travels subtree-intact and shows as one distinct dimmed row carrying its title and held-card count — an opaque unit that never expands, whose cards aren't individually addressable, and which restores whole or purges whole (its confirmation counting the cards it would take with it). The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it, and its newest-first order falls out of ordinary ranks with no timestamp sort. There is no Put Back: restore by dragging a card out into any lane at any position, or ⌘X in the trash and ⌘V into a lane — both are ordinary moves, so a restored card lands where you put it. Drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical move, and its shadow always takes the top row because that is genuinely where the card lands. Delete is one vocabulary staged by place: ⌫/⌘⌫ moves a board card to the trash and deletes a trash card permanently, and ⇧⌘⌫ Empty Trash… purges the whole container — each confirmed where the loss is real, named by count, and Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style…, Finder file drops — applies to a trash selection, and a selection never mixes trashed with live.
|
- **The trash** — deleting a card **moves** it: its folder travels into the board's reserved `.trash/`, always landing at the top, and View ▸ Show Trash reveals a trailing column where those cards live. A trashed card is an ordinary card in a special place — the same card face, the same colour stripe, the same attachments chip, the same search, the same selection, the same clipboard — so `.trash/` is self-describing in Finder and to agents, and there is no tombstone flag anywhere. **Lanes delete into the trash too**: the folder travels subtree-intact and shows as one distinct dimmed row carrying its title and held-card count — an opaque unit that never expands, whose cards aren't individually addressable, and which restores whole or purges whole (its confirmation counting the cards it would take with it). The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it, and its newest-first order falls out of ordinary ranks with no timestamp sort. There is no Put Back: restore by dragging a card out into any lane at any position, or ⌘X in the trash and ⌘V into a lane — both are ordinary moves, so a restored card lands where you put it. Drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical move, and its shadow always takes the top row because that is genuinely where the card lands. Delete is one vocabulary staged by place: ⌫/⌘⌫ moves a board card to the trash and deletes a trash card permanently, and ⇧⌘⌫ Empty Trash… purges the whole container — each confirmed where the loss is real, named by count, and Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style…, Finder file drops — applies to a trash selection, and a selection never mixes trashed with live.
|
||||||
|
|
||||||
@@ -49,13 +49,13 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
|
|
||||||
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board; on an ordinary free-tier board the popover ends there, and only a board carrying a `.git` gets a closing note — "This board has a git history. Lanework Pro works with it." Under a Lanework Pro subscription that slot becomes the board's git section instead, and it follows the board's mode: a board with no repository offers **Add Git**, a board that lives inside somebody else's repository gets a short honest explanation rather than a hidden or greyed-out action, and a git board carries the branch surface: the current branch with switching and create-and-switch beside it, the plain-language explanation when an outside-the-app merge or rebase has the git surface paused, and the commit-identity name and email fields that write the repository's own `.git/config`. The read-only lock disables the surface without closing it.
|
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board; on an ordinary free-tier board the popover ends there, and only a board carrying a `.git` gets a closing note — "This board has a git history. Lanework Pro works with it." Under a Lanework Pro subscription that slot becomes the board's git section instead, and it follows the board's mode: a board with no repository offers **Add Git**, a board that lives inside somebody else's repository gets a short honest explanation rather than a hidden or greyed-out action, and a git board carries the branch surface: the current branch with switching and create-and-switch beside it, the plain-language explanation when an outside-the-app merge or rebase has the git surface paused, and the commit-identity name and email fields that write the repository's own `.git/config`. The read-only lock disables the surface without closing it.
|
||||||
|
|
||||||
- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode.
|
- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Zoom In, Zoom Out, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode.
|
||||||
|
|
||||||
- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. The free tier runs this stack on every board; under Lanework Pro a git board binds git behind the same seam instead, without changing a keystroke — see "Undo as forward commits" below.
|
- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. The free tier runs this stack on every board; under Lanework Pro a git board binds git behind the same seam instead, without changing a keystroke — see "Undo as forward commits" below.
|
||||||
|
|
||||||
- **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is moved aside — Finder-style, never destroyed, with a quiet row naming where it went — because the app owns that name; and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption.
|
- **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is moved aside — Finder-style, never destroyed, with a quiet row naming where it went — because the app owns that name; and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption.
|
||||||
|
|
||||||
- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent. Which is which is decided **per file by the write-provenance ledger**, never by which kind of reload delivered it: a reconciling sweep on wake or reactivation announces whatever changed in the blind window (the app never vouches for changes it didn't witness), and a foreign edit that lands on a file the app had just written is still announced. A card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear. **Every size in the app is relative**: there is not one hard-coded point size left — the card face, the lane header, the masonry, the style editor's wells and every window's floor derive from the system body font, so the whole board grows with the system text size while the no-horizontal-scroll rule holds (the lanes compress, the strip never scrolls) and titles keep truncating gracefully. The system's visual accommodations are wired throughout: **Increase Contrast** thickens every border and selection ring and gives card and lane plates an outline they don't otherwise have, **Reduce Transparency** turns the transient search bar's glass and the trash column's washes solid, and **Reduce Motion** has a variant for every animated surface in the app — movement goes instant, appear/disappear goes crossfade, uniformly, the live-reload seam included. **A coloured board computes its own text colour.** The board background is the one surface the app lets a colour sit behind text, so the ink is chosen rather than assumed: WCAG relative luminance against the ≥ 4.5:1 threshold, with an `#RRGGBBAA` value composited over the window background of the appearance you are actually in — so lane and trash headers take light or dark glyphs on their own and re-decide the moment you switch to Dark Mode. One path serves both halves of the styling vocabulary: the twelve palette wells are pinned by a test that checks the ink the app *picks* for each of them in both appearances (a dark palette board is now readable in Light Mode, which it was not), and a hand-written hex — which stays fully honoured from disk — gets the identical computation as it renders. Nothing is ever said by colour alone (selection is a ring plus a trait, a cut card is dimmed plus "cut, pending paste", the trash header is hatched plus labelled, a mixed batch reads "mixed"), and under **Full Keyboard Access** the board is a single visible tab stop with the arrow grammar inside it while every control around it — lane buttons, popovers, the style grids, welcome rows, template tiles — is Tab-reachable, arrow-navigable and labelled.
|
- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent. Which is which is decided **per file by the write-provenance ledger**, never by which kind of reload delivered it: a reconciling sweep on wake or reactivation announces whatever changed in the blind window (the app never vouches for changes it didn't witness), and a foreign edit that lands on a file the app had just written is still announced. A card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear. **Every size in the app is relative**: there is not one hard-coded point size left — the card face, the lane header, the masonry, the style editor's wells and every window's floor derive from the system body font, so the whole board grows with the system text size while the no-horizontal-scroll rule holds (the lanes compress, the strip never scrolls) and titles keep truncating gracefully. **View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0) is that scaling's control**, since macOS ships no text-size setting of its own: eight rungs from 75% to 200%, moving the type, the card chrome and the lane chrome together off one ruler, remembered across launches and shared by every open board window. It is a zoom, not a magnification — the lanes still divide the window's width, because every lane being on screen is the rule the board is built around; what changes is how large the cards are and how many of them fit. Actual Size draws exactly what the board drew before zoom existed, down to the pixel, and each rung announces itself to VoiceOver. The system's visual accommodations are wired throughout: **Increase Contrast** thickens every border and selection ring and gives card and lane plates an outline they don't otherwise have, **Reduce Transparency** turns the transient search bar's glass and the trash column's washes solid, and **Reduce Motion** has a variant for every animated surface in the app — movement goes instant, appear/disappear goes crossfade, uniformly, the live-reload seam included. **A coloured board computes its own text colour.** The board background is the one surface the app lets a colour sit behind text, so the ink is chosen rather than assumed: WCAG relative luminance against the ≥ 4.5:1 threshold, with an `#RRGGBBAA` value composited over the window background of the appearance you are actually in — so lane and trash headers take light or dark glyphs on their own and re-decide the moment you switch to Dark Mode. One path serves both halves of the styling vocabulary: the twelve palette wells are pinned by a test that checks the ink the app *picks* for each of them in both appearances (a dark palette board is now readable in Light Mode, which it was not), and a hand-written hex — which stays fully honoured from disk — gets the identical computation as it renders. Nothing is ever said by colour alone (selection is a ring plus a trait, a cut card is dimmed plus "cut, pending paste", the trash header is hatched plus labelled, a mixed batch reads "mixed"), and under **Full Keyboard Access** the board is a single visible tab stop with the arrow grammar inside it while every control around it — lane buttons, popovers, the style grids, welcome rows, template tiles — is Tab-reachable, arrow-navigable and labelled.
|
||||||
|
|
||||||
- **App identity — icon, versioning, About** — the app carries its three-lane glyph icon and a real About window: icon, copyright, version and build stamped at build time from git (`CFBundleVersion` = commit count, plus `BuildDate` and `BuildHash` in the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away. The box carries the one quiet line naming Lanework Pro — one of the three places the app names it at all, per the quiet-signposts rule (DESIGN/12).
|
- **App identity — icon, versioning, About** — the app carries its three-lane glyph icon and a real About window: icon, copyright, version and build stamped at build time from git (`CFBundleVersion` = commit count, plus `BuildDate` and `BuildHash` in the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away. The box carries the one quiet line naming Lanework Pro — one of the three places the app names it at all, per the quiet-signposts rule (DESIGN/12).
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ Observation tracks whole **properties**. Reading `.background` off `store.snapsh
|
|||||||
|
|
||||||
**Selection is O(board) in card bodies.** Selecting one card re-runs all 180 faces, because `CardFaceView.body` reads `store.selection` through `isSelected`. This is the Observation half the gates explicitly do not cover, and narrowing it would mean each face taking its own selected-ness as a compared parameter — a design change, not a gate.
|
**Selection is O(board) in card bodies.** Selecting one card re-runs all 180 faces, because `CardFaceView.body` reads `store.selection` through `isSelected`. This is the Observation half the gates explicitly do not cover, and narrowing it would mean each face taking its own selected-ness as a compared parameter — a design change, not a gate.
|
||||||
|
|
||||||
|
### The zoom pair (2026-08-03)
|
||||||
|
|
||||||
|
Board zoom (03-board-ui.md ▸ Layout — zoom) added two steps to the suite, and they are the only ones here that assert a body count is **non-zero** — because for zoom, a suppressed render is the bug.
|
||||||
|
|
||||||
|
| Step | strip | containers | cards |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| zoom in one rung | 1 | **12** of 12 | **540** of 540 |
|
||||||
|
| Actual Size when already there | **0** | **0** | **0** |
|
||||||
|
|
||||||
|
A whole-board repaint is the *right* answer for the first row, not a budget overrun: every figure the strip draws is a multiple of the level, so every lane's chrome and every card's geometry genuinely changed. What is asserted is only that the numbers are non-zero.
|
||||||
|
|
||||||
|
**The gate must not swallow a level change, and it doesn't — because the level travels in the environment.** `CardFaceView.==` compares the card, its role, its store, the marquee and the drop context; a ⌘+ leaves all five identical. The faces repaint only because `@Environment` values are outside the comparison by design ("SwiftUI invalidates on those itself"), which is exactly why `BoardZoomContext` is an environment value rather than a read off `AppModel`. A zero in that row would be the board zooming its lane chrome while every card face stayed at 13pt — `zoomRepaintsTheCardFaces` is the tripwire.
|
||||||
|
|
||||||
|
**A board nobody zoomed pays nothing.** `@Observable` notifies on every set, equal or not, so `BoardZoomStore.setLevel` refuses an unchanged level outright rather than re-running the strip to draw what it was already drawing. `actualSizeIsTheUntouchedBoard` pins that guard, and pins that the resting ruler is the system's own body size.
|
||||||
|
|
||||||
## The signposts
|
## The signposts
|
||||||
|
|
||||||
| Signpost | Span | Emitted from |
|
| Signpost | Span | Emitted from |
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Market Overview — Synthesis of the Broad Pass
|
||||||
|
|
||||||
|
This is the synthesis of the 2026-08 broad pass over the kanban and adjacent todo/task-management markets, drawing on the four segment documents (02–05) and the agent-intersection deep slice (06). It maps the landscape, places Lanework in it, and distills what the evidence says about how apps in this space succeed and fail. Facts are cited loosely here; the segment docs carry the sources.
|
||||||
|
|
||||||
|
Researched: 2026-08-01
|
||||||
|
|
||||||
|
## The map: four rings around Lanework
|
||||||
|
|
||||||
|
The market divides cleanly into four rings, from farthest to closest to Lanework's position.
|
||||||
|
|
||||||
|
**Ring 1 — mainstream team SaaS (doc 02).** Trello, Asana, Monday, Jira, ClickUp, Notion, Linear, Airtable. Per-seat subscriptions, cloud-hosted, multi-billion-dollar revenues. Lanework does not compete here and shouldn't try; this ring matters as the source of the frustrations (bloat, seat-step billing, AI credit metering, cloud lock-in) that push users toward everything in the inner rings. Two structural facts stand out: kanban is usually a *view* over some other primitive (database, canvas, list), rarely the product itself; and offline/local-first is a near-universal gap treated as degraded fallback, never as architecture.
|
||||||
|
|
||||||
|
**Ring 2 — personal todo apps (doc 04).** Todoist, Things 3, TickTick, OmniFocus, plus the daily-planner wave (Sunsama, Akiflow, Motion). The durable businesses here are bootstrapped (Todoist, TickTick); the VC-funded ones keep pivoting out of the category (Motion to "AI Employees," Amie to meeting notes). Apple Reminders is the market's free ceiling — differentiation against it has to be structural, not featural. Kanban views exist (Todoist Boards, TickTick, Any.do, Lunatask) but always bolted onto a flat task list; nobody in this ring treats the board as the native data model.
|
||||||
|
|
||||||
|
**Ring 3 — native/indie/file-based tools (doc 03).** Lanework's actual neighborhood. The striking finding: at least six independent developers converged on "folder = column, file = card" within roughly 2024–2026 (Signboard, Tasks.md, Kanban MD, Kanbanero, banban, FileBoard). Tasks.md's 2.2k GitHub stars is the clearest demand signal for exactly this pattern. But every occupant is compromised for Lanework's purposes: Signboard is Electron; Tasks.md needs a self-hosted Docker container; Kanbanero is JSON with no card-body richness; FileBoard is brand-new and thin. The largest existing audience for markdown-backed kanban sits inside PKM tools (Obsidian's Kanban plugin, Logseq's plugin-only kanban) — real demand, always a secondary feature.
|
||||||
|
|
||||||
|
**Ring 4 — the agent intersection (doc 06).** The newest and fastest-moving ring. Markdown files are already the default substrate for feeding work to AI coding agents; Beads (~26k stars in months) proved pent-up demand for "an issue tracker an agent can drive"; OpenAI's Symphony turned a Linear board into a Codex control plane and reported a 500% increase in landed PRs — the strongest evidence anywhere that *a board with real states* unlocks agent throughput. Every mainstream tracker converged on hosted MCP + assignable agents within twelve months, so agent API access is now table stakes. A dense cluster of "kanban for agents" apps exists (Vibe Kanban, Conductor, Kanban Code), but all keep app-owned state.
|
||||||
|
|
||||||
|
## Where Lanework sits: the whitespace, precisely
|
||||||
|
|
||||||
|
The broad pass supports a precise statement of the gap: **nobody combines a genuinely good native human kanban UI with files-on-disk as the true source of truth, equally legible to any agent, script, or editor.**
|
||||||
|
|
||||||
|
Every agent-first tool has a weak or absent human GUI (Backlog.md, Beads, GNAP). Every polished GUI tool owns its state (Kanban Code's `~/.kanban-code/*.json`, Vibe Kanban's internal store, Kanbanero's JSON, Sprout's CoreData). Every mainstream tracker mediates agents through a server API. Lanework's "boards are folders and Markdown files; the app is the best human surface over that substrate" is validated from three independent directions — the file-over-app movement (Obsidian's scale, Steph Ango's essay), the convergent folder-as-board hobby projects, and the agent-tooling wave — yet unoccupied as a polished native product.
|
||||||
|
|
||||||
|
Two qualifications keep this honest. First, the window is narrowing: agent-friendliness went from novelty to expected-by-default in months, and an existing player (Signboard already ships an MCP server) could plausibly close the gap faster than Lanework builds breadth. Second, the demand signal for local-first is broad but not kanban-specific — the 9,300-post Reddit analysis found local-first demand at 7% of app wishes but no kanban-format requests within it; the kanban-specific demand evidence is developer-shaped (GitHub stars, HN threads), which says something about where early adopters will come from.
|
||||||
|
|
||||||
|
## What makes apps in this space succeed
|
||||||
|
|
||||||
|
Synthesized across all four rings, ordered by how consistently the evidence supports each.
|
||||||
|
|
||||||
|
1. **A sharp core promise kept undiluted beats feature breadth.** Things 3 thrives with no AI and no board view because the core promise (fast, beautiful, pay-once, reliable) never diluted; Linear won the Jira-refugee segment on focus and speed; every all-in-one player draws bloat complaints even while growing. Nobody has cracked simple-and-complete; picking a side and holding it is the proven pattern.
|
||||||
|
2. **One-time or low-commitment pricing is a trust signal, not just a price point.** Subscription fatigue is measured and rising (active cancellation 31%→47%, 2024→2026). The $0–25 one-time range dominates native Mac kanban; users revolt not at price but at value-removal (Todoist's regional-pricing pull, Akiflow's billing surprises). Agenda's "pay once, keep what you unlocked" hybrid and Curio's MAS-subscription-or-perpetual-key dual track are the interesting patterns worth studying for Lanework's own model.
|
||||||
|
3. **Bootstrapped survives; funded pivots or dies.** Every notable 2024–2026 failure (Height, Clockwise, Rise, Mayday) was VC-funded; the durable individual-focused businesses (Todoist, TickTick, Obsidian, TaskPaper's 15+ years) are all bootstrapped. Obsidian is the existence proof for the ceiling: ~$25M ARR, 18 people, zero VC, local-first — real money without the enterprise axis.
|
||||||
|
4. **Native polish and speed are stated purchase reasons in the indie niche.** Repeated Electron-sluggishness complaints, repeated "feels like a real Mac app" praise; keyboard-first workflows lead the marketing of nearly every respected tool in ring 3. Lanework's native-Swift, keyboard-complete architecture is squarely what this buyer says they want.
|
||||||
|
5. **AI that removes friction sticks; AI that replaces judgment doesn't (yet).** Natural-language capture demonstrably stuck (Todoist Ramble); summarization is used but not a switcher; full-delegation auto-scheduling is commercially fragile (Clockwise dead, Motion fleeing to B2B), and Height died five months after going all-in on autonomous PM. For Lanework the corollary is convenient: because the substrate is files, agents bring their own AI — Lanework never has to run, meter, or upsell compute, sidestepping the AI-credits resentment that plagues ring 1.
|
||||||
|
6. **Abandonment is fatal in the indie niche; users tolerate minimalism but not staleness.** TidyCards (beloved, then stalled, then complained about) and Wunderlist (acquired, starved, killed) are the standing cautionary tales. Visible, steady development is part of the product.
|
||||||
|
|
||||||
|
## Threats and cautions
|
||||||
|
|
||||||
|
- **Obsidian Bases shipping a first-party kanban view** could commoditize markdown-board demand inside the largest existing file-based audience.
|
||||||
|
- **Signboard** (Electron, same philosophy, MCP server, shipping since fall 2025) is the closest live competitor; "native, faster, more Mac-idiomatic" is the differentiation story against it and it must stay true.
|
||||||
|
- **Pure-file storage has known scaling costs.** Taskwarrior's retreat from flat files to SQLite, Beads' entire thesis (markdown degrades under concurrent agent writers), and the Backlog.md thread's context-budget caveats all mark real technical territory Lanework must navigate — the write-provenance ledger and atomic-write architecture are already aimed at this, but multi-agent concurrent writes remain the hard open problem in the whole space.
|
||||||
|
- **Category absorption from above:** Notion/Tana/Capacities absorbing tasks into cloud PKM graphs squeezes the casual end, and Apple Reminders squeezes the bottom. Both squeeze toward the same conclusion: Lanework's buyer is the person for whom file ownership and agent legibility are structural requirements, not nice-to-haves.
|
||||||
|
|
||||||
|
## The direct competitive set (for the narrow pass)
|
||||||
|
|
||||||
|
Ordered by how much attention each deserves in pass two: Signboard, Tasks.md, Kanban Code, Backlog.md, Beads, Kanbanero, Obsidian Kanban plugin (+ Bases trajectory), Vibe Kanban, FileBoard, Sprout, Conductor. Things 3 and Obsidian stay on the list not as competitors but as the business-model and community-playbook references respectively.
|
||||||
|
|
||||||
|
The Signboard deep dive is done: [07-schema-comparison-signboard.md](07-schema-comparison-signboard.md) compares the two on-disk formats and concludes the differentiation is durable and structural — Signboard's Obsidian-shaped format (mutable filenames as identity, prefix ordering, denormalized location keys) cannot adopt Lanework's posture (immutable UUIDs, rank ordering, versioned contract) without breaking every existing vault, and vice versa. It also sets the template for the remaining pass-two profiles.
|
||||||
|
|
||||||
|
## Open questions for the narrow pass
|
||||||
|
|
||||||
|
- Positioning and messaging study of the direct set above: exact feature matrices, pricing, and each one's actual traction beyond star counts.
|
||||||
|
- Concrete launch case studies (HN/Product Hunt) for file-based task apps with real numbers — this pass found none; searching HN/PH directly rather than via aggregators is the fix.
|
||||||
|
- Whether the agent-tooling audience (git/CLI discovery funnel) and the Mac-indie audience (MAS/Setapp/r/macapps funnel) overlap at all, since Lanework needs both and they may never see each other's channels.
|
||||||
|
- Reddit/App Store review mining for kanban-specific (not PKM-general) local-first demand.
|
||||||
|
- Pricing-model design: test the Agenda hybrid and Curio dual-track patterns against a straight Things-style one-time purchase for Lanework specifically.
|
||||||
|
- Superlist's post-merger direction, as the one well-funded personal-task player still actively expanding.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Mainstream / Team-Oriented Kanban and Project Boards
|
||||||
|
|
||||||
|
This document covers the mainstream, team-oriented kanban and project-board segment: cloud-hosted, account-based, multiplayer-first tools where the board lives on a vendor's server and is accessed through a web or desktop client.
|
||||||
|
It is the segment Lanework is explicitly not competing head-on with (Lanework's boards are folders and Markdown files on disk, single-tenant, editor/git/agent-friendly), but understanding how this segment wins, monetizes, and where it frustrates users is the baseline for positioning everything else against.
|
||||||
|
Researched: 2026-08-01
|
||||||
|
|
||||||
|
## Trello
|
||||||
|
|
||||||
|
- Positioning: the original consumer-friendly, card-based kanban tool; now Atlassian's low-friction on-ramp for individuals and small teams, sitting below Jira in Atlassian's portfolio.
|
||||||
|
- Pricing: Free, Standard $5/user/mo, Premium $10/user/mo, Enterprise $17.50/user/mo (annual billing shaves ~17% off Standard/Premium); free tier now ships unlimited Power-Ups (up from a 1-Power-Up cap in earlier years).
|
||||||
|
- Standout features / direction: 2025-2026 added Timeline, Dashboard, and Calendar views on Standard+; AI additions are incremental — AI quick-capture on Standard, AI content generation on Premium — described by reviewers as "help drafting, not project intelligence."
|
||||||
|
- Signals: still huge — 50M+ global users, ~36,850 paying customers, subscriber base up ~25% YoY, and Trello is credited with contributing 700M+ of Atlassian's annual revenue. A 2025 UI redesign drew user backlash but doesn't appear to have dented growth.
|
||||||
|
- Common complaints: to match what competitors ship natively (Gantt, time tracking, reporting) users stack 3-4 paid Power-Ups, which can add $20-40/user/mo on top of the subscription — at that point it's pricier than ClickUp or monday.com; no native task dependencies; permission/visibility gaps flagged by teams handling sensitive data; automation (Butler) run limits bite on the free plan.
|
||||||
|
|
||||||
|
## Asana
|
||||||
|
|
||||||
|
- Positioning: work-management platform for cross-functional teams that need process (goals, portfolios, workflows) more than a raw board; positioned above Trello, competing with monday.com and ClickUp.
|
||||||
|
- Pricing: Free (Personal), Starter ~$10.99-13.49/user/mo, Advanced ~$24.99/user/mo (annual pricing varies by source/region); AI Studio ships with Starter+ and includes a monthly AI-credit allowance.
|
||||||
|
- Standout features / direction: multiple views (kanban board, timeline, calendar, Gantt-style); Asana AI adds Smart Status (auto-analyzes project health) and Smart Summaries (AI recap of task/project activity).
|
||||||
|
- Signals: large enterprise install base, steady public-company revenue growth; frequently named in "vs monday.com" comparison content, suggesting the two are each other's primary consideration set for buyers.
|
||||||
|
- Common complaints: kanban board has no WIP limits, a real gap for teams trying to run disciplined flow; pricing escalates fast at scale; time tracking and other useful features are gated behind higher tiers; users want AI available below the Starter tier.
|
||||||
|
|
||||||
|
## Monday.com
|
||||||
|
|
||||||
|
- Positioning: "Work OS" — a highly visual, color-coded board-and-automation platform sold to ops-heavy teams (marketing, CRM, HR) as much as engineering; broad horizontal play.
|
||||||
|
- Pricing: Basic $9/user/mo, Standard $12/user/mo, Pro $19/user/mo (annual), custom Enterprise; seats are sold in fixed blocks ("seat step"), not exact headcount.
|
||||||
|
- Standout features / direction: repositioned in May 2026 as an "AI work platform with agents that actually do work"; AI is bundled into paid-plan seat pricing, but included credits cover only trial-level usage before teams must buy add-on credit packs.
|
||||||
|
- Signals: public company (NASDAQ: MNDY); FY2025 revenue $1.232B (+27% YoY), Q1 2026 revenue $351.3M (+24% YoY), full-year 2026 guidance $1.466-1.474B; >$500K-ARR customer cohort grew 74% YoY; enterprise is now >42% of ARR; 250K+ customers, 2.5M+ paying users; GAAP-profitable with strong operating cash flow. This is the clearest financial-success story in the segment.
|
||||||
|
- Common complaints: "seat step" billing forces paying for more seats than headcount; Reddit threads report serious customer-support frustration ("avoid this software at all costs" is a recurring sentiment); one analysis found tool-migration-away-from-monday.com activity up 23% YoY, suggesting churn pressure even as revenue grows — a "winning on financials, losing some goodwill" profile.
|
||||||
|
|
||||||
|
## Jira (incl. Jira's kanban story)
|
||||||
|
|
||||||
|
- Positioning: the engineering/agile default — kanban is a first-class board type alongside Scrum, but Jira's gravity is issue-tracking-for-developers rather than general-purpose kanban; kanban here means WIP-limited flow tightly coupled to sprints, backlogs, and dev tooling (branches, PRs, CI).
|
||||||
|
- Pricing: Free (up to 10 users, unlimited projects/issues, Scrum+Kanban boards, but capped at 2GB storage, 100 automation runs/mo, 100 emails/day, community support only), Standard ~$6.52-7.91/user/mo, Premium ~$14.54/user/mo, custom Enterprise; Data Center (self-hosted) pricing rose ~15% in Feb 2026.
|
||||||
|
- Standout features / direction: kanban vs. Scrum boards are differentiated primarily by sprint structure (Scrum = fixed iterations + ceremonies + roles; Kanban = continuous pull-based flow with WIP limits, no forced estimation) — this dual-mode flexibility inside one tool is Jira's core kanban pitch.
|
||||||
|
- Signals: still the default for software teams; steady Atlassian-wide growth (see Trello section for shared Q3 FY2026 figures: revenue $1.787B, +32% YoY).
|
||||||
|
- Common complaints: the real cost is the ecosystem, not the seat — no native time tracking or docs (needs Tempo/Confluence, both extra), Marketplace add-ons that can double per-user spend; steep learning curve alienates non-developer teams trying to use it as a general project board; regular price increases on both cloud and self-hosted tiers.
|
||||||
|
|
||||||
|
## ClickUp
|
||||||
|
|
||||||
|
- Positioning: "one app to replace them all" — maximalist all-in-one (docs, chat, whiteboards, goals, time tracking, kanban and 15+ other views) aimed at teams who want to consolidate tool sprawl.
|
||||||
|
- Pricing: Free, then per-user paid tiers; pricing has been volatile enough that one Reddit user reported a bill jumping from $150 to nearly $1,200 overnight without warning.
|
||||||
|
- Standout features / direction: ClickUp Brain (AI) expanded in 2026 with predictive timelines, smart task prioritization, and auto meeting notes; company reports 400% YoY AI sales growth and 800% YoY AI usage growth (Sept 2025).
|
||||||
|
- Signals: $4B valuation (2021 raise), $537.5M total raised, ~$300M ARR (Sept 2025) with a public $1B ARR target for 2026 — but on May 21, 2026 the company laid off ~22% of its workforce (~290 of ~1,300 employees), explicitly framed as restructuring around AI, while simultaneously offering remaining staff up to $1M salaries. A genuinely mixed signal: strong top-line growth alongside a major structural reset.
|
||||||
|
- Common complaints: "everything app" breeds feature bloat, confusing roles/permissions, and navigation/performance lag (status-update delays, calendar data loss) that persisted into 2026 per Trustpilot/Reddit reports; AI has gaps (no external file uploads, inconsistent task creation, no AI on custom dashboards); consistent theme across reviews: "great once configured, not plug-and-play."
|
||||||
|
|
||||||
|
## Notion (boards/databases)
|
||||||
|
|
||||||
|
- Positioning: flexible all-purpose workspace (docs + wiki + database) where kanban is one of several views on top of a database, not a dedicated product; wins teams that want one tool for notes and lightweight project tracking together.
|
||||||
|
- Pricing: Free, Plus $10/user/mo, Business $20/user/mo, custom Enterprise; the standalone $10/mo AI add-on was eliminated in May 2025 — full AI (AI Agents, Ask Notion) now requires Business ($20/seat), which reviewers call "the floor for meaningful AI."
|
||||||
|
- Standout features / direction: database "views" (table, kanban, list, calendar, gallery, timeline) over the same underlying records is the core mechanic; 2025 pushed autonomous multi-step AI agents and scheduled automations, aimed at moving upmarket.
|
||||||
|
- Signals: $11B valuation (Jan 2026 employee tender), $600M ARR in 2025 (up from $300M in 2024), 100M+ users / 4M+ paying customers, headcount ~1,000 (up from 808 in 2024) — strong, sustained growth story with no new funding needed since 2021.
|
||||||
|
- Common complaints: AI credit system has no rollover and burns fast (a simple daily-summary agent can eat ~3,000 credits/mo, ~$30); Business-tier billing practices draw complaints (guest-to-member auto-conversion charges, a Sept 2024 policy locking teams into unused seats until renewal); as a kanban tool specifically, it's "brilliant for docs, frustrating for automation" — the board is a view, not a purpose-built workflow engine, so things like WIP limits and swimlanes are weak or absent.
|
||||||
|
|
||||||
|
## Linear
|
||||||
|
|
||||||
|
- Positioning: opinionated, fast, keyboard-first issue tracker for software teams who find Jira too heavy; deliberately narrow scope rather than all-in-one.
|
||||||
|
- Pricing: Free, Basic ~$10/user/mo (5 teams, unlimited issues, admin roles), Business ~$16/user/mo (unlimited teams, private teams, guests, Linear Insights, Zendesk/Intercom integration), custom Enterprise.
|
||||||
|
- Standout features / direction: Cycles (lightweight sprint planning without ceremony), extremely fast native-feeling UI; 2025 added AI features — auto-categorization, sprint suggestions, AI-drafted release notes.
|
||||||
|
- Signals: $52M raised, $400M+ valuation, 50+ employees (based on older figures likely understating current scale); reviewers consistently call it good value at its price points, and it has become the default "Jira alternative" recommendation for product-led dev teams.
|
||||||
|
- Common complaints: per-seat pricing burdens fast-growing teams; enterprise-grade features locked behind the pricier tier; deliberately narrow scope means teams that need Jira/ClickUp-style breadth (arbitrary custom fields, non-eng workflows) outgrow it.
|
||||||
|
|
||||||
|
## Airtable (kanban view)
|
||||||
|
|
||||||
|
- Positioning: database-first, spreadsheet-flexible platform where kanban is one of 8+ views on a table of records; pitched at ops/marketing teams building custom internal tools as much as at project managers.
|
||||||
|
- Pricing: Free, Team $20/user/mo, Business $45/user/mo, custom Enterprise Scale; a 40-seat Business deployment runs roughly $21,600/yr.
|
||||||
|
- Standout features / direction: multiple views per table (kanban, grid, calendar, gallery, dashboard, form) with independent filters/sorts per view — powerful for teams where different roles need different lenses on the same underlying data.
|
||||||
|
- Signals: mature, well-established in the ops/no-code space; frequently recommended as the go-to when kanban needs to be backed by relational data rather than freeform cards.
|
||||||
|
- Common complaints: billing is asymmetric (mid-cycle seat additions charge immediately, removals don't refund the current invoice); performance degrades well below the platform's published record limits; steep learning curve ("incredibly powerful but will make you question your life choices"); uncontrolled Editor-seat sprawl is a real budget risk at scale.
|
||||||
|
|
||||||
|
## Height — DEFUNCT (signal worth flagging)
|
||||||
|
|
||||||
|
- Positioning (historical): venture-backed "autonomous project collaboration tool," an AI-native alternative to Linear/Jira aimed at product teams, founded 2018 by ex-Stripe engineer Michael Villar.
|
||||||
|
- Funding: raised ~$18.3M total, including a $14M Series A (Redpoint Ventures, 2021).
|
||||||
|
- Trajectory: launched "Height 2.0" in October 2024, marketed as the first fully autonomous PM tool with an embedded AI reasoning engine (auto bug triage, backlog pruning, self-updating specs) — then just five months later, on March 20, 2025, announced a full shutdown, going offline September 24, 2025.
|
||||||
|
- Signal: the CEO gave no substantive public reason for the shutdown, and the timing (right after an ambitious, heavily-marketed AI pivot) strongly suggests the AI-autonomy bet didn't convert to sustainable revenue. This is the segment's clearest cautionary tale in 2024-2026: a well-funded, well-reviewed tool that bet everything on "AI does the project management for you" and folded anyway. Worth remembering when evaluating how much AI-agent framing to lead with versus treating it as one integration among several.
|
||||||
|
|
||||||
|
## GitHub Projects
|
||||||
|
|
||||||
|
- Positioning: free, built-in kanban/board layer over GitHub Issues and PRs, for teams whose work already lives in GitHub; not a standalone product.
|
||||||
|
- Pricing: free with no meaningful limits for public repos; included with GitHub plans otherwise.
|
||||||
|
- Standout features / direction: board view, roadmap view, and custom fields have matured well; genuinely good when a team's unit of work is already a GitHub issue.
|
||||||
|
- Signals: default choice for open-source and GitHub-native dev teams; adoption is a function of GitHub's own dominance rather than independent PM-market share.
|
||||||
|
- Common complaints: every card must be a GitHub issue — no concept of a freeform card, and creating items programmatically means going through GitHub's full auth model; large boards (50+ active cards) get slow and hard to scan; WIP-limit enforcement and cumulative-flow/cycle-time analytics vary by plan and are missing in several tiers; "too narrow for a general list" if work doesn't already live in GitHub.
|
||||||
|
|
||||||
|
## Basecamp (Card Table)
|
||||||
|
|
||||||
|
- Positioning: deliberately simple, bundled project-management-plus-communication suite from 37signals; Card Table is Basecamp's kanban-equivalent, one tool among Message Boards, Campfire chat, Check-ins, etc. Self-funded, no VC — a notably different company-structure signal than everything else in this list.
|
||||||
|
- Pricing: Plus $15/user/mo (unlimited projects, guest invites, 500GB storage); Pro Unlimited $299-349/mo flat (no per-seat pricing at all) — the flat tier is the better deal for teams of ~20+.
|
||||||
|
- Standout features / direction: Card Table defaults to columns like Triage / Figuring It Out / In Progress / Done, fully renamable/reorderable; cards carry title, assignee, due date, description, attachments, comments — intentionally shallow compared to competitors.
|
||||||
|
- Signals: stable, profitable, long-lived (5+ years of reliability cited approvingly by users); no growth-at-all-costs narrative — 37signals is famously anti-VC and anti-feature-bloat by philosophy.
|
||||||
|
- Common complaints: task management is "too simple" for complex workflows versus Asana/Jira; minor bugs (e.g., copied cards/to-dos retaining original creation dates); reviewers frame the value proposition bluntly: "worked in 2010, hard to justify in 2026 unless you specifically value simplicity over capability" — flat pricing is the main thing keeping it competitive.
|
||||||
|
|
||||||
|
## MeisterTask
|
||||||
|
|
||||||
|
- Positioning: design-forward kanban tool from the MindMeister family (mind-mapping heritage), aimed at small-to-mid teams who want an attractive, easy board plus light time tracking.
|
||||||
|
- Pricing: Free (Basic), Pro ~€13.5/user/mo (~$4.99 annual), Business ~€24/user/mo (~$12.99 annual), custom Enterprise.
|
||||||
|
- Standout features / direction: built-in time tracking and MindMeister mind-map integration are the differentiators; 2026 added "Meister AI" (note search + writing assistant) on tiered AI credits.
|
||||||
|
- Signals: positioned as a budget-friendly, well-liked small-team tool; strong design/UX praise is the consistent throughline in reviews.
|
||||||
|
- Common complaints: bugs and slow customer support recur in reviews; admin visibility and viewer-permission limitations; mobile app lags the web app; and — notably — pricing has crept up to Asana-territory levels "without offering anywhere near the tools or polish," per one reviewer, i.e. the segment's classic small-vendor risk of pricing outrunning perceived value.
|
||||||
|
|
||||||
|
## Kanbanize / Businessmap
|
||||||
|
|
||||||
|
- Positioning: enterprise-grade, analytics-heavy kanban for teams practicing formal Kanban method / Lean flow management, rebranded from Kanbanize to Businessmap to reflect a broader "align strategy to daily work" (OKRs, portfolio, whiteboards) pitch.
|
||||||
|
- Pricing: from ~$149/yr for 15 users up to ~$10/user/mo tiers depending on plan; no free tier for individuals.
|
||||||
|
- Standout features / direction: deep analytics module — cumulative flow, cycle time, task distribution, block-resolution time, created-vs-finished charts, task hierarchy — is the clear differentiator versus lighter kanban tools; strong automation and integration support.
|
||||||
|
- Signals: well-regarded in Gartner Peer Insights for Kanban/PPM use cases specifically; positioned as the choice when a team is serious about flow metrics rather than just a visual board.
|
||||||
|
- Common complaints: steep setup/learning curve given the feature depth; no free tier limits trial-ability for individuals; occasional friction in the advanced-analytics and card-management UI.
|
||||||
|
|
||||||
|
## Zenkit
|
||||||
|
|
||||||
|
- Positioning: flexible, view-agnostic work-management suite (part of a small European "Zenkit family" of apps including Zenkit To Do and Zenkit Hypernotes) aimed at small teams wanting Trello-like simplicity with more view options.
|
||||||
|
- Pricing: Free (Personal), Plus ~$9/user/mo (~€8 annual, up to 10 users), Team ~€12/user/mo annual (unlimited users, advanced Gantt), Business ~$25/user/mo (~€21 annual, API/SCIM/SSO), custom Enterprise.
|
||||||
|
- Standout features / direction: kanban, table, mind map, calendar, and list views over shared data; reporting flexibility (700+ report combinations cited); real-time collaboration.
|
||||||
|
- Signals: smaller player, praised for a generous free tier and fair small-team pricing rather than for scale or growth headlines.
|
||||||
|
- Common complaints: limited offline functionality (notable gap versus a filesystem-first tool); mobile app lacks features present on desktop/web; reminder system seen as weaker than dedicated task-reminder apps; support seen as improved but still occasionally slow.
|
||||||
|
|
||||||
|
## Miro (kanban)
|
||||||
|
|
||||||
|
- Positioning: infinite-canvas visual whiteboard where kanban is one structured layout among many (alongside diagrams, sticky-note brainstorms, retros); wins teams that want workshop/ideation and board tracking in the same tool.
|
||||||
|
- Pricing: subscription tiers by feature/team size, monthly or annual; notably charges for every viewer, not just editors, which is a frequent point of user friction.
|
||||||
|
- Standout features / direction: Miro AI can generate a kanban board directly from document notes or a prompt, and AI is being woven into Tables/Timelines as well — leaning into "AI drafts the board structure for you."
|
||||||
|
- Signals: broad enterprise adoption as the default whiteboard tool, with kanban as a secondary but growing use case; strong G2/Gartner presence.
|
||||||
|
- Common complaints: pricing is "pricey" and per-viewer billing is unpopular; free-tier board resolution/access is restricted; performance slows on large or complex boards; boards become hard to manage once they grow past initial planning-stage scope — i.e., Miro's kanban is good for lightweight/visual use but doesn't hold up as a long-running system of record for ongoing work.
|
||||||
|
|
||||||
|
## Wrike / Teamwork (brief)
|
||||||
|
|
||||||
|
- Wrike: positioning as a capable, "polarizing" enterprise work-management platform; pricing from $9.80/user/mo up to $25/user/mo (Business); Board/kanban view is intuitive early but cards get cramped once subtasks pile up; advanced reporting, budgeting, and key integrations are gated to Business+ — reviewers call it powerful but excessive for small teams with simple workflows.
|
||||||
|
- Teamwork.com: pricing from $10/user/mo up to Grow at $24.99/user/mo; positioned similarly to Wrike for client-services/agency work; kanban is a core but not differentiating feature; both tools compete primarily on "can it also do budgeting/client-billing," not on kanban quality specifically.
|
||||||
|
|
||||||
|
## Notable newcomers, 2024-2026
|
||||||
|
|
||||||
|
- **Sprout** (sproutformac.com, Product Hunt Dec 2025) — a native macOS kanban board built by an independent developer (Elio Raineri), explicitly positioned against feature bloat: no accounts, no notifications, offline-first, $0.99/mo or $9.99/yr with a free tier. Storage is CoreData + iCloud sync, not plain files on disk, and boards export to JSON rather than living as an editable folder — so it validates the demand for "simple native Mac kanban" but does not occupy Lanework's filesystem-as-source-of-truth niche. Closest thing found in this pass to a direct stylistic precedent; worth a fuller look in the native/indie segment doc (03) rather than here.
|
||||||
|
- **Kanban Kanban** (Product Hunt Nov 2025) — a novelty/recursive concept where every card opens into its own nested board ("Kanban within Kanbans"); still in validation phase, more a design experiment than a competitor.
|
||||||
|
- Broader pattern: most genuinely new entrants in 2024-2026 are AI-agent-framed (per the market-trends search, "AI in Project Management" is projected to grow from $5.32B in 2025 to $14.14B by 2030 at ~22% CAGR) — but Height's collapse right after its own AI-autonomy relaunch is a strong counter-signal that "AI does your PM for you" alone isn't yet a durable product, even well-funded.
|
||||||
|
|
||||||
|
## Patterns across this segment
|
||||||
|
|
||||||
|
- **Monetization is almost universally per-seat SaaS**, usually 3-4 tiers (Free / Starter / Business / Enterprise) with the real feature ceiling (advanced automation, SSO, analytics, unlimited AI) reserved for the top two tiers; Basecamp's flat-fee Pro Unlimited tier is the one deliberate outlier, and reviewers consistently flag it as refreshing precisely because it's an outlier.
|
||||||
|
- **AI is now default table stakes, but monetized separately from the base seat** — nearly every vendor above added AI credits, add-on tiers, or AI-gated top plans in 2025-2026, and nearly every vendor's user complaints include some version of "the credits run out fast" or "AI should be included, not upsold." This is a live, unresolved tension across the whole segment, not a solved problem — an opportunity for any tool that doesn't need to meter AI because it isn't running the AI's compute.
|
||||||
|
- **Feature breadth is the main axis of both differentiation and complaint.** The all-in-one players (ClickUp, monday.com, Notion, Airtable) win deals by consolidating tool sprawl but consistently draw "bloat," "complexity," and "confusing pricing" complaints; the narrow players (Linear, Basecamp, GitHub Projects) draw praise for focus and speed but "outgrow it" complaints once teams need more. Nobody has cracked staying simple while also being complete.
|
||||||
|
- **Kanban itself is rarely the product — it's a view.** Notion, Airtable, Miro, and Basecamp all treat the board as one lens over some other underlying primitive (a database, a canvas, a to-do list). Only Trello, Jira, and the dedicated Kanban-method vendors (Businessmap, Zenkit, MeisterTask) treat kanban as the primary mental model — this maps onto Lanework's own bet that a dedicated, opinionated kanban tool has room next to the generalists.
|
||||||
|
- **Offline/local-first is a near-universal gap.** Every reviewed tool that mentions offline support treats it as a degraded fallback mode (cached view, sync-on-reconnect), never as the primary architecture; the one tool built local-first from day one (Sprout) is a solo-indie Mac app, not a team tool, and even it uses CoreData+iCloud rather than plain files. No mainstream team-kanban product treats "the board is just files you can point git or an editor at" as a feature — this remains open ground.
|
||||||
|
- **Signals of health are decoupled from user sentiment.** monday.com and Notion are financially thriving (strong ARR growth, profitability, high valuations) while simultaneously generating real churn/complaint signal on Reddit (support quality, seat-step billing, AI credit exhaustion); ClickUp is growing ARR fast and cutting 22% of staff in the same year. Financial success in this segment does not require having resolved the UX/pricing complaints — there's a wide gap between "big and growing" and "loved," and that gap is where smaller, opinionated tools find room.
|
||||||
|
- **AI-native repositioning is high-risk when it replaces rather than augments the core product.** Height's shutdown five months after going all-in on "autonomous PM" is the sharpest cautionary data point in this pass: a funded, well-reviewed tool bet the company on AI doing the PM work, and it didn't save them. The safer pattern among survivors (Notion, ClickUp, Trello, Linear) is AI as an added capability layered onto an already-working core product, not a replacement for it.
|
||||||
|
- **Where the segment is heading:** continued AI-agent framing (autonomous status updates, auto-triage, AI-drafted boards) as the primary marketing surface for 2026; continued per-seat pricing with AI carved out as a separate metered layer; consolidation pressure (all-in-one suites absorbing point-solution features) balanced against a steady trickle of focused, fast, opinionated alternatives (Linear-style) winning specific technical audiences; and early, still-marginal interest in data sovereignty / local-first as a reaction to cloud-dependency fatigue, which is the crack in this segment's armor that a filesystem-native tool like Lanework can credibly target.
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# Native macOS / Indie / Personal Kanban & Plain-Text-Adjacent Tools
|
||||||
|
|
||||||
|
Scope: this is Lanework's most direct competitive neighborhood — native or near-native macOS kanban apps built by indies, plus the wider ring of plain-text and markdown/file-based task tools (PKM apps with board views, open-source self-hosted kanban, CLI/plain-text ecosystems, and the newly-emerging category of AI-agent-native task trackers). For every app the key axis tracked is data model: does it own a proprietary database, or does it store state as files a human (or agent, or `git`) can read and edit directly. This is a broad first pass, not exhaustive — it's meant to map the neighborhood and surface patterns, not to be a definitive database.
|
||||||
|
|
||||||
|
Researched: 2026-08-01
|
||||||
|
|
||||||
|
## 1. Native macOS indie kanban apps (direct competitors)
|
||||||
|
|
||||||
|
### Kanbanier
|
||||||
|
One-line: polished native Mac/iOS kanban board app, the closest thing to an "established" indie kanban incumbent on the Mac App Store.
|
||||||
|
Platform: macOS + iOS, native (Swift/AppKit-family), historically on the Mac App Store.
|
||||||
|
App Store: **apparently delisted** (checked 2026-08-01) — the widely-cited Mac (id1206854548) and iOS (id1206858343) listings both 404, and the iTunes Search API returns no hits for the app or its developer in either storefront, while kanbanier.com still markets it as live. A quiet delisting is a stronger decline signal than anything else in this entry.
|
||||||
|
Data model: proprietary — boards live in the app's own storage, exported/synced via iCloud; no user-facing files.
|
||||||
|
Pricing: one-time purchase, ~$15, with a free tier of limited functionality; in-app purchase unlocks swimlanes/sublanes.
|
||||||
|
Standout features: swimlanes and hierarchical sub-lanes, tags/colors, iCloud sync, cross-device (iOS+Mac) support, no accounts/subscriptions.
|
||||||
|
Sync: iCloud only.
|
||||||
|
Signals: has been around for years (multiple SEO/download-mirror sites index it) and was the closest thing to an established indie incumbent — but the apparent App Store delisting (above) reclassifies it from "mature/low-growth" to likely abandoned or withdrawn; the incumbent slot on the MAS shelf may be effectively vacant.
|
||||||
|
|
||||||
|
### Kanbanero
|
||||||
|
One-line: minimalist native Mac kanban board explicitly built around "boards are files" — the single closest analog to Lanework's own pitch in this whole survey.
|
||||||
|
Platform: macOS only, native, universal binary, macOS 12+.
|
||||||
|
App Store: [Mac](https://apps.apple.com/us/app/kanbanero/id1645553955?mt=12)
|
||||||
|
Data model: **each board is a plain JSON file in a folder you pick** — explicitly pitched as diffable/git-friendly ("check them into your project repo, review changes in diffs, keep task state alongside your code").
|
||||||
|
Pricing: one-time $8.99 on the Mac App Store, no subscriptions/accounts/IAP.
|
||||||
|
Standout features: keyboard-driven (⌘N new task, ⌘L switch project), templates, themeable (fonts, accent colors, animated shader backgrounds), sync delegated to whatever the user already uses (iCloud Drive, Dropbox, Syncthing, git) rather than built-in.
|
||||||
|
Sync: none built-in by design — user puts the folder wherever they already sync.
|
||||||
|
Signals: small/new, marketed almost entirely on the "no accounts, no cloud, no tracking, your data is a file" pitch — i.e. explicitly courting the same buyer Lanework is courting, but with JSON rather than Markdown+folder-structure, and no first-class git/editor/agent story beyond "it's just a file."
|
||||||
|
|
||||||
|
### Sprout
|
||||||
|
One-line: newer (Dec 2025 Product Hunt launch) "calm," minimalist native Mac kanban app pitched against feature bloat.
|
||||||
|
Platform: macOS, native, Mac App Store.
|
||||||
|
App Store: [Mac](https://apps.apple.com/us/app/sprout-kanban-board-tasks/id6752939873?mt=12)
|
||||||
|
Data model: proprietary — Core Data, not user-facing files.
|
||||||
|
Pricing: free with an optional Pro/lifetime upgrade.
|
||||||
|
Standout features: zero onboarding, no accounts, offline-first, iCloud sync across devices, no notifications/ads.
|
||||||
|
Sync: iCloud.
|
||||||
|
Signals: fresh Product Hunt launch, positions directly against "everything apps" (implicitly Notion/ClickUp) — validates that "calm, native, boring-in-a-good-way kanban" is a currently-active niche indies are still entering.
|
||||||
|
|
||||||
|
### Kanba
|
||||||
|
One-line: free-with-IAP simple kanban board on the Mac/iOS App Store.
|
||||||
|
Platform: iOS-first; runs on Mac only via Apple Silicon's "iPad apps on Mac" compatibility (checked 2026-08-01), not a dedicated macOS binary — which explains the macOS 15+/Apple Silicon requirement.
|
||||||
|
App Store: [Mac + iOS (universal listing)](https://apps.apple.com/us/app/kanba-kanban-project-board/id6499422529)
|
||||||
|
Data model: proprietary, iCloud-synced.
|
||||||
|
Pricing: free with in-app purchases (amounts not published on listing).
|
||||||
|
Standout: clean layout, no sign-in, works offline, dark mode.
|
||||||
|
Sync: iCloud.
|
||||||
|
Signals: generic entrant, low differentiation; illustrates how crowded the "simple native kanban" MAS shelf is.
|
||||||
|
|
||||||
|
### Truly Simple Kanban
|
||||||
|
One-line: minimalist single-board-free kanban targeted at makers/students.
|
||||||
|
Platform: macOS + iPhone, native.
|
||||||
|
App Store: [Mac + iOS (universal listing)](https://apps.apple.com/us/app/truly-simple-kanban/id6746863668)
|
||||||
|
Data model: proprietary.
|
||||||
|
Pricing: free for one board; $4.99 one-time lifetime IAP for unlimited boards.
|
||||||
|
Standout: deliberately narrow scope, no subscriptions/hidden fees.
|
||||||
|
Signals: another data point that $0–5 one-time is the going rate for "simple kanban" on MAS.
|
||||||
|
|
||||||
|
### TidyCards
|
||||||
|
One-line: older, once-well-regarded native Mac kanban app now showing its age.
|
||||||
|
Platform: macOS, native.
|
||||||
|
App Store: [Mac](https://apps.apple.com/us/app/tidycards-awesome-kanban/id1285199566?mt=12)
|
||||||
|
Data model: proprietary; supports drag-and-drop file/calendar/email attachments into cards.
|
||||||
|
Pricing: paid app (legacy MAS pricing).
|
||||||
|
Standout (historically): considered by some reviewers "the best Mac native, dedicated kanban app" for its native-feel simplicity.
|
||||||
|
Complaints: reports of the app hanging/freezing after being open a while, requiring force-quit; no updates in ~2 years per App Store listing; developer only sporadically responsive.
|
||||||
|
Signals: a cautionary tale — a well-loved native indie kanban app that stalled once the solo dev's attention moved on, and users noticed and complained loudly about staleness/bugs. Relevant to Lanework as evidence that in this niche, users will tolerate few features but not bugs/abandonment.
|
||||||
|
|
||||||
|
### WipQ – Kanban Projects
|
||||||
|
One-line: kanban for solo professionals/small teams with an emphasis on WIP limits and full native macOS document model integration.
|
||||||
|
Platform: macOS + iOS (macOS 11+/iOS 13+), native, uses Apple's document architecture (versioning, tabs, full screen, etc.).
|
||||||
|
App Store: [Mac](https://apps.apple.com/us/app/wipq-kanban-projects/id1235518457?mt=12)
|
||||||
|
Data model: proprietary, but **data stored locally on the Mac or in iCloud Drive**, explicitly "not a third party web service."
|
||||||
|
Pricing: paid MAS app.
|
||||||
|
Standout: customizable WIP limits, inter-card dependencies, card locking, favorites/priorities, file attachments, multiple card-display themes.
|
||||||
|
Signals: one of the more feature-rich native entrants (closer to "real" kanban/WIP discipline than most indie boards), but data model is still app-owned files-in-iCloud-Drive rather than user-editable markdown.
|
||||||
|
|
||||||
|
### Propel
|
||||||
|
One-line: open-source, zero-dependency native SwiftUI kanban app for personal task management, tuned for content-creator workflows (blog posts, talks, videos, podcasts).
|
||||||
|
Platform: macOS 15+, native SwiftUI/Swift 6, no Xcode project (SPM only).
|
||||||
|
Data model: **human-readable JSON** in `~/Library/Application Support/Propel/`, atomic writes with backup, 1s-debounced autosave — file-based but not markdown, and not a user-chosen folder (it's tucked in Application Support rather than a project folder).
|
||||||
|
Pricing: free, MIT-licensed, open source.
|
||||||
|
Standout: global hotkey capture, 3 boards with ⌘1/2/3 switching, weekly review summaries, auto-archive of week-old completed cards, menu bar stats.
|
||||||
|
Signals: 11 GitHub stars/2 forks — small hobby project, but demonstrates the same instinct (native SwiftUI + plain files) an indie developer would reach for; useful as a build-pattern reference more than a market threat.
|
||||||
|
|
||||||
|
### FileBoard: Kanban for Files
|
||||||
|
One-line: turns any real folder on disk into a kanban board where **columns are subfolders and cards are the actual files in them** — not markdown cards, but literally your files.
|
||||||
|
Platform: macOS only, released Dec 2025 (very new, v1.1 as of late Dec 2025).
|
||||||
|
App Store: [Mac](https://apps.apple.com/us/app/fileboard-kanban-for-files/id6745226062?mt=12)
|
||||||
|
Data model: **the filesystem itself is the board** — no separate database, no card content of its own; dragging a card between columns physically moves the file between subfolders.
|
||||||
|
Pricing: free to download, "FileBoard Lifetime" IAP at $0.99.
|
||||||
|
Standout: real-time two-way sync with Finder (no duplication), file preview pane, folder tree browsing, keyboard-first navigation.
|
||||||
|
Signals: brand new, not enough MAS reviews yet to gauge reception, but conceptually the single closest cousin to Lanework found in this research — same "folder structure IS the board" idea, just applied to arbitrary files/folders rather than a markdown-card-per-task model with frontmatter/metadata. Worth watching as it matures; currently much thinner (no card metadata, tags, dates, etc.) than what Lanework offers.
|
||||||
|
|
||||||
|
### Brisqi
|
||||||
|
One-line: cross-platform "offline-first personal kanban" desktop+mobile app.
|
||||||
|
Platform: Windows/macOS/Ubuntu desktop + mobile, appears Electron-based.
|
||||||
|
Data model: local device storage, CSV export, no user-facing markdown/files during normal use.
|
||||||
|
Pricing: one-time purchase (amount not listed on landing page), pitched explicitly against subscription fatigue.
|
||||||
|
Standout: markdown-formatted card descriptions, monthly calendar view, "List Focus Mode," card linking, custom backgrounds, bulk paste-to-create.
|
||||||
|
Sync: none — local only, manual backup.
|
||||||
|
Signals: same buyer profile as Kanbanero/Sprout (privacy, offline, one-time price) but cross-platform Electron rather than native Mac.
|
||||||
|
|
||||||
|
## 2. Freeform / notebook apps with kanban as a feature
|
||||||
|
|
||||||
|
### Curio (Zengobi)
|
||||||
|
One-line: the Mac's long-running "advanced notebook" app — freeform idea-space canvases that include Kanban "Stacks" as one of many card/board tools alongside mind maps, outlines, and tables.
|
||||||
|
Platform: macOS, native, on the Mac App Store since long before most kanban-specific apps existed.
|
||||||
|
App Store: [Mac](https://apps.apple.com/us/app/curio-notes-ideas-mind-map/id1475550603?mt=12)
|
||||||
|
Data model: proprietary Curio project files stored locally on the Mac (offline-capable), not markdown.
|
||||||
|
Pricing: dual model — Mac App Store subscription (Standard $4.99/mo or $49.99/yr; Professional $6.99/mo or $69.99/yr) **or** a one-time license key purchased directly from Zengobi (current version + 1 year of updates, then owned outright). This "subscribe via MAS or buy a perpetual key direct" dual-track pricing is a notable, still-rare pattern worth flagging.
|
||||||
|
Standout: kanban is a minor feature inside a much larger freeform PKM/mind-mapping tool; not a kanban-first product.
|
||||||
|
Signals: long-lived (Zengobi has sold Curio for close to two decades), loyal niche audience of "notebook" power users; not really competing for the "I just want a simple board" buyer.
|
||||||
|
|
||||||
|
### Walling
|
||||||
|
One-line: card-based whiteboard/PKM app that combines a spatial "wall" view with a dedicated Kanban view over the same cards ("bricks").
|
||||||
|
Platform: web app (cross-platform via browser), not native Mac.
|
||||||
|
Data model: cloud/proprietary — data stored on Walling's servers, encrypted in transit and at rest; no local-file option.
|
||||||
|
Pricing: free up to 100 bricks; Premium ~$5–8/month (or $59.99/yr annual); Team plans from $8/user/month.
|
||||||
|
Standout: dual visual (freeform whiteboard) + kanban views of the same card set, OCR on images, templates.
|
||||||
|
Signals: this is the opposite end of the spectrum from Lanework — cloud-hosted, subscription, no local-file story — useful mainly as a contrast case (what "modern whiteboard SaaS" pricing/positioning looks like).
|
||||||
|
|
||||||
|
### Craft
|
||||||
|
One-line: polished all-in-one docs/notes app (Notion-adjacent) with a Kanban *view* over any Collection, not a dedicated kanban product.
|
||||||
|
Platform: native-feeling apps on Mac/iOS/Windows/web, but backed by Craft's own cloud sync.
|
||||||
|
App Store: [Mac + iOS (universal listing)](https://apps.apple.com/us/app/craft-notes-documents-ai/id1487937127)
|
||||||
|
Data model: proprietary Craft documents/blocks; Kanban view auto-generates a Status field (To do/In progress/Done) grouping cards by a field value — not files on disk.
|
||||||
|
Pricing: Free tier (1,500 blocks, 1GB storage, 15 AI credits/mo, 7-day history); Plus ~$8/mo (~$4.80/mo promo annual); Family $15/mo; Team $50/mo.
|
||||||
|
Standout: kanban is one of several interchangeable views (table, board, gallery) over structured "Collections"; strong design polish.
|
||||||
|
Signals: represents the "beautiful all-in-one workspace with a kanban view bolted on" category — direct evidence that kanban-as-a-feature inside a bigger subscription product is a well-trodden path Lanework is deliberately not taking.
|
||||||
|
|
||||||
|
## 3. File-based / markdown-native local kanban apps (closest philosophical peers)
|
||||||
|
|
||||||
|
This cluster is the most important for Lanework: every one of these stores board state as plain files on disk rather than in a database, which is Lanework's core bet.
|
||||||
|
|
||||||
|
### Signboard
|
||||||
|
One-line: local-first desktop kanban app built explicitly on the "file over app" philosophy — **lists are folders, cards are Markdown files**, moving a card between lists literally moves the file between folders.
|
||||||
|
Platform: macOS/Windows/Linux, **Electron** (not native) — Node.js/TypeScript codebase, universal binary on Mac.
|
||||||
|
Data model: cards = individual Markdown files with frontmatter for metadata (dates, labels, links); lists = directories; boards can be nested directly inside an Obsidian vault (e.g. `Vault/Project/Signboard/<Board Name>/`).
|
||||||
|
Pricing: MIT-licensed source; free for personal use, requested $49 one-time "sponsorship" for commercial/work use (honor system), optional tips.
|
||||||
|
Standout features: multiple views (Kanban drag-and-drop, dense Table view, Planner with Calendar/Week/Day/Agenda), Smart Actions powered by *local* LLMs, an MCP server for AI-agent integration, a CLI for scripting/automation, checklists with progress counters, start/due dates with notifications, Obsidian vault interop.
|
||||||
|
Sync: none built-in — relies on the folder being placed in iCloud Drive/Dropbox/Obsidian Sync/git, same as Lanework's approach.
|
||||||
|
Traction: ~83 GitHub stars, 8 forks, actively developed (devlog, v1.6.0 recently shipped with performance work), built by Colin Devroe (a known indie/blogging-world figure), launched publicly in fall 2025.
|
||||||
|
Deep-dive addendum (2026-08-01): launched Sept 16, 2025 deliberately unfinished ("very early and basic," no app icon) to invite community input; reached 1.6.0 by July 2026 — 1.4 added the Planner workspace (Calendar/This Week/Day/Agenda), 1.5 deepened Obsidian integration (properties, Bases generation, deep links, companion plugin), 1.6 added Smart Actions (local Ollama), start dates, and Table-view bulk actions. Stated principles: open source, performance, *clean markdown with minimal frontmatter*, no heavy frameworks (plain HTML/CSS/JS in Electron), minimal dependencies, zero web requests. Imports from Trello exports, Obsidian markdown boards, and Tasks.md workspaces — it is deliberately positioning as the consolidation point for the whole file-based-board niche. Multiple boards in tabs; labels with colors; card- and task-level due dates; Quick Add; archive/restore; notifications and a local calendar feed; MIT-licensed with honor-system $49 commercial payment plus tip jar. AI/MCP was not part of the original launch pitch (the Sept 2025 launch post doesn't mention agents at all) — it was added along the way, so its agent story is a bolt-on, not the founding thesis. Third-party coverage so far is thin (casual "looks interesting" blog mentions, no substantive reviews found), consistent with the ~83-star count: philosophy-aligned but small.
|
||||||
|
Schema (from docs/using-signboard.md + MIT source, 2026-08-01): board = folder; list = subfolder with a numeric ordering prefix in the folder name (reorder renames directories); card = single `.md` file named `[order-prefix]-[title]-[random-id].md` (retitle/reorder renames the file); frontmatter is deliberately flat and Obsidian-property-friendly — `title`, `signboard_id`, `signboard_board`, `signboard_list`, `status`, `signboard_uri`, `related`, `linked_objects` — note `signboard_board`/`signboard_list` denormalize location into the file, duplicating what the path already says; checklist-item dates are inline body syntax `(start:|due:|scheduled: YYYY-MM-DD)`; `board-settings.md` at root; archive = `XXX-Archive` folder. No schema version key, no stated round-trip/preservation guarantees, no formal spec document — the format is defined by prose docs plus the MIT code. Key structural contrasts with Lanework: identity lives in mutable filenames (vs immutable UUID folder names), ordering lives in name prefixes causing rename cascades (vs gapped fractional ranks rewriting one file), location is duplicated into frontmatter where hand/agent moves can leave it stale (vs level-is-position with zero redundancy), and cards are single files with reference-style linked objects (vs folder-per-card with real `attachments/`). Full analysis: [07-schema-comparison-signboard.md](07-schema-comparison-signboard.md).
|
||||||
|
**This is the single most directly comparable competitor found in this research.** Same file-over-app philosophy, same "folder=list, file=card" model, same explicit agent/MCP-friendliness pitch — but Electron rather than native macOS, and a paid-commercial-use/free-personal-use model rather than Lanework's presumed pricing. A native, faster, more Mac-idiomatic Lanework is a legitimate differentiation story against Signboard specifically.
|
||||||
|
|
||||||
|
### Tasks.md
|
||||||
|
One-line: the most established member of this cluster — a **self-hosted web app** where lanes are directories and tasks are Markdown files, no database at all.
|
||||||
|
Platform: self-hosted via single Docker image (Railway/Zeabur one-click deploys exist); frontend SolidJS, backend Koa/Node; accessed via browser, installable as a PWA.
|
||||||
|
Data model: filesystem is the database — one directory per lane, one `.md` file per task; subdirectories can be opened as separate "projects."
|
||||||
|
Pricing: free, MIT-licensed, open source.
|
||||||
|
Standout: theme support (Adwaita/Nord/Catppuccin) that follows OS light/dark, reverse-proxy subpath support, deliberately narrow feature scope "for long-term sustainability" per the maintainer.
|
||||||
|
Traction: **2.2k GitHub stars, 105 forks** — by far the most-starred file-based kanban tool found in this survey, suggesting real demand for exactly this "markdown files as kanban" model, just currently served by a self-hosted web tool rather than a native Mac app.
|
||||||
|
Signals: proves the underlying idea (folder=column, file=card, no DB) has substantial developer-community appetite; a native Mac app doing the same thing with better UX is a clear opportunity, which is exactly Lanework's bet.
|
||||||
|
|
||||||
|
### Kanban MD (kanbanmd.lecaro.me)
|
||||||
|
One-line: tiny desktop app that treats a handful of local text files as a kanban board — free, fast, offline, currently Linux-only.
|
||||||
|
Data model: plain text files, no cloud.
|
||||||
|
Signals: another indie solo-dev experiment validating the "text files as board" idea; Linux-only today means no direct macOS overlap yet, but shows the pattern recurring independently across multiple developers (Tasks.md, Signboard, Kanban MD, Kanbanero, banban, KanbanFiles, FileBoard all converged on similar ideas independently in roughly the same 2024-2026 window).
|
||||||
|
|
||||||
|
### KanbanFiles
|
||||||
|
One-line: native Windows desktop app that turns folders and markdown files into a two-way-synced kanban board (cards = markdown files as columns' contents).
|
||||||
|
Platform: Windows only (per search results) — not currently a Mac threat, but conceptually identical territory.
|
||||||
|
|
||||||
|
### kanban.md / Kanban Markdown (VS Code extensions)
|
||||||
|
One-line: editor extensions ("VS Code meets Kanban") that render a markdown file's nested lists as a draggable board directly inside the code editor.
|
||||||
|
Signals: shows the pattern extending into "kanban as an editor feature" rather than a standalone app — relevant to Lanework's "text editors are first-class users of a board" thesis, since these tools assume the *editor*, not a bespoke app, is where you'll often view the board.
|
||||||
|
|
||||||
|
### MarkdownTaskManager (ioniks)
|
||||||
|
One-line: small open-source local-first Kanban task manager that turns markdown files into an interactive board, no database or server.
|
||||||
|
Signals: yet another indie project independently reinventing the same "markdown files → kanban" pattern — further evidence of a real, recurring itch developers keep scratching for themselves.
|
||||||
|
|
||||||
|
### banban (two unrelated projects, same name)
|
||||||
|
One-line: (1) HubertK05/banban — productivity app inspired by GitHub Projects' kanban; (2) m0g/banban — self-hosted kanban board tool. Both small open-source projects, not directly Mac-native, but both part of the same "roll your own lightweight kanban" wave.
|
||||||
|
|
||||||
|
### imdone
|
||||||
|
One-line: the oldest/most mature entry in this space — turns `TODO`/`FIXME` code comments *and* markdown task files into a kanban board, tightly integrated with your editor and codebase.
|
||||||
|
Platform: Imdone Desktop app, plus VS Code and Obsidian integrations; imdone-core is an open-source "text based kanban processor" npm package.
|
||||||
|
Data model: **local-first**, markdown + code-comment-based; a CLI can pull Jira/GitHub issues into the repo as markdown files which then render as cards on the board.
|
||||||
|
Pricing: Imdone Desktop, ~$14.99 (a 2020s TechRepublic piece flagged its price drop to this level).
|
||||||
|
Standout: bridges the gap between "task tracker" and "code," making TODO comments in source files first-class kanban cards — a different but adjacent take on "the filesystem/repo is the board."
|
||||||
|
Signals: imdone has existed since well before the current file-based-kanban wave (it's the "OG" of this niche) and is still maintained; worth understanding as the elder statesman of the category, more code-centric than Lanework's general-purpose board.
|
||||||
|
|
||||||
|
## 4. Open-source / self-hosted kanban (broader OSS category, mostly team/web-first)
|
||||||
|
|
||||||
|
### Kanri
|
||||||
|
One-line: fully offline, cross-platform (Windows/macOS/Linux) Tauri+Nuxt kanban app, 100% free and open source.
|
||||||
|
Data model: **entire board state in one local JSON file** — no server, no accounts.
|
||||||
|
Pricing: free/OSS.
|
||||||
|
Standout: unlimited boards/columns/cards, zero-latency offline, "if you want no one else looking at your data, this is the way" (paraphrased user sentiment).
|
||||||
|
Complaints: no attachments, no card-to-card links (per a reviewer).
|
||||||
|
Signals: HN launch thread existed (id 36745421) showing the "offline-first personal kanban" pitch resonates on HN, though direct thread content wasn't retrievable here; broadly the JSON-file/Tauri sibling of Kanbanero, but cross-platform and free rather than Mac-only and paid.
|
||||||
|
|
||||||
|
### Focalboard
|
||||||
|
One-line: OSS Trello/Notion/Asana alternative from Mattermost, offered both as a team server and as a **standalone single-user "Personal Desktop" app** for Mac/Windows/Linux.
|
||||||
|
Data model: own database (not markdown files) even in the personal desktop edition.
|
||||||
|
Pricing: free/OSS; Mattermost Boards cloud/server editions exist for teams.
|
||||||
|
Signals: mainly a team-collaboration/self-hosted-SaaS play; the personal desktop edition is a lesser-known side door that competes loosely with indie personal-kanban apps, but without the file-based data model. Its Mac App Store listing (id1556908618) appears delisted as of 2026-08-01 — Mattermost's docs still describe a MAS edition, but the listing 404s and store search doesn't surface it — so the desktop edition is effectively direct-download/build-only now.
|
||||||
|
|
||||||
|
### Planka
|
||||||
|
One-line: OSS, Trello-look-alike self-hosted kanban (React/Node/Postgres), known for visual polish and snappy performance.
|
||||||
|
Data model: own Postgres DB, not files.
|
||||||
|
Licensing: **not** OSI-approved OSS — a custom "Fair Use License" that bars operating Planka as a hosted service for third parties and cross-company account sharing without a paid Pro tier.
|
||||||
|
Signals: primarily a self-hosted team tool; Docker-deployed (8M+ pulls) — relevant as market-size evidence for "people want to self-host their own Trello," but not a personal-file-based competitor.
|
||||||
|
|
||||||
|
### Wekan
|
||||||
|
One-line: long-running, MIT-licensed, Meteor/MongoDB OSS kanban, translated into ~50 languages, notable for a rules/automation engine.
|
||||||
|
Data model: MongoDB, not files.
|
||||||
|
Signals: mature (multi-year) OSS project, team/self-host oriented, not a Lanework analog but a market-size/longevity data point for "kanban that isn't Trello."
|
||||||
|
|
||||||
|
### Vikunja
|
||||||
|
One-line: OSS (AGPLv3), self-hosted task manager (Go+Vue) offering lists, kanban boards, Gantt charts, and tables, with import from Todoist/Trello/MS To-Do.
|
||||||
|
Data model: own DB; bootstrapped EU company also offers a hosted "Vikunja Cloud."
|
||||||
|
Signals: broader task-manager-with-a-kanban-view rather than kanban-first; shows the self-hosted-SaaS-alternative segment is viable as an actual (bootstrapped, non-VC) business, which is encouraging context for indie/solo economics generally.
|
||||||
|
|
||||||
|
## 5. PKM / outliner apps with markdown + task/board features
|
||||||
|
|
||||||
|
### Obsidian Kanban plugin (mgmeyers, now community-maintained)
|
||||||
|
One-line: the dominant way Obsidian users get kanban — markdown-backed drag-and-drop boards stored as a single markdown file per board, lists as headings, cards as list items.
|
||||||
|
Data model: plain markdown, lives in the vault, fully git/editor-compatible.
|
||||||
|
Pricing: free plugin; Obsidian itself is free for personal use ($50/yr commercial, $8-10/mo Sync/Publish add-ons).
|
||||||
|
Notable forks/variants: **Plain Text Kanban** plugin renders nested markdown lists as a board while keeping the underlying file dead simple/readable in any editor (no special block syntax); **obsidian-mkanban** goes further, splitting each column/card into its own file in a folder structure — i.e. independently reinventing something close to Lanework's own model, inside Obsidian.
|
||||||
|
Context: Obsidian shipped a native "Bases" feature (database-like views over notes) in 2025, and a first-party Kanban view for Bases is reportedly coming — which could commoditize the community plugin's core use case over time.
|
||||||
|
Signals: this is arguably the largest existing user base of "markdown file-backed kanban" today (Obsidian has millions of users, Kanban is a top-tier community plugin), and it's the best evidence that a large audience already wants exactly Lanework's data model — Lanework's differentiation is being a dedicated, native, non-PKM app rather than a feature bolted onto a notes tool.
|
||||||
|
|
||||||
|
### Logseq
|
||||||
|
One-line: open-source, local-first, block-based outliner/PKM (markdown or org-mode files) with kanban available only via community plugins/queries, not a first-class primitive.
|
||||||
|
Data model: plain markdown/org files on disk, fully local-first, no proprietary format.
|
||||||
|
Standout: a long-running (multi-year, 100+ reply) feature-request thread "Introduce Kanban boards as a new primitive" shows real, sustained user demand for native kanban that the core team hasn't prioritized — plugins (logseq-kanban-plugin, logseq-plugin-agenda's kanban view) fill the gap by treating parent blocks as columns and child blocks/queries as cards.
|
||||||
|
Signals: another large local-first/markdown-native user base whose kanban needs are underserved by the core product — a potential adjacent-audience signal for Lanework (Logseq users who want real kanban and don't want to fight plugins for it).
|
||||||
|
|
||||||
|
### NotePlan
|
||||||
|
One-line: markdown notes + calendar + tasks in one app, with each day as a markdown file; not kanban-first but heavily plain-text-native.
|
||||||
|
Platform: macOS/iPad/iPhone, native-ish (details not fully confirmed), iCloud sync.
|
||||||
|
App Store: [Mac + iOS (universal listing)](https://apps.apple.com/us/app/noteplan-to-do-list-notes/id1505432629)
|
||||||
|
Data model: markdown notes/tasks, calendar-integrated, timeblocking.
|
||||||
|
Pricing: subscription-based (via Setapp and direct), free trial.
|
||||||
|
Signals: shows the "daily markdown note + tasks" niche is healthy and Mac-native-first; not a kanban competitor per se but a neighboring plain-text productivity app with a loyal following (MacStories/MacSparky coverage).
|
||||||
|
|
||||||
|
### Agenda
|
||||||
|
One-line: date-focused notes app (notes meets calendar) for Mac/iPad/iPhone, not kanban but a well-known example of Mac-indie pricing innovation.
|
||||||
|
App Store: [Mac](https://apps.apple.com/us/app/agenda-notes-meets-calendar/id1287445660?mt=12) / [iOS](https://apps.apple.com/us/app/agenda-notes-meets-calendar/id1370289240)
|
||||||
|
Pricing model: notably generous — **free forever core app**, premium features unlocked via one-time-feeling IAP ($34.99/yr covers Mac+iOS, or $14.99/yr iOS-only) that stays unlocked permanently even if you stop paying (only *future* premium features added after your paid year require renewal). This "pay once, keep what you unlocked forever" hybrid is a well-regarded pricing pattern in the Mac indie world worth Lanework considering.
|
||||||
|
Data model: proprietary, not markdown.
|
||||||
|
|
||||||
|
### TaskPaper
|
||||||
|
One-line: the classic plain-text to-do list app for Mac — projects/tasks/notes/@tags encoded in a deliberately simple, human-readable plain-text syntax.
|
||||||
|
Platform: macOS, native, Mac App Store, by Hog Bay Software (Jesse Grosjean).
|
||||||
|
App Store: [Mac](https://apps.apple.com/us/app/taskpaper-plain-text-to-dos/id1090940630?mt=12)
|
||||||
|
Data model: plain `.taskpaper` text files, explicitly designed to be "future proof," editable in any text editor, with a documented format other developers have built compatible tools around.
|
||||||
|
Pricing: $24.99 one-time on the Mac App Store.
|
||||||
|
Signals: TaskPaper is the longest-running proof that "plain text is the file format, and a dedicated Mac app is the good way to interact with it" is a viable, sustainable indie Mac business (TaskPaper 3 dates to 2016, format traces to 2006) — a strong precedent for Lanework's own bet, applied to task lists rather than boards.
|
||||||
|
|
||||||
|
### Zavala
|
||||||
|
One-line: free, open-source, native outliner for Mac/iPad/iPhone.
|
||||||
|
App Store: [Mac + iOS (universal listing)](https://apps.apple.com/us/app/zavala/id1546457750)
|
||||||
|
Data model: proprietary outline format internally, but with OPML and Markdown *export*; iCloud sync/sharing.
|
||||||
|
Pricing: completely free, no ads, no IAP — funded as a personal/open-source project by developer Maurice Parker.
|
||||||
|
Signals: shows a native Mac indie tool can be given away entirely free and still be well-regarded (multiple positive reviews, MacPowerUsers community coverage) — an important pricing-strategy data point, though a very different economic model (not the developer's primary income) than what an indie kanban app like Lanework likely needs.
|
||||||
|
|
||||||
|
## 6. Plain-text / CLI task ecosystems
|
||||||
|
|
||||||
|
### todo.txt
|
||||||
|
One-line: the original minimalist plain-text task format/standard (`(A) Buy milk +groceries @errands`), with a large multi-platform app ecosystem built around it.
|
||||||
|
Data model: literally just a `.txt` file with a line-per-task convention; the "format" is the product, not any single app.
|
||||||
|
Ecosystem: mobile apps (Android/iOS) syncing via Dropbox/Google Drive, desktop GUIs (e.g. Sleek), VS Code extensions for syntax highlighting, and a reference CLI with archiving/filtering.
|
||||||
|
Signals: the longest-running proof that a dead-simple, fully open text convention can sustain a durable multi-app ecosystem for well over a decade — the strongest "format over app" precedent Lanework can point to, albeit for flat task lists rather than boards.
|
||||||
|
|
||||||
|
### Taskwarrior
|
||||||
|
One-line: powerful CLI task manager for developers/power users, with urgency scoring, tags, filtering, and recurring tasks.
|
||||||
|
Data model: **Taskwarrior 2.x stored plain `.data` files; Taskwarrior 3.x switched to a SQLite database** (`taskchampion.sqlite3`) — an instructive precedent of a plain-text-first tool *moving away* from plain text toward a database as it matured, presumably for performance/sync-conflict reasons. Worth noting as a counter-data-point: file-based purity can have real technical costs at scale that even beloved plain-text tools eventually pay down.
|
||||||
|
Signals: still has an active, vocal power-user community (HN, multiple 2025-era how-to articles) valuing CLI speed, scriptability, and (formerly) plain text; a useful cautionary tale about the limits of pure flat-file storage as feature/dataset size grows.
|
||||||
|
|
||||||
|
## 7. AI-agent-native task/kanban trackers (fast-emerging 2025-2026 category)
|
||||||
|
|
||||||
|
This category didn't really exist before AI coding agents did, and it's now moving fast — directly validating Lanework's "AI agents are first-class users of a board" bet, though these tools are aimed at *coding-agent task queues* specifically rather than general personal/team kanban.
|
||||||
|
|
||||||
|
- **backlog.md** — MIT-licensed CLI+web tool that turns any git repo into a self-contained project board: every task is a plain `.md` file in the repo, with acceptance criteria, milestones, and dependencies; status is tracked via git history; works with Claude Code, Gemini CLI, Codex, and any MCP-compatible agent; has both an instant terminal kanban view and a web UI. Closest agent-facing analog to Lanework's own repo-embedded, git-diffable philosophy.
|
||||||
|
- **claude-task-master / TaskMaster AI** — ingests a PRD and generates a dependency-aware task hierarchy for AI agents to work through; wired into Cursor/Windsurf/MCP hosts; markdown-adjacent but oriented around task IDs/dependencies more than a visual board.
|
||||||
|
- **TaskForge** — task manager built on markdown files living inside an Obsidian vault, explicitly designed to be "editable by Claude or any agent."
|
||||||
|
- **CodeAgentSwarm** — a literal kanban board (Pending/In Progress/In Testing/Completed) where Claude Code agents themselves read and move their own cards and write completion summaries.
|
||||||
|
- **Nimbalyst** — a Claude Code session kanban board where each running agent session is a card moving through backlog → planning → implementing → validating → complete; positions itself as the broadest "auto-tracks every session" option.
|
||||||
|
- **Cursor's Agent Kanban (SDK)** — a drag-and-drop board shipped as part of Cursor's TypeScript SDK where dropping a card into a column causes an agent to write code in a cloud VM and open a PR.
|
||||||
|
- Windsurf shipped a similar kanban-style view of local/cloud agent sessions in May 2026.
|
||||||
|
|
||||||
|
Pattern: across this whole sub-category, "kanban board" has become the default UI metaphor for supervising AI agents' work-in-progress, and markdown files are the default substrate for describing that work — which is strong external validation that Lanework's core thesis (files + boards + agents as first-class users) is not a niche eccentricity but a live, fast-growing design pattern in exactly this moment. The gap: every one of these tools is scoped to *coding-agent task queues inside a repo*, not general personal/creative/life kanban — Lanework's broader "any kind of board, any kind of file" scope is currently unserved by this cluster.
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
**Pricing norms in this niche.** One-time purchase in the $0–25 range dominates solo/personal-use native Mac kanban apps (Kanbanero $8.99, Truly Simple Kanban $4.99, TaskPaper $24.99, FileBoard $0.99 lifetime, Kanbanier ~$15); free-with-optional-tip/lifetime is common among newer indie entrants (Sprout, Kanri, Zavala); subscriptions appear mainly once an app broadens beyond "just a board" into a full workspace/PKM platform (Craft $8-15/mo, Walling $5-8/mo, Curio $5-7/mo *or* a one-time key). Users in this niche (TidBITS forum thread, App-Store-review sentiment, HN commentary) explicitly and repeatedly express subscription fatigue and a preference for paying once for a tool that "just works" locally — this is a receptive market for Lanework's likely pricing instincts, not a hostile one.
|
||||||
|
|
||||||
|
**What indie/native productivity-app buyers in this niche value, in rough order of how often it recurs across research:** (1) local-first / offline-capable / no-account-required, stated almost universally as a selling point rather than a footnote; (2) data ownership — increasingly phrased explicitly as "my data survives the app" (the "file over app" framing popularized by Obsidian's Steph Ango in 2023 has become a widely-cited north star across this whole space, not just inside Obsidian); (3) speed/native feel — repeated complaints about Electron sluggishness and repeated praise for "feels like a real Mac app"; (4) keyboard-first workflows (Kanbanero, TaskPaper, Zavala, Propel all lead with shortcuts); (5) restraint/simplicity as a stated differentiator against "everything apps" like Notion/ClickUp/Monday — nearly every newer entrant (Sprout, Truly Simple Kanban, Kanri) positions itself explicitly against feature bloat; (6) design polish still matters even in a minimalist tool (Kanbanero's animated shader backgrounds, Planka's Trello-grade visual fidelity) — "simple" doesn't mean "ugly is fine."
|
||||||
|
|
||||||
|
**Convergent evolution toward "folder = column, file = card."** Independently, and apparently without much cross-awareness, at least six different developers/teams arrived at essentially the same data model Lanework uses — Signboard, Tasks.md, Kanban MD, Kanbanero (JSON variant), banban, and FileBoard all map board structure onto filesystem structure, mostly within a roughly 2024-2026 window. Tasks.md's 2.2k GitHub stars is the clearest signal that real demand exists for this pattern; the fact that no single native, polished, well-funded macOS app has yet claimed this territory (Signboard is Electron; Kanbanero uses JSON not markdown and has no card-body/frontmatter richness; FileBoard is brand-new and thin; Tasks.md requires self-hosting a Docker container) is the whitespace Lanework is aimed at.
|
||||||
|
|
||||||
|
**PKM apps are the biggest existing audience for markdown-backed kanban, but treat it as a secondary feature.** Obsidian's Kanban plugin, Logseq's long-requested-but-still-plugin-only kanban, and Curio's "Stacks" all serve real, large user bases who already want exactly this data model, but in every case kanban is bolted onto a broader notes/PKM tool rather than being the product. A dedicated, kanban-first, markdown-native native Mac app is conspicuously not what any of these audiences currently have.
|
||||||
|
|
||||||
|
**AI-agent-friendliness has gone from novelty to expected-by-default in months, not years.** As of this research (Aug 2026), a whole cluster of tools (backlog.md, TaskMaster AI, TaskForge, CodeAgentSwarm, Nimbalyst, Cursor's and Windsurf's built-in agent-kanban views) treat "an AI agent can read/write this board" as a baseline requirement, and markdown-files-in-a-repo is their overwhelming default substrate. This validates Lanework's positioning but also means the window to be "the app that got there first" for a general-purpose (not just coding-agent-queue) board is closing — competitors could plausibly add agent/MCP support to existing kanban apps (Signboard already has one) faster than Lanework can build out breadth.
|
||||||
|
|
||||||
|
**Where the file-based/plain-text movement stands, 2025-2026.** It's no longer a fringe hobbyist position — "file over app" is now a widely-cited framing (helped enormously by Obsidian's scale and Steph Ango's essay), local-first software has its own annual conference (Local-First Conf, Berlin, July 2026, ~350 attendees) and a maturing technical toolkit (Automerge 3.0, CRDT-based sync engines, SQLite-as-universal-app-database patterns), and the practical proof points are stacking up: todo.txt's decade-plus multi-app ecosystem, TaskPaper's 15+-year survival as a paid product, Obsidian's mainstream success, and now Tasks.md's 2.2k-star validation of markdown-as-kanban specifically. At the same time, there are real technical costs to staying pure-file — Taskwarrior's own move from flat files to SQLite in its 3.x line is a pointed reminder that plain-text storage can hit real limits (query performance, conflict resolution, large datasets) that even a beloved plain-text-native tool eventually has to solve by adding structure back in. Lanework's "filesystem is source of truth, with git/editors/agents as first-class users" position sits close to the leading edge of where this movement currently is, particularly in combining it with native macOS polish (most of the current file-based kanban tools are Electron, self-hosted-web, or CLI-first) and with the newest wave of agent-facing demand.
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# Personal Todo / Task-Management Apps
|
||||||
|
|
||||||
|
This document covers the personal todo/task-management market adjacent to kanban: Todoist, Things 3, TickTick, OmniFocus, Microsoft To Do, Apple Reminders, Any.do, Structured, Sunsama, Akiflow, Motion, Amie, Superlist, Twos, Godspeed, and Lunatask, plus cross-cutting patterns on subscription fatigue, the Apple Reminders squeeze, and todo/kanban convergence.
|
||||||
|
|
||||||
|
Researched: 2026-08-01
|
||||||
|
|
||||||
|
## Todoist
|
||||||
|
|
||||||
|
Positioning: the default, cross-platform "does everything reasonably well" task manager for individuals and small teams; broadest platform reach in the category.
|
||||||
|
|
||||||
|
Pricing: freemium subscription (Beginner free tier; Pro $5/mo annual or $7/mo monthly; Business $8/user/mo annual or $10/mo monthly). Prices rose in December 2025 (Pro $4→$5, Business $6→$8 annual), and regional pricing was pulled for many international users, effectively doubling their cost overnight — a real source of anger in reviews.
|
||||||
|
|
||||||
|
Standout features / direction: added a native kanban view ("Todoist Boards") alongside lists; has layered in AI features; remains the most feature-complete free-to-paid ladder in the category.
|
||||||
|
|
||||||
|
Traction: bootstrapped (Doist Inc.), no outside investment, ~$11.2M revenue in 2025 on a claimed 30–50M user base (figures vary by source, 300K paying customers cited in 2024); ~93–102 employees. This is a durable, profitable indie-scale business, not a rocket-growth one.
|
||||||
|
|
||||||
|
Common complaints: pricing creep and the regional-pricing removal; cost scales steeply for teams; some users feel plan limits (free tier) are too tight to be useful.
|
||||||
|
|
||||||
|
## Things 3 (Cultured Code)
|
||||||
|
|
||||||
|
Positioning: the design-forward, Apple-only GTD app for people who want a beautiful, opinionated, single-owner tool and are willing to pay once instead of forever.
|
||||||
|
|
||||||
|
Pricing: one-time purchase per platform — $49.99 Mac, $19.99 iPad, $9.99 iPhone/Watch (~$80 for the full set); Things Cloud sync included free, no subscription anywhere. This model is a genuine differentiator and a point of loyalty in an increasingly subscription-saturated category.
|
||||||
|
|
||||||
|
Standout features / direction: in 2025 Cultured Code rewrote the Things Cloud backend in Swift (showcased by Apple at WWDC 2025 State of the Union); shipped OS 26 support (Liquid Glass redesign, Control Center integration, Spotlight quick-add on Mac, visionOS support). Development is steady but slow and incremental — no calendar view, no time blocking, no AI.
|
||||||
|
|
||||||
|
Traction / sentiment: reviews remain very favorable; the brand halo (Apple's own praise) is strong. The flip side is a recurring complaint that development pace is glacial next to subscription competitors with continuous funding — "beautifully designed but frozen in scope."
|
||||||
|
|
||||||
|
Common complaints: no AI (smart scheduling, task extraction, pattern analysis) at all in 2026, which is now a competitive gap as "every productivity app has AI assistants"; no kanban/board view natively (a third-party app, KanbanView, fills the gap); Apple-only, no Android/Windows.
|
||||||
|
|
||||||
|
Kanban: no native board view; one-dimensional lists only.
|
||||||
|
|
||||||
|
## TickTick
|
||||||
|
|
||||||
|
Positioning: the value-for-money all-rounder — Todoist-equivalent feature set (calendar sync, habit tracking, Pomodoro, kanban) at a fraction of the price, cross-platform including Android/Windows where Things and OmniFocus aren't.
|
||||||
|
|
||||||
|
Pricing: single Premium tier at $35.99/year (~$3/mo) or $3.99/mo — dramatically cheaper than most of this list; free tier is capped (9 lists, 99 tasks/list) but usable.
|
||||||
|
|
||||||
|
Standout features / direction: native kanban board view (rare in this list — only Todoist and TickTick have it natively); habit tracking, Pomodoro timer, 2-way calendar sync, custom filters all gated to Premium. No team/family plan — deliberately personal-only.
|
||||||
|
|
||||||
|
Traction: unfunded/bootstrapped (Santa Clara, founded 2010 by Zhimin Chen); estimated ~$5M company revenue, Android Play Store revenue estimated ~$1.8M/year; ranks in the 70s-100s on US App Store free/productivity charts — solidly mid-tier, durable, not viral.
|
||||||
|
|
||||||
|
Common complaints: fewer surfaced in research than for pricier competitors, consistent with its low price largely defusing the value objection; free tier is capped enough to push conversions.
|
||||||
|
|
||||||
|
Kanban: yes, native board view.
|
||||||
|
|
||||||
|
## OmniFocus (The Omni Group)
|
||||||
|
|
||||||
|
Positioning: the power-user GTD engine for people who want deep customization (perspectives, AppleScript, complex repeat/defer logic) and don't mind a learning curve; Apple-only.
|
||||||
|
|
||||||
|
Pricing: fragmented and expensive — Mac Standard $49.99 / Pro $99.99 one-time, iOS subscription $9.99/mo or $99.99/yr, web $4.99/mo. Effectively the most expensive app in this list to own across platforms.
|
||||||
|
|
||||||
|
Standout features: custom perspectives, AppleScript/Shortcuts automation, tagging and deep filtering — the most powerful rules engine in the category.
|
||||||
|
|
||||||
|
Common complaints: price is called "high" or "prohibitive" repeatedly, especially for solo/small users; steep learning curve; occasional sync issues; no collaboration features; Apple-only lock-in is explicitly cited as disqualifying for mixed-platform households ("expensive, complex, and Apple-locked — if that doesn't fit your life, you'll hate it").
|
||||||
|
|
||||||
|
Kanban: no native board view found.
|
||||||
|
|
||||||
|
## Microsoft To Do
|
||||||
|
|
||||||
|
Positioning: the free, bundled-with-Microsoft-365 option for people already living in Outlook/Teams; the spiritual (if diminished) successor to Wunderlist.
|
||||||
|
|
||||||
|
Pricing: free.
|
||||||
|
|
||||||
|
Direction / status: update cadence is uneven across platforms (Windows/iOS updated as recently as March 2026, Android's last update was over 11 months stale at time of research), consistent with a broader pattern of Microsoft consumer apps drifting toward "neglected relic" status as the company's real investment goes into Copilot/AI/enterprise/cloud. No sign of active feature investment or a stated roadmap; reads as maintenance-mode, not a growth product.
|
||||||
|
|
||||||
|
Historical note: it is literally the app Microsoft built to replace and then sunset Wunderlist (2015 acquisition → 2020 shutdown), a cautionary tale for what happens when a beloved indie todo app is absorbed and slow-walked to death (see Patterns below).
|
||||||
|
|
||||||
|
## Apple Reminders
|
||||||
|
|
||||||
|
Positioning: the free, built-in default that keeps getting better and is the single biggest headwind every paid competitor on this list has to answer.
|
||||||
|
|
||||||
|
Pricing: free, bundled with every Apple device.
|
||||||
|
|
||||||
|
Recent direction: iOS 26 shipped a real redesign (Liquid Glass), faster task entry, time-zone support (Calendar-style, useful for travelers), and — critically — Apple Intelligence features: automatic categorization of lists into grouped sections, and AI-suggested to-dos pulled from emails/notes. iOS 26.2 (Dec 2025) added alarm-style urgent reminders with snooze/slide-to-stop. This is a steady multi-year climb from "barely a checklist app" to "credible everyday task manager with light AI."
|
||||||
|
|
||||||
|
Assessment: for single-ecosystem Apple households, Reminders now covers the majority of what casual users need — Siri capture, native sync, decent organization — at zero cost. It remains meaningfully behind on cross-platform support (no real Android/Windows story), advanced recurrence, and power features, which is exactly the seam third-party apps still live in.
|
||||||
|
|
||||||
|
## Any.do
|
||||||
|
|
||||||
|
Positioning: sits between a lightweight personal task manager and a broader family/small-team planning platform — tasks, reminders, calendar, shared spaces, and (per its own 2026 marketing) workflow/kanban boards in one app.
|
||||||
|
|
||||||
|
Pricing: freemium; free tier plus Premium and Teams paid tiers (exact current price not confirmed in research, but occasional promo-driven annual discounting is called out).
|
||||||
|
|
||||||
|
Traction: ~25M+ users claimed; funding is old and modest ($9M total, last round 2016 seed) — this is not a growth-funded story, more a long-tail bootstrapped-since-funding operator. Revenue figures found in research were inconsistent/unreliable (one stale data point of ~$40K ARR looks wrong for a 25M-user app and should not be trusted).
|
||||||
|
|
||||||
|
Common complaints: Premium is seen by some as not worth it since competitors give the same features free; calendar view gets crowded/hard to read with many same-day tasks; feature overload for people who want something simple; not team-oriented enough for larger groups despite the "Teams" positioning.
|
||||||
|
|
||||||
|
Kanban: markets "workflow boards" / kanban-style views as of 2026, alongside its own blog content pitching kanban as a category it competes in.
|
||||||
|
|
||||||
|
## Structured
|
||||||
|
|
||||||
|
Positioning: visual, timeline-based daily planner — less "task database," more "see your whole day as blocks." iPhone/iPad-first (also Mac, Android, Watch).
|
||||||
|
|
||||||
|
Pricing: subscription, roughly $19.99–27.99/year (sources vary) or a lifetime option around $64.99; free tier lets you try the timeline UI.
|
||||||
|
|
||||||
|
Standout feature: the visual timeline itself is the product — the differentiator is the daily-planning UX, not task-database depth.
|
||||||
|
|
||||||
|
Traction / complaints: limited hard data found; positioned and reviewed as a well-liked, focused single-purpose planner rather than a broad task-management system.
|
||||||
|
|
||||||
|
## Sunsama
|
||||||
|
|
||||||
|
Positioning: premium daily-planning ritual tool for professionals who believe the *act* of intentional planning has value — pulls tasks in from other tools (Slack, email, Trello, Todoist, etc.) rather than replacing them, then has you build a realistic daily plan.
|
||||||
|
|
||||||
|
Pricing: raised prices in 2026 for the first time in five years — Pro now $20/mo annual or $25/mo monthly (up from $16/$20). Wirecutter named it "best scheduling app" in 2025.
|
||||||
|
|
||||||
|
Standout features: time tracking, focus mode, deep integrations for pulling tasks from other systems into one daily view; explicitly not an automatic scheduler — the user does the planning work each day.
|
||||||
|
|
||||||
|
Common complaints: mobile apps are widely called "read-only" or "companion apps" — real planning only happens on desktop; smart/automation features are thin relative to price; because it requires daily active planning, a lapsed user gets zero value while still paying — a structural churn risk called out directly by reviewers.
|
||||||
|
|
||||||
|
Kanban: not native; it's a daily-planning layer on top of other tools' task lists, not a board.
|
||||||
|
|
||||||
|
## Akiflow
|
||||||
|
|
||||||
|
Positioning: time-blocking command-center that aggregates tasks/calendars from many sources into one keyboard-driven, drag-to-schedule interface.
|
||||||
|
|
||||||
|
Pricing: no free plan, trial only — $34/mo monthly, $17/mo billed yearly, or a "Believer 730" plan at $14.90/mo billed every two years. Among the more aggressively tiered/expensive apps in this list.
|
||||||
|
|
||||||
|
Sentiment: mixed-to-concerning. Praise for consolidating everything into one view and a clean UX; but a "growing number of users" reporting billing problems, buggy mobile apps, and integrations that don't work as advertised. No evidence found of a shutdown or acquisition — it's still shipping updates as of April 2026 — but the billing/reliability complaints are a decline signal worth tracking.
|
||||||
|
|
||||||
|
Kanban: not found as a native feature; it's calendar/time-block-centric, not board-centric.
|
||||||
|
|
||||||
|
## Motion
|
||||||
|
|
||||||
|
Positioning: started as an AI auto-scheduler for individuals, has since pivoted hard toward B2B — marketing itself in 2025–2026 as an "agentic work suite" / "AI Employees" platform for small businesses rather than a personal todo app. Worth noting this pivot explicitly: it is drifting out of the personal-task category into agentic-workflow/AI-labor territory.
|
||||||
|
|
||||||
|
Pricing: complex and has gotten less transparent — AI Workplace plan from $29/user/mo annual; separate "AI Employees" tiers from $49 (Starter) up to $599 (Plus) for multi-seat bundles.
|
||||||
|
|
||||||
|
Traction: well-funded and growing fast — $75M total raised, $550M valuation (2025), revenue estimates range from $10M to $50M ARR depending on source, 10,000+ paying customers, 3x YoY growth in the AI-Employees line. This is the one app in the list with real venture-scale momentum.
|
||||||
|
|
||||||
|
Common complaints: difficult onboarding, cluttered UI, weak/frustrating mobile app (G2 desktop 4.5/5 vs mobile 2.7/5), pricing that has risen more than once, and reports of trial-to-annual billing surprises and refund friction.
|
||||||
|
|
||||||
|
Read for Lanework: Motion is instructive as a "where the AI-scheduling money is going" signal — it's leaving pure personal-task-management for agentic B2B, which somewhat validates that solo/individual task apps are not where the biggest funding rounds chase growth right now.
|
||||||
|
|
||||||
|
## Amie
|
||||||
|
|
||||||
|
Positioning: was an "opinionated," design-forward calendar + todo app; as of July 2024 it announced a pivot away from calendar/todo toward being an AI meeting notetaker, with a full UI rebuild completed by February 2025. Calendar/todo functionality still exists but is explicitly no longer the priority.
|
||||||
|
|
||||||
|
Pricing: moved from free to a subscription-only model (~€6/year cited by early users) with no free plan and no monthly option, which upset its early adopter base.
|
||||||
|
|
||||||
|
Funding: raised ~$7–8M (Spark Capital-led seed, 2022), small team (~14 people); no evidence of a shutdown, but the pivot away from its original todo/calendar identity is itself a decline signal for anyone evaluating it as a task-management tool specifically.
|
||||||
|
|
||||||
|
Read for Lanework: a cautionary example of a well-funded, well-designed calendar/todo app that couldn't find a sustainable business in the category itself and pivoted to a different product entirely (AI notetaking) rather than iterate task management further.
|
||||||
|
|
||||||
|
## Superlist
|
||||||
|
|
||||||
|
Positioning: the spiritual successor to Wunderlist, built by several of the original Wunderlist co-founders (led by Christian Reber) after Microsoft's shutdown of that app — explicit "we're rebuilding what you loved" positioning.
|
||||||
|
|
||||||
|
Pricing: reworked pricing with a genuinely free-forever Basic tier (unlimited private/shared lists, sublists, integrations) and Pro at $8/user/mo annual — deliberately undercutting Notion Team ($16) and beating Todoist Business on value.
|
||||||
|
|
||||||
|
Standout features / direction: added AI Meeting Notes in May 2025 (records Zoom/Meet/Teams/Slack calls, auto-generates actionable tasks) — the clearest "AI eating adjacent categories" move among the pure-personal-todo apps.
|
||||||
|
|
||||||
|
Traction: privately funded, Berlin-based; no acquisition found in research despite searching specifically. Marketed heavily to former-Wunderlist-diaspora as "the best Wunderlist alternative in 2026."
|
||||||
|
|
||||||
|
Kanban: a kanban view is a requested/roadmap feature per its own public feedback board (Frill), not confirmed shipped — i.e., users are actively asking for it, which is itself a signal of demand.
|
||||||
|
|
||||||
|
## Twos
|
||||||
|
|
||||||
|
Positioning: fast, flexible, gamified note-plus-task capture app aimed at brain-dump/quick-capture use cases — popular specifically among ADHD and bullet-journal-style users.
|
||||||
|
|
||||||
|
Pricing: notably not a recurring subscription — free tier is fully-featured with no data/note/task caps, and paid unlocks are one-time $2 purchases (plus an in-app currency/coin system for earning free unlocks). This is a real outlier pricing model in a subscription-saturated category.
|
||||||
|
|
||||||
|
Standout features: fast cross-device sync, tagging, rollover of undone items, light gamification, sharing.
|
||||||
|
|
||||||
|
Common complaints: modest learning curve up front; requests for better UI polish, widgets, shortcuts, habit tracking, and attachments.
|
||||||
|
|
||||||
|
Kanban: not found as a feature; it's a linear/list capture tool, not board-based.
|
||||||
|
|
||||||
|
## Godspeed
|
||||||
|
|
||||||
|
Positioning: a keyboard-first, "fastest to-do app" for power users who prefer typing to clicking — every interaction targets sub-50ms response, includes a "Hardcore Mode" that disables the mouse entirely. Mac + iOS.
|
||||||
|
|
||||||
|
Pricing: $6/mo — called out explicitly as "at the top of the task manager market" and a real barrier next to free alternatives (Todoist free tier, Microsoft To Do).
|
||||||
|
|
||||||
|
Reception: generally positive (8.0/10-class scores), praised for labels/attachments/notes/snoozing; some instability reported on iOS.
|
||||||
|
|
||||||
|
Kanban: not found as a feature; single-list, speed-first tool.
|
||||||
|
|
||||||
|
## Lunatask
|
||||||
|
|
||||||
|
Positioning: privacy-first, end-to-end-encrypted "better to-do list" that bundles notes, journaling, habit/mood tracking, and a relationship manager — pitched heavily at ADHD users and people who distrust cloud task apps with their data.
|
||||||
|
|
||||||
|
Pricing: $6/mo (annual) or $8/mo (monthly) for Premium; a lifetime license is available direct from the website for $220. Free tier exists, no credit card required to start.
|
||||||
|
|
||||||
|
Standout features: end-to-end encryption, no in-app analytics/tracking, no data sale — a genuine privacy differentiator most competitors don't offer. Lets users switch freely between multiple workflow paradigms on the same task list: list, kanban board (vertically-stacked sections rather than horizontal columns, but conceptually the same), and Eisenhower Matrix — without losing data switching between them.
|
||||||
|
|
||||||
|
Kanban: yes, native, and notably flexible (switch between kanban/matrix/list without restructuring data) — one of the few apps in this list that treats board view as a first-class, freely-interchangeable perspective rather than a bolted-on extra.
|
||||||
|
|
||||||
|
## Kanban-view summary
|
||||||
|
|
||||||
|
Native kanban/board view confirmed: Todoist (Boards), TickTick, Any.do (workflow boards), Lunatask (kanban workflow, vertical-stack style).
|
||||||
|
Requested but not confirmed shipped: Superlist (on public roadmap/feedback board).
|
||||||
|
No native board view: Things 3 (third-party workaround exists — KanbanView), OmniFocus, Sunsama, Akiflow, Godspeed, Twos, Structured, Microsoft To Do, Apple Reminders.
|
||||||
|
Motion and Amie are calendar/AI-scheduling-centric and don't fit the board paradigm at all.
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
**Subscription fatigue is real and rising, and this category is not exempt.** Active cancellation rates among US consumers jumped from 31% (2024) to 47% (2026); the fastest-growing fatigue category in 2026 is AI-feature subscriptions specifically, with the average person paying for four AI tools at ~$66/month and 53% saying they now treat subscriptions as churn-and-restart rather than long-term commitments. Weekly-subscription revenue share nearly doubled in two years (43%→56%), meaning even paid apps are being pressured toward more escapable, lower-commitment pricing. Every subscription todo app in this list (Todoist, TickTick, Sunsama, Akiflow, Motion, Godspeed, Lunatask) is competing against this headwind; the two structural outliers — Things 3 (one-time purchase) and Twos ($2 one-time unlocks) — are explicitly positioned against subscription fatigue and get credit for it in reviews. A one-time-purchase or low-commitment model reads as a genuine trust signal right now, not just a pricing choice.
|
||||||
|
|
||||||
|
**Apple Reminders is the market's quiet ceiling, not just a "free alternative."** It went from a bare checklist app to a genuinely competent task manager with Apple Intelligence categorization, AI-suggested to-dos pulled from mail/notes, and a real design pass (Liquid Glass) — all free, all pre-installed, all deeply Siri-integrated. For any single-ecosystem Apple household with light-to-moderate task needs, Reminders now covers "enough." The seam every paid competitor lives in is: cross-platform households (no real Android/Windows story for Reminders), power features (recurrence, filtering, automation), and specialized workflows (GTD rigor, time-blocking, kanban). This means positioning against Reminders on "basic todo list" grounds is now close to a losing argument — differentiation has to be structural, not featural.
|
||||||
|
|
||||||
|
**Todo and kanban are converging, but slowly and mostly at the UI-view layer, not the data-model layer.** Todoist and TickTick both ship kanban as an alternate view over the same flat task list; Any.do markets "workflow boards"; Lunatask goes furthest by making board/list/matrix freely interchangeable views over one underlying task set; Superlist's own users are asking for it on the public roadmap. What's notably absent across all of them: none treat the board as the *native* data model the way a dedicated kanban tool would — it's always "todo app that also has a board view," never "board app that also has a todo view." That's the open lane. Nobody in the personal-task category has built kanban-as-first-class-primitive with file-backed, git-friendly, agent-legible data — which is closer to Lanework's own territory (see doc 03) than to this list.
|
||||||
|
|
||||||
|
**Subscription vs. one-time is a real fork in user trust, and price alone doesn't explain reactions — the *removal of value* does.** Todoist's backlash wasn't really about the absolute price increase, it was regional pricing being pulled and effectively doubling cost overnight for non-US users; Sunsama's backlash risk isn't the $20–25/mo, it's that a lapsed week of not planning means paying for nothing; Akiflow's complaints cluster on billing surprises, not the sticker price. Users tolerate high prices for tools they actively use every day (Motion at $29+/user/mo has a fanbase); they revolt when the pricing model itself feels like it's taking something away or when it charges for idle non-use.
|
||||||
|
|
||||||
|
**Apple-only, cross-platform, and "no platform at all" (encrypted/local) are three distinct trust postures competing for the same user.** Things 3 and OmniFocus bet on deep Apple integration and accept the ceiling that puts on their addressable market; Todoist/TickTick/Any.do bet on maximum reach; Lunatask bets on privacy/encryption as the differentiator entirely separate from platform. None of the three postures is "winning" outright — they're serving genuinely different anxieties (design/quality vs. reach vs. data trust).
|
||||||
|
|
||||||
|
**AI is now table stakes for the subscription tier of this category, but its absence is not fatal if the rest of the product is trusted.** Every subscription-model competitor (Todoist, TickTick's roadmap direction, Any.do, Sunsama's integrations, Akiflow, Motion, Amie, Superlist's meeting notes) is adding AI — categorization, suggested tasks, auto-scheduling, meeting-to-task extraction. Things 3 is the explicit counterexample: reviewers call out "no AI" as a real gap in 2026, yet the app remains well-reviewed and trusted because the core promise (fast, reliable, beautifully simple, no subscription) hasn't been diluted chasing feature parity. The lesson for a well-differentiated tool: AI absence is a tolerated trade-off, not an automatic disqualifier, as long as the core value prop stays sharp.
|
||||||
|
|
||||||
|
**Funding flows toward agentic/B2B, not toward better personal todo lists.** Motion is the one app here with real venture money ($75M raised, $550M valuation) and it's spending that trajectory pivoting toward "AI Employees" for small businesses, not toward a better personal task manager. Amie, also venture-funded, pivoted away from calendar/todo into AI meeting notes. The two most durable pure personal-task apps in this list (Todoist, TickTick) are both bootstrapped, profitable, unfunded-by-choice-or-necessity businesses. This suggests venture capital doesn't see standalone personal task management as a category with room for outsized returns anymore — it sees the money in agentic B2B or adjacent categories (meeting notes, scheduling-as-a-service) that can upsell into task management rather than starting there.
|
||||||
|
|
||||||
|
**The historical cautionary tale every player in this space knows: Wunderlist.** Peaked at ~13M users and ~1B to-dos/day, was acquired by Microsoft in 2015, then was starved of updates and finally shut down in 2020 in favor of Microsoft To Do — described by users at the time as "migration hell that destroyed the fan-base." Superlist exists specifically to re-capture that diaspora, and Microsoft To Do itself now shows the same early-stage neglect signals (stale platform updates, no clear roadmap, strategic focus elsewhere) that preceded Wunderlist's decline. The pattern — beloved indie tool acquired by a platform giant, then slow-walked to death or absorbed into a bigger, blander umbrella product — is the standing risk case for any small todo app that considers an acquisition exit, and a reason users are wary of trusting their task data to venture-funded upstarts long-term.
|
||||||
|
|
||||||
|
**What personal-task users say they want heading into 2026, synthesized across reviews:** speed of capture above almost everything else (Twos, Godspeed, and Apple's own "faster add" work in Reminders all compete on this axis specifically); trustworthy AI that discloses what model powers it and lets them opt out of training use, not just AI-for-AI's-sake; genuine offline-first reliability after several cloud-only years burned people in low-connectivity moments; and — per multiple 2026 app reviews — a swing back toward "focused tools that do fewer things exceptionally well" after a decade of feature-bloat, which favors sharply-scoped products (Things 3, Twos, Godspeed) over kitchen-sink ones, even as those kitchen-sink apps (Any.do, TickTick, Superlist) keep adding surface area to compete on breadth.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Cross-Cutting Trends and Success Factors, 2024-2026
|
||||||
|
|
||||||
|
This document is a broad first pass on market-wide forces shaping task-management, kanban, and productivity software — market size and where the money is, the AI wave and what stuck, the local-first/data-ownership movement, pricing-model shifts, distribution channels, notable shutdowns and consolidation, and how "tools for thought" apps are eating into task management. It is cross-cutting by design; it does not attempt deep competitor profiles (see 02-04) or a deep dive on AI agents consuming task state (see 06-agents-and-tasks.md).
|
||||||
|
|
||||||
|
Researched: 2026-08-01
|
||||||
|
|
||||||
|
## Market size and where the money is
|
||||||
|
|
||||||
|
Market-research firms disagree on absolute numbers but agree on the shape: a multi-billion-dollar market growing at roughly 14-16% CAGR. Grand View Research puts task-management software at $3.94B in 2025 heading to $12.34B by 2033 (15.6% CAGR); another estimate has it at $5.1B in 2025 to $5.87B in 2026 (15.2% CAGR); a third puts 2025 at $5.71B heading to $19.84B by 2034 (14.84% CAGR). ([Grand View Research](https://www.grandviewresearch.com/industry-analysis/task-management-software-market-report), [Business Research Insights](https://www.businessresearchinsights.com/market-reports/task-management-software-market-119895))
|
||||||
|
|
||||||
|
Cloud-based deployment accounts for over 65% of the market, and North America holds over 36% of revenue share. Growth drivers cited across reports are consistent: remote/hybrid work, real-time collaboration demand, and — increasingly — AI/automation integration as a named growth driver in its own right, not just a feature checkbox. ([Grand View Research](https://www.grandviewresearch.com/industry-analysis/task-management-software-market-report))
|
||||||
|
|
||||||
|
Where the money concentrates is visible in the disclosed revenue of the category leaders, and it is overwhelmingly team/enterprise-seat revenue, not individual subscriptions: Notion reports ~$600M ARR in 2025 (up from $300M in 2024) at an $11B valuation with 100M+ users, reportedly grown with $0 in meaningful paid advertising; ClickUp reports ~$300M ARR (2025/2026) at a $4B valuation, publicly targeting $1B ARR, with 400% YoY AI-sales growth and 800% YoY AI-usage growth cited in its own materials. ([Latka — Notion](https://getlatka.com/companies/notion), [SaaStr](https://www.saastr.com/notion-and-growing-into-your-10b-valuation-a-masterclass-in-patience), [Latka — ClickUp](https://getlatka.com/companies/clickup))
|
||||||
|
|
||||||
|
By contrast, individual-focused indie tools top out one to two orders of magnitude smaller: Sunsama, a well-regarded premium daily planner, reports ~$1.5M ARR (2024) on a ~9-person team, having raised only $2.75M total and reaching profitability off that base in 2022. ([Latka — Sunsama](https://getlatka.com/companies/sunsama)) Obsidian is the outlier that proves the local-first/individual model can still produce real money: ~1.5M monthly active users (22% YoY growth as of Feb 2026), ~$25M ARR, $350M valuation, built by an 18-person team with zero VC funding. ([Fueler](https://fueler.io/blog/obsidian-usage-revenue-valuation-growth-statistics), [ARR Club](https://www.arr.club/obsidian/obsidian-arr-hit-25m-and-350m-valuation-with-just-9-persons))
|
||||||
|
|
||||||
|
Pricing-tier data reinforces the same pattern from the buyer side: most per-seat task tools cluster in an "Economy" $10-19/seat/month tier, "Professional" $19-77.50, and "Enterprise" $77.50+ — i.e., the addressable revenue per team scales with headcount, which is exactly why enterprise/team products (Notion, ClickUp, Monday, Asana) dwarf individual tools in ARR even when individual tools have devoted userbases. **Implication for Lanework**: the big money is in team seats, but that market is saturated and AI-feature-arms-raced; the individual/small-team, ownership-oriented segment is smaller in aggregate ARR but has at least one proof point (Obsidian) that a lean, non-VC team can build a durable, sizable business there without competing on the enterprise axis at all.
|
||||||
|
|
||||||
|
## The AI wave: what stuck vs what's gimmick
|
||||||
|
|
||||||
|
Three distinct AI feature categories are landing differently with users:
|
||||||
|
|
||||||
|
**Auto-scheduling AI (Motion, Clockwise, Reclaim)** — genuinely useful for a narrow persona (solo founders/execs with calendar chaos) but has proven commercially fragile at the company level, not just the feature level. Motion ($29-49/mo individual, $99/3 seats) is well-reviewed for "actually doing things, not just suggesting them" and is explicitly positioned for busy founders/freelancers, not general task management. ([Efficient App](https://efficient.app/apps/motion)) Clockwise, despite serving 40,000 organizations including Uber, Netflix, and Atlassian, was shut down entirely on March 27, 2026 when its team was acquihired into Salesforce Agentforce — all user data deleted, ~1 week's notice given, users routed to Reclaim.ai as the closest replacement. This is a cautionary tale about auto-scheduling specifically: it's a feature that large platforms want to absorb into an "agentic calendar" story rather than a standalone business that survives independently. ([Reclaim](https://reclaim.ai/blog/clockwise-shutdown-news), [usecarly.com](https://www.usecarly.com/blog/clockwise-shut-down/))
|
||||||
|
|
||||||
|
**Natural-language capture (Todoist Ramble, Task Assist)** — this is the AI feature category with the clearest "stuck" signal. Todoist's Ramble voice-capture feature (launched Jan 2026 after an Oct-Dec 2025 beta) improved end-to-end task-creation success from 40% to 62% over its beta and handles 40 languages; independent testing of Todoist's NLP found it correctly parsed 185/200 free-text task descriptions without user correction. Users describe it as "more capable than basic dictation... because it understands context." ([Skywork](https://skywork.ai/skypage/en/todoist-ai-assistant-alternatives/2035949431685529600)) This is the AI feature type most directly relevant to a kanban tool like Lanework — low-friction capture that reduces the "opening the app" tax — and the evidence is that users adopt it readily when it works, unlike auto-scheduling which requires trusting AI with judgment calls.
|
||||||
|
|
||||||
|
**Summarization / subtask generation / AI chat (Notion AI, ClickUp AI)** — high usage-metric growth (ClickUp cites 45% AI-adoption growth in 2024 and 800% YoY AI-usage growth) but murkier signal on whether it's driving retention or just inflating engagement dashboards; reviews describe it as useful for teams already embedded in the platform ("a natural extension of workflow") rather than a reason to switch platforms. Sentiment comparisons show Notion and ClickUp at parity on ease-of-use (75% positive) with no clear AI-driven differentiation reported by third-party review aggregators. ([Capterra comparison](https://www.capterra.com/compare/158833-186596/ClickUp-vs-Notion))
|
||||||
|
|
||||||
|
**Agentic AI ("AI agents managing tasks")** is the emerging 2026 frontier, not yet a proven-stuck feature for consumer task apps. Gartner projects 40% of enterprise apps will ship task-specific AI agents by end of 2026, up from under 5% in 2025, and 52% of gen-AI-using enterprises already have agents in production somewhere. ([Gartner](https://www.gartner.com/en/newsroom/press-releases/2025-08-26-gartner-predicts-40-percent-of-enterprise-apps-will-feature-task-specific-ai-agents-by-2026-up-from-less-than-5-percent-in-2025)) This is an enterprise-software statistic, not a personal-productivity-app statistic — it signals where big platforms (Salesforce/Agentforce absorbing Clockwise) are pointed, but there's no comparable adoption evidence yet for individual/small-team kanban tools. The more concrete, bottom-up version of "AI agents + tasks" is happening in developer tooling (see below and 06-agents-and-tasks.md), not in mainstream productivity apps.
|
||||||
|
|
||||||
|
**Net read**: capture/parsing AI has stuck because it removes friction without requiring trust; summarization AI is used but not obviously a retention driver; full delegation/auto-scheduling AI is high-value for a narrow persona but commercially unstable as a standalone product category (both Clockwise and, per the Height case below, ambitious "autonomous project management" pivots have failed to sustain independent businesses).
|
||||||
|
|
||||||
|
## Local-first / privacy / data-ownership
|
||||||
|
|
||||||
|
The Ink & Switch "local-first software" essay (2019) remains the movement's foundational reference; it now has real conference infrastructure behind it — Local-First Conf launched May 2024 in Berlin with speakers from Linear and Anytype, plus a LoFi Berlin 2025 follow-on event, indicating the ideas have moved from essay to community/ecosystem. ([Ink & Switch](https://www.inkandswitch.com/essay/local-first/))
|
||||||
|
|
||||||
|
Obsidian is the strongest evidence the philosophy converts to a real business: 1.5M+ MAU (22% YoY growth), ~$25M ARR, $350M valuation, 18 people, zero VC funding, low opex specifically because local-first architecture means no server/storage cost scaling with users. 110,000+ Discord members and 2,000+ community plugins signal an unusually durable, high-engagement community for a note-taking tool. ([Fueler](https://fueler.io/blog/obsidian-usage-revenue-valuation-growth-statistics))
|
||||||
|
|
||||||
|
Demand-side evidence: an analysis of 9,300+ Reddit "I wish there was an app that..." posts found 640+ posts (7% of all requests) specifically asking for local-first, self-hosted, or fully-offline tools — framed by users as wanting "control, reliability, and avoiding subscription fatigue" more than privacy per se, and wanting to "own outright" rather than rent. Notably, that analysis found no specific mentions of kanban or markdown-format requests within the local-first bucket — the demand signal is broad ("modern versions of classic desktop apps without mandatory cloud") rather than kanban-specific, which is a mild caution against over-reading a ready-made audience for Lanework's exact format. ([digitalbiztalk.com](https://digitalbiztalk.com/article/what-9300-reddit-posts-reveal-about-app-gaps-in-2026))
|
||||||
|
|
||||||
|
Open-source/local-first "tools for thought" competitors for mindshare in this space include Anytype (IPFS-based P2P sync, end-to-end encrypted, $13.5-29M raised, 43 employees) and AFFiNE (open-core, $6.75-7.99/mo cloud tier, $10/seat team tier, free self-host capped at 3 users). Both are VC-funded open-core plays rather than bootstrapped like Obsidian — a different local-first business model worth noting: open-source-with-paid-cloud-sync as the monetization wedge. ([fahimai.com](https://www.fahimai.com/anytype), [AFFiNE blog](https://affine.pro/blog/affine-vs-appflowy-vs-anytype))
|
||||||
|
|
||||||
|
Failure/near-failure case: Muse ("tool for thought" for iPad/Mac, local-first sync) hit unsustainable unit economics in mid-to-late 2023 — most of the team left, one developer continued, and the product was later relaunched/rebranded as Allume in 2024 with collaboration features added. The lesson generally drawn (including in the project's own HN retrospective) is that local-first sync engineering is expensive and hard to monetize thinly — being local-first doesn't exempt a product from needing a viable business model. ([HN: Muse Retrospective](https://news.ycombinator.com/item?id=40359893), [localfirst.fm interview](https://www.localfirst.fm/20/transcript))
|
||||||
|
|
||||||
|
## Pricing-model trends
|
||||||
|
|
||||||
|
Subscription fatigue is a named, widely-discussed 2025 consumer trend, and one-time-purchase is reported as "returning even when [it isn't] the cheapest option," valued by users for control/predictability and by indie developers for reduced platform dependence. ([influencers-time.com](https://www.influencers-time.com/subscription-fatigue-in-2025-why-one-time-buys-dominate/))
|
||||||
|
|
||||||
|
Things 3 remains the reference case for the one-time-purchase model in the exact macOS-native indie category Lanework sits in: $49.99 Mac / $9.99 iPhone / $19.99 iPad / $29.99 Vision Pro, no subscription tier at all, multiple Apple Design Awards, 4.8/5 across 27,750+ App Store ratings as of July 2026. The one recurring criticism is that per-platform pricing feels fragmented versus a single bundle — but reviewers note users still prefer that fragmentation to a subscription. ([ellieplanner.com](https://ellieplanner.com/productivity-copilot/things-3-pricing), [richardriviere.com](https://richardriviere.com/things-3-pricing))
|
||||||
|
|
||||||
|
At the other end, per-seat SaaS pricing is under visible strain in the team-tool segment: standard per-seat rates ($10-15/user/month across Monday/Asana/ClickUp/Teamwork) scale painfully with headcount (a 20-person team pays $220-300/mo), and per-seat costs are cited as a structural adoption blocker in enterprises needing broad cross-team visibility. This is driving a flat-rate counter-trend (e.g., Basecamp's flat $349/mo, which beats per-seat pricing once a team crosses ~75 people) and hybrid models (seat-plus-usage) for tools 3+ years in market. ([thickethq.com](https://www.thickethq.com/blog/flat-rate-project-management), Celoxis pricing guide)
|
||||||
|
|
||||||
|
Premium individual-tier daily planners (Sunsama $17-22/mo, Akiflow $14.90-34/mo) show the subscription model still works at the high end of individual productivity tooling when the product sells a habit/ritual, not just software — Sunsama's pitch is explicitly the "done for the day" daily-review ritual as the retained value, not feature count. ([lifestack.ai](https://lifestack.ai/blog/sunsama-planner-pricing), [thebusinessdive.com](https://thebusinessdive.com/akiflow-vs-sunsama))
|
||||||
|
|
||||||
|
Setapp remains a live secondary distribution/monetization channel for indie Mac developers: Setapp Membership takes ~10% and pools 20% of subscription revenue to partner developers by usage; standalone single-app distribution through Setapp takes ~25% (still lower than typical app-store cuts). Setapp's own 2025 developer survey found only 20% of surveyed Mac developers distribute exclusively via the Mac App Store — most indie Mac devs now run multi-channel distribution. ([Computerworld / Setapp survey](https://www.computerworld.com/article/3621224/setapp-survey-reveals-what-mac-developers-are-thinking-about-right-now.html), [setapp.com/developers](https://setapp.com/developers))
|
||||||
|
|
||||||
|
## Distribution and growth
|
||||||
|
|
||||||
|
Concrete, sourced detail on exactly which channels move the needle for productivity apps specifically was thin in this pass (this is a good candidate for deeper, narrower research later — e.g. pulling specific Product Hunt leaderboard entries and r/macapps threads directly rather than via aggregator commentary). What is corroborated:
|
||||||
|
|
||||||
|
- Product Hunt remains an active launch venue for productivity/AI tools specifically, but generic "best practices" content (Tuesday/Wednesday ~3am PST launches, hunter partnerships) dominates the available sources over concrete named 2025-2026 productivity-app launch case studies. ([Product Hunt 2026 categories](https://www.producthunt.com/categories/productivity))
|
||||||
|
- r/macapps is confirmed as an active, relevant community for Mac utility discovery, with the standard indie-marketing advice being to participate/contribute before pitching.
|
||||||
|
- Notion's growth to $600M ARR / 100M+ users is repeatedly cited as achieved with "$0 in meaningful paid advertising" — i.e., organic/content/community-led growth at the very largest scale in this market, which is directionally consistent with (though not proof of) productivity tools being unusually word-of-mouth/creator-driven relative to other SaaS categories. ([ehroo.com case study](https://ehroo.com/case-studies/notion-organic-growth-case-study))
|
||||||
|
- Sunsama's growth strategy is documented concretely: a 600+-member "owned influencer/ambassador community" built over four years, credited with contributing a significant share of annual revenue, plus organic screenshot-sharing of its "done for the day" completion screen as an unprompted word-of-mouth loop. This is a specific, replicable-sounding playbook for a premium individual productivity tool. ([getsaral.com](https://www.getsaral.com/customer-stories/sunsama))
|
||||||
|
- YouTube productivity creators (Ali Abdaal, 5.5M+ subscribers; Thomas Frank) function as de facto top-of-funnel for Notion and Todoist specifically — both creators build content and paid courses around these tools, and both are cited as recommending Notion for structured work and Todoist for task tracking. This channel appears to matter more for note-taking/PKM tools (Notion, Obsidian) than for kanban specifically. ([Medium roundup](https://medium.com/@kaivalshah/what-ive-learned-from-productivity-youtubers-ali-abdaal-thomas-frank-and-matt-d-avella-8afe3a929bdb))
|
||||||
|
|
||||||
|
**Gap flagged for a follow-up pass**: none of the sources found gave a concrete, named recent (2025-2026) HN/Product Hunt launch case study for a kanban or file-based task app with real numbers (upvotes, signups, revenue) — worth a targeted narrow-pass search later, e.g. searching HN/PH directly rather than through SEO aggregator commentary.
|
||||||
|
|
||||||
|
## Consolidation and failures, 2024-2026
|
||||||
|
|
||||||
|
| Product | What happened | When | Why (as reported) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Clockwise | Shut down entirely; team acquihired into Salesforce Agentforce; all user data deleted | Announced ~mid-March 2026, dead March 27, 2026 | Platform (Salesforce) wanted the autonomous-scheduling team/IP for its own agentic product, not the standalone business |
|
||||||
|
| Height | Full project-management app shut down | Announced March 28, 2025; final day Sept 24, 2025 | $18.3M raised (incl. $14M Series A, 2021), 7 years old; couldn't out-compete Linear despite "stunning design" and a late pivot toward autonomous PM; generic shutdown messaging drew criticism for lack of transparency |
|
||||||
|
| Rise Calendar | Shut down | Users told to export by March 31, 2025 | Company closure after 4+ years |
|
||||||
|
| Mayday (calendar) | Shut down after acquisition | All services ended May 5, 2024 | Acquired, then terminated rather than integrated |
|
||||||
|
| Amie | Not shut down, but pivoted away from its calendar/task core toward AI meeting notes as the headline feature | Through 2025 | Calendar/task side effectively deprioritized in favor of the AI notetaking bet |
|
||||||
|
| Muse | Near-failure, not full shutdown: team shrank drastically, later relaunched/rebranded as Allume | Mid-late 2023 unsustainability; Allume relaunch late 2024 | Local-first sync product with unit economics that couldn't sustain the original team size |
|
||||||
|
|
||||||
|
Sources: ([Reclaim on Clockwise](https://reclaim.ai/blog/clockwise-shutdown-news), [usecarly.com](https://www.usecarly.com/blog/clockwise-shut-down/), [Creativerly on Height](https://www.creativerly.com/height-app-is-shutting-down/), [AlternativeTo on Rise](https://alternativeto.net/news/2025/1/rise-calendar-is-shutting-down-users-are-urged-to-export-their-data-by-march-31-2025), [AlternativeTo on Mayday](https://alternativeto.net/news/2024/4/mayday-calendar-app-announces-acquisition-and-upcoming-termination-of-all-services), [skywork.ai on Amie](https://skywork.ai/blog/amie-review-2025-calendar-tasks-ai-meeting-notes/), [HN Muse Retrospective](https://news.ycombinator.com/item?id=40359893))
|
||||||
|
|
||||||
|
What did *not* consolidate or fail, contrary to a plausible assumption going in: Superlist (the Wunderlist successor) is still operating and expanding — it merged with Fragment Software in June 2025 rather than shutting down, and Trello (Atlassian, acquired 2017 for $425M) remains an active 50M+-user product with no signs of sunset. ([PitchBook on Superlist](https://pitchbook.com/profiles/company/436263-04), [sqmagazine on Trello](https://sqmagazine.co.uk/trello-statistics/))
|
||||||
|
|
||||||
|
**Pattern across the failures**: none of the notable 2024-2026 shutdowns were bootstrapped/indie products — Height ($18.3M raised), Clockwise (well-funded, acquihired), Rise, and Mayday were all VC-funded companies that either ran out of runway trying to out-feature a category leader (Height vs. Linear) or got absorbed into a bigger platform's agentic-AI roadmap (Clockwise into Salesforce). This is a mild positive signal for a small, low-burn indie approach: the failures cluster in "raised money, needed hypergrowth or an acquirer, didn't get either" — a dynamic a self-funded native Mac app with no VC runway clock doesn't face in the same way.
|
||||||
|
|
||||||
|
## "Tools for thought" eating task management
|
||||||
|
|
||||||
|
Notion, Tana, and Capacities represent a real convergence trend where PKM/notes tools are absorbing task management as one more "block type" or "object" rather than leaving it to dedicated apps. Tana specifically markets itself as ending "context switching between tools" by combining free-flowing notes, structured data, and — per its own positioning — working "especially well for tasks, projects, research, and meeting-heavy workflows," using an AI-native outliner/knowledge-graph model. Capacities takes a similar "everything is a connected object" approach as a Notion alternative. ([Ness Labs on Tana](https://nesslabs.com/tana-review), [xda-developers](https://www.xda-developers.com/i-found-a-notion-obsidian-and-capacities-killer-and-im-absolutely-thrilled/))
|
||||||
|
|
||||||
|
For a dedicated kanban/task tool like Lanework, this is a real competitive pressure at the low end (a Notion or Tana user may simply never feel the need for a separate task app, since the notes tool already "does tasks fine") but it is also a differentiation opportunity: these all-in-one tools are, without exception, cloud-native, app-mediated, and not filesystem-transparent — a Tana or Notion task doesn't exist as a file you can open in a text editor, diff in git, or hand to an arbitrary AI agent via the filesystem. Lanework's plain-folder-and-Markdown model is structurally incompatible with the "everything is a database object in our proprietary graph" approach these tools are converging on, which is a real point of difference rather than a feature gap to close.
|
||||||
|
|
||||||
|
## Adjacent and directly relevant: markdown/git-native kanban for AI coding agents
|
||||||
|
|
||||||
|
This surfaced unprompted while researching the AI-agent angle and is worth flagging prominently because it validates part of Lanework's core thesis from an unexpected direction: there is an active, fast-moving 2025-2026 niche of markdown-file-based, git-native kanban/task tools built specifically for AI coding agents (Claude Code, Cursor, Codex, GitHub Copilot, Gemini CLI) to read and write task state directly as plain files, with a human-usable kanban view layered on top. Examples found: **Backlog.md** (tasks as `.md` files with YAML frontmatter in a git repo, zero-config CLI, explicitly designed so "Claude, please take over task 33" works, plus a terminal/web kanban view — "nearly all of Backlog.md's own code is written by AI agents working through Backlog.md itself"), **Kanban-md** (Show HN, Feb 2026, "no server, no DB, no API tokens"), **Kanban CLI** (Rust, Show HN June 2026), **VS Code Agent Kanban** (Show HN, persistent markdown task files with `[user]`/`[agent]` conversation-log markers committed alongside code), **AgentsBoard**, **Agent Kanban**, and **AgentsRoom**. ([GitHub: Backlog.md](https://github.com/MrLesk/Backlog.md), [HN: Kanban-md](https://news.ycombinator.com/item?id=46957653), [HN: Kanban CLI](https://news.ycombinator.com/item?id=48256520), [HN: VS Code Agent Kanban](https://news.ycombinator.com/item?id=47307169))
|
||||||
|
|
||||||
|
This niche is distinct from Lanework's positioning but adjacent enough to matter directly: these tools validate that "plain Markdown files as the task-state source of truth, readable/writable by both humans and AI agents" is a real, actively-growing pattern with multiple independent implementations converging on it in the same 18-month window — not a niche idea unique to Lanework. The difference is audience and surface: these are developer-CLI/git-repo tools aimed at orchestrating coding agents inside a single repo, not native GUI kanban apps aimed at a general "boards are folders on disk" personal/team workflow with a first-class macOS UI. Lanework sits at the intersection of this validated agent-file-format pattern and the native-app-quality-bar end of the market that these CLI-first tools don't attempt to serve — worth a closer, dedicated look in 06-agents-and-tasks.md.
|
||||||
|
|
||||||
|
## Open questions for later, narrower passes
|
||||||
|
|
||||||
|
- Concrete named Product Hunt / HN launch case studies for kanban or file-based task apps specifically, with real traction numbers (this pass only found generic "how to launch" content).
|
||||||
|
- Whether the Backlog.md/Kanban-md/Agent-Kanban cluster has any user overlap or awareness of native GUI tools like Lanework, or whether the two remain in totally separate discovery funnels (git/CLI users vs. Mac App Store/Setapp users).
|
||||||
|
- A harder look at Superlist's post-Fragment-merger direction, since it's the closest thing to a "modern Wunderlist" and is actively expanding rather than consolidating.
|
||||||
|
- Reddit/App Store review mining specifically for kanban-format (not just PKM/notes) local-first demand, since the 9,300-post analysis found no kanban-specific signal within its local-first bucket.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# The AI-Agent + Task-Management Intersection
|
||||||
|
|
||||||
|
This document covers the segment Lanework's core bet actually lives in: how AI coding agents (Claude Code, Cursor, Codex, Copilot, Devin, aider) currently consume and update task/project state, the purpose-built tools emerging to serve that need, how mainstream trackers (Linear, GitHub, Jira/Atlassian, Notion) are courting agents, the multi-agent orchestration UIs that visualize agent work as boards, developer sentiment on the files-vs-API debate, and where the gaps sit for a human-grade native UI over agent-readable files.
|
||||||
|
Researched: 2026-08-01
|
||||||
|
|
||||||
|
## How agents are given task lists and project state today
|
||||||
|
|
||||||
|
- **Ad hoc markdown files (the default, no tooling required).** The most common pattern is still a plain `TODO.md`, `PLAN.md`, or `ROADMAP.md` at the repo root that a human or the agent itself writes and updates; Claude Code and similar tools read/edit it like any other file. Workflows described in the wild: "keep your tasks in a simple markdown file (todo.md) and let Claude Code implement them automatically," or a `ROADMAP.md` that gets updated as part of a "study codebase → update roadmap → do priority task" loop. Zero adoption friction (every agent already reads files) is the appeal; the cost is no structure — no dependencies, no status enum, no ownership, easy for the file to drift from reality across a long session.
|
||||||
|
- **AGENTS.md as the emerging cross-tool convention.** A markdown file at repo root giving agents project-specific context (build commands, conventions, architecture notes); as of 2026 it's read natively by 30+ tools including Claude Code, GitHub Copilot, Cursor, Devin, and aider. This is adjacent to task management (it's project *context*, not a task queue) but it's the closest thing to a cross-vendor standard that exists, and it establishes the pattern of "one markdown file every agent already knows to look for."
|
||||||
|
- **Claude Code's built-in TodoWrite/Tasks.** Claude Code ships a first-class in-session task list: the agent calls a `TodoWrite` tool with items carrying `content`/`activeForm`/`status` (pending/in_progress/completed), rendered live in the terminal UI so the human can see "where am I, what's next" turn to turn. As of Claude Code v2.1.16 (Jan 22, 2026) this was replaced/extended by "Tasks," which adds dependency tracking, filesystem persistence, and cross-session collaboration — i.e., Anthropic itself moved from an ephemeral in-memory todo list toward a durable, file-backed task model, which is a notable signal that even the agent vendor sees session-scoped todos as insufficient once work spans sessions.
|
||||||
|
- **Cursor / aider / Devin.** Cursor has no native task-tracker; a live "Simple Task Management" feature request thread on Cursor's community forum shows users want one and don't have it. aider is explicitly single-session/human-in-the-loop and doesn't attempt project-level task state. Devin instead plugs into whatever the team already runs — hand it a Jira ticket, bug report, or feature request and it manages that item's lifecycle, and teams can assign it 10 independent tickets to work in parallel sandboxes; Devin's task management is thus outsourced entirely to the host tracker rather than built in.
|
||||||
|
- **MCP servers as the connective tissue.** Rather than each agent vendor building tracker integrations one-by-one, the Model Context Protocol (MCP) has become the standard plug for wiring an agent to whatever tracker a team already uses — GitHub, Linear, Jira/Confluence, Notion all now ship official remote MCP servers (see below). This is the mechanism, not a tool in itself, but it's the dominant one as of 2026.
|
||||||
|
- **MCP Tasks extension (protocol-level, not tool-level).** The MCP spec itself added a Tasks extension formalizing a small state machine for long-running work — `working` / `input_required` / `completed` / `failed` / `cancelled` — with durable task IDs, polling, and TTL/poll-interval metadata, so a tool call that takes minutes or needs human approval doesn't require holding a connection open. It's infrastructure for async tool calls, not a project-management surface, but it shows the protocol layer itself is growing native task/state vocabulary.
|
||||||
|
|
||||||
|
## Purpose-built agent task tools
|
||||||
|
|
||||||
|
- **[Beads](https://github.com/steveyegge/beads) (steveyegge/beads).** A distributed, git-backed issue tracker built specifically to replace "messy markdown plans" with a dependency-aware graph so agents can handle long-horizon work without losing context. Storage is Dolt (a version-controlled SQL database) in embedded or server mode, with `.beads/issues.jsonl` as an export/interchange format, not the source of truth — data syncs across machines via `bd dolt push/pull` against git remotes. Dependency types include blocks, related-to, duplicates, supersedes, replies-to, and parent-child; IDs are hash-based (e.g. `bd-a1b2`) specifically to avoid merge collisions when multiple agents create issues concurrently; a "semantic compaction" feature summarizes closed tasks to conserve context windows. Created by Steve Yegge, released October 13, 2025; hit ~1,000 stars and ~50 forks in its first six days, and per a later check sits at roughly 25.8k stars / 1.7k forks — one of the fastest-growing tools in this whole space and the clearest signal of pent-up demand for "an issue tracker an agent can drive." Notably it explicitly rejects the "just use markdown" approach at scale, arguing a real dependency graph plus real merge semantics is needed once agents create issues faster than humans can triage them.
|
||||||
|
- **[Trekker](https://github.com/obsfx/trekker)** — a deliberately minimal reaction to Beads: a Claude Code plugin with a lightweight dashboard, built by a developer who used Beads, found "giving an agent a task tracker helps a lot with focus and continuity," but wanted a stripped-down alternative with fewer abstraction layers. Posted to Show HN Jan 22, 2026. Signals the space is already forking into "full dependency-graph database" vs. "minimal enough to not need one" camps.
|
||||||
|
- **[Backlog.md](https://github.com/MrLesk/Backlog.md)** — markdown-native task manager and terminal/web kanban visualizer for any git repo; explicitly "for humans and AI Agents," works with Claude Code, Gemini CLI, Codex, Kiro, and any MCP/CLI-compatible assistant. Tasks are per-file markdown (not one big TODO file) with acceptance criteria, milestones, and dependencies; ships both a CLI and an MCP server exposing task operations as typed tools ("the recommended approach for AI agents that support tool use"). 100% offline/private, git-native, no external services. Its Show HN thread (June 2025) is a good sentiment read: the creator's own success curve was 50% with docs alone → 75% with per-task markdown files → 95%+ once task generation was automated, and commenters converged on "lightweight, git-integrated markdown workflows suit AI-assisted development better than centralized platforms" — with the caveat that large markdown files blow agent context budgets, so atomic per-task files matter.
|
||||||
|
- **[claude-task-master](https://github.com/eyaltoledano/claude-task-master)** — an AI-powered task-management system dropped into Cursor, Lovable, Windsurf, Roo, etc.; parses a PRD into tasks and exposes MCP tools (`get_tasks`, `next_task`, `set_task_status`, `expand_task`, `parse_prd`, ...) in core/standard/full tool-count tiers (7/15/36 tools). One of the earliest and most-forked entries in the space (numerous community forks: vibehat/claude-task-manager, ben-vargas/ai-claude-task-master, developerz-ai's autonomous-orchestration fork, etc.) — the fork count itself is a signal of how much people want to bend this to their own workflow rather than adopt one opinionated shape.
|
||||||
|
- **Other MCP task servers**: `taskqueue-mcp` (structured task queue with optional human-approval checkpoints), Dooist (tasks carry intent/spec/desired-output/status so any session can pick one up), Tasks.md MCP server (plain-markdown Epics/Stories/Tasks/Milestones via MCP tools, no database), `madebyaris/agent-orchestration` (shared memory + task queue + resource locks for multi-agent collaboration). Long tail, mostly single-maintainer, mostly reinventing the same small set of primitives (queue, status enum, dependency edge, context blob).
|
||||||
|
- **GNAP — Git-Native Agent Protocol** ([farol-team/gnap](https://github.com/farol-team/gnap)) — an RFC-stage, zero-server coordination protocol where agents literally share a git repo as the task board: tasks live as JSON files in `board/todo/`, an agent claims one by moving it to `board/doing/`, and commits the result to `board/done/`; git history *is* the audit trail. Reference deployment: 4 agents (2 AI + 2 human) at "Farol Labs" coordinating 50+ tasks through one repo. Proposed as a bolt-on for Letta, OpenHands, Roo Code, Plandex, and others. This is the closest conceptual sibling to Lanework's own bet (state lives as files in a git-friendly tree, no server) but it's aimed at agent-to-agent coordination with no human-facing UI at all — it's infrastructure, not a surface.
|
||||||
|
|
||||||
|
## How mainstream trackers are courting agents
|
||||||
|
|
||||||
|
- **Linear.** Hosted MCP server (`mcp.linear.app`, built with Cloudflare + Anthropic) launched May 2025, covering every issue/project/cycle/comment/attachment the authorizing user can read; a Feb 2026 update added initiatives, milestones, and project updates/labels. As of April 23, 2026, "Linear Agent" can itself connect out to other tools via MCP, i.e. Linear moved from *being* an MCP target to *hosting* agents that call other MCP servers. Linear also became the standout proof point for "tracker as agent control plane": **OpenAI's Symphony** (open-sourced, openai.com/index/open-source-codex-orchestration-symphony) is a spec that turns a Linear board into the control plane for Codex — agents pull issues from "Todo," move them to "In Progress," open a PR and move to "In Review" on completion, and GitHub webhooks close the issue when the PR merges. OpenAI reported internal teams saw a **500% increase in landed pull requests** after adopting this pattern, with the caveat that the constraint became "humans writing executable tickets," not agent capability. This is arguably the single strongest piece of evidence in this whole research pass that structured, stateful trackers (vs. raw chat/markdown) meaningfully unlock agent throughput at scale — and that a *board with real states*, not a to-do list, is what did it.
|
||||||
|
- **GitHub.** Two tracks: (1) **Copilot coding agent** — assign it a GitHub Issue and it works autonomously in an Actions-powered sandbox, iterating through test failures and opening a PR for review with no further prompting; GA by 2026, used by ~90% of Fortune 100, Copilot overall at 20M users / 4.7M paid (+75% YoY as of Jan 2026). (2) **GitHub Agentic Workflows** (`github.github.io/gh-aw`) — repo automation written in plain Markdown that runs Copilot/Claude Code/Codex/Gemini inside Actions with sandboxed, read-only-by-default execution (e.g. an issue-triage workflow that labels and comments on new issues); and GitHub Projects now surfaces agent sessions directly in table/board views so a maintainer can see at a glance which items have an attached agent session and its status. GitHub's official `github-mcp-server` (19.9k+ stars) is the other half — issue/PR CRUD, repo search, and triage exposed as MCP tools for any client.
|
||||||
|
- **Jira / Atlassian (Rovo).** Rovo agents reached GA in Jira in 2026 — can be assigned work, @-mentioned in comments, and embedded in workflows, operating across Jira, Confluence, JSM, and 50+ connected apps (Slack, Drive, GitHub, Salesforce, Notion). Atlassian shipped an official remote MCP server (`atlassian/atlassian-mcp-server`, OAuth 2.1, built on Cloudflare's Agents SDK with Anthropic) connecting Jira/Confluence/JSM/Bitbucket/Compass to Claude, ChatGPT, Cursor, VS Code, etc. Framing at Atlassian Team '26 (May 2026, Anaheim) was explicit: AI moving "from assistance to active participation."
|
||||||
|
- **Notion.** Hosted MCP server (18 tools: search, page CRUD, database creation, comments, users) plus page content exposed as enhanced Markdown (`retrieve-page-markdown` / `update-page-markdown`) — notable because it means Notion, a database-backed product, deliberately presents itself to agents *as if* it were a markdown file store, echoing the "virtual filesystem" pattern seen elsewhere (see Files vs. API below). Notion Custom Agents (2026) can also participate in cross-tool workflows via pre-built partner MCP integrations (Linear, Figma, HubSpot) or custom servers.
|
||||||
|
- Pattern across all four: every major mainstream tracker independently converged on the same shape in the last ~12 months — official hosted MCP server + agents that can be assigned/mentioned/take actions inside the existing board — meaning "give the agent API access to our tracker" is now table stakes, not a differentiator. The differentiator is what happens on top (see Gaps, below).
|
||||||
|
|
||||||
|
## Multi-agent orchestration UIs
|
||||||
|
|
||||||
|
A genuinely dense cluster of tools now visualizes parallel-running coding agents as a board or queue — this is the segment closest to Lanework's own UI concept, though none of them treat the board itself as a portable, human-editable file tree the way Lanework does.
|
||||||
|
|
||||||
|
- **[Conductor](https://conductor.build)** (Melty Labs, YC S24) — free native Mac app; each new "workspace" runs `git worktree add` and starts Claude Code or Codex inside it, fully isolated; features include Checkpoints (rollback snapshots), Spotlight testing (sync a workspace's changes back to main for testing), and multi-model mode (run Claude and Codex on the same prompt side by side). Used by engineers at Linear, Vercel, Notion, Stripe, Life360 per its marketing. No plain-file board — the workspace list is the "board," implicitly, not a kanban.
|
||||||
|
- **Crystal → Nimbalyst** ([stravu/crystal](https://github.com/stravu/crystal), now deprecated/renamed) and **crystl** (a macOS-native Swift rewrite using Keychain, throttled off-screen rendering) — Electron/native apps for running multiple Claude Code/Codex sessions in parallel git worktrees, session-per-tab rather than card-per-task.
|
||||||
|
- **[Vibe Kanban](https://github.com/BloopAI/vibe-kanban)** — an open-source kanban board purpose-built for AI coding agents (Claude Code, Codex); each card is an agent task, parallel execution runs each in an isolated git worktree, one-click dev-server startup per card. Reported figures vary by snapshot but land around 8-9k GitHub stars, making it one of the most-starred tools in this whole document. Notably its creator (Bloop) shut down in April 2026 and the project transitioned to community maintenance — a caution flag that "hot open-source agent tool" and "sustainable business" are not the same thing yet in this space.
|
||||||
|
- **[Kanban Code](https://github.com/langwatch/kanban-code)** (LangWatch) — the closest direct conceptual neighbor to Lanework found in this research. Native macOS (SwiftUI, "liquid glass") + Windows (Tauri) app whose stated goal is "easing the context-switching bottleneck ... by centralizing all context needed for each Claude session into their cards." Each kanban card automatically links a Claude Code session, a git worktree, a tmux terminal, and a GitHub PR, flowing through Backlog → In Progress → Waiting → In Review → Done → All Sessions. Built with a clean unidirectional-data-flow architecture (`AppState`/`Reducer`/`EffectHandler`, a separated `KanbanCodeCore` Swift library) and an embedded terminal (SwiftTerm). Storage, notably, is *not* one-file-per-card the way Lanework or Backlog.md do it — it's `~/.kanban-code/settings.json` and `~/.kanban-code/links.json` (a central "links" record) plus scanning `~/.claude/projects/` for session discovery; the board state is app-owned JSON, not a portable folder of files a human or shell script would casually edit. ~300 stars / 31 forks, AGPLv3, explicitly synthesized from five predecessor projects by the same team. This is a real existence proof that "native Mac app, kanban-as-agent-orchestrator" is a validated shape people want — but it competes on session/terminal/worktree integration, not on being a human-grade editable-files surface.
|
||||||
|
- **A long tail of similar single-purpose boards**, mostly terminal (TUI) or small web apps, all converging on the same primitive (task = card = worktree = agent session): OpenKanban (TUI, TechDufus), agent-kanban ("mission control for your AI workforce," leader agent plans/assigns, workers claim and ship PRs), ai-agent-board (DanWahlin, drag-and-drop, multi-engine: Copilot/Claude/Codex/OpenCode), Cline's built-in `cline --kanban`, opencode-kanban (Rust TUI for OpenCode+tmux). None found with meaningful star counts beyond Vibe Kanban and Kanban Code; this is a "everyone is building the same toy" moment, which usually means the primitive is right but no single implementation has won yet.
|
||||||
|
- **Google Antigravity's Agent Manager ("Mission Control")** — not a kanban board but the same underlying need from a different angle: a dedicated surface (built on VS Code, powered by Gemini 3 Pro) to spawn/monitor/coordinate multiple agents working asynchronously across workspaces, flipping the usual "agent embedded in the IDE" relationship to "IDE surfaces embedded in the agent manager." Signals that even the big-lab-built IDEs are converging on "a board of parallel agents" as the primary developer-facing surface for agentic coding, not chat.
|
||||||
|
- **Enterprise-scale orchestrators worth noting from `awesome-agent-orchestrators`**: Fusion (kanban board + plan-review-execute gates + per-task worktrees + hierarchical missions), gastown (scales to 20-30 agents with a coordinator, git-backed issue tracking, Bors-style merge queue), multi-agent-shogun (tmux orchestrator, up to 10 parallel agents, zero coordination-API cost). These lean further into "swarm ops tooling" than "human-legible board."
|
||||||
|
|
||||||
|
## Developer sentiment: files vs. API/database, and "agents need issue trackers"
|
||||||
|
|
||||||
|
- **The core discourse has a name now**: multiple 2026 pieces frame it explicitly as "issue trackers as AI agent infrastructure" — the argument (from MindStudio, Nate B. Jones, and others covering Symphony) is that issue trackers already encode most of what an autonomous agent needs structurally: state, ownership, permission hierarchies, immutable history — things a raw chat context or a loose markdown file don't durably have. "A conversation has no durable state outside the context window" is the recurring one-liner.
|
||||||
|
- **Filesystem vs. API vs. database is being explicitly litigated**, not just assumed. An Arize engineering piece ("AI Agent Interfaces in 2026: Filesystem vs API vs Database") lands on: filesystems win as an *interface* not because they're theoretically best but because "everything else is worse" — agents have massive pretraining on bash/file operations, so a filesystem needs no schema explanation, while APIs burn context explaining endpoints and databases require query languages agents use less fluently. Its tradeoff table: filesystem = familiar but reinvents search/locking/validation poorly at scale; API = better for humans, context-expensive for agents; database = proper persistence/query but unfamiliar query surface. The resolving pattern it documents is a **"virtual filesystem"**: store data in a real database but expose it to the agent *as if* it were files (LangSmith is cited as doing this) — which is precisely what Notion's MCP server does by exposing pages as "enhanced Markdown" rather than raw JSON, and arguably what Backlog.md's MCP server does the other direction (real files, but with a typed-tool API layered on top for agents that prefer tool calls over `cat`/`grep`).
|
||||||
|
- **The Backlog.md and Beads reception both point the same direction**: plain markdown works great until (a) files get large enough to blow context budgets, or (b) multiple agents/humans are creating and closing issues fast enough that merge conflicts and lost updates start to matter — at which point people reach for atomic per-task files (Backlog.md's fix) or a real dependency graph with conflict-safe IDs (Beads' fix). Nobody in this research surfaced a strong voice arguing markdown-only *doesn't* scale to a single dev/small team; the scaling concern is specifically multi-agent-concurrent-writers and long-horizon-context.
|
||||||
|
- **Show HN threads as a sentiment proxy**: Trekker's post ("giving an agent a task tracker helps a lot with focus and continuity" — then building a *simpler* tool than Beads because Beads had drifted from what the poster actually needed) and Mission Control's post (commenters pushing on task provenance/auditability, and flagging that "agent completed the task correctly but solved the wrong problem" is a live failure mode current tools don't self-detect) both show a builder community actively iterating on primitives, not settled on one winner.
|
||||||
|
- **No one is defending Jira/enterprise-tracker UX on the merits for agent-driven work** in anything surfaced here — Jira/Linear/GitHub win this discourse specifically *because* they already have durable structured state and audit trails, not because their UI is considered good for the job; several pieces (and the Backlog.md thread) explicitly note enterprise users still want a *bridge* to Jira rather than abandoning file-based local tools, suggesting the two aren't seen as mutually exclusive.
|
||||||
|
|
||||||
|
## Gaps: what's missing
|
||||||
|
|
||||||
|
- **No one treats the board as a portable, human-editable file tree.** Every mainstream tracker (Linear/GitHub/Jira/Notion) exposes agent access via API/MCP over a server-owned database; every native-feeling orchestration UI found (Kanban Code, Conductor, Crystal, Vibe Kanban) stores its own state as app-owned JSON/SQLite, not as a folder of files a human could open in Finder, diff in git, or grep from a shell script independent of the app. Lanework's literal "boards are folders and Markdown files on disk" is not the pattern any competitor in this specific intersection actually ships, despite GNAP and Backlog.md gesturing at the same philosophy from the tooling/CLI side.
|
||||||
|
- **The CLI/MCP tools are agent-first with weak or no human GUI**; the GUI tools (Kanban Code, Vibe Kanban, Conductor) are agent-orchestration-first with app-owned state, not a general-purpose human kanban surface that happens to also be agent-legible. Nothing found combines "genuinely good native human kanban app" with "the underlying files are the actual source of truth, and any agent/script/editor can read and write them without going through the app's API or a bundled MCP server." That combination — first-class human UI *and* first-class agent-of-any-kind substrate, at once, with neither role privileged — is the gap Lanework's bet targets, and this research pass didn't surface a direct occupant of it.
|
||||||
|
- **Multi-agent-concurrent-write safety on plain files is still unsolved-ish.** Beads' entire reason to exist is that markdown/plain-file approaches degrade once agents create/close issues fast concurrently (merge collisions, lost updates); GNAP's answer (git as the coordination bus, todo/doing/done directories) is elegant but has no human review surface at all. A native app that could make git-mediated concurrent file edits legible and safely mergeable *for a human watching a board update live* is unaddressed.
|
||||||
|
- **Provenance/audit and "did the agent actually solve the right problem" surfaced as an explicit gap** in the Mission Control HN thread — current tools track task status (done/not done) but not whether the outcome matched intent, and rely on humans to catch scope drift. A board UI that made an agent's diff/PR trivially reviewable against the original card text (which Kanban Code and Symphony both gesture toward via PR-linking, but don't make the primary UI object) is only partially built out anywhere.
|
||||||
|
- **No dominant, sticky product yet.** Every purpose-built agent task tool in this document is either brand-new (Beads: Oct 2025), community-maintained after its creator shut down (Vibe Kanban), a low-star long tail (OpenKanban, agent-kanban, ai-agent-board, Kanban Code at ~300 stars), or a spec/RFC with a single reference deployment (GNAP, Symphony). The mainstream trackers (Linear/GitHub/Jira/Notion) have won the "agents can act on our data" layer but not the "this is where humans go to see agent work as a board" layer — that layer is still up for grabs, actively being prototyped by a dozen small teams, and none has separation yet.
|
||||||
|
- **Cross-repo / cross-project agent task state** was flagged as an open problem in the Backlog.md thread (users asking how to run one backlog across separate frontend/backend repos) and isn't solved by any tool surveyed — every file-based approach here is scoped to a single git repo.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- [Backlog.md](https://github.com/MrLesk/Backlog.md), [Backlog.md Show HN thread](https://news.ycombinator.com/item?id=44483530)
|
||||||
|
- [Beads (steveyegge/beads)](https://github.com/steveyegge/beads), [Beads Show HN](https://news.ycombinator.com/item?id=46709872), [Beads coverage, Better Stack](https://betterstack.com/community/guides/ai/beads-issue-tracker-ai-agents/)
|
||||||
|
- [Trekker](https://github.com/obsfx/trekker)
|
||||||
|
- [claude-task-master](https://github.com/eyaltoledano/claude-task-master)
|
||||||
|
- [GNAP — Git-Native Agent Protocol](https://github.com/farol-team/gnap)
|
||||||
|
- [Mission Control Show HN](https://news.ycombinator.com/item?id=47165602)
|
||||||
|
- [Linear MCP changelog](https://linear.app/changelog/2026-04-23-linear-agent-mcp-support)
|
||||||
|
- [OpenAI Symphony announcement](https://openai.com/index/open-source-codex-orchestration-symphony/), [Symphony/Linear 500% PR increase writeup](https://www.mindstudio.ai/blog/openai-symphony-spec-linear-agent-control-plane-500-percent-pr-increase)
|
||||||
|
- [GitHub MCP Server](https://github.com/github/github-mcp-server), [GitHub Agentic Workflows](https://github.github.io/gh-aw/), [Assigning issues to Copilot coding agent](https://github.blog/ai-and-ml/github-copilot/assigning-and-completing-issues-with-coding-agent-in-github-copilot/)
|
||||||
|
- [Atlassian remote MCP server](https://github.com/atlassian/atlassian-mcp-server), [Rovo agents in Jira](https://www.atlassian.com/blog/rovo/ai-agents-in-jira)
|
||||||
|
- [Notion official MCP server](https://github.com/makenotion/notion-mcp-server)
|
||||||
|
- [Claude Code Todo/Tasks docs](https://code.claude.com/docs/en/agent-sdk/todo-tracking)
|
||||||
|
- [Conductor (Melty Labs)](https://conductor.build)
|
||||||
|
- [Vibe Kanban](https://github.com/BloopAI/vibe-kanban)
|
||||||
|
- [Kanban Code (LangWatch)](https://github.com/langwatch/kanban-code)
|
||||||
|
- [awesome-agent-orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators)
|
||||||
|
- [AI Agent Interfaces in 2026: Filesystem vs API vs Database, Arize](https://arize.com/blog/agent-interfaces-in-2026-filesystem-vs-api-vs-database-what-actually-works/)
|
||||||
|
- [Google Antigravity Agent Manager](https://antigravity.google/blog/introducing-google-antigravity)
|
||||||
|
- [AGENTS.md best practices](https://www.betterclaw.io/blog/agents-md-best-practices)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Schema Comparison: Signboard vs Lanework
|
||||||
|
|
||||||
|
Signboard is the closest live competitor (see its profile in [03-native-indie-filebased.md](03-native-indie-filebased.md)), and both apps make the same headline promise: boards are folders and Markdown files on disk. This document compares the two on-disk formats in depth — how each is defined, the structural choices, and what those choices cost — because the format is the product's foundation and the differentiation turns out to live there. This is the first narrow-pass artifact; the pattern (per-competitor deep dive) is the template for the rest of pass two.
|
||||||
|
|
||||||
|
Researched: 2026-08-01
|
||||||
|
|
||||||
|
## How each format is defined
|
||||||
|
|
||||||
|
Signboard's format is open but informal: documented in prose in [docs/using-signboard.md](https://github.com/cdevroe/signboard/blob/main/docs/using-signboard.md), with the MIT-licensed source as the de-facto normative spec. There is no formal schema document, no schema version key on disk, and no stated compatibility or round-trip guarantees — the code is the spec, and the format is free to drift with releases.
|
||||||
|
|
||||||
|
Lanework's format is a normative contract: `DESIGN/01-storage-format.md` (graduating to `SCHEMA.md`), a `schema: 1` version key at board root, an explicit five-verdict defect taxonomy (refuse / refuse writes / tolerate / coerce / heal), and byte-level preservation guarantees. External writers can target the contract, not the implementation.
|
||||||
|
|
||||||
|
## The formats side by side
|
||||||
|
|
||||||
|
| Axis | Signboard | Lanework |
|
||||||
|
|---|---|---|
|
||||||
|
| Card | Single `.md` file | Folder with `index.md` + `attachments/` |
|
||||||
|
| Identity | Filename: `[order]-[title]-[random-id].md`, plus `signboard_id` in frontmatter | Folder name = immutable UUID; title only in frontmatter |
|
||||||
|
| List/lane order | Numeric prefix in the folder name | `order` rank in each item's frontmatter |
|
||||||
|
| Card order | Numeric prefix in the filename | Gapped fractional `order` rank |
|
||||||
|
| Location | Path, plus `signboard_board` / `signboard_list` duplicated in frontmatter | Path only ("level is position") |
|
||||||
|
| Dates | Inline body syntax `(due: YYYY-MM-DD)` on checklist items | Frontmatter (`due` reserved for the tracker era) |
|
||||||
|
| Frontmatter posture | Deliberately flat, Obsidian-property-friendly (`title`, `signboard_id`, `signboard_board`, `signboard_list`, `status`, `signboard_uri`, `related`, `linked_objects`) | Minimal schema-owned keys; unknown keys, comments, and formatting preserved verbatim |
|
||||||
|
| Versioning | None | `schema: 1` at board root |
|
||||||
|
| Round-trip promise | None stated | Byte-perfect: unknown keys, comments, line endings, untouched bodies all survive |
|
||||||
|
| Validation/healing | None documented | Integrity service: coercion rules, defect taxonomy, inline/on-touch/scheduled heals |
|
||||||
|
| Trash/archive | `XXX-Archive` folder (named to sort last) | Reserved `.trash/`, restore = ordinary move, `kind` discriminator |
|
||||||
|
| Settings | `board-settings.md` at root | Board `index.md` frontmatter |
|
||||||
|
|
||||||
|
## What the differences mean in practice
|
||||||
|
|
||||||
|
**Identity is the deep divergence.** Signboard encodes title and order into the filename, so a rename or reorder renames the file. That is friendly in Finder — a raw board reads at a glance — but identity is unstable: git sees renames instead of edits (history detaches unless rename detection catches it), sync engines see delete+create, and an agent mid-edit can have the file move out from under it. Lanework paid the Finder-legibility cost (opaque UUID folders) precisely to buy immutable identity: renames and reorders never move or rename anything, git history stays attached to the card forever, and the card window tracks its card by UUID through any foreign move. A genuine trade, not a strict win — Signboard's boards are more browsable raw — but for the agent/git thesis, stable identity is the right side of it.
|
||||||
|
|
||||||
|
**Ordering by name-prefix causes write cascades.** Inserting a card between positions in Signboard means renaming files to make room; reordering lists renames directories (their docs say so explicitly). Every rename is a sync-conflict surface and a git-history discontinuity. Lanework's gapped fractional ranks rewrite exactly one file per reorder, with a rare, localized renumber when midpoints exhaust.
|
||||||
|
|
||||||
|
**Signboard denormalizes; Lanework doesn't.** `signboard_board` and `signboard_list` inside each card duplicate what the path already says — done for Obsidian-properties interop, so a card is self-describing inside a vault. The cost: a card moved by hand, script, or agent — the exact users both apps court — carries stale location metadata afterward, and nothing in Signboard's docs describes healing it. Lanework's level-is-position rule means there is nothing to go stale, and where defects can arise the integrity service owns the repair story. Signboard has no documented equivalent of validation, coercion, or preservation rules; hand-edited files with unknown keys or odd YAML get whatever the code happens to do.
|
||||||
|
|
||||||
|
**Single-file cards vs folder cards.** Signboard's card is lighter and Obsidian-native (a card is a note), with `linked_objects` pointing at external files by reference. Lanework's folder-per-card is heavier but gives cards real owned contents — attachments that travel with the card through moves, copies, trash, and clipboard, and a future `comments/` thread. Reference-style links break when targets move; owned folders don't.
|
||||||
|
|
||||||
|
## Strategic read
|
||||||
|
|
||||||
|
The two formats optimize for different first-class citizens. Signboard's schema is shaped by Obsidian: flat properties, vault embedding, human-readable filenames, Bases generation. Lanework's is shaped by git and agents: immutable identity, minimal-write ordering, byte-perfect round-trips, a versioned contract, a seeded `CLAUDE.md`. Neither app can cheaply adopt the other's posture — Signboard cannot get stable identity without breaking every existing vault's filenames — so this is durable structural differentiation, not a feature gap either side closes with a release.
|
||||||
|
|
||||||
|
Consequences worth carrying into positioning and planning:
|
||||||
|
|
||||||
|
- The formats will not interoperate directly, but a Signboard importer would be mechanical to write (their docs show the full shape). Signboard already imports Trello, Obsidian boards, and Tasks.md — consolidating the niche via importers is their explicit playbook, and it can be Lanework's too.
|
||||||
|
- "Our format is a versioned, guaranteed contract" is a marketable claim Signboard structurally cannot match today, aimed exactly at the agent/script/git audience both apps court.
|
||||||
|
- The Obsidian-shaped choices that weaken Signboard's format for agents are the same choices that give it distribution into the largest markdown-board audience (vault embedding, companion plugin). Format rigor wins the agent story; vault interop wins the Obsidian story. Watch whether Signboard tries to have both — retrofitting stable identity would be its hardest possible migration.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Does Signboard's MCP server/CLI mediate writes in a way that compensates for the format's fragility (e.g. fixing up `signboard_list` on move), and what happens when an agent bypasses them and edits files directly?
|
||||||
|
- How does Signboard behave on hand-edited files: are unknown frontmatter keys preserved on rewrite? Is there any conflict story when a file changes mid-edit? (Testable empirically — it's free for personal use.)
|
||||||
|
- Whether Obsidian Bases' first-party kanban adopts a format of its own, which would pressure both apps' interop stories.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Market Research
|
||||||
|
|
||||||
|
Long-term competitive-landscape research for Lanework: the kanban app market plus the adjacent personal todo/task-management market, with special attention to the file-based/local-first niche and the emerging AI-agent + task-management intersection — the two axes Lanework's bet actually lives on.
|
||||||
|
|
||||||
|
This directory is a living body of work. The first pass (2026-08) is deliberately broad — mapping segments, trends, and success factors. Later passes will narrow: deep-dives on the direct competitive set, positioning, and pricing.
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
- [01-market-overview.md](01-market-overview.md) — synthesis: the map of the market, where Lanework sits, and what the broad pass says matters (written after the segment docs; start here)
|
||||||
|
- [02-mainstream-kanban.md](02-mainstream-kanban.md) — mainstream/team kanban and project boards: Trello, Asana, Monday, Jira, ClickUp, Notion, Linear, and company
|
||||||
|
- [03-native-indie-filebased.md](03-native-indie-filebased.md) — native macOS, indie, and file-based/plain-text tools: Lanework's nearest neighborhood
|
||||||
|
- [04-todo-adjacent.md](04-todo-adjacent.md) — personal todo/task apps: Things, Todoist, TickTick, OmniFocus, Reminders, and the daily-planner wave
|
||||||
|
- [05-trends-success-factors.md](05-trends-success-factors.md) — cross-cutting trends: AI features that stuck, local-first momentum, pricing models, distribution, notable failures
|
||||||
|
- [06-agents-and-tasks.md](06-agents-and-tasks.md) — the AI-agent + task-management intersection: how agents consume task state today and what's missing
|
||||||
|
|
||||||
|
Narrow-pass deep dives (per-competitor, added as they happen):
|
||||||
|
|
||||||
|
- [07-schema-comparison-signboard.md](07-schema-comparison-signboard.md) — Signboard's on-disk format vs Lanework's: definition rigor, identity, ordering, denormalization, and the strategic read
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Each document opens with a scope note and a `Researched: YYYY-MM-DD` line; treat facts as snapshots of that date.
|
||||||
|
- Broad passes favor coverage over depth; a claim worth acting on gets re-verified in a narrow pass first.
|
||||||
|
- Prose is unwrapped — one line per paragraph or list item.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Charter — Release & Distribution Strategy
|
||||||
|
|
||||||
|
Scope: the framing document for the strategy effort. It records the strategic goal, the two tier anchors, what is already ruled elsewhere, the decision surface (the knobs, decomposed into workstreams), the tensions the strategy must resolve, and the research agenda. It makes no decisions itself.
|
||||||
|
|
||||||
|
Drafted: 2026-08-01
|
||||||
|
|
||||||
|
## The strategic goal
|
||||||
|
|
||||||
|
A long-term, reliably sustainable business with a clear path to growth.
|
||||||
|
|
||||||
|
Working interpretation, informed by the market research (`research/01-market-overview.md` ▸ success factors): sustainability means revenue that supports indefinite continued development with no external funding — the evidence is unambiguous that bootstrapped survives in this space and funded players pivot or die, and that visible steady development is itself part of the product (abandonment is fatal in the indie niche). Growth means base→Pro conversion inside the personal/family garden first, with the Teams tier as a deliberate second act, not a parallel bet.
|
||||||
|
|
||||||
|
Obsidian is the standing existence proof for the ceiling: ~$25M ARR, 18 people, zero VC, local-first. Lanework does not need to reach that ceiling to be sustainable; it needs a numeric definition of "enough" (see workstream 1).
|
||||||
|
|
||||||
|
## The two anchors
|
||||||
|
|
||||||
|
These two statements are the fixed points the strategy is built around (stated 2026-08-01):
|
||||||
|
|
||||||
|
1. **Base and Pro tiers** ship in the App Store and aim to create a "walled garden" for the user: create and manage kanban boards for whatever personal or family tasks/projects they engage with. The experience aims to be delightful and reliable on every level — from bug-free operation, to near-frictionless integration with third-party systems including AI, to multi-device multi-user board sharing, to guarantees around data ownership through open formats that hold regardless of the author's availability.
|
||||||
|
2. **Teams tier** (not yet engaged) is about a single surface of access to project management across multiple tracking systems — GitLab, GitHub, Atlassian, and more.
|
||||||
|
|
||||||
|
### The inversion that resolves "walled garden" vs "openness"
|
||||||
|
|
||||||
|
The garden's walls are around the *experience*, not the *data*. The app is the closed, polished, opinionated surface; the substrate underneath is open, documented, and legible to anything. This is the exact inverse of the mainstream pattern (open-feeling UI, captive data), and it is simultaneously the positioning line, the trust story, and the moat: competitors with captive state cannot cheaply adopt it (see the Signboard schema analysis), and the openness pledge costs Lanework nothing it wasn't already architecturally committed to. Every strategy decision should be checked against this inversion — anything that closes the substrate or cheapens the surface works against the whole position.
|
||||||
|
|
||||||
|
## What is already ruled (defaults, not open questions here)
|
||||||
|
|
||||||
|
- **Channel:** Mac App Store, one record (`dev.rzen.indie.Kanban`), shipping as 2.0 of the existing listing; notarized direct distribution deliberately not pursued for 2.0 (RELEASE.md, 2026-07-29).
|
||||||
|
- **Pro mechanics:** Pro is a subscription inside the app, never a second record; the monthly product id and StoreKit scaffolding exist (DESIGN/12-editions.md, re-ruled 2026-07-30).
|
||||||
|
- **Launch coupling:** 2.0 does not ship before pro-m1 is ready — the tiers launch together (user-ruled 2026-07-31).
|
||||||
|
- **Format posture:** versioned on-disk contract, immutable UUIDs, rank ordering — the durable structural differentiation vs Signboard (`research/07-schema-comparison-signboard.md`).
|
||||||
|
|
||||||
|
Rulings stand as defaults. A strategy workstream may surface evidence that argues for a re-ruling (e.g. the shape of the subscription offer), but must do so explicitly, naming the ruling it challenges.
|
||||||
|
|
||||||
|
## The decision surface
|
||||||
|
|
||||||
|
The original knobs — licensing, marketing, openness, goals — decomposed into six workstreams. Each becomes a numbered document when worked.
|
||||||
|
|
||||||
|
1. **Goals & guardrails** (the *goals* knob). Make "sustainable" numeric: the cost floor of running the business, the revenue level that clears it durably, the conversion and volume assumptions that level implies, and what the numbers say about pricing latitude. Record the guardrails as commitments: no external funding; no growth move that breaks the openness pledge; steady visible development cadence. Define trigger conditions and success/kill criteria for engaging the Teams tier.
|
||||||
|
2. **Monetization architecture** (the *licensing* knob). The tier boundary — what base offers free (base is both the funnel and the public proof of the trust story) versus what Pro sells. The offer shape within the ruled subscription mechanism: monthly/annual mix, price points, and whether a hybrid softens subscription fatigue — the market research flags the Agenda "pay once, keep what you unlocked" hybrid and Curio's dual-track as the patterns to test against a straight subscription, and measured subscription fatigue (active cancellation 31%→47%, 2024→2026) as the risk a pure monthly carries. Family Sharing question: multi-user family boards are an anchor promise, so whether the Pro subscription participates in App Store Family Sharing is a monetization decision, not a detail. Teams pricing is out of scope until the tier engages, beyond noting it will not be MAS-consumer-shaped.
|
||||||
|
3. **Openness architecture** (the *openness* knob). Turn the data-ownership pledge into concrete, credible artifacts: a published versioned format specification; possibly open-source tooling (validator, exporter, CLI, MCP server) under a permissive license; the licensing instrument for each artifact (spec vs code vs app are three different licenses). Define the "author unavailability" guarantee mechanically — what a user can do with their boards if the app stops being developed, and what makes that claim credible on day one rather than aspirational. Equally: state what stays closed (the app itself) and why that is not a betrayal of the pledge.
|
||||||
|
4. **Positioning & messaging** (half the *marketing* knob). The one-sentence promise; the audience definition — the research found two funnels that may not overlap (the agent-tooling/git-discovery audience and the Mac-indie/MAS audience) and the positioning must decide whether to lead with one, and which; the naming and framing of the openness guarantee so it lands as a feature, not a spec footnote; the differentiation story against Signboard ("native, faster, more Mac-idiomatic") kept honest.
|
||||||
|
5. **Launch & growth playbook** (the other half of *marketing*). Launch sequencing and channels (HN Show, Product Hunt, r/macapps, Mac press), grounded in the launch case studies the research pass still owes; content and community strategy, with Obsidian's community playbook as the study reference; development cadence as marketing — the changelog and visible momentum are part of the growth engine, not overhead.
|
||||||
|
6. **Teams tier as a second act.** A deliberately thin placeholder until triggered: different buyer, different economics, different channel (likely not MAS-only), a structural new risk class (dependency on third-party tracker APIs), and a different competitive set (aggregators and single-pane tools) needing its own landscape scan. The bridge from the garden to Teams — same app, same substrate, remotes as the mechanism — is what makes it an act two rather than a second company.
|
||||||
|
|
||||||
|
## Tensions to resolve
|
||||||
|
|
||||||
|
The real strategy questions — each is an intersection of two workstreams pulling opposite directions.
|
||||||
|
|
||||||
|
- **Subscription mechanism vs subscription-fatigue evidence.** The ruling fixes the *mechanism* (in-app subscription, one record); the research says one-time/low-commitment pricing is a trust signal in exactly Lanework's niche. The open question is the *offer shape* inside the ruled mechanism: pure monthly, annual-first, or a hybrid that lets lapsed subscribers keep something. This is the single highest-stakes open decision.
|
||||||
|
- **Free-tier generosity vs conversion.** The openness pledge forbids ransoming data — a lapsed or free user must never lose access to their boards' content. So Pro must sell *capability* (sync, history, remotes, sharing), never data hostage-taking. The tension is making the free tier generous enough to prove the trust story while leaving Pro something people who love the free tier actually pay for.
|
||||||
|
- **Two audiences, one product.** If the agent-tooling funnel and the Mac-indie funnel truly don't overlap, positioning, launch channels, and even the App Store listing copy cannot serve both at once. Sequencing (which audience first) is a strategy decision the funnel-overlap research must inform.
|
||||||
|
- **Credibility of the availability guarantee.** "Your data outlives the author" is easy to say and hard to prove before there is a track record. The openness workstream must find the artifacts that make it verifiable now (published spec, working third-party reader path, maybe license terms that trigger on abandonment) rather than a promise to be trusted.
|
||||||
|
- **Walled garden vs openness in messaging.** The inversion resolves the substance; marketing must not muddle it. Language that sounds like lock-in ("walled garden" is an internal term, never a public one) or like an open-source project (it is not one) both misposition the product.
|
||||||
|
|
||||||
|
## Research agenda
|
||||||
|
|
||||||
|
Evidence the strategy needs, feeding the workstreams above. Overlaps with the narrow-pass agenda in `research/01-market-overview.md`; items here are strategy-shaped rather than landscape-shaped.
|
||||||
|
|
||||||
|
- **Pricing deep dive** (workstream 2): Agenda's hybrid, Curio's dual track, Things' one-time, Obsidian's free-personal/paid-commercial + add-on services, Signboard's actual model — mechanics, communicated rationale, and any public signals about how each performs.
|
||||||
|
- **Launch case studies with real numbers** (workstream 5): HN/PH launches of file-based or local-first task tools — carried over from the research open questions, still unfilled.
|
||||||
|
- **Funnel overlap study** (workstream 4): whether the agent-tooling and Mac-indie audiences share any channels at all.
|
||||||
|
- **Openness precedents** (workstream 3): "open format, closed app" done well — Obsidian (closed app, open vault), TaskPaper (format outlived ecosystems), Ghost/Tailscale (open core with a different boundary), Sketch (open file format as ecosystem play) — what artifacts each published, under what licenses, and what credibility each bought.
|
||||||
|
- **Family/multi-user sharing precedents** (workstream 2): how MAS apps price and deliver family multi-user features, and what App Store Family Sharing does and doesn't permit for subscriptions.
|
||||||
|
- **Teams-tier landscape scan** (workstream 6): deferred until the tier's trigger conditions are met; noted so it isn't forgotten.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user