Compare commits
305
Commits
e6c34c0ccf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eac1c02a7d | ||
|
|
c67c1037f1 | ||
|
|
7414fc8400 | ||
|
|
99ebb69a1d | ||
|
|
798a8bac73 | ||
|
|
da0d7fd2d7 | ||
|
|
93a3423e6b | ||
|
|
7d7e892617 | ||
|
|
7ac34651a2 | ||
|
|
3d231d6454 | ||
|
|
fb96e30df0 | ||
|
|
56e37be158 | ||
|
|
9c857ae0cc | ||
|
|
5779da2b6c | ||
|
|
c21c53be9c | ||
|
|
8aefaf23ce | ||
|
|
b0ffff1aa1 | ||
|
|
c686242a14 | ||
|
|
caaa0c0776 | ||
|
|
ccf55f1d49 | ||
|
|
38ff520ae9 | ||
|
|
42fb10aa65 | ||
|
|
43f87a538b | ||
|
|
1e21d8cd60 | ||
|
|
51cf994cb9 | ||
|
|
f126614b56 | ||
|
|
eef0a4539f | ||
|
|
cfee4a4b41 | ||
|
|
d5ad21c3da | ||
|
|
190a8e36f1 | ||
|
|
9766e1f61c | ||
|
|
73698cd77b | ||
|
|
b1ea97c03e | ||
|
|
1980570b27 | ||
|
|
b1aa0c4483 | ||
|
|
13388776e1 | ||
|
|
2e0e8ae871 | ||
|
|
639d9839bf | ||
|
|
e147e9cd96 | ||
|
|
d23ead4b27 | ||
|
|
d9b6232dea | ||
|
|
e1ffe71e5b | ||
|
|
531ca2c595 | ||
|
|
3fc3dc629a | ||
|
|
d9797acf57 | ||
|
|
3385ec61ee | ||
|
|
3c64e5ae68 | ||
|
|
bb936068db | ||
|
|
0d8ecdb78b | ||
|
|
52df210284 | ||
|
|
26239200ea | ||
|
|
503ec4872c | ||
|
|
ea15d1ac74 | ||
|
|
a60d97689e | ||
|
|
c60553f17f | ||
|
|
43e6ad229e | ||
|
|
1ffb64913b | ||
|
|
1fc8aaa249 | ||
|
|
0218ae4c21 | ||
|
|
409f430813 | ||
|
|
f6105d4389 | ||
|
|
a51ad750ad | ||
|
|
84f909a720 | ||
|
|
ef423bb9d2 | ||
|
|
8a8ec4dfd1 | ||
|
|
8345378972 | ||
|
|
988a7245a3 | ||
|
|
5e6417e749 | ||
|
|
31fee00c73 | ||
|
|
0933ac1b01 | ||
|
|
ba1726fa77 | ||
|
|
94e60cd444 | ||
|
|
6f2e0d15da | ||
|
|
8206a78ecc | ||
|
|
adf4fc5973 | ||
|
|
153d12558c | ||
|
|
274ccd9ff5 | ||
|
|
16ef3779e8 | ||
|
|
bc27a0cbd2 | ||
|
|
01f1194a56 | ||
|
|
d076427ee0 | ||
|
|
54951e92ef | ||
|
|
a381fac742 | ||
|
|
9119aa1e9a | ||
|
|
25d2513ccc | ||
|
|
71664dab02 | ||
|
|
c0c741fe62 | ||
|
|
fecedab60d | ||
|
|
986347a95a | ||
|
|
cb87ea79c1 | ||
|
|
11981a2a99 | ||
|
|
c1d4d7be7b | ||
|
|
9a35b52261 | ||
|
|
c6b5f6b691 | ||
|
|
2e4dde5655 | ||
|
|
3bd6187b94 | ||
|
|
bec75e4282 | ||
|
|
542ab169a3 | ||
|
|
889f7ff5ce | ||
|
|
34ee34aef6 | ||
|
|
b8e4f83028 | ||
|
|
56fad6b3b0 | ||
|
|
92f4d386f4 | ||
|
|
40746a0cbc | ||
|
|
a5dbc88a3a | ||
|
|
2dc008e39f | ||
|
|
5d946a1076 | ||
|
|
167d3de68a | ||
|
|
4105d1ae7d | ||
|
|
0a1b5f657a | ||
|
|
ade7d34cd8 | ||
|
|
b8667699ae | ||
|
|
a7f35a0e5a | ||
|
|
1f7d84bf64 | ||
|
|
142c6e75fe | ||
|
|
563999655f | ||
|
|
3c07c26fda | ||
|
|
189af238a1 | ||
|
|
9f8eebe23b | ||
|
|
f174a524af | ||
|
|
95133860e1 | ||
|
|
015e539b51 | ||
|
|
9588f7b1f0 | ||
|
|
fe3ffac48e | ||
|
|
f68ac3668e | ||
|
|
e6dd4c0aa6 | ||
|
|
3b19883593 | ||
|
|
2c6b8fe63a | ||
|
|
092300c7d2 | ||
|
|
f7c8088783 | ||
|
|
8014bde7c6 | ||
|
|
785ef5fe14 | ||
|
|
9ca8ed84de | ||
|
|
0bec9a6be5 | ||
|
|
c741b02016 | ||
|
|
2ec2c95a63 | ||
|
|
ae1dd96af6 | ||
|
|
69084fdff7 | ||
|
|
5ae48de0ea | ||
|
|
90954a47d6 | ||
|
|
546ed94412 | ||
|
|
27158a06cd | ||
|
|
bebbc877db | ||
|
|
566deab506 | ||
|
|
a99e1a52f0 | ||
|
|
0463540aea | ||
|
|
68fa503250 | ||
|
|
f153e79156 | ||
|
|
61c31c0c5d | ||
|
|
05331b4a87 | ||
|
|
115409b553 | ||
|
|
b1db43137a | ||
|
|
683c3bfb76 | ||
|
|
feae6d07d6 | ||
|
|
f80f838620 | ||
|
|
480336bc69 | ||
|
|
4c9528f158 | ||
|
|
abadf11929 | ||
|
|
c42e2e5447 | ||
|
|
176c8520fc | ||
|
|
1d1796a7f1 | ||
|
|
0e4fc525e0 | ||
|
|
3a9db2e78b | ||
|
|
0d846c634e | ||
|
|
62c47a2209 | ||
|
|
bf559bbbb8 | ||
|
|
a2bde31290 | ||
|
|
1b02f6e063 | ||
|
|
aad8857ea0 | ||
|
|
f34707e17e | ||
|
|
71b112d04c | ||
|
|
f83385e79f | ||
|
|
28ef9eef7e | ||
|
|
1c263b9f2e | ||
|
|
89d4d983e6 | ||
|
|
5880838e66 | ||
|
|
28ca2c3f50 | ||
|
|
065c6f0678 | ||
|
|
95f00211c1 | ||
|
|
c5edcd8528 | ||
|
|
92a088fdd3 | ||
|
|
8564814754 | ||
|
|
c339b4cecf | ||
|
|
273c182ef4 | ||
|
|
7ba90a8cc9 | ||
|
|
b3812ed928 | ||
|
|
3aa80db2a4 | ||
|
|
e7b48d2d53 | ||
|
|
b6f559375b | ||
|
|
797d020d01 | ||
|
|
53bc71f7fb | ||
|
|
16c10d61c3 | ||
|
|
4cf5f09d93 | ||
|
|
96c4014fef | ||
|
|
50669489cb | ||
|
|
2148ebb379 | ||
|
|
93fad2ef1e | ||
|
|
d61ce422a3 | ||
|
|
f37892c9a9 | ||
|
|
06ee59e24b | ||
|
|
40322247e0 | ||
|
|
46397c740e | ||
|
|
40c0a75c24 | ||
|
|
e989c1f26e | ||
|
|
6dc84176fb | ||
|
|
7f1adf47c5 | ||
|
|
1e65b7c986 | ||
|
|
af1860debf | ||
|
|
5c0c0e5619 | ||
|
|
524488122f | ||
|
|
7be9bb2345 | ||
|
|
33bf425f25 | ||
|
|
1020d9fca4 | ||
|
|
3f4125e324 | ||
|
|
1487b391ad | ||
|
|
7be4eec9fd | ||
|
|
88364b20c0 | ||
|
|
bf18512abc | ||
|
|
756e936291 | ||
|
|
15006ad233 | ||
|
|
e6d3673891 | ||
|
|
cc4cc99c71 | ||
|
|
ac5ac4c2cd | ||
|
|
14e752895a | ||
|
|
2fc4020a2b | ||
|
|
6c490ec71c | ||
|
|
c1f304d3fe | ||
|
|
cf87b72092 | ||
|
|
7eee0934ee | ||
|
|
8f116934b4 | ||
|
|
90cf82d740 | ||
|
|
f2d9f3ad07 | ||
|
|
cacd48cb0f | ||
|
|
edc094a5f4 | ||
|
|
21a5a6dbfd | ||
|
|
4035ba7986 | ||
|
|
2e229735b1 | ||
|
|
4b97ecf3f0 | ||
|
|
5ebf9fb90c | ||
|
|
3be38fcb47 | ||
|
|
2090d742b9 | ||
|
|
aa6aaf2a11 | ||
|
|
c6298c2e41 | ||
|
|
bea6d02d1d | ||
|
|
b4c90838b4 | ||
|
|
b35566e0fe | ||
|
|
ff3ba298f0 | ||
|
|
fccdf56cf4 | ||
|
|
61e18c3dfa | ||
|
|
3245060822 | ||
|
|
8cf954a8c0 | ||
|
|
adfe87fdec | ||
|
|
23e761120f | ||
|
|
8285e19497 | ||
|
|
3a0a5b5e7c | ||
|
|
e618658bda | ||
|
|
fdec965700 | ||
|
|
c145cfb35a | ||
|
|
2ee88a6015 | ||
|
|
fd0f826c3e | ||
|
|
c26a6e3deb | ||
|
|
4418b7f981 | ||
|
|
1d7449e49a | ||
|
|
71e7f85328 | ||
|
|
747dea552d | ||
|
|
dea77c3459 | ||
|
|
ce1d618117 | ||
|
|
c3e919be37 | ||
|
|
764a4d4a59 | ||
|
|
e012147476 | ||
|
|
110ef1e623 | ||
|
|
7980bb692b | ||
|
|
c66fae0b09 | ||
|
|
c2ff51cf88 | ||
|
|
01ae3fb4b8 | ||
|
|
515708a11b | ||
|
|
abca29054c | ||
|
|
0ab0e58412 | ||
|
|
cb86316506 | ||
|
|
f0e1738964 | ||
|
|
9818dfefbe | ||
|
|
68530ff569 | ||
|
|
2ba6989481 | ||
|
|
66516d38d1 | ||
|
|
d32f03f8c9 | ||
|
|
cadb62564c | ||
|
|
048bb8d244 | ||
|
|
4dcfd636a4 | ||
|
|
b11fb9d070 | ||
|
|
328cfb629f | ||
|
|
840528d14c | ||
|
|
fa834501b5 | ||
|
|
eb9e1e413f | ||
|
|
8c0cec06ee | ||
|
|
19f67bdb78 | ||
|
|
a131399c02 | ||
|
|
1b530e1b9a | ||
|
|
092d226df6 | ||
|
|
7ef3a64a49 | ||
|
|
06d172a804 | ||
|
|
5fff6e8244 | ||
|
|
3d7457d80f | ||
|
|
498d23e15e | ||
|
|
9fb5845a5b | ||
|
|
7470255886 |
@@ -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.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Copy to .env.release (gitignored) in the app's repo root and fill in.
|
||||
# Used by Scripts/release.sh and the asc-*.swift scripts.
|
||||
#
|
||||
# These are account-level — the same Apple developer account and API key
|
||||
# serve every indie project, so copy this file (as .env.release) from an
|
||||
# existing sibling project rather than filling it out from scratch.
|
||||
#
|
||||
# Apple Developer team that signs the build (same as DEVELOPMENT_TEAM).
|
||||
APPLE_TEAM_ID=C32Z8JNLG6
|
||||
|
||||
# App Store Connect API key (App Store Connect > Users and Access > Integrations > App Store Connect API).
|
||||
# Create a key with "App Manager" access, download the .p8 ONCE, and store it somewhere safe.
|
||||
ASC_KEY_ID=XXXXXXXXXX
|
||||
ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
# Absolute path to the downloaded AuthKey_XXXXXXXXXX.p8 file.
|
||||
ASC_KEY_PATH=/Users/rzen/.appstoreconnect/private_keys/AuthKey_XXXXXXXXXX.p8
|
||||
@@ -9,3 +9,10 @@ xcuserdata/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# App Store credentials (appstore-publish skill)
|
||||
.env.release
|
||||
|
||||
# Claude Code — personal permission grants stay local
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
+95
-1
@@ -1,3 +1,97 @@
|
||||
**August 2026**
|
||||
|
||||
The board's symbol now appears in the title bar beside the board's name, in its chosen tint.
|
||||
|
||||
The board's symbol can now wear a color: the symbol picker carries a row of tints below the glyphs, with None to clear it.
|
||||
|
||||
The board popover's new Theme tab dresses the board in a solid color or a generated pattern: filter by light or dark, colors and saturation, then pick from eight hues.
|
||||
|
||||
The board popover is now organized into three tabs — Info with the board's vital statistics, Theme for backgrounds, and Git.
|
||||
|
||||
The rubber band now highlights cards the moment it touches them, instead of lagging behind on large boards.
|
||||
|
||||
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**
|
||||
|
||||
Development begins — nothing user-facing yet.
|
||||
Version 2.0: Lanework's first release — a kanban app whose boards are ordinary folders of Markdown files on your Mac.
|
||||
|
||||
Edit a board from any other app, script, or AI agent and the open window updates live; outside edits are first-class, never overwritten.
|
||||
|
||||
Cards open in their own window with a formatted preview, a Markdown editor with checkable task lists, and a raw-source view.
|
||||
|
||||
Attach files to a card by dropping them onto its face or into its card window.
|
||||
|
||||
Drag cards and lanes to rearrange them, move or copy them between boards, or drop files from Finder to create new cards.
|
||||
|
||||
Cut, copy, and paste cards and lanes — within a board, across boards, or as plain text into other apps.
|
||||
|
||||
Deleted cards land in a trash lane you can show beside your lanes; drag a card out (or cut and paste it) to restore it.
|
||||
|
||||
Undo and redo cover every board action, including bringing back a deleted lane with all its cards.
|
||||
|
||||
A card window keeps its own undo trail while open; closing it folds everything you did there into one board-level undo step.
|
||||
|
||||
Start new boards from ten bundled templates, or save any board as a template of your own.
|
||||
|
||||
Search filters the board as you type, and new cards you create clear the filter so they never vanish under it.
|
||||
|
||||
Style cards and lanes from a twelve-color palette, tint lanes with a band, and give each board its own background.
|
||||
|
||||
The whole app works from the keyboard — arrow-key navigation, drag-free card moves, and Full Keyboard Access on every control.
|
||||
|
||||
VoiceOver reads boards as lanes of cards, announces outside changes in one polite digest, and names a focused card that was deleted externally.
|
||||
|
||||
Every board gets a guide file teaching AI agents the folder format, kept up to date automatically.
|
||||
|
||||
A hand-made card file no longer needs an order or schema line — the card simply lands at the end of its lane until you place it.
|
||||
|
||||
Every board carries a .gitignore naming which files count as noise, so system files like .DS_Store stay put instead of being gathered into a card's attachments.
|
||||
|
||||
Boards open into their own window right away, with a quiet spinner while a large one is read, and ⌘W cancels an open in progress.
|
||||
|
||||
A board that won't open now explains itself in that window, listing every problem file by file and grouped by what's wrong.
|
||||
|
||||
Each listed file offers Reveal in Finder and Open in Editor, so you can fix it yourself and press Re-check.
|
||||
|
||||
Repair and Open makes the fixes that are safe to make — a folder missing its board file, a board missing its format line — and opens the board.
|
||||
|
||||
You can skip a file Lanework can't fix and open the board without it; the board then names what was left out, and asks again next time.
|
||||
|
||||
Every card can carry a comment thread, shown beside or below the card's text in its window.
|
||||
|
||||
The comment composer keeps its draft inside the board itself, so a half-written comment is waiting whenever and wherever you reopen the card.
|
||||
|
||||
Comments render Markdown like the card body, take file drops of their own, and note when they've been edited.
|
||||
|
||||
Board search now matches comment text, and ⌘F in a card window steps through matches across the whole thread.
|
||||
|
||||
Deleting a comment takes effect immediately, stays undoable in the card's window, and board-level undo can still bring it back after the window closes.
|
||||
|
||||
Every board can now gain git-backed history — commits, undo and redo as history entries, and branches — with remote sync still to come.
|
||||
|
||||
The board popover can now add git version history to any board, with no git installation needed.
|
||||
|
||||
A board that's already a git repository opens with its history live automatically — cloning a shared board just works.
|
||||
|
||||
Every settled change on a git board becomes a history entry with a plain-language description, like *Move card 'Fix login' to Doing*.
|
||||
|
||||
Edits from agents and other apps are recorded under their own authorship, so a git board's history always says who changed what.
|
||||
|
||||
Undo and redo on git boards restore earlier states as new history entries — nothing is ever erased — and the undo trail survives relaunching the app.
|
||||
|
||||
Every card window on a git board gains a History section listing the changes that touched that card, newest first.
|
||||
|
||||
On git boards, everything done in a card window becomes a single history entry when the window closes.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"identifier" : "A9F1C4E2-7B30-4D6A-9E51-1C7D2B8F0A34",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
|
||||
],
|
||||
"settings" : {
|
||||
"_askToBuyEnabled" : false,
|
||||
"_billingIssuesEnabled" : false,
|
||||
"_disableDialogs" : false,
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_locale" : "en_US",
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
|
||||
]
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
{
|
||||
"id" : "20250730",
|
||||
"localizations" : [
|
||||
|
||||
],
|
||||
"name" : "Lanework Pro",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "2.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "2025073001",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Git-backed board history and sync.",
|
||||
"displayName" : "Lanework Pro",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "dev.rzen.indie.kanban.pro.monthly",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "Lanework Pro Monthly",
|
||||
"subscriptionGroupID" : "20250730",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version" : {
|
||||
"major" : 4,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -16,10 +16,14 @@ The defining consequence: **anything that can read and write files is a first-cl
|
||||
|
||||
1. **Files first.** Every feature must degrade gracefully to "it's just folders of Markdown." If the app vanishes, the data remains fully usable.
|
||||
2. **The app never surprises the file.** Unknown frontmatter keys survive verbatim; untouched bodies are never rewritten; writes are atomic. Hand edits and app edits coexist without ceremony.
|
||||
3. **Fail fast on malformed input.** A broken file surfaces a loud, specific error with the offending path — never silent fixing, never partial loads, never data loss by "repair."
|
||||
3. **Fail fast on malformed input.** A broken file surfaces a loud, specific error with the offending path — never partial loads, never data loss by "repair," never a rewrite of anyone's bytes. The one scoped softening is 01-storage-format.md's read-side rescue family (duplicate-key last-wins, the unquoted-colon recovery): an obvious hand-editor slip reads as what the writer meant — silently, with a log line, bytes preserved verbatim — because bricking a board over a recoverable slip fails files-first harder than leniency does. Fail-fast keeps guarding structure the rescues can't legitimize.
|
||||
4. **Native to the bone.** SwiftUI, macOS conventions (Finder-style rename, ⌥-drag copy, package documents, real windows), no web tech, no JS runtime.
|
||||
5. **Agents are users, not integrations.** The schema, the agent guide, and the tolerance rules are designed for programmatic writers from day one.
|
||||
|
||||
## Tiers
|
||||
|
||||
Lanework ships as **one free Mac App Store app** with tiers from one codebase and one format (12-editions.md, re-ruled 2026-07-30): **Lanework** (free — no git; macOS-native undo — 13-native-undo.md), **Lanework Pro** (an auto-renewable subscription unlocking git-backed history, branches, remote sync — 06/07), and **Lanework Teams** (tracker integration over the reserved enhanced schema; deferred, probably a separate app). The deeper reason for the tier seam: history and sync live behind a provider boundary, so Teams' sync can be backend-agnostic (git *and* trackers) instead of git being load-bearing everywhere.
|
||||
|
||||
## App identity
|
||||
|
||||
The previous version was a **pathfinder** — it never shipped. This rewrite is the app. It keeps the internal codename `Kanban` (Xcode target, scheme, bundle id `dev.rzen.indie.Kanban`) and ships under the display name **Lanework**. Because nothing shipped, there is no migration story and no compatibility obligation to pathfinder boards; the schema number stays `1`, redefined by this design (see 01-storage-format.md).
|
||||
|
||||
+64
-26
File diff suppressed because one or more lines are too long
+36
-24
@@ -20,37 +20,44 @@ BoardStore (one per open board, @Observable, MainActor)
|
||||
SwiftUI views (board window + card windows share the store)
|
||||
```
|
||||
|
||||
One-way flow: **files → watcher → loader → store → views**. User actions go through a Writer that mutates files; the change comes back around through the watcher like any external edit. The app trusts its own writes no more than anyone else's — this is what makes external editors and agents first-class.
|
||||
One-way flow: **files → watcher → loader → store → views**. User actions go through a Writer that mutates files; the change comes back around through the watcher like any external edit. The app trusts its own writes no more than anyone else's — **for rendering** (settled scope): the snapshot is only ever built from disk, never from memory of what the app meant to write — this is what makes external editors and agents first-class. Provenance is a separate, downstream concern: the **EchoLedger** (Components below) remembers what the app wrote so commit attribution (06-history-undo.md) and VoiceOver announcements (10-accessibility.md) can tell the app's own echo from a foreign change — without the render path ever trusting memory over disk. **The write path's do-nothing guards read the pending truth** (ruled 2026-08-06): never-trust-memory is the *render* path's scope, and it does not extend to a guard deciding whether a write would change disk. Between a write's bracket and its echo the snapshot describes the past — a guard comparing an asked-for value against it answers the wrong question, and a fast gesture pair silently loses its second half (the found case: two lane resizes released within one echo, A→B→A — the second compares A against the stale snapshot's A, writes nothing, and the first's echo settles the board at B, the width the user last dragged away from). So a do-nothing guard's baseline is the snapshot **as amended by this store's own in-flight writes** — the value it last wrote to that field and has not yet seen echo (a small pending-value record of the write path's own, *not* the EchoLedger, whose feeds-attribution-only charter stands); a gesture whose meaning is relative (step one width unit) resolves its base against the same amended truth, or a fast double-step loses its second press to the same staleness; and the undo step's recorded prior reads it too, or its inverse restores a state that never was. The amendment dies with its echo — a landed snapshot agreeing with the write clears it — and a failed or refused write never enters it (no echo is coming; the snapshot is still the truth). Per-gesture coalescing was weighed and set aside: one gesture is one write, one echo, one commit (the style batch's rule), and merging two gestures' writes would merge their commits.
|
||||
|
||||
The **one named exception** is transient UI state rendering things that don't exist on disk — concretely the **new-card placeholder** (04-interactions.md): the inline editor for a card being created renders as a pseudo-card overlaid on the snapshot, with no disk presence and no UUID until the title commits. Commit creates the folder through the Writer and round-trips through the watcher like any write — the placeholder stays visible until the real card arrives, then hands off. Abandoning (Escape, empty commit, click-away) discards it; disk was never touched. Watcher reloads swap the snapshot *underneath* the overlay (like selection surviving a reload); if the placeholder's lane vanished in the reload, it is discarded — consistent with card windows dismissing when their card is deleted. Everything durable still round-trips through files.
|
||||
The **one named exception** is transient UI state rendering things that don't exist on disk — concretely the **new-card placeholder** (04-interactions.md): the inline editor for a card being created renders as a pseudo-card overlaid on the snapshot, with no disk presence and no UUID until the title commits. Commit creates the folder through the Writer and round-trips through the watcher like any write — the placeholder stays visible until the real card arrives, then hands off. **The handoff must read as one arrival** (settled): the placeholder renders at the arriving card's exact geometry — same slot, same size, same chrome — so the identity swap's cross-fade is imperceptible; two view identities are fine, two visible objects are not (no matched-geometry machinery across the overlay/snapshot boundary, just matched rendering). Abandoning (Escape, empty commit, click-away) discards it; disk was never touched. **A failed create discards it too** (settled): if the Writer create throws after the title commits, the create flow discards the placeholder — the failure surfaces as the ordinary one-shot banner (Write-failure surfacing below), and the overlay never waits for a card that cannot arrive. **Starting a new creation while a placeholder is open is a click-away for the draft** (settled): the open draft discards per its rule and the new placeholder begins — and ⌘N can't even reach this case (board commands disable while the editor is focused, 04-interactions.md), so only pointer paths do. Watcher reloads swap the snapshot *underneath* the overlay (like selection surviving a reload); if the placeholder's lane vanished in the reload, it is discarded — consistent with card windows dismissing when their card is deleted. Everything durable still round-trips through files.
|
||||
|
||||
### Components
|
||||
|
||||
- **Frontmatter** — YAML value model: parse, serialize, atomic write, unknown-key preservation with key order. Owns the byte-identical round-trip guarantee. Pure, heavily unit-tested.
|
||||
- **BoardLoader** — walks the folder tree, applies the fail-fast/skip rules, produces an immutable `BoardModel` snapshot. Pure function of the tree.
|
||||
- **BoardWriter** — every mutation (create, move, reorder, tombstone, style) as an explicit filesystem operation. No hidden state; a write is done when the file is on disk.
|
||||
- **BoardStore** — per-board `@Observable` object holding the current snapshot plus transient UI state that must be shared across that board's windows (selection, drag state, search query, pending cut, the new-card placeholder, trash visibility). Debounces watcher reloads.
|
||||
- **BoardWriter** — every mutation (create, move, reorder, delete, style) as an explicit filesystem operation. No hidden state; a write is done when the file is on disk. (The EchoLedger's receipts are not this bullet's "hidden state": a receipt describes a *completed* write, and the ledger lives beside the Writer, not in it — no write is ever pending in memory.)
|
||||
- **EchoLedger** — the write-provenance ledger (the "Writer/echo machinery" that 06-history-undo.md ▸ Interaction with external writers — its Commit attribution rule — and 10-accessibility.md ▸ Live board announcements consume; settled). Every BoardWriter operation drops a receipt of its expected on-disk outcome before returning: path → content hash for writes (attachment imports hash during the copy — the bytes stream through the app anyway), an absence marker for deletes, an old→new pair for folder moves; a newer app write to the same path supersedes the receipt. Classification runs per observed changed file in a debounce window: current on-disk content matches the receipt → **app-mediated**, receipt consumed; no receipt, or mismatch → **foreign**. Final content deciding is what settles the races: an agent writing byte-identical bytes over a fresh app write matches and classifies app-mediated — with identical bytes the misattribution is unobservable in the tree, accepted; a foreign edit landing on an app-written path inside the same window misses the hash and the file classifies foreign — last writer wins the file, the app's subsumed intermediate never separately recorded (the diff compares snapshots, not a journal — 06-history-undo.md). Consumers: the auto-committer's author field and two-commit split (06), and the announcement filter (10) — on no-git boards the ledger runs identically with the announcer as its only consumer. **In-memory, per-store, dies with the session** — losing it costs attribution and nothing else, so the launch catch-up commit (06) classifies everything foreign: the app never vouches for changes it didn't witness. Bracketed operations don't consult it (they commit themselves and announce once at completion), and the reload-granularity origin tag (Live-reload resilience below) is orthogonal: it classifies *reloads*, the ledger classifies *files*. Feeds attribution and announcements only — never the render path (Layering above).
|
||||
- **BoardStore** — per-board `@Observable` object holding the current snapshot plus transient UI state that must be shared across that board's windows (TransientBoardState — see Changes from Kanban). Coalesces watcher reloads — at most one tree walk in flight, signals landing mid-walk fold into one follow-up; the debounce itself lives in FolderWatcher (below). **The reload publishes a changed-path channel — paths only, advisory** (ruled 2026-07-31): each reload vends the debounce window's observed path set alongside the generation bump; the initial load, wholesale/bracket-ending reloads, and root recovery vend **nil — "assume everything changed."** The channel is an **optimization surface, never a correctness input**: FSEvents can coalesce and drop, so every consumer must stay correct against nil or an over-broad set, and the render path never consults it — the snapshot remains the only render input. Consumers classify for themselves (the comments pane and search's comment index filter by `CommentPath`; the announcer keeps consuming the EchoLedger, which stays the sole provenance authority — the channel carries no app/foreign classification). This is what lets window-scoped readers stop re-reading on every generation change.
|
||||
- **BoardStoreRegistry** — refcounted registry so a board window and its card windows share one live store and one watcher. **The board window owns the board** (settled): card windows never outlive it — closing the board window closes its card windows too, so the last-window teardown and board-window close coincide. (The refcount still earns its keep ordering teardown while multiple windows close.)
|
||||
- **FolderWatcher** — FSEvents (debounced), attached best-effort to whatever path the board lives at. There is only this one watching path: no NSMetadataQuery for iCloud Drive, no polling fallback for network volumes — on those warned-against locations (07-sync-collab.md) FSEvents delivery is unreliable and live reload silently degrades, accepted per 07's no-accommodations stance.
|
||||
- **FolderWatcher** — FSEvents (debounced: **200 ms trailing**, the timer restarting per event so a burst yields one reload after quiet, over 50 ms FSEvents latency — settled numbers), attached best-effort to whatever path the board lives at. **Events under any `.git` path component are filtered out** (settled): the board's own root-level repo (a worktree-link `.git` file included) is the app's auto-commit churn, and a repo nested deeper — a card folder containing a clone, a submodule — is a stray (01-storage-format.md) whose internals never render; neither can alter the rendered tree, so neither drives reloads. (A nested repo's *working files* still fire events like any stray's — those reloads are value-equal and quiet.) There is only this one watching path: no NSMetadataQuery for iCloud Drive, no polling fallback for network volumes — on those warned-against locations (07-sync-collab.md) FSEvents delivery is unreliable and live reload silently degrades, accepted per 07's no-accommodations stance.
|
||||
- **Ranks** — gapped fractional ordering math + compaction. Pure.
|
||||
- **DropSlot** — drop-geometry math: hit zones and insertion-position targeting for drags (lane/position within the masonry, cross-board, Finder file drops). Pure, like Ranks.
|
||||
- **AgentGuide** — writes/upgrades the board-root `CLAUDE.md` (see 08-agent-integration.md).
|
||||
- **HistoryStore** — git plumbing for undo/redo (see 06-history-undo.md).
|
||||
- **AgentGuide** — writes/upgrades the board-root `CLAUDE.md` (see 08-agent-integration.md). Its refresh is a scheduled heal riding the HealScheduler (below).
|
||||
- **IntegrityRules** — the one pure vocabulary of object validity (01-storage-format.md ▸ Validation and healing; settled 2026-07-29): the identity predicate and its canonical form (one rule shared by `ItemID` and the Writer's string-level checks — today's parallel `canonicalIdentity` derivation folds in), the per-field coercion rulebook, shape classification (the readable-but-uneditable shapes), per-kind index validation (the card validator generalized per kind — board, lane, card, the enhanced schema's comment when it lands), the reserved-name tables (card children, board-root claimed names — today scattered), the trash `kind` discriminator, and the typed **Defect** vocabulary the loader reports. `LoadResult`'s ad-hoc repair channels (loose files, legacy tombstones) become one typed defect stream; tolerate-tier warnings stay warnings — information, not work. **Loader and Writer remain the enforcement points and call in** — the service consolidates rules and policy, never relocates enforcement; a service smeared across the read/write/orchestration boundaries would be worse than the current discipline.
|
||||
- **HealScheduler** — the scheduled-heal engine: the six-step pattern today re-derived per healer in BoardStore (loose-file relocation, tombstone migration, agent-guide refresh), expressed once — compute work from the latest defects → resting-clear when empty → lock-and-writability gate (the read-only-lock deferral plus the guide's narrow `isWritableFile` defense, generalized to every healer) → signature compare → arm the memo *before* attempting → one write bracket whose write half re-verifies each defect against disk → post per one banner-posture table (each defect class declares loss row / silent / failure-only once; BannerCenter still owns all phrasing) → **clear the memo explicitly on success** (today only the guide does; the others' resting states merely happen to converge). Fires uniformly at the reload tail and at registry acquire — closing today's asymmetry where tombstone migration never fires at open. Inline heals (the midpoint-exhaustion renumber-and-retry, the import-boundary remint) stay gesture-scoped, with the renumber's ask-renumber-ask-again two-step as one shared helper instead of today's nine hand-rolled copies; on-touch heals live at the Writer's `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is rewriting (`kind` backfill; the span editor's duplicate-key twin removal and quote-on-first-write are the same class, named). **Window-scoped heals are memo-less — their trigger is their guard** (ruled 2026-07-31; comments' thread-level claimed-name displacement is the first): the memo exists to break reload-cadence hot loops, and window-scoped work runs only on window open or file change — a failed heal changes no files, so failure cannot trigger its own retry, and a partial success converges on the next read. The class-keyed memo stays board-wide (a thread's picture must never overwrite the board's); each new window-scoped heal owes the same no-self-trigger argument, and if one ever gains a self-triggering shape, scoping the memo key by class × container path is the named next step.
|
||||
- **HistoryStore** — the history provider behind the tier seam (12-editions.md): the board session binds one `HistoryProviding` implementation at composition, chosen by the entitlement's local read — the free tier's native undo stack (13-native-undo.md, inverse `WriteOperation`s over NSUndoManager) or Pro's git plumbing (06-history-undo.md). One target since the 2026-07-30 collapse: libgit2 and the git provider compile in dormant, and nothing outside the seam touches git machinery.
|
||||
|
||||
### Live-reload resilience
|
||||
|
||||
- **A failed reload never replaces a good snapshot.** Fail-fast (01-storage-format.md) is the *initial-load* contract, where there is nothing to fall back on. Once a board is open, a watcher-triggered reload that fails (unparseable YAML, missing required fields — typically a non-atomic external write caught mid-flight) keeps the last good snapshot on screen and raises a **non-modal banner** carrying fail-fast's specifics (offending path + what's wrong). The watcher keeps watching; the next successful reload clears the banner automatically — transient breakage self-heals without the user losing the board, persistent breakage stays loudly visible. Editing is not locked out: writes go through the Writer as usual (the breakage is per-file and localized), and the reload debounce already absorbs most momentary invalid states before they surface.
|
||||
- **Duplicate-id detection heals silently** (re-ruled 2026-07-29, superseding the repairable-condition banner): the loader's board-wide dedupe (01-storage-format.md ▸ Fractal layout rules) withholds losing occurrences from every snapshot; a scheduled heal remints them through the Writer (the loader itself never writes) and a warning-tone notice reports the repair — no banner, no button, nothing waits on consent. The withheld window is one heal cycle, not a standing condition; a remint racing a vanished duplicate (repaired elsewhere, a hand-deleted copy) is a no-op, never an error.
|
||||
- **The watcher is self-reconciling, never trusted blindly** (settled): every reload is already a full tree walk producing a value-type snapshot, so recovery from any blind window is always the same act — reload. A **reconciling reload** runs on wake-from-sleep and on app re-activation (debounced; an identical tree swaps in value-equal — and, blessed 2026-07-31, the store **skips the assignment entirely** when the fresh snapshot equals the current one: assigning an equal tree into an `@Observable` property still costs a render pass, so "costs nothing visible" becomes *costs nothing*), on any FSEvents flag admitting missed events (`MustScanSubDirs`, queue overflow — degrade to the reload rather than trust the gap), and after any stream re-creation. **The skip's structural consequence — the two-counter split (blessed 2026-08-06):** once value-equal landings stop bumping the applied-snapshot generation, anything whose subject is the *walk* rather than the applied snapshot can no longer key on it. The store therefore carries two counters — snapshots **applied** (the committed-overlay hold's event, meaning unchanged) and walks **landed** with a snapshot in hand, bumped on every successful reload, equal or not; a failed reload bumps neither. The rule for choosing: **anything outside the snapshot, or about the walk itself, watches landed walks, not applied snapshots** — the card window's comment thread (comments are outside the snapshot, so a foreign comment arriving leaves the model value-equal), the comment search index (same shape), and the auto-committer's covering gate (what covers a flush is a completed walk, whether or not it found anything to show); on the applied counter each would sleep through exactly the value-equal landing it exists to notice. **Streams die and are recreated, not merely kept**: a volume unmount kills the stream with its root; the vanished-root and rename re-resolution rules (below) attach a *fresh* stream at the current root when it returns, reconciling reload included. A silently stale board — the worst failure for a files-are-truth app — is structurally excluded: every known blind window ends in a reload. **A reconcile request arriving mid-bracket is banked** (settled): the mandatory post-bracket reload delivers as the *reconciling* kind rather than app-mediated — an explicit reconciliation is never silently lost. FSEvents missed-events flags arriving mid-bracket are, by contrast, simply swallowed: the post-bracket reload is a full walk either way, and only the origin tag differs (it feeds commit attribution and the VoiceOver announcement vocabulary — a deliberate asymmetry). **The walk memoizes its parse, never its result** (blessed 2026-07-31 — a performance posture, not a semantic change): the loader may reuse the previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged — the previous snapshot *is* the memo — while directory enumeration (folder discovery, attachment listings, trash entries) stays fresh every walk, because attachment changes never touch `index.md`. The loader's contract is result-purity with cost unspecified: same tree in, same snapshot out, and the memo can only change how fast. The mtime+size trust is the git-index heuristic; a writer that defeats it — content changed, mtime and size both preserved — is outside the app's care (blessed 2026-08-06 as a decision on file, the boundary being reachable in practice — a byte-length-preserving edit plus deliberate utimes, a restore tool replaying old attributes — and pinned by test: git itself lives with the same blind spot, and the named tightenings — content hashing, which is the read the memo exists to avoid, or fileSystemFileNumber/generation stamps — wait for a real-world defeat, not a hypothetical one).
|
||||
- **App-initiated git churn is bracketed.** Operations the app runs itself (pull-rebase, branch switch, undo restore — 06-history-undo.md, 07-sync-collab.md) suspend watcher reloads for their duration and finish with one full reload — half-checked-out trees are never rendered. **The bracket also locks writes** (settled): for its duration the board is read-only with exactly the failed-reload lock's scope — mutating commands disable via menu validation, drops are refused, selection/navigation/search/copy-out stay live. 07's interaction-rest rule composes: the bracket starts only at gesture rest, so nothing in flight is interrupted; the lock ends with the final reload — seconds, honestly signaled by the operation's in-progress banner row (▸ The banner surface). External git activity (the user running git in a terminal) can't be bracketed: the debounce coalesces its churn, and a transiently inconsistent but parseable tree may render briefly and heals on the next event — accepted.
|
||||
- **Selection survives reloads by UUID.** Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. **A liveness flip is a vanish for this purpose**: re-resolution matches UUID *and* liveness side, so a foreign edit that tombstones a selected live card — or restores a selected tombstoned one — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's homogeneous-by-liveness invariant true across reloads. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is deleted (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map).
|
||||
- **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The lock's scope** (shared with the vanished-root case below) spans every window sharing the store — card windows included: every mutating command disables via menu validation — creation, delete and Put Back, paste, Move/Style/rename, trash operations, the popover's git controls, and the card window's write paths: the flip into Edit mode, raw-source entry and Apply, Add Attachment and the whole-window file drop, the sidebar's mutating actions, and task-list checkbox toggles — and the board refuses drops, including drags arriving from another board's window. Drags *out* of a locked board offer the copy variant only — copy-out is a read; a ⌘-drag move's source-side delete is a write, so the modifier doesn't take. **An Edit buffer already open when the lock lands keeps its content and stays typable** — memory is not disk — but its debounced save suspends for the lock's duration; the held text's fate follows the lock's cause: a branch switch or undo restore can't leave a session open behind the lock at all (both settle editors first — 06-history-undo.md), a post-pull buffer saves on clear and wins per the sync model (05-card-window.md, 07-sync-collab.md), and a returned root saves normally (below). Selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it).
|
||||
- **Selection survives reloads by UUID.** Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. **A container crossing is a vanish for this purpose** (resettled 2026-07-28 — the materialized trash): re-resolution matches UUID *and* container side (board vs `.trash/`), so a foreign move that trashes a selected board card — or restores a selected trash card — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's container-boundary invariant true across reloads. The old effective-liveness ancestor walk is retired with the tombstone model — presence in the snapshot is the whole question. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is deleted or moved to the trash (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map).
|
||||
- **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The rule arms on every bracket exit, thrown operations included** (settled): an operation that fails or aborts mid-flight is precisely when the tree's state is least known, so the mandatory final reload runs regardless — succeeding, it renders whatever the operation left (often value-equal after a clean failure, whose tree is left as it was — 06-history-undo.md); failing, it locks exactly as above. **The lock's scope** (shared with the vanished-root case below) spans every window sharing the store — card windows included: every mutating command disables via menu validation — creation, delete and restore, paste, Move/Style/rename, trash operations, the popover's git controls, and the card window's write paths: the flip into Edit mode, raw-source entry and Apply, Add Attachment and the whole-window file drop, the sidebar's mutating actions, and task-list checkbox toggles (these disable in place as controls — 05-card-window.md's in-content rule) — and the board refuses drops, including drags arriving from another board's window. **The lock is a predicate every mutating entry point consults, not a menu-validation sweep** (settled): mutating paths without a menu item exist beyond the checkboxes — the attachment row's ⌫/Remove (grammar key + context menu only, 11-command-nexus.md ▸ Context menus), plain-⌫ delete, Return-creation — and each disables with its surface or follows its command twin's validation; menu validation is the lock's most visible face, never its whole mechanism. **File ▸ Duplicate and File ▸ Save as Template join the disabled set** (settled): both copy the on-disk tree, which the lock marks as gone (vanished root), unknown (this failed-reload state), or unwritable — and both are specified to run the close flush first (03-board-ui.md, 09-templates.md), which the lock's suspended saves make impossible to honor. **One carve-out: under the unwritable-location lock alone (below), Save as Template stays live** — it reads the board and writes into Application Support, the copy-out-is-a-read principle applied (archiving the read-only DMG board being inspected is a legitimate errand). **The carve-out gates on the hazard itself, open sessions, not on lock provenance** (settled): the item disables while any open Edit or raw-source session holds unsaved content — content the lock's suspended saves cannot flush, which the template would silently miss (09-templates.md's never-misses-keystrokes guarantee outranks availability) — and re-enables when those sessions settle or the lock clears. A lock standing since open never meets this state: it disables the flip into Edit mode, so no session can start beneath it and the gate is vacuously open; unsaved sessions under this lock exist only when the symmetric probe (▸ Write-failure surfacing) raised it mid-session. Duplicate stays disabled even there — its destination is the same unwritable parent (a writable-parent/unwritable-board permission split was weighed and set aside as too rare to earn the inconsistency). Drags *out* of a locked board offer the copy variant only — copy-out is a read; a ⌘-drag move's source-side delete is a write, so the modifier doesn't take. **An Edit buffer already open when the lock lands keeps its content and stays typable** — memory is not disk — but its debounced save suspends for the lock's duration; the held text's fate follows the lock's cause: a branch switch or undo restore can't leave a session open behind the lock at all (both settle editors first — 06-history-undo.md), a post-pull buffer saves on clear and wins per the sync model (05-card-window.md, 07-sync-collab.md), and a returned root saves normally (below). Selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it).
|
||||
|
||||
### Write-failure surfacing
|
||||
|
||||
The read-side rules above have a write-side mirror — one banner vocabulary for both directions:
|
||||
|
||||
- **The one-way flow makes write failures honest by construction.** Views render only what is on disk, so a failed Writer operation (disk full, permissions, volume error) never shows phantom state — the action visibly doesn't happen. The failure surfaces in the same non-modal banner as read-side breakage, naming the operation and the cause ("Couldn't move 'Fix login' — disk full"). One-shot actions (move, tombstone, style, create) fail once and wait for the user to act again; nothing is queued behind their back.
|
||||
- **The debounced body save retries on its own cadence** — keystrokes stay in the dirty buffer, so nothing is lost while the window stays open; the banner stands until a save lands. The **one modal moment on the write-failure path**: closing a window (or the board, or quitting) with a dirty buffer that cannot be written — the only state that exists nowhere but memory — raises an alert (retry / save a copy elsewhere / discard) instead of failing silently. Everything else on this path stays non-modal. (Deliberate confirmations elsewhere are their own stories: Empty Trash… and Delete Immediately on boards without git history — 03-board-ui.md, the branch-switch save-or-discard step — 06-history-undo.md, machine-key regeneration — 07-sync-collab.md, the raw-source Apply validation alert — 05-card-window.md, the once-per-board iCloud/network-volume warning on open/create — 07, and the SSH trust-on-first-use fingerprint confirmation with its mismatch hard-block — 07.)
|
||||
- **A vanished board root locks the board read-only** — the bracketed-reload vocabulary applied to a root that is gone (volume unmounted, folder Finder-deleted while open): every write would land nowhere, so the last-good snapshot stays on screen, read-only, banner up. The watcher keeps watching; if the root returns (remount, Finder undo), the next successful reload clears the lock and pending dirty buffers save normally.
|
||||
- **The one-way flow makes write failures honest by construction.** Views render only what is on disk, so a failed Writer operation (disk full, permissions, volume error) never shows phantom state — the action visibly doesn't happen. The failure surfaces in the same non-modal banner as read-side breakage, naming the operation and the cause ("Couldn't move 'Fix login' — disk full"). **The operation is a closed enum, not a string** (settled): Writer errors identify the failed operation as an enum case (create, move, reorder, delete, restore, style, …) carrying the affected item's title where known; the banner owns all user-facing phrasing and localization from that vocabulary, and a new Writer operation without a banner rendering is a compile-time hole, not a silent default. **The vocabulary grows with the surfaces** (settled): inline rename and the body save get their own cases when wired ("Couldn't rename 'Fix login'…", "Couldn't save 'Fix login'…") — style stays styling-only, never the generic frontmatter bucket; growing the enum is cheap by design. Free-form English survives only inside the diagnostic `reason`, never as the banner's verb. One-shot actions (move, delete, style, create) fail once and wait for the user to act again; nothing is queued behind their back.
|
||||
- **The debounced body save retries on its own cadence** — keystrokes stay in the dirty buffer, so nothing is lost while the window stays open; the banner stands until a save lands. The **one modal moment on the write-failure path**: closing a window (or the board, or quitting) with a dirty buffer that cannot be written — the only state that exists nowhere but memory — raises an alert (retry / save a copy elsewhere / discard) instead of failing silently. Everything else on this path stays non-modal. (Deliberate confirmations elsewhere are their own stories: Empty Trash… and the trash's permanent Delete on boards without git history — 03-board-ui.md, the branch-switch save-or-discard step — 06-history-undo.md, machine-key regeneration — 07-sync-collab.md, the raw-source Apply validation alert — 05-card-window.md, the once-per-board iCloud/network-volume warning on open/create — 07, and the SSH trust-on-first-use fingerprint confirmation with its mismatch hard-block — 07.)
|
||||
- **A renamed or moved board root follows its file identity** (settled): the board the app has open is the *file*, not the path string — the registry's security-scoped bookmark is the identity, mid-session as much as across opens (01-storage-format.md calls Finder renames ordinary, and mid-session must honor that). On any root-gone signal — the watcher's path stops delivering, a write lands on a stale path — the app first **re-resolves the bookmark**: if it resolves to a new location, the rename/move is absorbed transparently — the watcher re-attaches there, Writer URLs and card-window keys re-derive from the new root, one full reload runs, and the window title follows the folder-name fallback where it applies — no banner, no lock, nothing was ever wrong. Only when the bookmark does not resolve is the root truly vanished (below). Either way, a root-gone signal **cancels any armed debounced tree-event delivery** (settled): both outcomes end in a full reload — at the re-resolved root, or on the root's return from the vanished-root lock — so delivering a stale tree event for a path that just stopped being the root would only be noise.
|
||||
- **A vanished board root locks the board read-only** — the bracketed-reload vocabulary applied to a root that is gone (volume unmounted, folder Finder-deleted while open — and the rename re-resolution above found nothing): every write would land nowhere, so the last-good snapshot stays on screen, read-only, banner up. The watcher keeps watching; if the root returns (remount, Finder undo), the next successful reload clears the lock and pending dirty buffers save normally. **A root change landing mid-bracket is owned by the root-change path, not the bracket** (settled): re-resolution runs immediately even inside a bracket — a rename is absorbed transparently and the bracket's final reload simply runs at the re-resolved root; a true vanish raises this lock at once and the bracket's eventual final reload becomes a no-op rather than a redundant failure. Nothing is lost by skipping it: the root's return runs the reconciling reload, and an operation the vanish killed mid-flight is 06-history-undo.md's own-leftovers case, recognized at the next open or flush. (The composition matters for a pull-rebase mid-flight when a volume unmounts — 07-sync-collab.md.)
|
||||
- **An unwritable board location enters the read-only lock at open** (settled): opening probes the root's writability — a read-only volume (DMG, snapshot, read-only share) or a permission-denied folder opens straight into the read-only lock, banner naming the cause — **which specific cause, not a shared line** (settled): the probe distinguishes read-only volume from permission-denied folder and the lock reason carries it ("this board's volume is read-only" vs "you don't have permission to change this folder"), the fixes being different acts — rather than letting every gesture fail one at a time — fail loudly, specifically, *once*. The open-time agent-guide write (08-agent-integration.md) is skipped-with-log, the `CLAUDE.user.md`-taken precedent. Writability re-probes on every reconciling reload (wake, activation — above), **and the probe is symmetric** (settled): a rewritable remount or fixed permission clears the lock without ceremony, and a volume gone read-only mid-session *raises* it at the next probe — banner up front, not every gesture failing one at a time (the lock's own founding rationale). Between probes, a write that hits the newly read-only volume fails as an ordinary one-shot; the next reconciliation converts the condition into the standing lock. The lock's read affordances stay live as always — inspecting an archived board on a DMG is a legitimate errand, and viewing-first is the point.
|
||||
- **Auto-commit failures beyond `index.lock` contention** (06-history-undo.md covers the lock) — disk full mid-commit, repo corruption: the files are safely on disk but history stops advancing, which quietly suspends the undo trail and the flush-before-overwrite guarantee. That degradation is surfaced, not hidden: the banner states that changes aren't being recorded to history; the committer retries on the next debounce and the banner clears on the first successful commit.
|
||||
- **Attachment import copy failures** (source unreadable, destination full): the drop was accepted — "never refuses the drop" (01-storage-format.md ▸ Attachments) is policy, not an I/O guarantee — so a failed copy surfaces in the banner with the filename, and any partial file is removed; no half-copied attachment is ever left in `attachments/`.
|
||||
|
||||
@@ -58,12 +65,12 @@ The read-side rules above have a write-side mirror — one banner vocabulary for
|
||||
|
||||
The non-modal banner named throughout the read- and write-side rules above is one UI component, specified here:
|
||||
|
||||
- **Hosted by the window of origin.** Every window hosts a banner strip; a condition surfaces in the window whose action produced it — debounced body save, attachment drop, and raw-source Apply failures in their card window; reload breakage, one-shot write failures, commit failures, and lock states in the board window. A card window that closes while its condition persists re-homes the banner to the board window (the condition is still true; it must stay visible somewhere).
|
||||
- **Hosted by the window of origin.** Every window hosts a banner strip; a condition surfaces in the window whose action produced it — debounced body save, attachment drop, and raw-source Apply failures in their card window; reload breakage, one-shot write failures, commit failures, and lock states in the board window. A card window that closes while its condition persists re-homes the banner to the board window (the condition is still true; it must stay visible somewhere). **Reaffirmed 2026-08-06 against the shipped board-strip interim**: everything posts to the board window's strip today, defended in code by "a card window is not always the frontmost thing on screen" — but a full-screen or other-Space card window whose failing save banners into a window the user cannot see is a silence trap, exactly what the form-anchored rule (06 ▸ form-anchored operations) exists to prevent; the window of origin *is* under the user's eye at the moment of the action. The interim's one good idea is absorbed rather than discarded: **rows name their card wherever ambiguity exists, in per-window strips too** — naming and hosting answer different questions ("whose failure" vs "where the user is looking"), and re-homing keeps the name when a card row lands on the board strip.
|
||||
- **One-shots dismiss, conditions heal.** One-shot failures ("Couldn't move 'Fix login' — disk full") carry an explicit dismiss control and no timeout — an error never evaporates unread. Persistent conditions (reload breakage, suspended auto-commit, read-only locks) have no dismiss: they describe ongoing state, standing until the next success clears them, per the rules above.
|
||||
- **Concurrent conditions stack.** The strip presents independent rows, precedence-ordered: read-only lock > reload breakage > one-shot write failures > commit and attachment failures; newest first within a class. Each row heals or dismisses independently; beyond three rows the remainder collapse behind a "+N more" disclosure.
|
||||
- **Tones, not components.** The banner has kinds — error, warning, info — sharing layout and the accessibility announcement path (10-accessibility.md). The card window's remote-change signpost (07-sync-collab.md) is this same component in the info tone: visually calm, no error color.
|
||||
- **In-progress operations are info rows** (settled): bracketed git operations ("Pulling…", "Switching to 'main'…") and long non-git work (big-board Duplicate, template instantiation, large attachment imports) each show an info-tone row with a spinner — determinate where progress is knowable. Completion clears the row (the VoiceOver completion announcement of 10-accessibility.md rides the same event); failure swaps it for the error row. Sighted and VoiceOver users learn one vocabulary.
|
||||
- **Cancel appears on safe copies only** (settled): copy-shaped work — attachment imports, Duplicate, template instantiation — carries Cancel, meaning "remove the partial copy, nothing lost". Git brackets get no Cancel: seconds long, and aborting a rebase mid-flight is a repair job, not a cancel.
|
||||
- **Concurrent conditions stack.** The strip presents independent rows, precedence-ordered: **in-progress rows (pinned) >** read-only lock > reload breakage > one-shot write failures > **loss rows** > commit and attachment failures **> passive info rows** (the remote-change signpost); newest first within a class. **The one-shot failure class carries two shapes** (settled 2026-07-31): the `BoardWriteError`-shaped write failure, and a message-carrying **git-operation failure** — the operation named in the user's words plus the underlying error, phrasing still BannerCenter's — because failures rank by what they are, not by which error vocabulary threw them. A failed undo restore, branch switch, or (pro-m2) pull/push is an action that didn't happen: it presents in the error tone at the failure rank, never as a warning-tone loss row (the shipped loss-row compromise is retired). Git operations stay off the closed `WriteOperation` vocabulary — only the banner tier learns the second shape. Recovery *notices* — "a branch switch was interrupted — the previous state is restored" — report a success, not a failure, and stay warning-tone. **Loss rows are the warning-tone class for non-failure losses** (settled 2026-07-28): content that didn't arrive though nothing failed — folders skipped from a Finder drop, a legacy migration's folded notices, their future kin (the degraded paste left the class 2026-07-29 — a snapshot-less paste now refuses outright, a one-shot failure, 04-interactions.md ▸ Clipboard). They take the one-shot's lifecycle (dismissable, untimed — a loss the user didn't notice is the harm, so it never auto-expires), rank below the true failures (an action that didn't happen outranks one that partially did), and above the ambient notices. Each row heals or dismisses independently; beyond three rows the remainder collapse behind a "+N more" disclosure. **In-progress rows are exempt from the collapse and don't count toward its budget** (settled): they are the strip's only explanation for a bracket's write lock and for a close/quit deferring teardown, and the copy rows carry the reachable Cancel — a spinner may never hide behind "+N more". They're safe to pin: few at once, self-clearing, **newest first within the class like every other** (ratified — insertion order; the class rarely holds more than two rows, and one ordering rule beats a special case). Passive info rows rank last and may collapse — calm by design, nothing gated on seeing them instantly.
|
||||
- **Tones, not components.** The banner has kinds — error, warning, info — sharing layout and the accessibility announcement path (10-accessibility.md). The card window's remote-change signpost (07-sync-collab.md) is this same component in the info tone: visually calm, no error color. **Its lifecycle is the one-shot's — dismissable, untimed** (settled): 07's "transient" means non-modal and non-blocking, never auto-expiring; the strip has exactly two lifecycles (one-shots dismiss, conditions heal) and the signpost doesn't add a third.
|
||||
- **In-progress operations are info rows** (settled): bracketed git operations ("Pulling…", "Switching to 'main'…") and long non-git work (big-board Duplicate, template instantiation, large attachment imports, and cross-board transfers — drag copies and moves, staged-clipboard pastes) each show an info-tone row with a spinner — determinate where progress is knowable. Completion clears the row (the VoiceOver completion announcement of 10-accessibility.md rides the same event); failure swaps it for the error row. Sighted and VoiceOver users learn one vocabulary.
|
||||
- **Cancel appears on safe copies only** (settled): copy-shaped work — attachment imports, Duplicate, template instantiation, cross-board copies and pastes — carries Cancel, meaning "remove the partial copy, nothing lost"; a cross-board ⌘-drag *move* cancels the same way during its copy phase — the source deletes only after the copy lands, so Cancel leaves the original untouched. Git brackets get no Cancel: seconds long, and aborting a rebase mid-flight is a repair job, not a cancel.
|
||||
|
||||
## Windows
|
||||
|
||||
@@ -72,22 +79,27 @@ The non-modal banner named throughout the read- and write-side rules above is on
|
||||
- **Card windows** — `WindowGroup(for: CardWindowRef.self)`; at most one per card (reopen focuses); follows its card across lanes; dismisses itself if the card is deleted.
|
||||
### Launch and window lifecycle (settled)
|
||||
|
||||
- **Restoration is a preference** — "Restore open boards at launch", default on. On: boards open at last quit reopen (bookmark-resolved), with their per-board frames and 05's card-window restoration. Off: every launch starts at welcome.
|
||||
- **Restoration is a preference** — "Restore open boards at launch" in Settings (⌘, — 11-command-nexus.md), default on. On: boards open at last quit reopen (bookmark-resolved), with their per-board frames and 05's card-window restoration. Off: every launch starts at welcome.
|
||||
- **The restoration set is a live open marker, never an at-quit write** (settled): each registry record (Per-board app state below) carries an open-now flag — set when the board's window opens and cleared on *user-initiated* close — quit's teardown closes deliberately leave it standing (the boards were open at quit by definition; teardown distinguishes user-close from quit-close, and that distinction is the whole mechanism). Restoration reads the flagged records, reopening by `lastOpened` order. Crash recovery falls out for free: after a crash the flags describe what was open at crash time, so relaunch restores it — no separate recovery logic, no once-at-quit stamp to race teardown or miss on a crash. The preference gates only whether the flagged set is consulted; the flags are maintained regardless.
|
||||
- **Welcome appears only when nothing restores** — restoration off, nothing was open, or every restoration failed. Always reachable via Window ▸ Welcome to Lanework. Opening a board from welcome closes welcome.
|
||||
- **Every open passes through a pre-snapshot loading state** (ruled 2026-07-29): the board window appears **immediately** — welcome click, File ▸ Open…, Finder double-click, restoration alike — at its saved frame, its chrome carrying the registry record's cached title and icon (the same no-scan sources the welcome row reads; a first-ever open shows the folder name, the record's provisional display name). The content area holds a quiet loading surface: a centered system spinner appearing only after a short grace (~200 ms) so ordinary fast opens never flash it — no skeleton lanes, the motion language animates real data only. The first snapshot replaces the surface in place (a snap — there is no prior arrangement to animate from). **The walk is cancellable**: ⌘W during loading cancels it and closes the window, an ordinary user-initiated close clearing the open-now flag. **Restoration is parallel**: every flagged window appears at once in loading state (stacked by `lastOpened` order), each walk independent — a slow network board never delays the others and stays closeable while it loads. Failure resolves by attendance (amended 2026-07-31 — 01-storage-format.md ▸ The decision surface): an **attended** open's fail-fast walk transforms the loading content in place into the aggregated repair surface — a live decision, not an error display — and only Cancel retires the window to the row-level welcome landing; a **restored** window's walk keeps the settled behavior, retiring to welcome row-level — welcome returning if the open came from it or from Finder — and the row's retry click is the attended open that earns the surface. The open walk earns no in-progress info row: the loading state is the surface, and the info-row class stays scoped to copy-shaped work.
|
||||
- **Closing the last board window leaves the app windowless** (menu bar alive) — the close is respected. Reactivation (Dock click) with no windows shows welcome.
|
||||
- **A restored board that fails surfaces on welcome, row-level**: its window doesn't open; welcome appears alongside whatever did restore, the failed board's recents row carrying fail-fast's specifics (load error) or the unavailable state per Graceful orphaning (offline volume, dead bookmark). Other restorations proceed unaffected — never a launch-time modal chain, never a silent drop.
|
||||
- **A restored board that fails surfaces on welcome, row-level**: its loading window retires (the pre-snapshot state above); welcome appears alongside whatever did restore, the failed board's recents row carrying fail-fast's specifics (load error) or the unavailable state per Graceful orphaning (offline volume, dead bookmark). Other restorations proceed unaffected — never a launch-time modal chain, never a silent drop.
|
||||
- **Opening a board from Finder is a standard document open** (settled): double-clicking a `.kanban` folder (or `open -a`) routes through the app's open-documents handler into the exact path welcome and File ▸ Open… already use — registry record (created before loading, Per-board app state below), board window, recents stamp. A board already open focuses its existing window — file-identity match, never a second window (one board window per root, above). A fail-fast failure surfaces row-level on welcome, uniform with the restored-board failure row.
|
||||
|
||||
- **Close flushes**: closing a board window (and app quit) first closes the board's card windows — each open Edit session ends with its normal session commit (06-history-undo.md's granularity) — then flushes pending debounced work, editor saves before the pending auto-commit, before the store tears down. Nothing about this is conditional: a card window cannot exist without its board window (the ownership rule above), so the close flush is always the whole story.
|
||||
- **Close waits for in-flight operations** (settled): a close or quit landing while an in-progress banner row is live — a bracketed git operation or copy-shaped work (The banner surface above) — defers teardown until that operation completes: the window stays open with its row spinning, and completion (or failure) resumes the close-flush sequence unchanged. Nothing is interrupted and nothing initiated is silently discarded — a copy row's Cancel stays available throughout for a user who'd rather expedite the quit ("remove the partial copy, nothing lost"). 06-history-undo.md's own-leftovers stamp recovery is thereby a *crash* net only; no deliberate quit or close ever leans on it.
|
||||
|
||||
## Per-board app state
|
||||
|
||||
State that belongs to the app, not the user's files — the recents list, per-board window frames, the push-on-commit setting and the once-per-board iCloud warning flag (07-sync-collab.md), and whatever accumulates later — lives in a **board registry in Application Support**: one record per known board, anchored by the **security-scoped bookmark** the sandboxed app keeps anyway for reopening boards.
|
||||
State that belongs to the app, not the user's files — the recents list, per-board window frames, the open-now restoration flag (Launch and window lifecycle above), the push-on-commit setting and the once-per-board iCloud warning flag (07-sync-collab.md), and whatever accumulates later — lives in a **board registry in the app's Application Support container** (re-ruled 2026-07-30 — the one-app collapse removed the App Group wholesale; 12-editions.md ▸ App-side state): one record per known board, anchored by the **security-scoped bookmark** the sandboxed app keeps anyway for reopening boards.
|
||||
|
||||
- **Keyed by file identity, never by path.** Bookmarks track renames and moves on the same volume; an opened URL is matched to its record by bookmark resolution / file identity, so a moved board keeps its settings. The recents list *is* this registry sorted by last-opened.
|
||||
- **Recents counts are registry-cached.** The lane/card counts in the welcome window come from the record, stamped at last close — no directory scan at welcome time (which would be slow or hang on big/unavailable boards). Staleness until the next open is accepted. Records that can't be counted show without counts: unavailable boards per Graceful orphaning below; a board that fails to load just fails on open, fail-fast — the welcome row doesn't pre-detect it.
|
||||
- **Keyed by file identity, never by path.** Bookmarks track renames and moves on the same volume; an opened URL is matched to its record by bookmark resolution / file identity, so a moved board keeps its settings. The recents list *is* this registry sorted by last-opened. **One bookmark per open board** (settled): the open flow mints it once and threads it through — the persisted record and the live store's mid-session re-resolution share the same bookmark, never two independent mints.
|
||||
- **Recents counts are registry-cached.** The lane/card counts in the welcome window come from the record, stamped at last close — no directory scan at welcome time (which would be slow or hang on big/unavailable boards). Staleness until the next open is accepted. **The counts are working items only** (settled, re-grounded 2026-07-28; extended for the lanes-era trash 2026-07-29): items in `.trash/` don't count — a trashed lane subtracts from the lane count and its nested cards subtract from the card count; the row advertises the board's working size, and the trash is an errand, not inventory. Records that can't be counted show without counts: unavailable boards per Graceful orphaning below; a board that fails to load just fails on open, fail-fast — the welcome row doesn't pre-detect it. **A first open that fails fail-fast still records** (settled): the registry record is created before loading, so the failed board lands in recents carrying fail-fast's specifics — retry after fixing the file is one click, uniform with the restored-board failure row. **The record's provisional display name is the folder name** (settled): fail-fast means the frontmatter can't be trusted, and the folder name is the Finder document name the user just picked; the first successful load replaces it with the cached title. A never-successfully-opened record is not a special class — it lingers in recents like any other, and Forget is the eraser for a genuinely mistaken open. **Records that collapse onto one file identity merge silently** (settled — a restored registry file, or an orphan whose bookmark re-resolves onto a recreated board): on detection, the record with the newest `lastOpened` wins wholesale and the others retire — recents never shows one board twice, and per-board settings are conveniences that don't earn a merge UI.
|
||||
- **The row's title and icon are registry-cached too — with live write-through** (settled): the record carries the board's `title`, `icon`, and `iconColor` beside the counts, and the welcome row reads only the record — it never opens any board's `index.md` (the same hang-avoidance that motivated the counts rule). Unlike the counts' at-close stamp, these three refresh **whenever an open board's reload changes them**: the store already holds the new snapshot, so an in-app Board rename (03-board-ui.md) lands in the record instantly — never a welcome row showing a name the user just changed away from — and a foreign rename of an *open* board rides the same path for free. The honest residual: renaming a board that isn't open (an agent editing its root `index.md`) stays stale until the next open — accepted, the counts' staleness class. The title falls back to the folder name per 01-storage-format.md, cached at the same moments.
|
||||
- **Files-first stays absolute**: nothing app-private is ever written into the board folder — no frontmatter keys, no sidecar files, no xattrs. Two machines sharing a board via a remote each keep their own record (push-on-commit and window frames are genuinely per-machine choices).
|
||||
- **Graceful orphaning**: a record whose bookmark no longer resolves (board deleted, or moved across volumes where bookmarks can't follow) is orphaned — recents surface it as unavailable with Forget; its settings are conveniences and die with it (accepted).
|
||||
- **App-wide state has the same home.** Not everything app-side is board-scoped: quick-style recents (03-board-ui.md), the SSH host-key assignment table and TOFU fingerprint store (07-sync-collab.md — host-scoped), the last-used card-window size (05-card-window.md), and peers live beside the registry in Application Support (or `UserDefaults` where a scalar fits) — no per-board record involved. Secrets are the named exception: Keychain only, never here (07).
|
||||
- **App-wide state has the same home.** Not everything app-side is board-scoped: quick-style recents (03-board-ui.md), the SSH host-key assignment table and TOFU fingerprint store (07-sync-collab.md — host-scoped), the last-used card-window size (05-card-window.md), the user template store (09-templates.md — plain board folders), and their peers live beside the registry in Application Support (or standard `UserDefaults` where a scalar fits) — no per-board record involved. Secrets are the named exception: Keychain only, never here (07).
|
||||
|
||||
## Caching and search
|
||||
|
||||
@@ -100,7 +112,7 @@ The old app loaded boards fast enough that the planned SwiftData cache was never
|
||||
|
||||
## Changes from Kanban
|
||||
|
||||
- The store's transient-state grab-bag (selection, drag, search) gets an explicit home rather than accreting — exact shape TBD during implementation planning.
|
||||
- The store's transient-state grab-bag gets an explicit home (settled, m3): **TransientBoardState**, one per store, holding state by how a reload treats it. **Item-referencing sets** — selection, drag membership, the pending cut — share one shape (a UUID set plus the container side it lives on, board or trash) and one constraint rule, *members must exist in the current universe*, applied in two directions by one primitive: a reload re-resolves each set independently against the new snapshot (present on the same container side — resettled 2026-07-28, the materialized trash: presence is the whole test, no ancestor walk, no effective liveness), and the search filter constrains the selection to its visible set — the hidden-cards-leave-the-selection rule and the reload-survival rule are one rule, expressed once. **Derived state is stored as its inputs only**: the search query is kept, its result set never is — the predicate re-runs against each snapshot (04-interactions.md's live filter). **The overlays** — transient render state covering the gap between a gesture and its disk echo, each discarding itself at handoff. The **new-card placeholder** is anchored to its lane, not to items: no UUID until the title commits, discarded when a reload drops its lane, and handed off by discarding itself the moment the created card's UUID appears in a snapshot. The **held drop proposal** (settled — 03-board-ui.md ▸ Motion) is its kin on the other side of a write: at drag release the proposed arrangement keeps rendering over the snapshot while the move write brackets, and the proposal discards itself when the echo reload lands (positions match, nothing visibly moves); a failed write or a reload that vanishes the dragged items discards it and the board animates back to snapshot order. **Its home is the app-wide DragSession, not this per-store state** (settled 2026-07-28 — the one overlay that outlives a store's scope): a drag inherently crosses boards, so the hold lives once on AppModel, keyed by board root and snapshot generation; per-store homing would need a store-to-store hand-off mid-gesture for no behavioral gain. The placeholder kinship is semantic — hold, hand off at echo, discard on vanish — not residential. A refused write produces no echo reload, so **a short timeout stands in for the failed-write discard signal** (accepted): the hold snaps back animated when no echo arrives. Trash visibility rides along as a plain per-open value: hidden on every open, never persisted — visiting the trash is an errand, not a layout choice (the trash itself is `.trash/` on disk — 03-board-ui.md).
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
+61
-31
@@ -4,72 +4,102 @@ The board window: layout, lanes, cards, and styling. Interaction mechanics (sele
|
||||
|
||||
## Layout — full visibility
|
||||
|
||||
- **Every lane is always on screen.** The window width divides across the lanes' width units — no horizontal scroll, no enforced minimum lane width. Resizing the window is the width control. (Settled emphatically in the old app: horizontal scroll strays from what kanban is for.) The degenerate case is accepted, not floored: enough lanes/units in a small window compress every lane, titles and cards truncate gracefully, and the remedy is the user's (fewer units, bigger window). A minimum-width setting that reintroduces scroll was considered in the pathfinder and deliberately rejected. **Lane-count changes re-divide, never resize**: new lane (⇧⌘N), lane paste or cross-board lane drop, lane tombstone, Put Back, and Show/Hide Trash (the quasi-lane's fixed unit joins and leaves the division — Trash below) all re-divide the existing window width across the new unit total — window-growing behavior belongs to the right-edge drag alone (Lane below).
|
||||
- **Every lane is always on screen.** The window width divides across the lanes' width units — no horizontal scroll, no enforced minimum lane width. Resizing the window is the width control. (Settled emphatically in the old app: horizontal scroll strays from what kanban is for.) The degenerate case is accepted, not floored: enough lanes/units in a small window compress every lane, titles and cards truncate gracefully, and the remedy is the user's (fewer units, bigger window). A minimum-width setting that reintroduces scroll was considered in the pathfinder and deliberately rejected. **Lane-count changes re-divide, never resize**: new lane (⇧⌘N), lane paste or cross-board lane drop, lane delete (and its undo), and Show/Hide Trash (the trash lane's fixed unit joins and leaves the division — Trash below) all re-divide the existing window width across the new unit total — window-growing behavior belongs to the right-edge drag alone (Lane below).
|
||||
- A lane spans a **whole number of width units** (`width` frontmatter, ≥ 1, no cap). Cards stay standard width; a wide lane flows them into as many interior masonry columns as it has units.
|
||||
- New lanes are created via a **File-menu item** (the one committed surface; ⇧⌘N — 11-command-nexus.md); new cards from the lane (see 04-interactions.md for creation flows).
|
||||
- **Zoom scales the ruler, never the strip** (settled 2026-08-02 — View ▸ Zoom In / Zoom Out / Actual Size, 11-command-nexus.md). The board's whole geometry is already derived from the body font's point size (10-accessibility.md ▸ Full relative scaling — every figure an em multiple, no fixed point sizes), and macOS supplies no text-size control to move it, so the zoom commands *are* that control: a rung on the level ladder raises the effective body size, and type, card chrome, lane chrome and card heights grow together off it. **Full visibility above is untouched, and that is the whole design constraint**: a canvas magnification would have to widen the strip and reintroduce the horizontal scroll this section rejects, so zoom does not do that — lane *width* stays the window's division at every rung. Zoom does move the inter-lane gap (an em multiple like everything else), so lanes narrow by a few percent across the ladder's whole range; the felt effect is the intended one — zoom in for bigger, more legible cards and fewer per screen, out for a denser board. **The level is app-wide, persisted, and never a property of a board**: it lives beside Show Comments in the app's preferences, not in any lane's or board's frontmatter, because it describes how a user likes to read rather than what a board is. **Zoom never moves the window** — the minimum content size stays pinned to the *system* body size, since window-growing behavior belongs to the right-edge drag alone (Lane below).
|
||||
- New lanes are created via a **File-menu item** (the one committed surface; ⇧⌘N — 11-command-nexus.md); new cards from the lane (see 04-interactions.md for creation flows). **The created lane becomes the sole selection** (settled): ⇧⌘N → Board ▸ Rename is a pure keyboard path — the create-then-act texture (Return-creation re-selects its lane, ⌫ picks a successor).
|
||||
|
||||
## Toolbar (board + card windows)
|
||||
|
||||
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. **Catalog** (available via Customize): New Card, New Lane, Undo, Redo (the pair disabled on boards without undo — no-git and repo-nested boards, matching their menu items — 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).
|
||||
|
||||
## Lane
|
||||
|
||||
- Title bar: leading SF Symbol (the lane's `icon`), title, **card-count badge** (quiet, secondary styling), new-card button. The whole title bar is the lane's drag surface — no separate grip. The count reads the search filter like every other surface (04-interactions.md): during a search it shows the visible count, not the total.
|
||||
- Title bar: leading SF Symbol (the lane's `icon`), title, **card-count badge** (quiet, secondary styling), new-card button. The whole title bar is the lane's drag surface — no separate grip; a plain click (no movement) on it selects the lane (04-interactions.md ▸ Selection). **The lane has one context menu** (settled), invoked on the header or on lane empty space alike — Rename, Style…, the quick-style recents row, the Width stepper, Delete (inventory normative in 11-command-nexus.md ▸ Context menus); a full lane still has its header, so the menu is always reachable. The count reads the search filter like every other surface (04-interactions.md): during a search it shows the visible count, not the total.
|
||||
- Body: vertical card stack (masonry grid when wide — settled, the pathfinder's masonry works), scrolls vertically.
|
||||
- Right-edge **drag-to-resize** between integer widths (1×, 2×, 3×, … — no cap): shadow snaps at the inter-column gap with 10pt release hysteresis; the window grows/shrinks by one standard width per snap so other lanes keep their exact size. **Growth hard-stops at the screen's visible frame, with rubber-band feedback** (the dragged edge gives a fraction of the overshoot and snaps back, signalling the bound — pathfinder behavior, proven): the drag never compresses siblings and the window never overflows the screen. The header context menu's Width control (stepper, uncapped) is the precise control — and deliberately the opposite mechanism: it never touches the window, it **re-divides** the existing width across the new unit total (siblings compress). Widths beyond the screen's capacity stay reachable through it. The **Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘← — 11-command-nexus.md) are this stepper's keyboard face** — same re-divide semantics, never the window's size; window-growing behavior belongs to the drag alone.
|
||||
- Right-edge **drag-to-resize** between integer widths (1×, 2×, 3×, … — no cap): shadow snaps at the inter-column gap with 10pt release hysteresis; the window grows/shrinks by one standard width per snap so other lanes keep their exact size. **Growth hard-stops at the screen's visible frame, with rubber-band feedback** (the dragged edge gives a fraction of the overshoot and snaps back, signalling the bound — pathfinder behavior, proven): the drag never compresses siblings and the window never overflows the screen. The header context menu's Width control (stepper, uncapped) is the precise control — and deliberately the opposite mechanism: it never touches the window, it **re-divides** the existing width across the new unit total (siblings compress). Widths beyond the screen's capacity stay reachable through it. The **Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘← — 11-command-nexus.md) are this stepper's keyboard face** — same re-divide semantics, never the window's size; window-growing behavior belongs to the drag alone — and they **batch over a multi-lane selection** (settled, the styling precedent): each selected lane steps one unit, one gesture, one commit; the context-menu stepper itself stays single-lane by nature. **A width write landing on 1 removes the `width` key** (settled — the remove-at-default family: the empty rename removes `title`, the None well removes `background`): a default lane's frontmatter stays clean, drag, stepper, and menu items alike; a hand-written `width: 1` is legal and preserved until the app itself next edits width. **A failed width commit at drag release rolls the window back** (settled): the failure surfaces as the ordinary one-shot banner and the window animates back by the uncommitted delta — 02-architecture.md's write-failure honesty (the action visibly doesn't happen) applied to the one control that moves the window.
|
||||
|
||||
## Card face
|
||||
|
||||
- Leading icon + title. The only face chip in scope is **attachments** (a quiet indicator when the card has files — the title dominates). Metadata chips (labels/assignees/due) went to the enhanced schema with their fields — out of scope.
|
||||
- **No body excerpt** (settled): the face stays title-only — the old "iterate on the card face later" item is closed with no growth.
|
||||
- **Titles are optional at every level.** On cards and lanes, a missing `title` renders as a quiet placeholder ("Untitled", secondary styling) wherever the title would appear. On boards, the fallback is the folder name (sans extension), never "Untitled" — see 01-storage-format.md's board-naming rule; window title and welcome recents show `title` when present, folder name otherwise.
|
||||
- Attachments: **the sole selected card** shows the paged media carousel when it has attachments (the pathfinder's selection-keyed in-place expansion, minus its body blurb — no body excerpt, above). Single selection only: multi-selections and unselected cards stay compact, and the expansion animates under the selection-keyed transaction (Motion below). QuickLook thumbnails for anything previewable, Finder icon fallback, page dots on a glass underlay, paged by trackpad pan / dot click / scroll wheel.
|
||||
- Attachments on the face: **the chip only — there is no face carousel** (resettled 2026-07-28, reversing the carry-over): the pathfinder's selection-keyed in-place expansion — compact unselected, media carousel when sole-selected — **proved undesirable and does not carry over**. A card has **one presentation**: selection changes styling (the selection treatment), never geometry, so the masonry never reflows on click and a card face is the same object whatever the selection state. The attachment chip above is the face's whole attachment story; viewing media is the card window's job (⌘↩ / double-click — the attachments section and QuickLook, 05-card-window.md). The earlier 2026-07-28 carousel settlements (metrics, tick paging, clamp, dots, marquee suppression, trash exclusion) are superseded with it — recorded on their Resolved cards.
|
||||
|
||||
## Styling
|
||||
|
||||
### Capabilities (settled)
|
||||
|
||||
- **`background`** on board / lane / card: palette name (kebab-case, hand-editable) or `#RRGGBB[AA]` hex. Board color paints the board window's content background (the surface behind and between lanes). Lane and card color are **edge accents, not fills** (settled in the pathfinder's treatment shootout — its settings matrix of C-series lane / K-series card variants landed on **C7 · full-column top edge** and **K1 · left edge stripe**): a lane's color paints a full-width band along its top edge, a card's a stripe along its left edge; the surfaces themselves keep the standard chrome, so colored title text never sits on a colored fill.
|
||||
- **`background`** on board / lane / card: a mapping, `{color: …, image: …}`, and only a mapping (settled 2026-08-06 — 01-storage-format.md § Frontmatter; a bare scalar has no reading and paints nothing). `color` is a palette name (kebab-case, hand-editable) or a `#RRGGBB[AA]` hex; either subkey may stand alone. Board color paints the board window's content background (the surface behind and between lanes). **A board's background may also carry an image**: the `image` path is relative to the board root so the picture travels with the document. Color and image both paint the **full window** — the content runs under a transparent title bar, with a frosted strip across the title-bar/toolbar band keeping the chrome legible over them; the extended chrome applies only while the board has a background of its own, and a board without one keeps the standard chrome unchanged. The image draws over the color, scaled to fill and cropped, with the color standing in while it loads or if it can't be read; an unresolvable path paints nothing, the lenient degrade an unrecognized color already gets. **There is no in-app control for the image** — the raw file is the escape hatch (the stance custom hex held until the 2026-08-06 combo reversal; for images it stands) — and **the board-level ink rule still derives from the color reading alone**: an image makes no AA claim (10-accessibility.md), since a picture has no single luminance to threshold against. Lane and card color are **edge accents, not fills** (settled in the pathfinder's treatment shootout — its settings matrix of C-series lane / K-series card variants landed on **C7 · full-column top edge** and **K1 · left edge stripe**): a lane's color paints a full-width band along its top edge, a card's a stripe along its left edge; the surfaces themselves keep the standard chrome, so colored title text never sits on a colored fill.
|
||||
- **The standard chrome is the pathfinder's surface stack** (settled 2026-08-06): the window keeps the neutral system background; every lane wears a quiet quaternary-wash plate (the trash plate's own figure — translucent, so a board-chosen color shows through and the board-level ink rule keeps its premise; opaque under Reduce Transparency, the trash precedent); every card sits on an opaque `controlBackgroundColor` plate — white over the washed lane in light appearance, a step *darker* than the window in dark. One plate value for every face a card draws (resting, replicas, placeholder, arriving), so a card is the same object wherever it renders.
|
||||
- **`icon`**: SF Symbol per item with per-level defaults (board `rectangle.split.3x1`, lane `square.stack`, card `doc.text`).
|
||||
- **`iconColor`**: resolved — **schema yes, control no**. The field renders when hand-written (tint palette name or hex); the app offers no control for it (Controls below).
|
||||
- The pathfinder's palettes (12 icon tints, 12 backgrounds) carry over as the starting point.
|
||||
- The pathfinder's palettes (12 icon tints, 12 backgrounds) carry over as the starting point. **The 12+12 split is a picker split, not a name split** (settled): color resolution searches foregrounds then backgrounds, so a hand-written `background: carnation` (an icon-tint name) resolves and paints — the split governs what the grids offer, never what a name means.
|
||||
|
||||
### 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 two anchors: **embedded** in the card window sidebar's Style section (05-card-window.md), 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 — 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 Style… popover carries both grids in full. (The board popover was briefly a third anchor — the background half embedded in its Theme tab — until the 2026-08-07 Theme rework made that tab preset-only; Style… with nothing selected is the board's manual grid now, and the popover's symbol picker beside the rename field still owns the board glyph.)
|
||||
|
||||
- **Palette-only in-app**: the background grid offers the 12 palette colors — every pair AA-verified at design time (10-accessibility.md) — plus a leading **None** well that removes the `background` key. Custom hex is not pickable in-app but stays fully honored from disk (runtime contrast, 10-accessibility.md): curated in-app, unlimited on disk.
|
||||
- **Curated symbol grid**: a hand-picked set (roughly five dozen kanban-relevant SF Symbols); its leading well is the level's default symbol and removes the `icon` key. Any other SF Symbol name works written by hand — the palette stance again. No full-browser escape hatch in-app; the raw file is the escape hatch.
|
||||
- **Curated-first, panel-backed** (re-ratified 2026-08-06, reversing 2026-07-29's palette-only ruling): the background grid offers the 12 palette colors — every one AA-verified through one code path (ratified 2026-07-29: palette names route through the same runtime ink-selection seam as hand-written hex — the appearance flip picks the readable label vocabulary — and PaletteContrastTests pins that the chosen ink meets AA in both appearances for all 12 backgrounds, so palette drift can never silently break it) — plus a leading **None** well that removes the `background` key. Beside the grid the vocabulary now has a second, compact form: the **color combo** — a swatch-faced popup listing None, the role's twelve, the current off-palette value verbatim when there is one, and **Other…**, which opens the system Colors panel. The panel is the in-app escape hatch the 2026-07-29 ruling withheld: a pick landing exactly on a palette color stores the *name* (so a re-pick never drifts to a hex spelling), anything else stores the hex — the same unlimited vocabulary hand-editing always had, now pickable. An arbitrary pick changes no contrast story (it lands on the identical runtime ink computation hand-written hex already gets — 10-accessibility.md), and the quick-style recents stay palette-vocabulary: a panel pick never enters them.
|
||||
- **Curated symbol grid**: a hand-picked set (roughly five dozen kanban-relevant SF Symbols); its leading well is the level's default symbol and removes the `icon` key. Any other SF Symbol name works written by hand — named symbols the running OS knows, that is: inventories grow per macOS release, so a newer-OS name renders the level default on an older Mac, value preserved on disk — the palette stance again. No full-browser escape hatch in-app; the raw file is the escape hatch. (Symbols keep this stance deliberately — the 2026-08-06 color-panel reversal above is colors only: the system offers a Colors panel worth deferring to, and no symbol browser of equal standing.)
|
||||
- **Off-palette values display leniently**: a hand-written hex background or uncurated symbol shows as the current value in the editor (labeled verbatim, outside the grids); choosing any well replaces it.
|
||||
- **Batch edits**: a multi-selection shows per-dimension mixed state (no well selected, "—" where a value would read); choosing a well applies to the whole selection — one gesture, one commit on git boards.
|
||||
- **Quick-style row, recents only**: card and lane context menus carry one compact row of recently used backgrounds plus the Style… item — one-click recolor for the common case; the pathfinder's second full-palette tier is gone. Recents are app-wide and persist app-side (user preference, never board data).
|
||||
- **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.
|
||||
- **Quick-style row, recents only**: card and lane context menus carry one compact row of recently used backgrounds plus the Style… item — one-click recolor for the common case; the pathfinder's second full-palette tier is gone. Before any background has ever been applied, the row is omitted entirely — never an empty strip. Recents are app-wide and persist app-side (user preference, never board data).
|
||||
- **Keyboard path**: Style… is a menu item with a shortcut (04-interactions.md's contract); inside the editor the grids are arrow-navigable and every well Tab-reachable (10-accessibility.md).
|
||||
|
||||
## Board popover
|
||||
|
||||
The window-title widget opens the **board popover** — the one board-level surface, hosting:
|
||||
**The tabbed popover (2026-08-07, restructure complete — all three tab sessions settled).** The symbol/name header stays at the top — the board's glyph with its tint row beside the rename field; below it sit tabs — **Info**, **Theme**, **Git** — each the settings surface for one aspect of board configuration, each settled in its own dedicated session. **Tab membership is the git posture's** (the Git session's ruling): the Git tab joins the strip only when the git section has something true to say — `BoardGitSection` resolves to anything but absent — so a free-tier board with no `.git` shows Info | Theme alone. This carries 12-editions.md's "absent, no placeholder" rule up to the tab strip: a standing Git tab on every free board would be the standing ad for Pro that 12 forbids. *(Pivot 2026-08-07, same day — 12: git left the paywall, so the absent posture is unreachable and every board carries all three tabs. The membership rule stands structurally — the strip still asks the posture — it just never hears "absent" anymore.)* **Selection resets to Info on every open** (ruled in the Git session, closing the question the earlier tab sessions deferred): the popover is transient and Info is the board's face — and a remembered tab could strand selection on a tab the next posture doesn't offer. *(Added 2026-08-07, the same night the settings sheet retired: a fourth tab, **Sync** — a standing placeholder, last in the strip, rendering one honest "Nothing here yet." caption. It claims the position where the remote half of the git story will live — tracking, Pull/Push, the badges, and whatever home 07-sync-collab.md's setup surfaces are ruled into — without ruling any of that: the open Redesign card owns the question, and the placeholder is deliberately empty rather than a greyed-out preview.)*
|
||||
|
||||
- **Board rename** (settled: this function stays in-app, unlike the pathfinder which dropped it with the inspector). Rename edits the board's frontmatter `title` only — the folder is never renamed by the app; the Finder document name is Finder's to change (01-storage-format.md's board-naming rule).
|
||||
- **Board styling** — the embedded style editor (Styling ▸ Controls above).
|
||||
- **Git integration** — mode-aware (06-history-undo.md, 07-sync-collab.md): on a mode-none board, the **add-git** action (opt-in init; on repo-nested boards replaced by the honest this-board-lives-inside-a-repository explanation — 06); on git boards, branch/source display, branch switching and creation, the commit-identity name/email fields (06), and **add/change remote** (a remote can be added or changed at any point — 07); for remote-backed boards additionally remote tracking (ahead/behind) with Pull/Push controls and the push-on-every-commit option. **Remote authentication surfaces inline here** (07 ▸ Remote authentication): credential fields on add/verify, the machine SSH key with Copy, and the Authentication-needed badge state.
|
||||
**The Theme tab** (settled 2026-08-07, its dedicated session; reworked same day from the first "Background" cut — the style-editor embed was in, then ruled out): a **preset-only** surface — no palette grid, no "Board" subtitle, no manual controls (the manual surface is Style… with nothing selected; the raw file remains the image escape hatch). One **Solid color / Pattern** segmented choice at the top, then the filters that apply to the chosen kind, then one carousel of the eight wheel hues with tall skinny chevrons flanking it (compact-chevron paging buttons, ~three swatches per press, each disabling at its end of the strip). **Solid color** shows Tone (defaults to the current appearance) and Saturation only; the carousel holds flat swatches — each hue at the selected tonality's base saturation and lightness, exactly the primary color the matching facets recipe would write — and clicking one sets `background.color` and *removes* `background.image` in one write (the generated PNG stays on disk so undo can restore the field that pointed at it). **Pattern** is the faceted-background picker (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery, the reviewed recipe): four filters — Tone and Saturation shared with Solid, plus Colors mono/duo/trio and Mesh coarse/medium/fine — over rendered previews, one seed per hue, plus a Reroll button that re-mints the seeds (filters change the treatment, Reroll the geometry; preview and file share a seed, so what's clicked is what lands). Clicking a pattern swatch renders the recipe at the 3072 px decode ceiling off-main and lands it in one write: the PNG into the board root as `facets.png` (Finder-ladder rename only when a foreign file owns the name), `background.image` pointed at it, and `background.color` set to the recipe's primary color — the underlay that stands in while the image decodes or if the file ever goes missing. The mode opens on whichever kind the board currently wears (Pattern when `background.image` is the generated file, Solid otherwise). **Backgrounds ship as static pixels, never live-rendered views** (the perf/sync ruling, 2026-08-07): the generator runs at pick time, the render loop only ever composites a decoded bitmap. Native undo restores the two fields, not overwritten bytes — regenerating over our own PNG is destructive, documented, and accepted. The whole tab disables under the read-only lock as one surface.
|
||||
|
||||
### Info tab (settled 2026-08-07)
|
||||
|
||||
The board's vital statistics, read-only, in two registers with one honest split: **model facts** off the live snapshot — Lanes, Cards, Attachments — counting *the board you see* (the welcome-count live-only rule; the Cards row grows a quiet "· N in Trash" tail only when the trash holds anything, counting freight the way the purge confirms do — never a standing "0 in Trash"); and **disk facts** off one background whole-folder walk — File (the `.board` folder's name), Files, Size, Created, Modified — counting *everything*, `.git` and `.trash/` included, because their job is to agree with Finder's Get Info about the same folder (a size that quietly excluded the repository would send a user hunting for missing gigabytes). Disk facts are honest-as-of-appearance, refreshed per tab visit, never live — `FolderWatcher` filters `.git` churn out of the reload stream by design, so there is no event they could honestly hang off, and a ticking size is motion without meaning on a settings surface; an em dash holds each disk row until the walk answers. Modified is the tree's newest content-modification date, directories included (a deletion-only change moves no file's mtime, only its parent folder's); Created is the folder's filesystem birth date, not frontmatter — the folder may predate any stamp in it. A **Reveal in Finder** link closes the tab — the rows describe the folder, and this is the door to it; not disabled under the read-only lock, since revealing is not a mutation.
|
||||
|
||||
### Git tab (settled 2026-08-07)
|
||||
|
||||
The pre-tab closing git section rehomed whole — and, later the same day, **the retired settings sheet's contents with it**: this tab is where a board's repository is both operated and set up (a repository-facts dossier in the Info register was considered this session and declined — the popover's git surface is for operating, and per-item history is the card History section's). The postures (06-history-undo.md ▸ Rules; 12-editions.md) render one tab surface each, with the tab's own label doing the work the section's "Git" header used to:
|
||||
|
||||
- **No repository** → one caption stating the fact, then **add-git** directly under it — the fact-then-offer posture blessed 2026-08-06, whose middle term was a **Board Settings…** door for the week the sheet existed *(amended 2026-08-07: the split reversed, so the offer is the control itself again)*. *(Pivot 2026-08-07 — 12: this is every tier's posture now, and git stays opt-in per board: the button is an offer, never an auto-init.)*
|
||||
- **Repo-nested** and **unverifiable** → their settled prose, no action (nothing setup-shaped can apply).
|
||||
- **Git mode** → the branch display with the **switch picker**, the abnormal-state notes (paused, unreadable, switch failure — 06), and a **Commit Identity** block under a heading VoiceOver navigates by *(rehomed 2026-08-07 from the sheet; withheld when the repository won't open, since writing an identity is a write into a repository the app can't open — the branch surface's own unreadable sentence stands alone there)*; on remote-backed boards, remote tracking (ahead/behind) with **Pull/Push** controls and the status badges (Authentication needed, queued pushes, last error) join as one more block under the branch controls (07-sync-collab.md's cards, whose own setup half needs a home now that the sheet is gone — open).
|
||||
- **Free + inert `.git`** and **absent** — *retired by the 2026-08-07 pivot (12: git left the paywall)*: the Pro pointer described a gate that no longer exists, and with no free-only postures every board resolves one of the three families above, so the tab is always in the strip.
|
||||
|
||||
**Branch creation is the switch menu's again** *(2026-08-07, reversing the 2026-07-31 relocation to the sheet)*: below the switch targets sits a divider and a **New Branch…** entry that reveals an inline name field with Create under the branch row — the shape creation had before the split, restored when the sheet retired. Escape steps outward one layer per press (a dirty field clears, an empty one closes the reveal, then the popover dismisses); create-and-switch runs 06's identical settle sequence, and its failures answer at the section's own caption.
|
||||
|
||||
**A single-branch board's picker opens onto a disabled explanatory row** (ruled 2026-08-06, built with the tab): the menu's upper half holds only the *other* local branches, and an unexplained gap above the divider reads as a menu that lost something — a disabled "No other branches" row says why there is nothing to pick. *(Amended 2026-08-07: the row taught a second thing while creation lived in the sheet — that creation was no longer here — and that half retires with the reveal's return.)* The popover is now the one configuration home; each control keeps exactly one home within it.
|
||||
|
||||
The window-title widget opens the **board popover** — the one board-level surface. **The widget is a two-line identity block** (reworked 2026-08-07): the board's glyph at roughly double its drawn text height, sized to span both lines, beside a stack whose first line is the board's name (titlebar weight, the string the window title would show) and whose second — **git-mode boards only** — is the current branch, smaller and secondary; then the disclosure chevron. It was one line reading `glyph Name — branch ⌄`, and the em-dash retired with the rework: a separator was doing a hierarchy's job, and the branch was competing for width with the name it qualifies. Both lines truncate at the tail inside the widget's 400pt cap; a board with no branch is the same block with one line, centered against the same glyph. Its header hosts, on every board:
|
||||
|
||||
- **Board rename** (settled: this function stays in-app, unlike the pathfinder which dropped it with the inspector). Rename edits the board's frontmatter `title` only — the folder is never renamed by the app; the Finder document name is Finder's to change (01-storage-format.md's board-naming rule). A foreign rename landing while the popover is open resyncs the field from the snapshot only while the field is unfocused — a focused field keeps the user's keystrokes, the dirty-buffer courtesy applied here (settled).
|
||||
- **The board glyph** — the symbol picker with its tint row beside the rename field owns the board's `icon`/`iconColor` (Styling ▸ Controls above); manual board styling beyond the glyph is Style… ⌥⌘S with nothing selected, and the Theme tab owns the preset backgrounds.
|
||||
|
||||
## Board settings sheet — RETIRED 2026-08-07
|
||||
|
||||
**The surface is gone, and the 2026-07-31 popover/sheet split with it.** The split's promise was one home per control across two committed surfaces; a week of it showed the cost — setup a user could only reach through a door, a second surface whose existence had to be validated before either door could point at it, and a menu command that did nothing but open it. So the popover is the board's **one configuration home** again: **add-git** and **commit identity** render inline in the Git tab's postures (▸ Git tab above), **branch creation** went back into the switch menu's New Branch… reveal, the sheet's **availability rule** retires with the surface it gated, and **Board ▸ Board Settings…** leaves the menu bar (11-command-nexus.md). Board Info ⌘I is the door to all of it. The mechanical arguments the split rested on stand as *unfinished business*, not as a case for the sheet: 07-sync-collab.md's credential and SSH surfaces still want confirmation alerts, inline network probes and drag-in key import, and where those live is that card's to rule — the popover is not obviously wrong for them (an alert can present over it), but nothing here decides it.
|
||||
|
||||
The retired ruling, kept for its reasoning:
|
||||
|
||||
**The setup home** (ruled 2026-07-31 — the popover/sheet split, 04-interactions.md's configuration carve-out): a board-scoped, titled, sectioned sheet on the board window, opened from the popover's Board Settings… row and from Board ▸ Board Settings… (11-command-nexus.md). It hosts everything setup-shaped: **add-git** (mode none; opt-in init — 06), **add/change remote** with the inline verify probe (07 ▸ Setup verifies right there), **credentials** — HTTPS username/token fields and the whole SSH surface (machine key Copy + Verify, key import by paste or **drag** — the sheet's stable frame is part of why it exists — the per-host key picker, unreferenced-import removal, confirm-gated machine-key regeneration), the TOFU first-connect confirm and mismatch block, **commit identity** name/email (06 — the visibility-scoped 2 s config re-read rides with the fields), **branch creation** (switching stays in the popover; create-and-switch runs 06's identical settle sequence from here), and **push-on-commit**. The mechanics that forced the split live comfortably here: confirmation alerts present over the sheet without dismissing the flow that owns them, network probes and their spinners survive focus changes, and typed-but-unverified credentials are never discarded by a stray click. Under the read-only lock the sheet's mutating controls disable in place (the Style-popover rule); every control is Tab-reachable and labeled (10-accessibility.md). Each control has exactly one home — the popover never duplicates a sheet control, the sheet never hosts the daily surface.
|
||||
|
||||
## Trash
|
||||
|
||||
Deletion is a two-stage, Finder-style story: ⌫ tombstones (01-storage-format.md), and the **trash quasi-lane** is where tombstoned items live on screen. It is a **pure view** — tombstoned cards keep their `deleted:` key and stay exactly where they are on disk; nothing about the storage schema is trash-specific.
|
||||
**Resettled 2026-07-28 — the materialized trash.** The tombstone model (a `deleted:` flag on items left in place, rendered by a pure-view quasi-lane) is **retired**: it generated a standing tax of nesting rules — ancestor walks, effective liveness, entry-vs-universe splits, kind-homogeneous selection — that this design replaces wholesale. Deletion is now a **move**: deleting a card moves its folder into **`<board-root>/.trash/`**, a reserved, materialized container (01-storage-format.md). A trashed card is an ordinary card in a special place — search, selection, rendering, styling, and clipboard all treat it exactly like any other card, and `.trash/` is self-describing in Finder and to agents.
|
||||
|
||||
- **Rendering**: trailing (rightmost) position, visually distinct — dimmed/hatched header, trash SF Symbol, count badge; no new-card button; not draggable, not resizable, excluded from lane reordering. It spans a **fixed one width unit** — no `width` frontmatter, and neither the stepper nor the edge drag applies — consumed only while shown: Show/Hide Trash is a re-divide trigger (Layout above), dividing the window across lane units + 1. A small window compresses like any lane add — accepted, not floored.
|
||||
- **Contents**: the board's tombstoned cards, sorted by `deleted` timestamp (newest first). A tombstoned *lane* appears as a single restorable entry — its cards were hidden with it, not individually tombstoned, and it restores as a whole.
|
||||
- **Visibility**: hidden by default; **View ▸ Show Trash** toggles it (⇧⌘T; stable title with checkmark state, per 04-interactions.md's configurable-bindings rules). Transient board-scoped state, held in the BoardStore (02-architecture.md; one board window per board, so board-scoped and per-window coincide today) — resets to hidden on open, not persisted (visiting the trash is an errand, not a layout choice). Hidden trash is invisible to search; shown, it participates in the filter like any lane.
|
||||
- **Put Back** (context menu, Finder vocabulary; ⌘⌫ on a tombstoned selection — Finder's own symmetry): removes `deleted:` — the item reappears in its lane at its old `order` (ties break deterministically). Putting back a card whose parent lane is tombstoned restores the lane too. Restore fidelity is perfect because nothing ever moved.
|
||||
- **Drag-to-restore**: dragging a card out of the trash into one of its own board's lanes restores it at the drop position (key removed, `order` set, folder moved only if the destination lane differs). Dropped on another board it follows the drag locality model (04-interactions.md) — a live copy by default, the tombstoned original staying put; ⌘-drag for the true restore-move.
|
||||
- **Keyboard, selection, and clipboard semantics** inside the shown trash (navigation, no mixed live/tombstoned selections, copy-out-only clipboard, inert moves) are specified in 04-interactions.md ▸ The trash, keyboard-first.
|
||||
- **No editing in the trash**: tombstoned cards don't open — double-click does nothing beyond selection; Put Back or drag out first (Finder vocabulary: the trash is for restoring or purging, not working). Tombstoning a card whose window is open dismisses that window (05-card-window.md).
|
||||
- **Delete Immediately** (per item, ⌥⌘⌫) and **Empty Trash…** (confirmed, ⇧⌘⌫) physically remove the folder(s) — Finder's trash trio throughout. **Delete Immediately confirms exactly where the loss is real** (settled): on boards without app-managed git history — mode none and repo-nested — the alert stands between one keystroke and unrecoverable deletion; on git boards it acts immediately, since the content remains reachable in history (06-history-undo.md's delete-never-forgets). A deliberate divergence from Finder's always-confirm: the prompt tracks actual recoverability, not ceremony. Empty Trash… confirms everywhere (bulk scope, not per-item recoverability, is what it guards). Time-based auto-purge remains a deferred follow-up (01-storage-format.md).
|
||||
- Every trash operation is an ordinary file write — auto-committed and undoable on git boards; on no-git boards the trash itself is the delete-recovery story (07-sync-collab.md).
|
||||
- **Naming constraint**: two "Trash" concepts coexist — attachment Remove moves the file to the *system* Trash (05-card-window.md), while card/lane deletion lands in this in-app quasi-lane. UI copy must keep them distinguishable: Finder's "Move to Trash" phrasing is reserved for the system Trash; board deletion says "Delete", and the quasi-lane is "Trash" / "Show Trash". Final strings settled in one naming pass when the trash UI copy is written.
|
||||
- **Lanes trash too** (re-ruled 2026-07-29, retiring "cards only" and with it the design's sole destructive delete): deleting a lane moves its folder — subtree intact — into `.trash/`, exactly as a card moves; `kind: lane` in its frontmatter is what tells a trashed lane from a card in the flat container (01-storage-format.md ▸ Deletion), stamped on the way in when absent. The no-dialog posture survives for a better reason: the move is recoverable, so nothing needs confirming. Native undo's inverse is the ordinary move back (13-native-undo.md — the capture/recreate machinery retires). **A trashed lane is an opaque unit**: one distinct dimmed row showing its title and held-card count ("Doing — 5 cards"), no styling accents, never expandable; its cards are invisible to search and not individually addressable — it restores whole or purges whole. The row matches the search filter by lane title only. **The held count is a load-time disk fact** (pinned 2026-07-31): counted from a subtree the snapshot deliberately does not hold — the model's one number not derivable from the model — refreshed by reload like every snapshot field (its changes speak in the digest, 10-accessibility.md); the freight confirm's "…and its 5 cards" is thereby the design's one confirmation counting unrendered content, honest as of the latest reload — the opaque-unit trade, deliberate. Lane rows and cards interleave in the one trash column by `modified` descending (the trash's sort — Entry below).
|
||||
- **Entry is always at the top — the trash sorts by `modified` descending** (re-ruled 2026-07-31, retiring the arrival rank mint): every arrival, card or lane — ⌫/⌘⌫ delete and drag-to-trash alike — lands at the top because the move **stamps `modified`** (a container-changing move — 01's `modified` scope; deletion is an edit to the card's story), and that stamp is the position: newest-first with no `order` rewrite, no rank minting, the item's `order` key riding along untouched for its eventual restore. Ties break by title (case-insensitive), then folder name. **Undated entries sort after every dated one** (blessed 2026-07-31 — the comments rule's rung applied here: malformed sorts after dated siblings), then fall to the same tail. **Whole-second stamps make batch deletes tie deliberately** (blessed 2026-07-31): `modified` serializes at second granularity, so a multi-item delete bracket — and any two deletes inside one wall-clock second — carries identical stamps and orders by the tail; "newest first" reading as title-order within a second is the accepted consequence, not worth a schema-wide move to fractional stamps. **The merged order is one derivation** (both kinds interleaved — the column, the keyboard grammar, and the path resolver all read the same sequence; a second implementation of "the row below this one" is a bug by definition). The stamp is also what a future age-based auto-purge will read (deferred, 01-storage-format.md).
|
||||
- **Rendering**: trailing (rightmost) position when shown, visually distinct — dimmed/hatched header, trash SF Symbol, count badge — **the badge counts rows** (blessed 2026-07-31): its one invariant is matching what the column draws, so a trashed lane counts as one whatever its freight; card-level totals surface where consequences are decided — the freight confirm and the spoken accessibility value; no new-card button; not draggable, not resizable, excluded from lane reordering. Fixed one width unit, consumed only while shown; Show/Hide Trash is a re-divide trigger (Layout above). **Visibility**: hidden by default; View ▸ Show Trash toggles (no default chord — ⇧⌘T belongs to the system's Show Tab Bar, 11-command-nexus.md); per-open transient state, resets to hidden, never persisted. Hidden, the trash is invisible to every gesture and to search; shown, its cards participate in the filter **exactly like any other card** — the point of the pivot.
|
||||
- **No Put Back** (settled): the valuable item is the card (or lane); where it goes on the way out is the user's cheap decision. Restoring is an ordinary move out: **drag** a trash card into any lane at any position — or a trashed lane row to a lane-strip slot — or **⌘X in the trash, ⌘V** — into a lane for cards, after the anchor lane for a trashed lane (04-interactions.md's lane-paste rule verbatim); the clipboard works on trash items like on any item, which is also the keyboard-native restore path (10-accessibility.md). Dropped on another board it follows the drag locality model (04-interactions.md).
|
||||
- **No editing in the trash**: trash cards don't open — double-click stops at selection; move it out first — and a trashed lane row never expands. Moving a card to the trash dismisses its open card window (05-card-window.md), and an external move-in observed by reload does the same; a lane entering the trash dismisses the open card windows of every card it carries (they entered the trash with it).
|
||||
- **Permanent deletion**: on a trash selection, **Delete (⌫/⌘⌫) is permanent** — one delete vocabulary, staged by place: on the board it moves to the trash, in the trash it removes the folder. **Delete Immediately is deliberately absent** (removed 2026-07-30): Finder's ⌥⌘⌫ answers disk-space pressure boards don't have, and it was the one gesture reaching unrecoverable straight from the board — permanence is only reachable inside the trash, where the staging makes the loss visible. The trash-side Delete **confirms exactly where the loss is real** (carried over): on boards without app-managed git history the alert stands between one keystroke and unrecoverable deletion; on git boards it acts immediately (delete-never-forgets). Confirms name the freight honestly — a trashed lane's alert counts its cards ("Permanently delete lane 'Doing' and its 5 cards"). **Empty Trash…** (⇧⌘⌫) confirms everywhere and purges the whole `.trash/`, lane subtrees walked, search-independent, the confirmation naming the full count ("Permanently delete 41 cards", "… 41 cards and 2 lanes containing 9 more cards" — 06-history-undo.md's plural folding); menu validation's "non-empty" reads `.trash/`, not the filtered view.
|
||||
- Every trash operation is an ordinary file operation — auto-committed and undoable on git boards; native undo restores a delete-to-trash in-session on any board (a trash move undoes as a move back — durable across relaunch, since it is just a move), while a permanent delete registers no step (13-native-undo.md: the confirm is the safety).
|
||||
- **Naming constraint** (carried over): attachment Remove moves files to the *system* Trash (05-card-window.md); board deletion says "Delete" and this container is "Trash" / "Show Trash" — Finder's "Move to Trash" phrasing stays reserved for the system Trash.
|
||||
- **Materialized reserved lanes are a pattern, not a one-off**: `.trash` is the first; an **archive lane** (`<root>/.archive`, intake criteria to be designed) is the anticipated second (WISHLIST) — same mechanics, ordinary cards in a reserved dot-named container, different entry semantics. Nothing beyond `.trash` is committed yet.
|
||||
|
||||
## Welcome screen & templates
|
||||
|
||||
@@ -77,7 +107,7 @@ The welcome window carries over from the pathfinder unchanged — confirmed, it
|
||||
|
||||
- Welcome: resizable, no title bar (background drag); recents list with board icon, name, location, counts; single click selects, double click opens; context menu Open / Reveal in Finder / Forget.
|
||||
- **Templates**: New Board (⌥⌘N — ⌘N is new *card*; 11-command-nexus.md) opens a Pages-style chooser with a mini per-lane preview per template. Inventory and definition format: 09-templates.md.
|
||||
- File menu: Open Recent (with Clear Menu; available everywhere), and Duplicate (⇧⌘S) — **board window only** (11-command-nexus.md), duplicating the frontmost open board to a Finder-style "copy" sibling; it never acts on a welcome-selected recent. The copy is preceded by the close flush (02-architecture.md ▸ Windows; the rule and its Edit-session exception are stated at 09-templates.md ▸ Save as Template), so neither the tree nor the copied history misses pending work. The duplicate **opens in its own board window** once copied — macOS Duplicate convention; the original stays open too. On a git board, the duplicate **keeps `.git` but has its remote configuration stripped** — remotes only: the repo-local `user.name`/`user.email` (06-history-undo.md's identity home) survives, so the fork keeps its commit identity. The copy keeps every GUID — a whole-board copy is 01-storage-format.md's explicit carve-out from the copies-remint rule (a new identity namespace, no collision possible), and keeping them is what keeps the copied history true: its commits name paths that still exist. A fork of the board keeps its history (undo trail, delete-never-forgets — and it opens straight in git mode via 06-history-undo.md's adoption rule), but it must not silently push into the original's remote — sharing stays a deliberate per-board opt-in. (Push-on-commit lives app-side in the board registry and never carries to a new board path anyway.)
|
||||
- File menu: Open Recent (with Clear Menu; available everywhere), and Duplicate (⇧⌘S) — **board window only** (11-command-nexus.md), duplicating the frontmost open board to a Finder-style "copy" sibling; it never acts on a welcome-selected recent. **A sandbox refusal of the sibling write falls back to a save panel** (settled — the board's security-scoped bookmark grants its subtree, not its parent, so the sibling destination may be unwritable): the silent Finder-style sibling is attempted first; on a permission refusal a save panel opens pre-filled with the parent folder and the "copy" name — the panel's grant is the sandbox's own answer, and it doubles as a choose-another-location affordance. Cancelling the panel cancels the duplicate quietly (no banner — the user declined, nothing failed); non-permission failures (disk full, …) keep the ordinary one-shot banner. **The copy itself is cancellable** (settled — 02-architecture.md's in-progress banner promises Cancel on copy-shaped work, and Duplicate honors it): the copy runs as a per-item file walk that checks cancellation between items — never one monolithic `copyItem` — and Cancel removes the partial sibling before dismissing the banner (the attachment partial-cleanup precedent): a cancelled duplicate never happened. The copy is preceded by the close flush (02-architecture.md ▸ Windows; the rule and its Edit-session exception are stated at 09-templates.md ▸ Save as Template), so neither the tree nor the copied history misses pending work; under the read-only lock Duplicate disables in every state (02-architecture.md — the flush can't run and the sibling destination shares the board's fate). The duplicate **opens in its own board window** once copied — macOS Duplicate convention; the original stays open too. On a git board, the duplicate **keeps `.git` but has its remote configuration stripped** — remotes only: the repo-local `user.name`/`user.email` (06-history-undo.md's identity home) survives, so the fork keeps its commit identity. The copy keeps every GUID — a whole-board copy is 01-storage-format.md's explicit carve-out from the copies-remint rule (a new identity namespace, no collision possible), and keeping them is what keeps the copied history true: its commits name paths that still exist. **The trash is carried too** (settled, re-grounded 2026-07-28): Duplicate is a full fork, `.trash/` included — dropping it would leave the copy's working tree disagreeing with its own copied HEAD (the trash folders are tracked), where keeping it means the duplicate is born exactly matching its history; Empty Trash in the copy is one command away. Save as Template makes the opposite choice — a template isn't a fork (09-templates.md). A fork of the board keeps its history (undo trail, delete-never-forgets — and it opens straight in git mode via 06-history-undo.md's adoption rule), but it must not silently push into the original's remote — sharing stays a deliberate per-board opt-in. (Push-on-commit lives app-side in the board registry and never carries to a new board path anyway.)
|
||||
|
||||
## Editing surfaces summary
|
||||
|
||||
@@ -87,7 +117,7 @@ The welcome window carries over from the pathfinder unchanged — confirmed, it
|
||||
| Card body | Card window (05-card-window.md) |
|
||||
| Lane title | Inline rename on the header |
|
||||
| Colors / icons | The style editor — card sidebar Style section (05-card-window.md), board popover, or Style… (context menu / Board ▸ Style…) |
|
||||
| Lane width | Edge drag + header context-menu picker |
|
||||
| Lane width | Edge drag + header context-menu stepper + Increase/Decrease Lane Width |
|
||||
| Board title, board styling | Board popover |
|
||||
| Board/lane descriptions (bodies) | File-only — hand-edit `index.md`; live-reload reflects it |
|
||||
|
||||
@@ -100,9 +130,9 @@ The pathfinder's animation behavior carries over as the committed motion languag
|
||||
- **Equivalent operations share one dialect.** Paste animates exactly like a drop commit (same curve, same duration) so the clipboard's move story *feels* like drag landing; keyboard one-slot moves slide for the same reason a drop does — an item that teleports is harder to follow than one that slides; cut dims the card in place, Finder-style, until paste moves it (04-interactions.md).
|
||||
- **Appear/disappear is scale + fade** (cards scale from ~0.8, lanes ~0.9, combined with opacity). A restore that moves a card across lanes flies it from old frame to new via matched geometry. Search-hiding rides the same structural transition — hiding is removal, not a special fade.
|
||||
- **Some things deliberately never animate**: the rubber-band marquee tracks the cursor 1:1 (an eased band visibly lags the mouse), and the selection highlight rides whatever transaction is active rather than easing on its own.
|
||||
- **Animated transactions are keyed narrowly** — on the sole-selected card (carousel expansion), on the search query (filter reflow), on the drag's **drop proposal** (the reflow-to-make-room above animates under it, ~0.18 s) — never on broad state like the selection set. What stays animation-free by construction rather than by suppression: the drag replica and the marquee rectangle (1:1 cursor tracking — animating input echo would be lag), and multi-select churn.
|
||||
- **Animated transactions are keyed narrowly** — on the search query (filter reflow) and on the drag's **drop proposal** (the reflow-to-make-room above animates under it, ~0.18 s) — never on broad state like the selection set (selection changes styling only, never geometry — Card face above, the no-carousel resettlement). What stays animation-free by construction rather than by suppression: the drag replica's tracking and the marquee rectangle (1:1 cursor following — animating input echo would be lag), and multi-select churn. **The replica's bracketing transitions do animate** (settled): the pickup lift (scale + shadow as it detaches from the card) and the cancel fly-back are the system drag session's own behaviors and match the spec verbatim; only the tracking between them is verbatim input echo. **The drop settle is the board's, not the replica's** (resettled 2026-07-28 — drags are system `NSItemProvider` sessions, required for cross-board transfer and the copy badge, and a successful drop's drag image has no fly-to-slot hook, only AppKit's brief fade): at release the held overlay (below) renders the dropped arrangement instantly while the system fade dissolves the drag image over it — the item is in its slot the moment the mouse releases, which is the promise that matters. A custom fly-to-slot animator (shadow-window replica, masked system fade) remains a deliberate later upgrade, not a commitment (WISHLIST). **The settle holds the drop proposal until the echo lands** (settled — the one-way flow means the write is still in flight at release, and a snapshot-order re-render would glide the dragged item back before the reload animates it forward again): the proposal survives release as overlay state in the app-wide DragSession (02-architecture.md — the placeholder's kin in semantics; app-wide in home because a drag crosses boards), the board keeps rendering the proposed arrangement under the system fade (the drop settle above) — **the release presentation is an open question** (reopened 2026-07-28): the first treatment — swapping the shadow for the dropped card(s) drawn in place immediately at release — was implemented and backed out on user review; the pause between release and the card's appearance still wants a designed answer, revisited separately (Redesign board ▸ Issues to Resolve). Until then the shadow holds through the gap and the card appears at the echo — and the proposal discards itself when the bracket's echo reload lands — positions already match, so the handoff moves nothing. A **failed write discards the proposal** and the board animates back to snapshot order with the ordinary one-shot banner — the width-drag rollback posture (the action visibly doesn't happen); a foreign reload that vanishes the dragged item discards it too (02-architecture.md's constraint rule).
|
||||
- **Motion never feeds back into logic** (the pathfinder's animation-proof-inputs rule, kept as a hard constraint): drop-proposal math reads analytically computed resting zones, the physical mouse position, and item sizes frozen at drag start — never mid-flight measured frames, which are garbage precisely during the ~0.2 s reflow they trigger.
|
||||
- **Reduce Motion is a rewrite obligation, not an inheritance**: the pathfinder ships zero reduced variants; 10-accessibility.md's commitments (crossfade or instant for reflow, search animate-out, the drag replica, trash) are new work.
|
||||
- **Reduce Motion is a rewrite obligation, not an inheritance**: the pathfinder ships zero reduced variants; 10-accessibility.md's commitments (crossfade or instant for reflow, search animate-out, the drag replica's lift and settle, the lane-resize rubber-band feedback, trash) are new work.
|
||||
|
||||
## Changes from Kanban
|
||||
|
||||
|
||||
+37
-32
@@ -4,9 +4,11 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
|
||||
|
||||
## Selection
|
||||
|
||||
- Cards: click selects; cmd-click toggles; shift-click range-extends; click-drag rubber-bands across lanes. Lanes: cmd/shift-click multi-select.
|
||||
- Selection is **homogeneous**: cards XOR lanes.
|
||||
- Lane empty-space: single click selects the lane (click again to unselect); double click creates a card at the bottom, title editor focused.
|
||||
- Cards: click selects; ⌘-click toggles; ⇧-click range-extends; click-drag rubber-bands across lanes. Lanes: ⌘/⇧-click multi-select.
|
||||
- **The range anchor** (settled — standard macOS list semantics): the anchor is the last plain- or ⌘-clicked item, per board window, transient — never persisted. ⇧-click ranges from anchor to target in the flatten order (cards), lane order (lanes), or the trash's own order — rows of both kinds included, since trash selection went kind-blind (re-ruled 2026-07-31; The trash below) — replacing the selection and leaving the anchor in place. A marquee and wholesale selections (Select All) set no anchor, so a following ⇧-click acts as a plain click; a reload that drops or liveness-flips the anchor clears it.
|
||||
- Selection is **homogeneous**: cards XOR lanes — on the live board. The trash's selection is **kind-blind** (re-ruled 2026-07-31; The trash below): cards and lane rows select together there, and the guard lives at the exits instead.
|
||||
- **Board background** — the margins around and between lanes, and below short content (settled): a plain click clears the selection — the pointer twin of Escape's deselect, Finder's behavior; modified clicks (⇧/⌘) are no-ops there — extension needs an item to extend to; the background is also a rubber-band origin surface on the live side, alongside lane empty space (live) and the trash column's empty space (trashed) — which extends the full column height below the last row, card and lane rows alike (re-affirmed 2026-07-29; the rewrites dropped the clause, the ruling never changed): no dead zone, a band can arm from anywhere in the shown trash's column.
|
||||
- Lane empty-space: single click selects the lane (click again to unselect); double click creates a card at the bottom, title editor focused. **The lane header is click-to-select too** (settled — a full lane has no empty space left): a plain click on the title bar selects the lane — **and toggles like empty space** (settled): a click on the already-selected lane's header unselects, one lane-click behavior everywhere, so a full lane keeps a pointer path out of selection; the drag surface (03-board-ui.md ▸ Lane) engages only on movement — the click-vs-drag split cards already have.
|
||||
- **Clicking never edits** (pivot from the pathfinder's Finder-rename two-stage click): one click selects, and that is all a single click ever does — no slow-second-click rename, no timers, no accidental edit on a hesitant click. Inline rename is **Return** on a sole selected card, or Board ▸ Rename — the menu item is a lane's only rename path, since Return on a lane creates a card (Grammar below). A fast double-click opens the card window (⌘↩'s pointer twin). Committing an empty rename on an existing item removes its `title` key (titles are optional; the face shows the untitled placeholder).
|
||||
|
||||
## Drag & drop
|
||||
@@ -15,17 +17,18 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
|
||||
- Cards reorder within a lane and move between lanes (folder move). Lanes reorder; a full-size replica travels under the cursor.
|
||||
- **Multi-drag**: dragging any member of a multi-selection drags the whole selection; N contiguous shadows; drop inserts contiguously in preserved relative order — defined, for any multi-selection, as lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, top-to-bottom).
|
||||
- **Locality picks the default — the Finder volume model** (settled): within a board a drag is a **move** (rearranging); between boards it is a **copy** (transferring — the system copy badge shows over the foreign board). **⌥ always forces copy** and **⌘ always forces move**, Finder's exact modifier grammar; each is a no-op where its behavior is already the default. The badge tracks the effective operation live as the cursor crosses a board boundary.
|
||||
- **Within-board ⌥-drag copies**: originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop. Lane drags never copy *within their board* — a lane duplicate inside its own board stays unsupported; ⌥ is simply ignored there (the drag stays a move and the badge never shows copy).
|
||||
- **Cross-board copy** (the default): cards and lanes (including multi-selections) drag between open boards; fresh-GUID duplicates land at the drop, originals stay, `created` is kept (a copy is a fork — 01-storage-format.md). Lanes copy cards and all — transferring workflow structure between boards is safe by default. A lane copy **strips tombstoned cards**: the copy transfers content, and trash isn't content (09-templates.md's instantiation precedent — a board isn't born with trash); the tombstoned originals stay recoverable in the source board. A ⌘-drag *move* carries them whole — the folder moves as-is, and they land in the destination's trash.
|
||||
- **Within-board ⌥-drag copies**: originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop. Lane drags never copy *within their board* — a within-board lane duplicate is **not available by drag** (⌥ is simply ignored there: the drag stays a clean reorder and the badge never shows copy); the duplicate itself is supported, via the clipboard (Lane paste below) — the usual shape: the keyboard path is the canonical one, drag the enhancement (10-accessibility.md).
|
||||
- **Cross-board copy** (the default): cards and lanes (including multi-selections) drag between open boards; fresh-GUID duplicates land at the drop, originals stay, `created` is kept (a copy is a fork — 01-storage-format.md). **Locality means the board the drag was picked up from** (ruled 2026-08-06): a drag knows where it came from — the Finder volume model — so a rename or Finder move of the source board absorbed mid-drag is not a departure: the relocation carries the live drag with it (the drag holds its source *store's* identity, not a frozen copy of its key — a frozen key would let a new board opened at the vacated path compare equal to the renamed-away one, turning a copy into a silent cross-board move, the worse failure), and a within-board reorder stays a reorder across a mid-drag rename. Until the carry ships, the recorded residue — the minted key follows the folder, so a mid-drag rename finishes a reorder as a copy — is cosmetic and bounded by the seconds a drag is in flight. Lanes copy cards and all — transferring workflow structure between boards is safe by default. A lane carries exactly its cards — the trash is board-level (`.trash/` — 03-board-ui.md), so there is nothing lane-nested to strip or carry: copy and ⌘-drag move alike transfer the lane's folder as it is (resettled 2026-07-28; the old tombstone-stripping rule is retired with the tombstone model).
|
||||
- **Cross-board move** (⌘-drag): a real filesystem move, works across volumes — identity travels. A moved folder whose UUID already exists in the destination board arrives as a fresh-UUID copy (01-storage-format.md's import-boundary rule); in a compound move (lane with cards, multi-selection) only the colliding folders are reminted — the rest is a true move (01's per-folder degradation).
|
||||
- **Files from Finder**: dropped on a card → copied into its `attachments/` (any type, multi-file; card highlights while hovered). Dropped on lane empty space → creates a card with the file attached, titled with the filename without its extension (multi-file drop: one card per file).
|
||||
- **Files from Finder**: dropped on a card → copied into its `attachments/` (any *file* type, multi-file; card highlights while hovered). Dropped on lane empty space → creates a card with the file attached, titled with the filename without its extension (multi-file drop: one card per file). **Folders are refused at hover** (settled — the attachment model is flat top-level files, and the importer refuses directories by design): a drag containing only folders never engages — no highlight, no drop proposal, the standard incompatible-payload read; a mixed drag proposes for its files only, and the drop imports the files while a loss row (02-architecture.md's warning tone) names the skipped folders ("Folders can't be attached — 2 skipped"). The create path thereby only ever fires with at least one importable file — no card is minted for an import that cannot succeed. **Created cards land at the drop position** (settled): resolved through the same card-grid zones an ordinary card drag uses, shadow included — drops are positional everywhere, and append-at-bottom stays the creation *trio's* rule, not the drop's. A multi-file drop shows **one nominal-height shadow per incoming file** (the multi-drag precedent; when macOS withholds item counts during hover the count floors at one shadow, the commit unaffected). **A release on the lane header resolves to the topmost position** (settled — forgiving beats a dead stripe: the header's chrome roles don't collide with a file payload). **The landing shadow is the create path's whole feedback** (settled): no lane-level highlight on top — each target gets one clear signal, and the card-attach highlight exists precisely because that target has no shadow.
|
||||
- **A foreign reload mid-drag re-grounds the drag, never corrupts the drop** (settled — a two-second drag racing agent edits is the designed concurrency). Three rules compose: (1) **geometry re-derives** — the frozen-at-drag-start inputs are the *dragged items'* sizes and the physical pointer only (03-board-ui.md ▸ Motion); the analytic resting zones recompute against each new snapshot, so a foreign lane-count re-divide mid-drag just moves the zones and the next proposal targets the board as it now is. (2) **Proposals re-validate by liveness** — a proposal whose target lane vanished in the reload is invalidated; the shadow withdraws and no proposal stands until the pointer reaches a live target, and **release with no valid proposal cancels** — items return, nothing is written; a card is never filed under a vanished parent. (3) **An emptied drag cancels itself** — drag membership is already a UUID set that vanished items leave silently (02-architecture.md); when the *last* dragged item vanishes the replica dissolves and release is a no-op. Partial vanishing drops the survivors, matching the pending-cut precedent. **A cross-board lane arrival pre-divides the destination strip during hover** (settled): while a foreign lane drag proposes into a board, the destination's standard width is computed with the arriving run's units included, so the shadow draws at the width the lane will actually take — without this it overflows the strip (the pathfinder's stripWidthUnits). The first entry samples the un-widened standard for one frame before hysteresis settles — accepted, imperceptible.
|
||||
|
||||
## Clipboard
|
||||
|
||||
- ⌘X/⌘C/⌘V on cards **and lanes** (resettled — lanes joined the clipboard so cross-board structure transfer has a keyboard path under the every-function contract; the cards-XOR-lanes selection rule means the clipboard holds cards or lanes, never both). Hybrid clipboard: pasteboard carries a JSON manifest + plain text; full folder snapshots staged in Application Support so paste reproduces the item byte-for-byte — cards, attachments and all — across boards. Each manifest entry embeds the full `index.md` as a staging-less fallback (a lane entry embeds its cards' too, attachment-less).
|
||||
- **Cut is Finder-style deferred**: cut items dim in place until paste moves them; voided if another app takes the pasteboard or the source board closes; second paste materializes copies. **Deletion voids per item**: a cut item that is tombstoned or vanishes externally before paste drops out of the pending cut — 02-architecture.md's UUID-set rule; transient state never resurrects what's gone — so paste moves only the survivors, and a cut voided down to nothing is simply void (paste disabled, no error).
|
||||
- Paste lands after the anchor card (or appends to a selected lane). Copies keep `created` (a duplicate is a fork) and take fresh GUID/`order`/`modified`.
|
||||
- **Lane paste** lands after the anchor lane — the selected lane, or the selected card's lane; nothing selected = the board's right end. Semantics mirror the drag pair above exactly: a pasted *copy* takes fresh GUIDs throughout and **strips tombstoned cards**; a cut-paste is the ⌘-drag move — the folder moves whole, tombstoned cards landing in the destination's trash.
|
||||
- ⌘X/⌘C/⌘V on cards **and lanes** (resettled — lanes joined the clipboard so cross-board structure transfer has a keyboard path under the every-function contract; the cards-XOR-lanes selection rule means the clipboard holds cards or lanes, never both). Hybrid clipboard: pasteboard carries a JSON manifest + plain text; full folder snapshots staged app-side (02-architecture.md ▸ Per-board app state's app-wide home) so paste reproduces the item byte-for-byte — cards, attachments and all — across boards. Each manifest entry embeds the full `index.md` — identification metadata (menu validation, refusal wording, the plain-text flavor's source), **never a materialization source** since the 2026-07-29 refuse-don't-degrade ruling below (a lane entry embeds its cards' too). **Staging lifecycle** (settled): snapshots are staged **eagerly at ⌘C/⌘X time** — copy captures the source as it is at the gesture, immune to later deletion or unmount — and the store holds at most the *current* copy: a new Lanework copy replaces the previous snapshot, and a sweep at launch and on each copy purges entries the pasteboard no longer references (another app taking the pasteboard orphans the snapshot; the next sweep collects it). The snapshot survives relaunch exactly as long as the pasteboard still points at it — a copy made before quitting pastes whole after restart. **A paste is an import boundary, so normalization applies** (settled 2026-07-28 — 01-storage-format.md's loose-file rule): loose files the staged snapshot carries beside a card's `index.md` land in the pasted card's `attachments/`, Finder-renamed on collision — nothing the snapshot preserved is dropped on arrival. **A paste whose staged snapshot is missing or unreadable refuses loudly — never degrades** (re-ruled 2026-07-29, retiring the degraded embedded-`index.md` fallback and its loss row; Finder's invariant adopted, and 01's leniency doctrine applied — proceed-partially-lose-a-little is never a verdict): the paste produces **nothing**, and a one-shot failure banner names it from the manifest's metadata ("The copied cards are no longer available" / "Couldn't paste 'Fix login' — the copied content is gone"; BannerCenter owns the phrasing). An item arrives **whole — index, attachments, loose files, and comments when they ship — or not at all**; a hollowed card is never materialized, so the loss-accounting problem (what didn't arrive, and whether the totals are honest) dissolves rather than being solved. The refusal is transactional — all-or-nothing for the whole paste, the copies-are-transactions posture (01). With eager staging and the shared-store sweep discipline this is a rare corner, not a flow: the refusal names it, and ⌘C again is the recovery. **The pasteboard is re-read lazily, and the brief lie is accepted** (settled): changeCount is checked on activation, on menu validation, and before paste — no timers; a background app taking the pasteboard while Lanework stays frontmost can leave Edit ▸ Paste enabled until the next check, and the paste itself re-validates and no-ops — nothing stale ever lands, which is the guarantee that matters.
|
||||
- **Cut is Finder-style deferred**: cut items dim in place until paste moves them; voided if another app takes the pasteboard or the source board closes; second paste materializes copies. **Deletion voids per item**: a cut item that is deleted (moved to the trash or destroyed) or vanishes externally before paste drops out of the pending cut — 02-architecture.md's UUID-set rule; transient state never resurrects what's gone — so paste moves only the survivors, and a cut voided down to nothing is simply void (paste disabled, no error).
|
||||
- Paste lands after the anchor card (or appends to a selected lane); a multi-selection anchors at its last member in flatten order — the ⌘N target rule's shared anchor (The map below). Copies keep `created` (a duplicate is a fork) and take fresh GUID/`order`/`modified`. **A trash selection never anchors paste** (settled — the ⌘N target rule's own wording, returned to the precedent it cites): ⌘V stays enabled and behaves exactly as with nothing selected — a card payload appends to the last-active lane, a lane payload lands at the board's right end; the trash is never the destination (▸ The trash), and a trashed card's live disk-lane never leaks in as "the selected card's lane".
|
||||
- **Lane paste** lands after the anchor lane — the selected lane, or the selected card's lane (several selected: the last, per the shared anchor rule); nothing selected = the board's right end. Semantics mirror the drag pair above exactly: a pasted *copy* takes fresh GUIDs throughout; a cut-paste is the ⌘-drag move — the folder moves whole (nothing lane-nested to strip or carry — the trash is board-level, resettled 2026-07-28). **Pasting into the source board is supported and is the within-board lane duplicate** (settled): fresh GUIDs apply as anywhere else, no menu-validation special case — the drag path deliberately lacks this operation (⌥ ignored on lane drags, above), the clipboard is its one home.
|
||||
|
||||
## Keyboard
|
||||
|
||||
@@ -33,12 +36,14 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
|
||||
|
||||
### Grammar (fixed keys — deliberately not remappable)
|
||||
|
||||
- **Arrows**: spatial card navigation (nearest card in the direction, across interior grid columns and lanes); with a lane selected, ←/→ move lane selection; ⇧-arrow extends; selection scrolls into view; all grammar keys inert while a title editor is focused, and menu dispatch narrows to the text domain (focused-editor rule below).
|
||||
- **⌥-arrows jump**: ⌥↑/⌥↓ to the current lane's first/last card; ⌥←/⌥→ to the first/last lane.
|
||||
- **Arrows**: spatial card navigation (nearest card in the direction, across interior grid columns and lanes); with a lane selected, ←/→ move lane selection; ⇧-arrow extends — except **⇧↑/⇧↓ in the lane domain, which are inert** (settled: there is nothing above the lane domain and no vertical range within it); selection scrolls into view; all grammar keys inert while a title editor is focused, and menu dispatch narrows to the text domain (focused-editor rule below).
|
||||
- **⌥-arrows jump**: ⌥↑/⌥↓ to the current lane's first/last card; ⌥←/⌥→ to the first/last lane. **The horizontal jumps land on a card** (settled — ⌥↑ is the keyboard's one entry to lane selection, so ⌥←/⌥→ never select the lane itself): the first card of the first/last *non-empty* lane, scanning inward past empty lanes; ⌥→ prefers the shown non-empty trash — its first entry — per the last-container rule (The trash below). **⌥↑ escalates into the lane domain** (settled — the keyboard's one entry to lane selection): with the lane's first card already selected, ⌥↑ selects the *lane* itself — up in the hierarchy sense, the same key one press deeper; with a lane selected, ↓ (or ⌥↓) descends back into its cards at the first (last) card, and ⌥↑ is inert. **An empty selection seeds at the first lane's first card** on any plain arrow (deterministic origin; the ⌥-jumps behave as specified regardless) — two ⌥↑ presses from nothing reach the lane domain.
|
||||
- **Return** on a selected lane: creates a card at its bottom, editor focused; Return commits and re-selects the lane (next Return = next card); ⌘↩ commits and opens the card window. Abandoned placeholders (Escape, empty commit, click-away) are discarded — creating-then-abandoning never leaves an empty card behind (untitled cards exist only when made deliberately, e.g. by an external writer or by clearing an existing title). The placeholder is store-transient overlay state — the named exception to 02-architecture.md's one-way flow; nothing exists on disk until the title commits.
|
||||
- **Inline rename tracks its target by UUID, and vanishing discards it** (the placeholder and card-window kin rules — 02-architecture.md — applied to the third inline editor): a foreign *move* mid-rename is invisible — the editor follows the UUID and the commit writes the title wherever the card now lives; a target that is trashed, deleted, or gone at commit time discards the editor and its keystrokes silently (entering the trash is a vanish from the board; nothing is ever written into a vanished folder). A write that fails *after* a valid commit is the ordinary one-shot write-failure banner. VoiceOver announces the vanished target per 10-accessibility.md's recovery rule.
|
||||
- **Return** on a sole selected **card**: inline rename. Return disambiguates on card selection — sole card = rename, lane = create (above) — and is **inert on a multi-card selection**; a lane's rename path is Board ▸ Rename. **Escape** steps outward one layer per press: abandons an open editor; else clears search, returning focus to the board (Search below); else **clears the selection** — the keyboard deselect.
|
||||
- **Focused editor = text domain** (settled): while an inline title editor — rename or the new-card placeholder — is focused, board-scoped menu commands (Delete, New Card, Paste, Move, Style, …) disable via menu validation; text-domain chords route to the field as standard text ops — ⌘Z/⇧⌘Z are the editor's text undo (06-history-undo.md ▸ Undo routing), ⌘X/⌘C/⌘V/⌘A act on the text. The one board-command carve-out is **Open Card ⌘↩**, which stays enabled to commit the edit — placeholder or rename — and open the card window. Exits are otherwise unchanged: Return commits, Escape abandons; click-away splits by editor kind — a **rename commits** (focus loss = commit, matching the card window's title field in 05-card-window.md and the branch-switch parenthetical in 06-history-undo.md), while the **placeholder discards** per its rule above, the deliberate exception because nothing exists on disk yet.
|
||||
- **⌫** on a live selection: delete (tombstone) — the plain-key synonym for File ▸ Delete ⌘⌫ (see The map). Grammar, not a menu item: giving it a menu home would require a second "Delete"-titled item, which would collide for title-matched remapping (Configurable bindings). Inert while a title editor is focused, like every grammar key.
|
||||
- **Caret chords yield to any focused text control** (settled): Board ▸ Move Left/Move Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable via menu validation whenever *any* text control has keyboard focus — inline title editors, the board search field, board-popover fields (rename, git identity, remote), and card-window fields — because an enabled menu key equivalent fires before the field ever sees the key, and ⌘←/⌘→ are the standard line-start/end caret chords. Caret motion always wins in text (the Safari pattern: ⌘← is Back, yet moves the caret while a field is focused); the lane commands re-enable the moment focus returns to the board. This is a narrow, per-command broadening of the focused-editor rule, not a general one: board commands whose chords carry no text meaning keep their surface-specific dispatch — in particular the search field's board-commands-stay-enabled rule (Search below) — and the search field's explicitly ruled ⌘⌫ steal (File ▸ Delete, not delete-to-line-start) stands.
|
||||
- **⌫** on a selection: delete — the plain-key synonym for File ▸ Delete ⌘⌫, staged by place like the menu item (see The map). Grammar, not a menu item: giving it a menu home would require a second "Delete"-titled item, which would collide for title-matched remapping (Configurable bindings). Inert while a title editor is focused, like every grammar key.
|
||||
- The card window speaks the same grammar: **Return** in Preview enters Edit, **Escape** returns to Preview (05-card-window.md) — plain keys, not menu items.
|
||||
- These plain-key behaviors are platform grammar (Finder's own Return/arrows aren't remappable either) and sit below the remapping mechanism, which handles modifier chords on menu items only — see Configurable bindings.
|
||||
|
||||
@@ -46,32 +51,32 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
|
||||
|
||||
Every command is a menu item. The full inventory — every command and action, its default binding, applicable context, and customizability class — lives in **11-command-nexus.md**, the single source of truth for what the app can do; the command titles there are the stable strings the remapping mechanism keys on (Configurable bindings below). The rules below are the behavior behind those bindings and stay normative here.
|
||||
|
||||
- **⌥⌘↑/⌥⌘↓ sort within the lane** (the move-vs-jump question, resettled: moves live on the ⌥⌘ chord, joining ⌥⌘←/⌥⌘→ lane width in a "⌥⌘ modifies" family; plain ⌥-arrows stay jumps; plain ⌘↑/⌘↓ are unassigned): the selected card(s) move one position within the lane — logical `order`, across interior masonry columns (10-accessibility.md's logical-order rule). A non-contiguous multi-selection **gathers on the first press**: the cards collect into a contiguous block anchored at the first selected card (first = lowest logical order; the rest follow in preserved relative order), and subsequent presses move the block one position. **Cards never change lanes by ⌘-arrow** (settled): inter-lane movement is drag or Cut/Paste (the clipboard rules above), so ⌥⌘↑/⌥⌘↓ disable when a card selection spans lanes and ⌘←/⌘→ are inert on card selections. With a **lane** selected, ⌘←/⌘→ move the lane one slot — closing 10-accessibility.md's lane-move defect — and ⌥⌘↑/⌥⌘↓ are inert.
|
||||
- **⌫/⌘⌫ delete** (unchanged): tombstone into the trash quasi-lane (03-board-ui.md); lanes included, no dialog. Selection moves to the deleted item's successor sibling, Finder-style (next card in the lane, next lane on the board; the last sibling's predecessor otherwise; empty container = nothing selected) — repeated ⌫ walks down a lane. Deliberate deletes pick a successor; *external* vanishing never does (02-architecture.md's reload-survival rule: the selection just shrinks). On a **tombstoned** selection ⌘⌫ is **Put Back** instead — Finder's exact symmetry (⌘⌫ trashes and un-trashes). The dual role is carried by **twin menu items sharing the chord** — File ▸ Delete ⌘⌫ and File ▸ Put Back ⌘⌫, validation enabling exactly one by selection state; AppKit routes a shared key equivalent to the enabled item (Finder ships this exact pair as Move to Trash/Put Back; ours says Delete per 03-board-ui.md's naming constraint). Both titles stay stable (titles-are-API), and each is independently remappable — remapping one never moves the other's role. Plain ⌫ performs the same tombstone as fixed grammar (see Grammar above) — there is no Edit ▸ Delete item, so the two Delete-titled homes never collide for title-matched remapping.
|
||||
- **Select All**: all visible cards on the board — filter-respecting, like every surface (Search below).
|
||||
- **The contract's one carve-out is configuration** (settled): form-like git and board setup — add git, add/change remote, branch switching and creation, commit identity, credentials — lives in the board popover only, its committed home; its keyboard path is Board Info (⌘I) plus Tab-reachable controls (10-accessibility.md's Full Keyboard Access). Recurring remote *operations* stay under the contract: Board ▸ Pull and Board ▸ Push are menu items (no default chord, remappable; validation enables them only on remote-backed boards — 07-sync-collab.md).
|
||||
- **⌘N target rule** (settled): with a card selected, the new card is created in that card's lane, immediately after it (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency); with nothing selected — or a **tombstoned** selection, which never anchors creation — the **last-active lane** — the lane that most recently held selection or a creation in this window session — falling back to the first lane. Title editor focused; same placeholder/abandon semantics as Return-creation. **Zero-lane board** (hand-made, or every lane deleted): card creation and card paste have no target — New Card, Return-creation, and Paste with a *card* payload disable via menu validation until a lane exists. New Lane (⇧⌘N) is one way in; Paste with a **lane** payload is the other — it stays enabled and lands at the board's right end (the lane-paste rule above), so cross-board structure transfer never needs a lane to exist first.
|
||||
- **⌥⌘↑/⌥⌘↓ sort within the lane** (the move-vs-jump question, resettled: *card* moves live on the ⌥⌘ chord, joining ⌥⌘←/⌥⌘→ lane width in a "⌥⌘ modifies" family; plain ⌥-arrows stay jumps; plain ⌘↑/⌘↓ are unassigned): the selected card(s) move one position within the lane — logical `order`, across interior masonry columns (10-accessibility.md's logical-order rule). A non-contiguous multi-selection **gathers on the first press**: the cards collect into a contiguous block anchored at the first selected card (first = lowest logical order; the rest follow in preserved relative order), and subsequent presses move the block one position. **Cards never change lanes by ⌘-arrow** (settled): inter-lane movement is drag or Cut/Paste (the clipboard rules above), so ⌥⌘↑/⌥⌘↓ disable when a card selection spans lanes and ⌘←/⌘→ are inert on card selections. With a **lane** selected, ⌘←/⌘→ move the lane one slot — closing 10-accessibility.md's lane-move defect — and ⌥⌘↑/⌥⌘↓ are inert.
|
||||
- **⌫/⌘⌫ delete** (resettled 2026-07-28; lanes rejoined 2026-07-29): on cards *and lanes*, a move into the trash (`.trash/`, top position — 03-board-ui.md; a lane travels subtree-intact, `kind: lane` stamped when absent, no dialog — recoverable now, so nothing needs confirming); on a **trash** selection the same chord deletes **permanently** (one Delete vocabulary, staged by place — confirmation per 03's recoverability rule, a lane's alert counting its cards, a mixed trash selection's alert counting both kinds). Selection moves to the deleted item's successor sibling, Finder-style (next card in the lane, next lane on the board; the last sibling's predecessor otherwise; empty container = nothing selected) — repeated ⌫ walks down a lane. **In the trash the successor walk is kind-blind** (ruled 2026-07-31): the next row of either kind, in the same all-rows order plain arrows walk — a successor is a fresh singleton selection, so the landing violates no grammar, and repeated ⌘⌫ empties a mixed trash without dead-ends, each delete confirm-gated per its kind. Deliberate deletes pick a successor; *external* vanishing never does (02-architecture.md's reload-survival rule: the selection just shrinks). **Put Back is retired with the tombstone model** (resettled 2026-07-28): File ▸ Delete is the chord's only owner — no twin menu items, no shared-equivalent routing; restore is drag-out or ⌘X/⌘V (The trash below). Plain ⌫ performs the same delete as fixed grammar (see Grammar above) — there is no Edit ▸ Delete item, so the two Delete-titled homes never collide for title-matched remapping.
|
||||
- **Select All**: all visible cards on the board — filter-respecting, like every surface (Search below). **On the active trash side it selects the trash** (resettled 2026-07-28): with the trash visible and a non-empty trash selection, Select All selects all visible trash cards; in every other state, all visible live cards — the container boundary decides which "all" is meant (The trash below).
|
||||
- **The contract's one carve-out is configuration** (settled; containers re-ruled 2026-07-31 — the popover/sheet split — and **re-ruled back 2026-08-07**): form-like configuration keeps exactly one home per control, and that home is the **board popover** — rename, styling, branch display and *switching*, branch *creation*, add git, commit identity, ahead/behind with Pull/Push, the status badges; its keyboard path is Board Info (⌘I) plus Tab-reachable controls (10-accessibility.md's Full Keyboard Access). *(For a week, setup lived in a board settings sheet reached by Board ▸ Board Settings…; the split's mechanical argument — setup flows fire confirmation alerts, run inline network probes, and accept drag-in key import, acts that want a surface a stray click can't dismiss — did not survive the cost of a second validated surface for controls a user could otherwise reach directly. 03-board-ui.md ▸ Board settings sheet carries the retirement, and 07-sync-collab.md's credential and SSH surfaces are where the argument gets its next hearing.)* Recurring remote *operations* stay under the contract: Board ▸ Pull and Board ▸ Push are menu items (no default chord, remappable; validation enables them only on remote-backed boards — 07-sync-collab.md).
|
||||
- **⌘N target rule** (settled): with a card selected, the new card is created in that card's lane, immediately after it (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency); **a multi-selection anchors at its last member in flatten order** (settled — lane `order`, then card `order`, the multi-drag order; the same anchor serves paste): creation follows the last selected card, or appends to the last selected lane; the lane header's new-card button **overrides this rule** — the click names its target lane, selection notwithstanding (11-command-nexus.md ▸ Pointer grammar); with nothing selected — or a **trash** selection, which never anchors creation — the **last-active lane** — the lane that most recently held selection or a creation in this window session — falling back to the first lane. Title editor focused; same placeholder/abandon semantics as Return-creation. **Zero-lane board** (hand-made, or every lane deleted): card creation and card paste have no target — New Card, Return-creation, and Paste with a *card* payload disable via menu validation until a lane exists. New Lane (⇧⌘N) is one way in; Paste with a **lane** payload is the other — it stays enabled and lands at the board's right end (the lane-paste rule above), so cross-board structure transfer never needs a lane to exist first.
|
||||
|
||||
### The trash, keyboard-first (settled)
|
||||
### The trash, keyboard-first (resettled 2026-07-28 — the materialized trash)
|
||||
|
||||
The trash quasi-lane (03-board-ui.md ▸ Trash) speaks the same keyboard language when shown; hidden, it is invisible to every gesture. Rules:
|
||||
The trash lane (03-board-ui.md ▸ Trash — cards and lanes moved into `<root>/.trash/`; lanes rejoined 2026-07-29 as opaque-unit rows) speaks the board's ordinary keyboard language when shown; hidden, it is invisible to every gesture — and **hiding it clears a trash selection** (nothing invisible stays selected, so the toggle-off drops the selection rather than leave commands enabled against rows nobody can see). Trash cards are ordinary cards; a trashed lane is one opaque row (title + card count) — the old liveness machinery stays retired: no ancestor walks, no entry-vs-universe split, one container boundary plus the board's own kind rule. Rules:
|
||||
|
||||
- **Navigation**: the shown trash is the **last container for card navigation** — arrows walk into and out of it, and ⌥→ jumps to it. The quasi-lane itself is never selectable *as a lane* (no lane op applies to it): with a lane selected, ←/→ and ⌥→ stop at the last real lane.
|
||||
- **Moves are inert across the boundary**: no move or paste ever targets the trash (deleting is ⌫/⌘⌫), and ⌥⌘↑/⌥⌘↓ are inert *on* tombstoned cards (moving out is Put Back or drag-to-restore).
|
||||
- **Selection is homogeneous by liveness** (extending the homogeneous-selection rule): a selection never mixes live and tombstoned cards. Select All selects visible live cards only; a rubber-band stays on the side of the boundary it started on. Menu validation stays binary — Delete for live selections, Put Back / Delete Immediately for tombstoned ones. External liveness flips can't breach the invariant: a reload that flips `deleted:` on a selected card ejects it from the selection (02-architecture.md's reload-survival rule — a flip is a vanish from its side of the boundary), so validation never sees a mixed selection.
|
||||
- **Tombstoned lane entries are full keyboard citizens, homogeneous by kind**: arrows walk every trash entry in its sorted order — card and lane entries alike (a lane's single restorable entry, 03-board-ui.md ▸ Trash) — and the board's cards-XOR-lanes rule extends into the trash: a selection never mixes card entries and lane entries (on top of never mixing live and tombstoned). Put Back (⌘⌫) and Delete Immediately (⌥⌘⌫) apply to lane entries exactly as to cards — a put-back lane returns whole, cards and all. A lane entry is not draggable (its entry is a compact row, not the lane); its copy-out is ⌘C only, and its move-out is Put Back.
|
||||
- **Clipboard: copy out only.** ⌘C (cards and lane entries), ⌥-drag, and the cross-board drag default (cards) always yield *live* copies — `deleted:` is stripped on paste/duplicate/drop, like copying a file out of Finder's Trash; a lane entry's copy additionally strips its tombstoned interior cards (the lane-copy rule — copies transfer content, and trash isn't content). ⌘X is disabled: the move-out vocabulary is Put Back or drag-to-restore, nothing else. The *cross-board restore-move* (⌘-drag below) needs no command of its own — its keyboard equivalent is the composition Put Back → ⌘X → ⌘V in the destination: same folder, same identity.
|
||||
- **Everything edit-shaped is disabled** on tombstoned selections — Open Card, Rename, Style… (File ▸ Duplicate is untouched: it duplicates the board, never the selection — 11-command-nexus.md; card copies out of the trash are ⌘C or ⌥-drag, which name a live destination). Finder file drops (attachment import) on tombstoned cards are inert — 03-board-ui.md's no-editing-in-the-trash.
|
||||
- **Drag-to-restore follows the locality model**: dropping a tombstoned card into one of its own board's lanes restores it at the drop position (`deleted:` removed, `order` set). Dropped on *another* board it follows the copy default — a live copy lands there and the tombstoned original stays in the source trash (copy-out, like ⌘C); ⌘-drag forces the true cross-board restore-move (the tombstone leaves the source board; ordinary cross-board move semantics, `deleted:` cleared at the destination).
|
||||
- **Navigation**: the shown trash is the **last container for card navigation** — arrows walk into and out of it, and ⌥→ jumps to it; inside, plain arrows walk every row, card and lane row alike (navigation crosses kinds). The trash lane itself is never selectable *as a lane* (no lane op applies to it): with a lane selected, ←/→ and ⌥→ stop at the last real lane.
|
||||
- **Dropping a live card — or lane — on the shown trash deletes it** (lanes extended 2026-07-29): the drag is the pointer's delete gesture — release moves the dragged item(s) into `.trash/`; a lane drag over the shown trash proposes the delete alongside its strip slots. The drop diverges from positional drops in one way: **the shadow always takes the topmost position** — honest, not arbitrary: every trash arrival stamps `modified` and the trash sorts newest-first by that stamp (03 ▸ Trash), so a fresh delete genuinely lands on top. The trash takes no drops while hidden, like every gesture. Cross-board arrivals and ⌥-copies refuse too (a transfer-and-delete compound and a copy-into-the-trash are operations the design doesn't name), and a refusal falls through to the strip retarget rather than cancelling the held drag. **A refusal arriving over a standing trash proposal withdraws it** (ruled 2026-08-06 — the stationary ⌥ flip made the state reachable without a mouse move): the fall-through's honest reading — the column declines to be a target, it does not cancel the drag the user is still holding — covers only promises the refusal leaves keepable: shadows standing in a lane keep drawing, and an ⌥-copy released over the column lands where they are. A standing proposal naming the trash itself is the one promise the refusal just broke — tombstone rows promising a delete the release will refuse — so it withdraws rather than holds: the drag goes proposal-less over the column (the foreign-reload rule's shape — the shadow withdraws, no proposal stands until the pointer reaches a live target, and a release with no valid proposal cancels, which now agrees with the empty picture). Lifting ⌥ re-asks at the same address (the stationary flip's re-read) and the trash's proposal returns.
|
||||
- **Selection keeps one container boundary — and goes kind-blind inside the trash** (re-ruled 2026-07-31, superseding the lanes-rejoin pass's kind-homogeneous trash grammar): a selection never mixes trash items with board items, but *within* the trash cards and lane rows select together — clicks, ⇧-click ranges, ⇧-arrow extension, and the rubber band all sweep every row (the band's full-height backdrop covers both kinds), and Select All with a non-empty trash selection selects **all visible trash rows**. The live board keeps cards XOR lanes, and its Select All stays card-scoped, as everywhere. The guard moves to the exits (the mixed-payload drop refusal and ⌘C/⌘X validation below) — inside the trash the only verbs are Delete and the restore paths, so upstream homogeneity bought nothing the exits don't. ⇧-arrow extension still stops at the container boundary. Menu validation stays binary by container: Delete = move to trash on board selections, Delete = permanent on trash selections (03 ▸ Trash) — and Delete works on a mixed selection, the alert counting both kinds. An external move observed by reload re-resolves the selection by presence, as everywhere (02-architecture.md).
|
||||
- **Within-trash moves are inert**: no move or paste ever targets the trash (deleting is ⌫/⌘⌫ or the drag above), and ⌥⌘↑/⌥⌘↓ are inert on trash rows — the trash's order is its arrival order, not a workspace to arrange.
|
||||
- **Clipboard: the restore path.** ⌘C copies a trash card (a live copy lands wherever pasted — like copying out of Finder's Trash); **⌘X works** (resettled — it was disabled under the tombstone model): cut in the trash, paste is the keyboard-native restore, an ordinary folder move (10-accessibility.md's drag-free contract) — a card pastes into a lane, a trashed lane pastes after the anchor lane (the lane-paste rule above, verbatim). **⌘C and ⌘X validate against mixed selections** (ruled 2026-07-31, with kind-blind selection): the pasteboard's payload types are per-kind, so Cut and Copy grey out via ordinary menu validation while a trash selection mixes kinds — no failed gesture, no beep; the drag path's drop-time explanation (Drag-to-restore below) is where the rule teaches itself. **An item entering the trash voids its pending cut** (the deliberate-removal rule): a cut card — or lane — that gets deleted drops out of the pending cut, as under the old model.
|
||||
- **Everything edit-shaped is disabled** on trash selections — Open Card, Rename, Style…, and lane width ops on lane rows; Finder file drops on trash rows are inert (03's no-editing-in-the-trash). Creation never anchors to the trash: ⌘N and paste with a trash selection fall back to their nothing-selected targets.
|
||||
- **Drag-to-restore follows the locality model**: dropping a trash card into one of its own board's lanes — or a trashed lane row onto its own board's strip — is an ordinary move to the drop position. Dropped on *another* board it follows the copy default — a live copy lands there, the original stays in the source trash; ⌘-drag forces the true cross-board restore-move. **A mixed-kind drag never leaves the trash** (ruled 2026-07-31): pickup is allowed — the selection is legal — but every out-of-trash drop target refuses the mixed payload, and the release surfaces a notice explaining the rule ("Cards and lanes leave the trash separately — restore one kind at a time"); the refused drag ends like any refusal, rows staying put. Within-trash drops stay inert as above.
|
||||
|
||||
### Configurable bindings (settled)
|
||||
|
||||
Custom shortcuts are **system-native, with no in-app remapping UI**: macOS's App Shortcuts mechanism (System Settings ▸ Keyboard ▸ App Shortcuts, stored as `NSUserKeyEquivalents` in the app's defaults) remaps any menu item, and AppKit applies it automatically — menus always display the *effective* binding, so the menu bar is the self-documenting keyboard map. Because every board function is a menu item (the contract above), coverage is complete for all modifier-chord commands; the fixed grammar keys stay fixed by design. An in-app shortcut-recorder pane was considered and set aside as ceremony (WISHLIST.md); the Help content carries one line teaching the System Settings path. Constraints this mechanism imposes, adopted as design rules:
|
||||
Custom shortcuts are **system-native, with no in-app remapping UI**: macOS's App Shortcuts mechanism (System Settings ▸ Keyboard ▸ App Shortcuts, stored as `NSUserKeyEquivalents` in the app's defaults) remaps any menu item, and AppKit applies it automatically — menus always display the *effective* binding, so the menu bar is the self-documenting keyboard map. Because every board function is a menu item (the contract above), coverage is complete for all modifier-chord commands; the fixed grammar keys stay fixed by design. An in-app shortcut-recorder pane was considered and set aside as ceremony (../WISHLIST.md); the Help content carries one line teaching the System Settings path. Constraints this mechanism imposes, adopted as design rules:
|
||||
|
||||
- **Menu item titles are API.** The mechanism matches on exact titles — renaming a menu item orphans users' bindings. Titles change only with the deliberateness of a schema change.
|
||||
- **Toggles keep one stable title** with a checkmark state — "Show Trash" stays "Show Trash" when checked, never becomes "Hide Trash". (Same for Edit Body and Raw Source.)
|
||||
- **Undo/Redo are effectively not remappable** — NSUndoManager rewrites their titles dynamically ("Undo Move Card…"), which defeats title matching. Accepted; nobody remaps ⌘Z.
|
||||
- **Two items may share a default chord when validation is mutually exclusive** (Delete / Put Back on ⌘⌫) — AppKit fires the enabled one. Each keeps its own stable title, so remapping stays per-item. Corollary: no two menu items share a *title* either (titles are the remap key), which is why plain-⌫ delete is grammar rather than a second Delete item.
|
||||
- **Two items may share a default chord when validation is mutually exclusive** (a pattern currently unused — Put Back's retirement removed its one instance) — AppKit fires the enabled one. Each keeps its own stable title, so remapping stays per-item. Corollary: no two menu items share a *title* either (titles are the remap key), which is why plain-⌫ delete is grammar rather than a second Delete item.
|
||||
|
||||
## Accessibility
|
||||
|
||||
@@ -79,9 +84,9 @@ Custom shortcuts are **system-native, with no in-app remapping UI**: macOS's App
|
||||
|
||||
## Search
|
||||
|
||||
- Search field invoked with ⌘F (the board toolbar's sole default item; removed from the toolbar, ⌘F surfaces it transiently — 03-board-ui.md ▸ Toolbar; in the **card window**, Edit ▸ Find is find-in-text instead — 05-card-window.md), live filter: cards whose title *and* body both miss the query animate out; case/diacritic-insensitive substring. Scope is **title + body only** (settled) — attachment filenames are not searched.
|
||||
- The filter is the single source of truth for "what's on the board": layout, drop zones, marquee, ranges, arrow nav, and lane count badges all read it. Hidden cards leave the selection; creating a card clears the search. Escape clears, then returns focus to the board.
|
||||
- **Dispatch while the search field is focused** (settled): the field is a *control*, not a content editor — the focused-editor lockdown (Grammar above) does not apply. Text-domain keys route to the field: ⌘A/⌘X/⌘C/⌘V act on the query, plain ⌫ edits the query and never reaches the board, horizontal arrows move the caret. Board menu commands stay enabled and act on the board selection exactly as when the field is unfocused — ⌘N included (creating a card clears the search, above) — and the Delete pair stays unambiguous by construction: plain ⌫ is query editing, ⌘⌫ is File ▸ Delete on the selection.
|
||||
- Search field invoked with ⌘F (the board toolbar's sole default item; removed from the toolbar, ⌘F surfaces it transiently — 03-board-ui.md ▸ Toolbar; in the **card window**, Edit ▸ Find is find-in-text instead — 05-card-window.md), live filter: cards whose title *and* body both miss the query animate out; case/diacritic-insensitive substring. Scope is **all card content the format makes meaningful** (re-ruled 2026-07-29, superseding title-plus-body-only): title + body today; **comment bodies join when comments ship** — via a search-owned transient comment index, never the snapshot: the first live-query keystroke kicks an async sweep of `comments/*/index.md` bodies (`.draft` and `comments/.trash/` excluded), kept fresh by the same FSEvents stream while a query is active and discarded when it clears — the board walk stays O(cards), 01's window-scoped read untouched; **attributes join as they activate** (title now; labels/tags and their kin are reserved, inert keys this version — nothing to search until a future version gives them life). Attachment filenames stay unsearched. Scope options (content vs attributes, either/or) are WISHLIST #10.
|
||||
- The filter is the single source of truth for "what's on the board": layout, drop zones, marquee, ranges, arrow nav, and lane count badges all read it. **A drop under an active filter counts in the rendered space** (ruled 2026-08-06, closing the divergence found 2026-08-01): the zones, the shadow, and the write all resolve through the same filtered list — the position the shadow shows is the landing the write performs (the shipped interim — zones and write counting the unfiltered lane while the slots rendered filtered — was self-consistent but named a position the user could not see; retired). The slot's meaning in the full order is its **visible anchor**: the dropped card enters immediately *after* the slot's visible predecessor — or immediately *before* its visible successor when the slot has none (top of the lane) — and a lane the query emptied appends at its true end; a multi-card drop enters in proposal order beside the same anchor. Hidden cards keep their ranks untouched, never displaced by a gesture that could not see them — and the anchor is what makes the gesture's meaning survive the query's clearing: when the filter lifts, the card sits exactly beside the card it was dropped against. The rendered space means everything rendered — the new-card placeholder occupies its slot for the zones exactly as it does for layout. Hidden cards leave the selection; creating a card clears the search — creation's carve-out exists because a brand-new card must not be born invisible, and it is **stated by mechanism, not by gesture** (settled): *any* user-initiated creation on the board clears the query — ⌘N, Return-creation, the header button, empty-space double-click, paste, and Finder file drops alike — while foreign/agent-filed cards keep riding the live filter (02-architecture.md's derived-result rule). **Rename deliberately gets no carve-out**: a rename committed during an active search re-runs the predicate like any edit — a title that stops matching animates the card out and drops it from the selection, exactly as an agent's edit would; the filter stays a pure predicate with one exception, not two. **Escape is staged** (settled): in a non-empty field it clears the query, focus staying in the field; in an empty field it returns focus to the board; with *board* focus and an active search, one press clears the search and the full board returns — search takes Escape before its clear-selection meaning, which applies only when no search is active. **A lane the query empties keeps its slot** (settled): lanes are never filtered out — an all-misses lane stays on the board at its width with a 0 badge (the count reads the filter, 03-board-ui.md); the search filters cards, and the board's structure is not a search result. **A leaving card stays input-reachable for its out-transition** (settled): marquee and arrow targets deregister when the ~0.28 s animate-out ends, so a card mid-departure is briefly reachable while already out of the selection — accepted: it is literally on screen for that span, and closing the window would teach three input sites a predicate the layout already applied. **An open inline rename survives the filter hiding its card** (settled): the editor is a surface the filter doesn't reach — it stays open and focused, commits by UUID wherever the card lives, Escape abandons; keystrokes are never silently discarded for a card that still exists (the dirty-buffer courtesy), and the vanish-discard rule stays reserved for true liveness flips. The typed-query path can't even occur — focusing the search field is focus loss, which commits the rename first — so the rule covers foreign edits that stop the card matching.
|
||||
- **Dispatch while the search field is focused** (settled): the field is a *control*, not a content editor — the focused-editor lockdown (Grammar above) does not apply. Text-domain keys route to the field: ⌘A/⌘X/⌘C/⌘V act on the query, plain ⌫ edits the query and never reaches the board, horizontal arrows move the caret. **Every key with the field focused acts on the field — stock NSSearchField behavior, no pass-throughs** (settled): vertical arrows are caret movement, ⇧-arrows select query text, and **Return is a swallowed no-op** (the filter is live, there is nothing to submit — it never reaches the board's rename/create grammar). **Tab is the keep-filter path**: plain key-view traversal moves focus to the board with the query intact, and the whole board grammar (arrows, ⌥↑ escalation, Return, ⌘↩) then applies over the *filtered* board; ⌘F returns to the field. Board menu commands stay enabled and act on the board selection exactly as when the field is unfocused — ⌘N included (creating a card clears the search, above) — **except the caret-chord commands**: Move Left/Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable while the field is focused (Grammar above, caret-chords rule), so ⌘←/⌘→ stay line-start/end in the query even with a lane selected — and the Delete pair stays unambiguous by construction: plain ⌫ is query editing, ⌘⌫ is File ▸ Delete on the selection, and ⌘Z/⇧⌘Z are the field's own text undo, never git undo (06-history-undo.md ▸ Undo routing's control-class rule).
|
||||
|
||||
## Changes from Kanban
|
||||
|
||||
|
||||
+27
-13
@@ -2,14 +2,15 @@
|
||||
|
||||
The standalone per-card window. Opened by fast double-click, ⌘↩, or the context menu; at most one window per card (reopening a live card focuses the existing window); board and card windows share one live store, so edits reflect everywhere instantly (02-architecture.md).
|
||||
|
||||
> **Status: designed** — composition, body column, and the attributes sidebar are all settled. The storage-facing rules the pathfinder settled painfully — Preview/Edit over WYSIWYG, the untouched-body byte-identical guarantee, validate-before-write on the raw outlet, dirty-buffer-wins — carry over unchanged.
|
||||
> **Status: designed** — composition, body column, the attributes sidebar, and the comments column (2026-07-29) are all settled. The storage-facing rules the pathfinder settled painfully — Preview/Edit over WYSIWYG, the untouched-body byte-identical guarantee, validate-before-write on the raw outlet, dirty-buffer-wins — carry over unchanged.
|
||||
|
||||
## Composition
|
||||
|
||||
Two full-height columns: a wide **body column** (leading) and a narrow **attributes sidebar** (trailing), each scrolling independently. Visual reference: [assets/card-sidebar-reference.png](assets/card-sidebar-reference.png) — a GitLab-style issue pane; its *pattern* (stacked small-caps sections, quiet read-first rows, actions at the bottom) is what carries over, not its enhanced-schema content.
|
||||
Three **componentized panes** (re-composed 2026-07-29, comments design): a wide **body pane** (leading), the **comments pane** (middle, when shown — The comments column below), and the narrow **attributes sidebar** (trailing) — each an independent component with its own scroll, arranged by the window's layout rather than wired to each other; componentization is the rule, so the comments pane mounts beside the body or below it (the layout option, next) without either pane knowing which. Visual reference: [assets/card-sidebar-reference.png](assets/card-sidebar-reference.png) — a GitLab-style issue pane; its *pattern* (stacked small-caps sections, quiet read-first rows, actions at the bottom) is what carries over, not its enhanced-schema content.
|
||||
|
||||
- **Body column, top to bottom**: the **title field** — large, borderless; edits write through to frontmatter on commit (Return or focus loss); clearing it removes the `title` key (titles are optional — the untitled placeholder shows here as on the face); Return commits and moves focus into the body. Beneath it, a **quiet created/modified line** ("Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", secondary styling, omitting whichever keys are absent — the "by" segment renders only when the self-reported provenance stamp is present, 01-storage-format.md; this is provenance made visible where git history may not exist) — the read-only readout the pathfinder dropped with its inspector, back where the mockup puts it. Then the **body** (Preview/Edit, below).
|
||||
- **Body column, top to bottom**: the **title field** — large, borderless; edits write through to frontmatter on commit (Return or focus loss); clearing it removes the `title` key (titles are optional — the untitled placeholder shows here as on the face); Return commits and moves focus into the body. **Escape abandons** (settled — the board inline rename's abandon, applied here): the field reverts to the on-disk title and focus moves into the body, never a commit. The Edit-mode collision resolves by focus, 06-history-undo.md's first-responder rule: while the title field is focused, Escape is the title abandon even with the body in Edit; with the body editor focused, Escape is the Edit→Preview flip as specified. Beneath it, a **quiet created/modified line** ("Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", secondary styling, omitting whichever keys are absent — the "by" segment renders only when the self-reported provenance stamp is present, 01-storage-format.md; this is provenance made visible where git history may not exist) — the read-only readout the pathfinder dropped with its inspector, back where the mockup puts it. Then the **body** (Preview/Edit, below).
|
||||
- **Attributes sidebar**: everything about the card that isn't the body — sections below. Fixed narrow width derived from font metrics (full relative scaling, 10-accessibility.md); the window's resize flex goes to the body.
|
||||
- **Body/comments layout is an option** (ruled 2026-07-29 — componentized panes make it cheap): side-by-side is the default; **View ▸ Comments Beside Body** unchecked stacks them — body pane above, comments pane below at a fixed ≈3:2 split, each keeping its own scroll — for narrow displays. App-wide, persisted. The panes are identical in both mounts, and the thread stays visible through body **Edit** in either (Edit swaps only the body pane's content — the sidebar's own precedent); the window's minimum width grows only while the column is shown side-by-side, and resize flex always goes to the body, never the fixed panes.
|
||||
|
||||
(The pathfinder's compositions are both gone: the metadata bar — labels, assignees, due — left with those fields' move to the enhanced schema, and the horizontal attachments strip dissolves into the sidebar.)
|
||||
|
||||
@@ -28,15 +29,15 @@ Settled the hard way in the pathfinder (WYSIWYG built, then reversed): the body
|
||||
|
||||
- Renders headings, bold/italic/code, bullet/ordered/task lists, fenced + indented code, nested quotes, GFM tables (per-column alignment, columns sized to contents with the browser sizing rule), thematic breaks, HTML shown **verbatim as literal code-styled text** (never interpreted — no web view, per 00-vision.md's no-web-tech stance), and images resolved against the card's own folder (``).
|
||||
- **Remote images are never fetched** — Preview does no networking (sandbox-quiet, files-first). An `` renders as a quiet placeholder chip carrying the alt text (or the URL); the file-relative form above is the supported image story.
|
||||
- **Task-list checkboxes are live**: clicking a `- [ ]` / `- [x]` checkbox flips exactly that marker in the source — a single-character textual edit; every other byte of the body is untouched. This is the deliberate exception to "Preview only reads": checklists are kanban's working currency, and a mode flip to tick a box is ceremony. A toggle is an ordinary user edit — the standard atomic write, auto-committed and undoable on git boards.
|
||||
- **Task-list checkboxes are live**: clicking a `- [ ]` / `- [x]` checkbox flips exactly that marker in the source — a single-character textual edit; every other byte of the body is untouched. This is the deliberate exception to "Preview only reads": checklists are kanban's working currency, and a mode flip to tick a box is ceremony. A toggle is an ordinary user edit — the standard atomic write, auto-committed and undoable on git boards. **The pointer-free path is the system focus model** (settled): checkboxes — like Preview's links — are real controls in the keyboard-focus and accessibility tree, so Full Keyboard Access Tab-reaches them and Space toggles, and VoiceOver toggles with VO-Space (10-accessibility.md's real-accessible-checkboxes promise, honored natively). Without FKA they are not in the key loop — standard macOS content behavior, so ordinary Tab users never wade through a long checklist. In-content controls are *content*, not commands: no menu item, no chord — 04's every-function-has-a-menu-item contract covers commands, and 11-command-nexus.md scopes them accordingly. Under the read-only lock (02-architecture.md) the controls disable in place — an in-content mutation menu validation can't reach (and not the only such path: the attachment row's ⌫/Remove shares the posture — 02's every-entry-point predicate).
|
||||
- Links: external URLs open in the browser; relative links open the target file with its default app (resolved against the card folder, like images).
|
||||
- **Edit ▸ Find (⌘F) is find-in-text here** — the standard find bar over the focused body surface (Preview's selectable text, the Edit editor, raw source); board search is a board-window concern (04-interactions.md ▸ Search).
|
||||
- **Edit ▸ Find (⌘F) is find-in-text here** — the standard find bar over the focused surface (Preview's selectable text, the Edit editor, raw source — and the comments pane, where it searches the thread's **content — every comment's body**, cross-row with wraparound, `.draft` excluded (tightened 2026-07-31: author and date lines are metadata, not find targets — a match the bar cannot highlight is worse than none); the composer and an inline comment edit are their own focused text surfaces with the editor's ordinary find). Board search is a board-window concern (04-interactions.md ▸ Search — which reaches comment bodies through its own transient index since the 2026-07-29 re-ruling, so the two finds never overlap in scope).
|
||||
|
||||
### Edit
|
||||
|
||||
- A monospaced editor with **lightweight Markdown syntax highlighting** — headings emphasized, bold/italic rendered as such, code tinted, link targets and structural markers dimmed. Highlighting is presentation only: the text is the raw Markdown, character for character — no hidden transforms, no smart substitutions.
|
||||
- Saved on a ~700 ms debounce; flushed on leaving Edit, entering source mode, and window close.
|
||||
- **⌘Z here is the text view's own undo** — session-scoped, ending when the editor loses focus or the mode flips; it works on every board, git or not. Board-level undo routing and commit granularity (one commit per Edit session — the Edit→Preview flip is the effective Save button; never per save tick): 06-history-undo.md ▸ Undo routing.
|
||||
- **⌘Z here is the text view's own undo** — session-scoped, ending when the editor loses focus or the mode flips; it works on every board, git or not. Board-level undo routing: 06-history-undo.md ▸ Undo routing; commit granularity (one commit per Edit session — the Edit→Preview flip is the effective Save button; never per save tick): 06 ▸ Rules ▸ Auto-commit.
|
||||
|
||||
### Write rules (settled, storage-facing)
|
||||
|
||||
@@ -45,7 +46,7 @@ Settled the hard way in the pathfinder (WYSIWYG built, then reversed): the body
|
||||
|
||||
## Raw source outlet
|
||||
|
||||
A toggle (View ▸ Raw Source, ⌥⌘E — 11-command-nexus.md) swaps the **entire content area — title, body, and sidebar —** for the literal on-disk `index.md` (frontmatter and all) in a monospaced editor with Cancel/Apply: the same frontmatter is being edited as raw text, so interactive controls over it would fight the raw edit. Entering source mode flushes any pending title/body edits first, then reads the file fresh from disk. Apply validates through the same fail-fast parse the loader uses (detailed alert on error, stays in source mode) before writing byte-for-byte (including a `modified-by` stamp the user typed or kept — Apply is the one app write that doesn't clear it, 01-storage-format.md); the watcher reload then refreshes every window. Cancel (and window close) discards without ceremony. This is the escape hatch that keeps *everything* — unknown keys, exotic formatting — reachable in-app.
|
||||
A toggle (View ▸ Raw Source, ⌥⌘E — 11-command-nexus.md) swaps the **entire content area — title, body, and sidebar —** for the literal on-disk `index.md` (frontmatter and all) in a monospaced editor with Cancel/Apply: the same frontmatter is being edited as raw text, so interactive controls over it would fight the raw edit. Entering source mode flushes any pending title/body edits first, then reads the file fresh from disk. Apply validates through the same fail-fast parse the loader uses (detailed alert on error, stays in source mode) before writing byte-for-byte (including a `modified-by` stamp the user typed or kept — Apply is the one app write that doesn't clear it, 01-storage-format.md); the watcher reload then refreshes every window. Cancel (and window close) discards without ceremony. This is the escape hatch that keeps *everything* — unknown keys, exotic formatting — reachable in-app. A pull landing mid-session neither blocks on the open buffer nor invalidates it (07-sync-collab.md — same-card signpost, Apply stays last-writer-wins); branch switch and undo restore instead settle it explicitly via save-or-discard (06-history-undo.md ▸ Branch switching).
|
||||
|
||||
Key grammar in source mode, completing the window's key story: **Escape is Cancel**, **⌘↩ is Apply**, and toggling off via ⌥⌘E (menu or toolbar) is **Apply too** — leaving-by-toggle commits, mirroring leaving-Edit-flushes; a failed validation keeps source mode open (toggle stays checked) with the alert. Return just types — it's an editor. View ▸ Edit Body (⌘E) disables while source mode is active, matching its toolbar item.
|
||||
|
||||
@@ -56,13 +57,13 @@ Stacked sections under small-caps headers, in this order; quiet rows, read-optim
|
||||
### Attachments
|
||||
|
||||
- Shows **every top-level file of `attachments/`** — including files also embedded in the body (settled: the section is the card's complete file inventory, no reference-tracking magic; an image appearing in both places is honest, not a bug). Subfolders are tolerated but not surfaced (01-storage-format.md's attachments rules).
|
||||
- **Compact rows**: small QuickLook thumbnail (Finder-icon fallback) + middle-truncated filename, one row per file. The section header carries a quiet add affordance; empty, the section stays with a one-line hint (drop files, or File ▸ Add Attachment…, ⇧⌘A) — the drop surface remains the **whole window** (name collisions auto-rename, Finder-style — 01-storage-format.md). **Drop precedence is split by payload** (settled): file drops import as attachments anywhere in the window — Edit mode included, the text editor never intercepts a file drop; dragged *text* lands in the Edit editor at the caret within its bounds as ordinary insertion, and is inert elsewhere in the window.
|
||||
- **Compact rows**: small QuickLook thumbnail (Finder-icon fallback) + middle-truncated filename, one row per file. The section header carries a quiet add affordance; empty, the section stays with a one-line hint (drop files, or File ▸ Add Attachment…, ⇧⌘A) — the drop surface remains the **whole window** (name collisions auto-rename, Finder-style — 01-storage-format.md). **Drop precedence is split by payload** (settled): file drops import as attachments anywhere in the window — Edit mode included, the text editor never intercepts a file drop; dragged *text* lands in the Edit editor at the caret within its bounds as ordinary insertion, and is inert elsewhere in the window. **One carve-out by hover target** (ruled 2026-07-29 — comment attachments are authorable): a file dropped **within the comment composer's bounds** imports to the draft's `attachments/`, and within an **inline comment edit session's bounds** to that comment's — the window-wide card default covers everywhere else (The comments column below).
|
||||
- Row interactions: double-click or Return opens; context menu Open / Reveal in Finder / Remove (moves to the **system** Trash, never hard-deletes — 03-board-ui.md's naming constraint keeps this distinct from board deletion); rows drag out their file URL.
|
||||
- **Keyboard-native, new in the rewrite** (the pathfinder's strip was pointer-only): the section is focusable; arrows move between rows, **Space QuickLooks** the selected row, Return opens it, ⌫ removes it (same system-Trash semantics).
|
||||
|
||||
### 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
|
||||
|
||||
@@ -79,23 +80,36 @@ The card-level styling home: the **embedded style editor** — background palett
|
||||
|
||||
### Actions (bottom)
|
||||
|
||||
- **Delete** — tombstones the card (destructive styling; the window then dismisses itself per Deletion & lifecycle below; recoverable from the board's trash quasi-lane).
|
||||
- **Delete** — moves the card to the trash (destructive styling; the window then dismisses itself per Deletion & lifecycle below; recoverable from the board's trash lane — 03-board-ui.md).
|
||||
- **Reveal in Finder** — the card's folder.
|
||||
|
||||
## The comments column
|
||||
|
||||
Designed 2026-07-29 (storage: 01-storage-format.md ▸ Enhanced schema). Ships in **every tier** — only tracker sync is tier-gated (12-editions.md). Feature lands post-2.0.
|
||||
|
||||
- **Visibility** (re-ruled 2026-07-29 — the pane obeys the user, not the content): **View ▸ Show Comments** is a checkmark toggle à la Show Trash, and its choice is **app-wide and persisted across restarts** (`UserDefaults.standard`, beside Comments Beside Body — the one-app collapse's scalars rule), and it **defaults ON** (ruled 2026-07-31): the trash's hidden-by-default bargain hides destructive residue, while the pane invites content — a new feature behind an unchecked menu item would never be discovered; one persisted uncheck opts out forever. One bit, no content-derived auto-show: checked, every card window carries the pane (a comment-less card shows the empty thread and the composer — the invitation is the point); unchecked, threads and drafts are out of sight until the user says otherwise, the Show Trash bargain. The checkmark reads the bit — the menu never lies. **File ▸ Add Comment** flips the bit on when it's off (the gesture *is* the user choosing to see comments — same persistence) and focuses the composer in one gesture (11-command-nexus.md). Deleting the last comment never closes the pane — nothing but the toggle does.
|
||||
- **The thread**: one comment = an author line (self-reported `author`, unattributed when absent; timestamp; "· edited" when `modified` differs from `created`), the rendered Markdown body (the card-body subset), and attachment chips when its `attachments/` is non-empty (Quick Look, the sidebar section's pattern). No avatars — there is no identity system, and initials faked from self-reported strings would be decoration. The section header carries the count ("Comments · 3") and the **sort-direction control**: chronological ascending by default, flippable to newest-first (app-wide, persisted).
|
||||
- **The composer edits `comments/.draft/`** (ruled 2026-07-29 — the draft is user content in the board, the `.trash` pattern applied to composition): an always-visible text area ("Add a comment…", Edit-mode Markdown highlighting) whose backing file is the card's single draft — a reserved dot-named folder under `comments/` holding ordinary comment schema, `attachments/` included, excluded from the thread listing. Restore-on-reopen falls out for free (the composer just reads its file); drafts ride git and sync across machines like any file; concurrent drafts on two machines are an ordinary file race (local-wins). **The composer sits at the thread's newest end** (bottom ascending, top descending) and the window opens scrolled to it — a thread opens where the conversation is happening. **Comment attachments author here** (ruled 2026-07-29): a file dropped within the composer's bounds imports to the draft's `attachments/` (the hover-target carve-out — Attachments above), a quiet **paperclip affordance** on the composer covers the no-drag path (the section header's add-affordance pattern; File ▸ Add Attachment… stays card-scoped), and the same pair applies within an inline comment edit session, targeting that comment's `attachments/`. Chips on an authoring surface carry remove (to the **system** Trash — the sidebar row's rule); a posted comment's chips are read-only, Quick Look only — Edit the comment to change its files.
|
||||
- **Draft saves are slow-cadence, never prompted** (flow breakage minimized): the draft writes on composer blur, window close, quit, and a lazy interval (~30 s) — not the body editor's 700 ms, so a Pro user's typing never becomes a commit stream; the saves that do land compose the quiet path-shaped **"Draft comment on '⟨card⟩'"**. Close and quit just proceed — no DirtyBufferGuard on the ordinary path, nothing to lose while saves land. **The failure path gets the guard** (re-ruled 2026-07-31, narrowing "nothing to lose" to its true premise): a close-time draft flush that *fails* with typed text in the buffer raises the DirtyBufferGuard modal (retry / save a copy / discard) exactly as the body's does — the buffer is then the only home the text has, 02-architecture.md's one-modal-moment class; ordinary closes stay ceremony-free since the guard only ever fires on a failed write. A draft emptied of text with no attachments deletes its folder — no litter. **Escape moves focus out of the composer, draft untouched** (ruled 2026-07-29 — Escape never discards: the draft is a durable file, so "abandon" has no meaning here; emptying the draft is the discard gesture, and the title field's abandon-Escape stays the transient-bubble exception).
|
||||
- **⌘↩ posts** (a Comment button twins it): posting renames `.draft` → a fresh lowercase UUID and **restamps `created`/`modified`** in the same write bracket — chronology is when it was posted, not when drafting began — one gesture, one commit ("Comment on '⟨card⟩'" — 06-history-undo.md's verb family per 01).
|
||||
- **Edit and delete**: every comment is editable and deletable — files-first has no enforced identity. The comment's context menu (the per-item inventory — 10-accessibility.md) carries **Edit / Delete / Reveal in Finder**. Inline Edit is a **body-edit session in miniature** (no second draft mechanism): debounced saves to the comment's own file keep it crash-safe, Save (or ⌘↩) ends the session as its commit point, Cancel — or Escape, its keyboard twin (ruled 2026-07-29; 11's grammar table) — reverts to session-start bytes, window close flushes the session exactly as the body's does. Delete is immediate and undoable, no confirm (01's ruling — undo is the net: the comment moves into `comments/.trash/`, undo is the move back on the **window's own stack**; after close, board-level undo of the session restores it, and the folder purges only when undo no longer needs it — 13-native-undo.md's session-coarsening model, re-ruled 2026-07-31).
|
||||
- **Live updates**: the pane reloads its thread from the same FSEvents stream (01's window-scoped rule — the board snapshot never loads comment content); foreign arrivals snap in per the motion language, and the announcer speaks them path-shaped ("New comment on '⟨card⟩'" — 10-accessibility.md).
|
||||
- **Raw Source still swaps the entire content area** — all panes, comments included; the raw outlet's rule is unchanged.
|
||||
|
||||
## Window
|
||||
|
||||
- **The subtitle shows the card's place**: "⟨board⟩ › ⟨lane⟩" under the window title, live-updating as the card moves (the window follows its card).
|
||||
- **The subtitle shows the card's place**: "⟨board⟩ › ⟨lane⟩" under the window title, live-updating as the card moves (the window follows its card). **Hidden with the title, and the loss is blessed** (ruled 2026-08-06): the no-title titlebar (Toolbar below — `titleVisibility = .hidden`) collapses AppKit's title and subtitle as one field, so the pair hides together; the window is usually beside its board, and `window.title`/`window.subtitle` keep feeding the Window menu, Exposé, VoiceOver, and restoration, so only the visual rendering is lost. The computation and live-update wiring stand untouched — if a visible placement surface is ever wanted (a details-sidebar placement row is the named candidate), it is a surface away, not a rewiring; deliberately not built now.
|
||||
- New windows open at the last-used card-window size, cascaded; frames restore per card across relaunch where state restoration allows.
|
||||
- **Toolbar (settled — 03-board-ui.md ▸ Toolbar)**: default set Edit Body (single toggle, on-state in Edit) · Raw Source (toggle; while active, Edit Body disables) · Add Attachment; user-customizable like the board window's.
|
||||
|
||||
## Deletion & lifecycle
|
||||
|
||||
- The window follows its card across lanes (keyed by board URL + GUID) — *within its board*. A **cross-board move dismisses the window like a delete**: the card left this board — its UUID travels with the move (reminted only on an import-boundary collision, 01-storage-format.md's identity lifecycle), but the window's key is board URL + GUID, and the board half no longer names it.
|
||||
- Window dismisses itself if the card is deleted — and a **tombstone counts as deleted**: ⌫ on the board closes the card's open window (the card is gone from the board's perspective; Put Back and reopen if it was a slip). Cards in the shown trash quasi-lane don't open at all — restore first (03-board-ui.md). Reopening a live card focuses the existing window.
|
||||
- Window dismisses itself if the card is deleted — and **entering the trash counts as deleted** (resettled 2026-07-28, the materialized trash): ⌫ on the board closes the card's open window (the card left the working set; restore and reopen if it was a slip), an external move into `.trash/` observed by reload does the same, and deleting the card's *lane* deletes the card with it — the window dismisses because the card is gone. Restoring reopens nothing — reopening is the user's act, like any open. **Dismissal never eats typed work silently where a save can land** (settled): a dirty Edit buffer flushes into the card's folder at its new `.trash/` location before the window dismisses — a surgical body write, so the keystrokes survive a later restore and enter history on git boards (the composer reads it as an edit to a trashed card — accurate). An open raw-source buffer discards instead: its Apply would write a whole stale `index.md` over the trashed card — a delete is never fought by a stale buffer. A card whose lane was deleted discards both — nowhere left to write. A card hard-deleted externally (folder gone) discards both — nowhere left to write, the inline-rename rule. Cards in the shown trash lane don't open at all — restore first (03-board-ui.md). Reopening a live card focuses the existing window.
|
||||
|
||||
## Changes from Kanban
|
||||
|
||||
- **Two-column composition** replaces the pathfinder's vertical title/strip/body stack: body column (title atop it, created/modified line beneath) plus a full-height attributes sidebar.
|
||||
- **Three componentized panes** replace the pathfinder's vertical title/strip/body stack: the body pane (title atop it, created/modified line beneath), the comments pane (when shown — the 2026-07-29 re-composition), and a full-height attributes sidebar.
|
||||
- **The sidebar revives the dropped readouts**: created/modified return under the title; unknown frontmatter keys get the read-only Details section (the pathfinder's inspector casualties, rehomed).
|
||||
- **Empty body opens in Edit**; Return in Preview enters Edit; Escape returns to Preview (the pathfinder always opened in Preview, toggle-only).
|
||||
- **Live task-list checkboxes in Preview** — the pathfinder's preview was fully inert.
|
||||
|
||||
+34
-25
@@ -1,77 +1,86 @@
|
||||
# History & Undo
|
||||
|
||||
Git is the undo substrate — on boards that have git. **Git is opt-in per board (a pivot from the pathfinder, which auto-initialized every board): a board may be created without git, and git can be added later** (via the board popover; see 07-sync-collab.md's mode progression). A board without git has **no undo/redo** (board history, that is — text editors keep their standard typing undo everywhere; see Undo routing below) — consistent with the settled no-undo stance for repo-nested boards; deletes are the exception, recoverable on every board via the tombstone trash (03-board-ui.md). On git-enabled boards, every settled change auto-commits; those mechanics are carried over from the pathfinder with their hard rules intact.
|
||||
**Tier scope: every tier** (Pivot 2026-08-07 — 12-editions.md: git left the paywall; this line formerly scoped the doc to Lanework Pro, with the free tier shipping mode:none only over the now-retired inert-`.git` posture). This doc is the git HistoryProvider, composed on git-mode boards in every tier; boards without app-managed git bind macOS-native undo (13-native-undo.md). The Undo routing section below was always tier-independent — both substrates dispatch through it.
|
||||
|
||||
Git is the undo substrate — on boards that have git. **Git is opt-in per board (a pivot from the pathfinder, which auto-initialized every board): a board may be created without git, and git can be added later** (via the board popover's Git tab — 03-board-ui.md; see 07-sync-collab.md's mode progression). A board without app-managed git binds the **native undo stack in every tier** — repo-nested included (re-ruled 2026-07-31, twice — the provider follows the board, 13-native-undo.md; formerly no-undo under Pro, which made upgrading remove undo from mode-none boards, and the repo-nested no-undo residue retired the same day: the native stack touches no git, so leave-strictly-alone is untouched and no board lacks ⌘Z). (Text editors keep their standard typing undo everywhere; see Undo routing below.) Deletes — card or lane — are recoverable on every board via the materialized trash (03-board-ui.md). Add-git swaps native → git mid-session, discarding the in-session native stack and seeding the git trail — the branch-switch discard-and-reseed precedent. On git-enabled boards, every settled change auto-commits; those mechanics are carried over from the pathfinder with their hard rules intact.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Opt-in init**: adding git to a board initializes a local repo at the board root. Bundled libgit2 — no git install required. No silent auto-init, ever.
|
||||
- **Opt-in init**: adding git to a board initializes a local repo at the board root. Bundled libgit2 — no git install required. No silent auto-init, ever. **The initial branch is `main`** (blessed 2026-07-31): the host's `init.defaultBranch` lives in config layers the sandbox can't read, so add-git sets it deterministically — git's modern default, the pathfinder's choice.
|
||||
- **Adoption**: a board whose root already contains `.git` opens **in git mode, silently** — adoption is not init. The no-silent-auto-init rule forbids *creating* a repository the user didn't ask for; recognizing one that exists is the opposite of that: the repo's presence *is* the opt-in (someone ran `git init` or `git clone`), and this is the primary way a second machine joins a shared board — clone in a terminal, open in the app (07-sync-collab.md's second entry arrow). All git-mode behavior applies from the first open: auto-commit, undo reseeded from the existing HEAD's first-parent ancestry, remote tracking if a remote is configured.
|
||||
- **Detection is nearest-`.git`-wins**, checked at every board open: `.git` at the board root → git mode (adoption above); no `.git` at the root but one at any ancestor → repo-nested (below); neither → mode none. A board can therefore change mode between opens (e.g. the user ran `git init` in a terminal) — the app just reflects what it finds. **Open-time only, deliberately**: a `git init` under an open mode-none board takes effect at the next open — the running session keeps its mode, and the watcher does not scan for `.git` appearing (no mid-session mode flips; stated here so it isn't rediscovered as a bug).
|
||||
- **Abnormal repo states** (settled; adoption never assumes a tidy clone): an **unborn HEAD** (`git init`, no commits yet) is normal git mode — the first auto-commit creates the root commit on the branch HEAD names, and the undo trail simply starts empty. A **detached HEAD**, or an **in-progress merge/rebase/cherry-pick** left by outside-the-app git (`MERGE_HEAD`, `rebase-merge`/`rebase-apply`, `CHERRY_PICK_HEAD` — pause states that load fine on a clean tree and are otherwise invisible), instead **pauses the git surface honestly**: auto-commit holds (the auth-pause posture, 07-sync-collab.md — pause, badge, explain, never hammer), Undo/Redo and the branch controls disable, and the popover's git section names the state plainly ("HEAD is detached — commits would belong to no branch"; "a merge is in progress") and says resolving it belongs to the tool that created it. Edits keep landing on disk — files are the board — and commit as one settled batch when the state clears. The app **never mutates repo state it didn't create** (no auto branch-at-HEAD, no `merge --abort`); the check runs at open and again before every flush, so finishing the operation in a terminal resumes the pipeline without ceremony. **The one exemption is the app's own leftovers** (settled): every bracketed operation stamps its intent app-side (per-board registry) before touching the repo, so an interrupted app-run rebase or checkout is recognizable as Lanework's — finding a pause state with a matching stamp, the app **aborts its own unfinished operation** to restore the pre-operation state and says so via banner ("a branch switch was interrupted — the previous state is restored"), then clears the stamp. Abort discards nothing: fetched commits stay fetched, local commits are restored — the rebase's own no-loss accounting. Without a matching stamp the leftover is outside git's, and the pause-and-defer stance above holds unchanged.
|
||||
- **Boards nested inside an existing repository are left strictly alone** — git cannot be added to them (no nested repo, no commits into the user's repo), so they get **no undo** (settled; no app-managed undo journal, which would violate self-containment). The board popover's git section must say so honestly: not a hidden "add git" but a short explanation ("this board lives inside a repository; Lanework leaves it to that repository") — the option is absent because it *can't* apply, and the UI should teach that rather than look broken.
|
||||
- **Auto-commit**: every settled change (debounced past drag/typing churn) commits with a descriptive message ("Move card 'Fix login' to Doing"). Board undo sees **Edit sessions, not save ticks** (resettled from typing-settle granularity): the body editor's ~700 ms disk saves (05-card-window.md) keep the file crash-safe throughout a session but stay **uncommitted** — the body commit lands when the session ends, the **Edit→Preview flip being the effective Save button** (raw-source entry and window close end the session too). The committer **stages around open Edit sessions**: a board change committing mid-session excludes the session card's folder from staging, so a lane move never sweeps half-typed body text into its commit. Settled-tree events that cannot wait — a pull's flush-before-overwrite — commit the session's on-disk saves as-is (a mechanical exception; 07-sync-collab.md's same-card signpost covers the visible half); branch switch instead gates on explicit save-or-discard (Branch switching below). The cadence constraint below demands batching at least this coarse. **Board-window close and app quit flush the pipeline** — any pending editor save (05-card-window.md), then the pending auto-commit — before teardown; nothing settled is ever left unsaved or uncommitted by closing.
|
||||
- **Undo routing is by focus** — the platform's first-responder rule, stated here because two undo systems coexist. While a text-editing surface is focused (card title field, body Edit mode, raw source, board inline rename), ⌘Z/⇧⌘Z are that editor's own **text undo** — standard, transient, session-scoped: leaving the editor (mode flip, focus loss, close) ends the session, and from then on that content's undo story is the git trail. Text undo works on **every** board — no-git and repo-nested included; "no undo/redo" above means board history, not typing. With focus anywhere else, Edit ▸ Undo/Redo are git undo (and are disabled on boards without it). **No fall-through**: exhausting a focused editor's stack beeps; it never reaches board history.
|
||||
- **Flush-before-overwrite**: before an app write overwrites on-disk state that differs from the last-loaded snapshot (an uncommitted external change — e.g. an agent's body rewrite racing the card editor's debounced save, 05-card-window.md), the pending auto-commit is flushed so the external version enters history first. "Both versions exist as commits" is thereby a guarantee, not a likelihood. The same flush settles the tree before a pull runs (07-sync-collab.md).
|
||||
- **Detection is nearest-`.git`-wins**, checked at every board open: `.git` at the board root → git mode (adoption above); no `.git` at the root but one at any ancestor → repo-nested (below); neither → mode none. **Denial is not absence** (ruled 2026-07-31): the ancestor walk crosses paths above the board's sandbox grant, and a check the sandbox *refuses* (EACCES/EPERM) must never read as "no repo there" — detection distinguishes **clean none** (every ancestor answered not-found) from **unverifiable** (a check was denied); add-git is offered only on clean none, and unverifiable takes the repo-nested posture (conservative — the popover explains rather than offers). As hardening, add-git's create re-runs full detection and refuses unless it reads clean none, so the forbidden nested init is impossible even on a raced or stale read. Whether the shipped sandbox actually denies ancestor stats is an open empirical question (manual-verification list: a board deep inside an ungranted repo, and an ordinary board under an ungranted parent) — if it denies everywhere, every board would read unverifiable and this posture needs a data-informed revisit. A board can therefore change mode between opens (e.g. the user ran `git init` in a terminal) — the app just reflects what it finds. **Open-time only, deliberately — for *discovery***: a `git init` under an open mode-none board takes effect at the next open — the running session keeps its mode, and the watcher does not scan for `.git` appearing (no mid-session mode flips from watching; stated here so it isn't rediscovered as a bug). The one deliberate mid-session transition is the app's own **add-git** (Opt-in init above): clicking it flips the open board into git mode immediately — the tab flows straight from the add-git offer into the git-mode posture, the first auto-commit follows — the rule forbids *discovered* flips, never commanded ones.
|
||||
- **A `.git` that isn't a valid repository still reads as git mode — and fails loudly** (ruled 2026-07-31): detection is presence-shaped (any root `.git` entry, directory or worktree/submodule pointer file), so a corrupt or unopenable repo never falls to mode none — Add Git is never offered against an existing `.git`, whatever its condition (init into a repairable repo is exactly the never-mutate hazard). The board itself loads and edits normally — files are the board — but the failure is **loud**: a standing breakage-class banner at detection ("This board's git repository can't be read — history is paused; Lanework leaves the repository untouched"), announced per 10-accessibility.md, with the whole git surface paused (the abnormal-states posture below) and the popover's git section naming the state; the banner clears when a later open or reload finds the repo readable. Never a silent placeholder discovered only in the popover.
|
||||
- **Abnormal repo states** (settled; adoption never assumes a tidy clone): an **unborn HEAD** (`git init`, no commits yet) is normal git mode — the first auto-commit creates the root commit on the branch HEAD names, and the undo trail simply starts empty. **The root commit has its own subject** (settled): whenever the app creates a repo's first commit — immediately on the app's own add-git (init doesn't wait for the debounce; the board is protected from the moment git exists), or at the first settled change on an adopted unborn repo — it commits the whole tree as **"Initial board state"**, never a folded diff-from-empty: there is no last-committed snapshot to diff against, and forty Adds would bury the event. **The root commit is never split and is user-authored** (blessed 2026-07-31): it is a baseline, not a change-set — the event it records is the user's act of putting the board under git, adopted unborn repos' unwitnessed files included. A **detached HEAD**, or an **in-progress merge/rebase/cherry-pick** left by outside-the-app git (`MERGE_HEAD`, `rebase-merge`/`rebase-apply`, `CHERRY_PICK_HEAD` — pause states that load fine on a clean tree and are otherwise invisible), instead **pauses the git surface honestly — the *whole* surface, remote half included** (settled): auto-commit holds (the auth-pause posture, 07-sync-collab.md — pause, badge, explain, never hammer), Undo/Redo and the branch controls disable, **and Pull, Push, and push-on-commit hold with them** — with auto-commit held, 07's clean-tree-by-pull-time invariant is false, and a pull's rebase (or a rejected push's fetch→rebase→push) would run against a dirty tree carrying uncommitted edits, precisely what flush-before-overwrite exists to prevent; the ahead/behind badge keeps counting (a fetch is a read), and the popover's git section names the state plainly ("HEAD is detached — commits would belong to no branch"; "a merge is in progress") and says resolving it belongs to the tool that created it. Edits keep landing on disk — files are the board — and commit as one settled batch when the state clears. The app **never mutates repo state it didn't create** (no auto branch-at-HEAD, no `merge --abort`); the check runs at open and again before every flush — and, because a terminal's cleanup moves only files under `.git`, which the watcher never delivers, **a standing pause re-reads the repository state every 15 s** (blessed 2026-07-31; injectable cadence, only while paused, a handful of stats — not a retry, nothing is attempted) — so finishing the operation in a terminal resumes the pipeline without ceremony. **The one exemption is the app's own leftovers** (settled): every bracketed operation stamps its intent app-side (per-board registry) before touching the repo, so an interrupted app-run rebase or checkout is recognizable as Lanework's — finding a pause state with a matching stamp, the app **aborts its own unfinished operation** to restore the pre-operation state and says so via banner ("a branch switch was interrupted — the previous state is restored"), then clears the stamp — **on success only; a failed abort keeps the stamp** (blessed 2026-07-31): the stamp is the sole evidence the leftover is the app's, and clearing it on failure would demote the leftover to somebody-else's forever — the pause would then send the user to "the tool that created it," which was this app. Kept, the next open or paused-state re-read recognizes it and retries; recovery converges instead of orphaning. Abort discards nothing: fetched commits stay fetched, local commits are restored — the rebase's own no-loss accounting. Without a matching stamp the leftover is outside git's, and the pause-and-defer stance above holds unchanged.
|
||||
- **Boards nested inside an existing repository are left strictly alone** — git cannot be added to them (no nested repo, no commits into the user's repo), so they get **no app-managed history — while the native session stack still serves ⌘Z there in every tier** (re-ruled 2026-07-31, retiring the old no-undo residue: 13's stack is memory-only and journal-free, so self-containment holds; what repo-nested denies is git, never undo). The board popover's git section must say so honestly: not a hidden "add git" but a short explanation ("this board lives inside a repository; Lanework leaves it to that repository") — the option is absent because it *can't* apply, and the UI should teach that rather than look broken.
|
||||
- **Auto-commit**: every settled change (debounced past drag/typing churn) commits with a descriptive message ("Move card 'Fix login' to Doing"). Board history sees **card-window sessions, not gestures** (re-ruled 2026-07-31, widening the Edit-sessions-not-save-ticks rule — 13-native-undo.md's session-coarsening model, applied to the commit substrate): while a card's window is open, everything happening inside it — the body editor's ~700 ms crash-safe disk saves, comment posts and deletes, draft-save cadence, sidebar changes — stays **uncommitted**, and the committer **stages around the whole open card folder** (the former Edit-session stage-around, widened; comments included); **window close flushes the session as one commit** ("Update card 'Fix login'"-shaped, the composer folding the card-scoped diff, body bullets carrying the events) — granular window activity never litters history. **The flush awaits the snapshot that covers it** (ruled 2026-07-31): the composer diffs `store.snapshot` against HEAD, so the close flush awaits a snapshot generation covering its changed paths before the committer runs — the commit's subject can never be outrun by its own reload; the cadence margin (2 s debounce vs 200 ms watcher) is the practical cushion, never the guarantee (the awaiting-bracket machinery is the shape). **The await's baseline is the bracket close, not the walk's start** (blessed 2026-08-06): a reload already in flight when the bracket closes clears the gate without a started-after guarantee — only the store knows when a walk began, and the committer deliberately does not ask. The racer window is academic under the cadence margin, the post-bracket reload is scheduled unconditionally either way, and the deadline already accepts composing from a near-covering snapshot when the watcher is broken — the tree, not the snapshot, is what gets staged, so the exposure was always the message's precision, never the commit's content. Walk-start bookkeeping (the store exposing when a walk began) is the named escalation if the window ever stops being academic. **A commit's comment bullets sort chronologically** (blessed 2026-07-31): by the comments' own `created`, folder name on ties — event order reads as the conversation did, never UUID-arbitrary. The EchoLedger's two-commit split still applies at close when the held window mixes foreign changes to that card with the app's own. Settled-tree events that cannot wait — a pull's flush-before-overwrite — commit the session's on-disk state as-is (a mechanical exception; 07-sync-collab.md's same-card signpost covers the visible half); branch switch instead gates on explicit save-or-discard (Branch switching below). The cadence constraint below demands batching at least this coarse. **Board-window close and app quit flush the pipeline** — any pending editor save (05-card-window.md), then open card-window sessions, then the pending auto-commit — before teardown; nothing settled is ever left unsaved or uncommitted by closing.
|
||||
- **Flush-before-overwrite**: before an app write overwrites on-disk state that differs from the last-loaded snapshot (an uncommitted external change — e.g. an agent's body rewrite racing the card editor's debounced save, 05-card-window.md), the pending auto-commit is flushed so the external version enters history first. "Both versions exist as commits" is thereby a guarantee, not a likelihood — **as strong as the watcher's knowledge** (bounded, blessed 2026-07-31): the gate fires on *known* foreign changes, learned from landed reloads, so a foreign write still inside the watcher debounce can be overwritten unflushed; the window is the debounce (~200 ms), the disk-level outcome inside it is 05's last-writer-wins — the same ruling at history granularity — and `index.md` writes compose over fresh disk bytes anyway, so wholesale loss needs a body-save or raw-Apply race. A **failed reload counts as a foreign change** (can't know, so protect). A pre-write disk compare was weighed and declined — a read per write to buy back the debounce corner. **The flush commits synchronously on the main actor** (blessed 2026-07-31): the write path it must precede is synchronous, so the ordering guarantee can only be kept by a synchronous commit — 02's hang-avoidance doctrine and this guarantee genuinely conflict here, and the guarantee wins, being rare (the known-foreign window only), bounded (one stage-and-commit plus the composer's HEAD-tree materialization — a few board-sized walks, accepted so exactly the commit that preserves somebody else's version keeps its full semantic subject), and load-bearing (the alternative loses a version no commit protects). One attempt, no lock backoff on this path — a held `index.lock` falls through to the next quiet debounce. The same flush settles the tree before a pull runs (07-sync-collab.md).
|
||||
- **Cadence constraint** (agreed): the auto-commit cadence must not make the history of a remote-shared board unbearable — one commit per drag is fine for a local undo trail but noisy as a shared log. The debounce/batching design here must serve both consumers; the push/pull side is settled in 07-sync-collab.md (optional push-on-commit, automatic fetch-rebase-push on rejected pushes).
|
||||
- **Undo never rewrites history.** Undo (⌘Z) and redo (⇧⌘Z) restore earlier states as **new forward commits** — never reset, never force. The whole trail stays inspectable in any git client. The one deliberate rewrite anywhere in the app is pull's rebase of **unpushed local** commits (07-sync-collab.md); published history is never touched.
|
||||
- **Undo restore vs open Edit sessions** (settled): a restore materializes only the diff between the current tree and the target state, so a card whose open Edit session the diff doesn't touch is simply unaffected — its uncommitted ~700 ms saves and the stage-around rule continue undisturbed, and most undos never meet an editor at all. When the diff *does* touch a session card, the restore **gates on the branch-switch save-or-discard step** (Branch switching below — Save All / Discard / Cancel, same machinery, same rationale): silently flushing would commit a tree the user deliberately hasn't saved, a checkout over uncommitted on-disk saves would destroy text no commit protects (the one place "both versions exist as commits" could otherwise fail), and a surviving dirty buffer's next debounced save would write pre-undo text over the restored card — a ⌘Z that visibly doesn't happen. With sessions settled the restore runs on a settled tree. Redo is symmetric. Open raw-source buffers get the branch-switch settle treatment too (Branch switching below).
|
||||
- **The stack is HEAD's first-parent ancestry, live** (settled): foreign commits — watcher-auto-committed agent work and agents' *self*-commits alike — push onto the in-session undo stack as ordinary steps as they land. The stack re-syncs its top to HEAD before every undo/redo (self-commits move HEAD outside the app's committer; the pre-flight sync is how the stack learns), so ⌘Z always steps back exactly **one** commit — it can never silently revert twenty minutes of agent work landed since the user's last operation. Any commit arriving from anywhere clears the redo stack (classic behavior; redo also starts empty on the relaunch reseed below). In-session and post-relaunch behavior are thereby one rule — the reseed is the same ancestry walk from scratch.
|
||||
- **The stack is HEAD's first-parent ancestry, live** (settled): foreign commits — watcher-auto-committed agent work and agents' *self*-commits alike — push onto the in-session undo stack as ordinary steps as they land. The stack re-syncs its top to HEAD before every undo/redo (self-commits move HEAD outside the app's committer; the pre-flight sync is how the stack learns), so ⌘Z always steps back exactly **one** commit — it can never silently revert twenty minutes of agent work landed since the user's last operation. Any commit arriving from anywhere clears the redo stack (classic behavior; redo also starts empty on the relaunch reseed below) — **except a heal-only window** (blessed 2026-07-31): heal transparency (below) makes heal commits invisible to the stack, and a heal-only clear would half-defeat it — the trap would dissolve for undo while redo still died on every landed repair; a window mixing a heal with real changes clears via the real changes. In-session and post-relaunch behavior are thereby one rule — the reseed is the same ancestry walk from scratch. **The root commit is the stack's floor, not a step** (settled 2026-07-31): it is a baseline, not a change-set (Abnormal repo states above), and it has no parent — crossing it could only mean restoring the empty tree, every file on the board deleted by one ⌘Z. The stack stops above it: undo bottoms out at the initial board state, and the reseed walk stops in the same place.
|
||||
- **Undo survives relaunch**: the undo stack reseeds from HEAD's first-parent ancestry on load; redo starts empty. In-session it behaves as classic dual stacks; after relaunch, past restore commits reappear as ordinary undoable steps. Deliberate: no sidecar state, nothing ever lost. Interaction with pull (07-sync-collab.md): a pull rebases unpushed local commits, so the in-session stack must remap onto the rewritten commits — the pre-rebase hashes are orphaned. A pleasant consequence of the reseed rule: the fetched remote commits sit in HEAD's first-parent ancestry, so after the next relaunch remote work becomes ordinary undoable steps too.
|
||||
- **Heal commits are transparent to undo, in-session** (ruled 2026-07-29): heal-class commits — their paths known by the Writer's heal-marked receipts (Commit messages below) — never become undo steps: the stack pointer passes over them, and a restore materializing an older target **excludes paths whose divergence is heal work**, so a ⌘Z run never reverts a repair and never summons the scheduler (reverting one would re-arm the memo on the recreated defect signature, land a fresh heal commit, and — redo cleared — trap the run on an ever-renewing top; transparency dissolves the trap instead of suppressing the healer). The in-session qualifier is honest: receipts live in memory and the reseed is deliberately sidecar-free, so after relaunch old heal commits reappear as ordinary steps — undoing one recreates its defect and the scheduler re-heals within a reload, restore commit plus fresh heal commit, notice included. That **residual bounce is accepted family-wide** — the agent-guide quirk (Agent collaboration below) generalized to the relocation, the legacy migration, the displacement, and the remint — and it is **self-limiting to one bounce**: the fresh heal commit is in-session, transparent, and the undo run continues past it. Redo is symmetric.
|
||||
- **Undo is board-local.** A cross-board move-out undone at the source resurrects the card even though it lives on in the destination — per-board histories cannot and must not mutate other boards. The resulting same-UUID fork across boards is legitimate (boards are independent identity namespaces); if the two ever meet through a move-in, the import boundary remints the arrival (01-storage-format.md's identity lifecycle).
|
||||
- The git surface lives in the **board popover** (03-board-ui.md): branch/source display, branch switching and creation, and the commit-identity name/email fields (see Interaction with external writers below) — alongside board rename and styling.
|
||||
- The git surface is the **board popover's Git tab**, whole (03-board-ui.md): branch/source display, the switch picker with its New Branch… reveal, the posture lines, add-git, and the commit-identity name/email fields (see Interaction with external writers below), alongside board rename and styling in the popover's other tabs. *(Amended 2026-08-07 — the 2026-07-31 popover/sheet split is reversed: setup lived in a board settings sheet for a week, and that surface retired with its menu command; 03 ▸ Board settings sheet carries the reasoning. Remote and credentials — 07 — need a home ruled on that card.)*
|
||||
|
||||
## Undo routing
|
||||
|
||||
**Routing is by focus** — the platform's first-responder rule, its own section because two undo systems coexist and four docs cite the rule. While a text-editing surface is focused (card title field, body Edit mode, raw source, board inline rename), ⌘Z/⇧⌘Z are that editor's own **text undo** — standard, transient, session-scoped: leaving the editor (mode flip, focus loss, close) ends the session, and from then on that content's undo story is the git trail. Text undo works on **every** board — and since the 2026-07-31 repo-nested re-ruling, so does board-level undo: every board binds a provider (native or git), so "no undo" is no longer a state any board is in. **Control-class text fields route the same way** (settled): the search field (04-interactions.md ▸ Search), the popover's rename field, and the popover's own configuration fields (commit identity, the New Branch… name, and 07's credentials and remote URL when they land) own ⌘Z/⇧⌘Z as field-local text undo while focused — "board menu commands stay enabled" never hands Edit ▸ Undo to git while a text-bearing control has focus; a reflexive undo over a typo must never become a tree checkout. With focus outside every text-bearing surface — editor or control — Edit ▸ Undo/Redo are, **in a card window, that window's own session stack** (13-native-undo.md's two-level model, re-ruled 2026-07-31 — fine-grained window gestures, both tiers; the coarse close unit is the tier-split: one native board step, or one commit), and on board surfaces board history — the board's bound provider, git or native (every board binds one since 2026-07-31; disabling is locks and empty stacks). **No fall-through**: exhausting a focused editor's — or the window's — stack beeps; it never reaches board history.
|
||||
|
||||
## Commit messages
|
||||
|
||||
The pathfinder's message engine carries over as the model — it is what earns the "semantic" in semantic commit messages, and it stays a pure, testable function:
|
||||
|
||||
- **Pure snapshot diff, no write-site tagging.** Messages compose at commit time from a structural diff of two board snapshots (last-committed vs. current) — never by intercepting operations. Items match by id across the *whole* board, so a lane change is distinguishable from delete+add and a cross-lane move reads as a move. Bookkeeping — `order` changes that preserve sibling sequence (a renumber's rescale — 01-storage-format.md), `modified`/`created` — produces no events: a diff touching only those composes nothing. Sequence is what the diff compares, not raw `order` values: an order change that *repositions* an item among its siblings composes Reorder, so a foreign writer's single-file reorder still reads as one. A midpoint-exhaustion renumber batches with the insert or move that triggered it, so its commit reads as that event.
|
||||
- **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus Attach / Remove for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"). One commit per window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows fall back to "Update board" — always with a bulleted body naming every event, so the oneline log stays scannable and the full message stays complete.
|
||||
- **Implied events don't steal the subject**: deleting a lane with five cards reads "Delete lane 'X'" with the card deletions as body bullets — not "Update board".
|
||||
- **Titles truncate in subjects only** (~40 chars, keeping `git log --oneline` sane); body lines carry full titles. An untitled item reads "(untitled)" — never a bare `""` (a pathfinder edge fixed, not carried). Undo/redo restores commit as "Undo: ⟨subject⟩" / "Redo: ⟨subject⟩"; the undo-menu labels are the *crossed* commit's subject, so labels never nest.
|
||||
- **Pure snapshot diff, no write-site tagging.** Messages compose at commit time from a structural diff of two board snapshots (last-committed vs. current) — never by intercepting operations. Items match by id across the *whole* board, so a lane change is distinguishable from delete+add and a cross-lane move reads as a move. Bookkeeping — `order` changes that preserve sibling sequence (a renumber's rescale — 01-storage-format.md), `modified`/`created`, and an on-touch heal's backfilled `kind` (01-storage-format.md ▸ Validation and healing) — produces no events: a diff touching only those composes nothing. Sequence is what the diff compares, not raw `order` values: an order change that *repositions* an item among its siblings composes Reorder, so a foreign writer's single-file reorder still reads as one. A midpoint-exhaustion renumber batches with the insert or move that triggered it, so its commit reads as that event.
|
||||
- **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus Attach / Remove / **Replace** for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"; Replace added 2026-07-31 — a changed file under a card's `attachments/` with an unchanged listing is a content replacement, named from the path alone: "Replace attachment 'photo.png' — card 'X'", never the anonymous path generic), plus **Repair** for the duplicate-id remint ("Repair duplicate of 'Fix login'" — 01-storage-format.md's silent scheduled heal, re-ruled 2026-07-29 from its former banner gate; app-mediated and heal-marked, so its separate heal commit names the remint directly instead of reading the folder swap as Permanently delete + Add), plus the trash pair (settled) — **Restore** and **Permanently delete** — distinguished by diff shape alone, keeping the composer a pure snapshot diff: a move into `.trash/` is Delete, a move out of it is Restore ("Restore card 'X'" — drag-to-restore, cut+paste), and an item *leaving the tree entirely* is Permanently delete ("Permanently delete card 'X'" — the trash's Delete, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free); the inverse oddity — a card *arriving from outside the tree directly in `.trash/`* — composes "Add card 'X'" with a to-the-trash detail (blessed 2026-07-31: the arrival shape is an Add, the detail names the destination). The trail thereby tells moved-to-trash from gone-forever — the distinction Deleting never forgets (below) asks users to learn. Plural folding applies as usual: Empty Trash reads "Permanently delete 12 cards", cleanly distinct from a multi-select ⌫'s "Delete 12 cards". One commit per debounce window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows **say so in the subject** (re-ruled 2026-07-31, retiring the bare "Update board" fallback): **"Mixed update — N changes"**, always with a bulleted body naming every event, so the oneline log stays scannable and never dresses a grab-bag as one thing; when every event in the window shares one item — the card-window session flush's usual shape — the subject keeps the name: **"Mixed update — N changes to card '⟨title⟩'"**.
|
||||
- **Implied events don't steal the subject**: deleting a lane with five cards reads "Delete lane 'X'" with the card deletions as body bullets — not "Update board". **One level further down, a card's own event swallows its thread entirely** (blessed 2026-07-31): a card moved, deleted, restored, purged, or reminted carries its `comments/` paths silently — the card's event already explains every file under it, and comment bullets trailing "Delete card 'X'" would be the burying this rule exists to prevent.
|
||||
- **Non-snapshot files commit too** (settled — the repo tracks more than the model: the agent guide, `CLAUDE.user.md`, the seeded `.gitignore`, and strays at every level): the committer **stages the whole board root** — whatever `git status` shows, `.gitignore` respected, **open Edit sessions still staged around** (settled): the session card's folder stays excluded exactly as in Rules ▸ Auto-commit, whole-root staging widening *what* commits, never overriding the exclusion — and its commit condition is the *tree*, not the snapshot diff, so a stray-only window commits rather than leaving the tree dirty (firing mid-session, it commits the strays and leaves the session folder untouched) (a permanently dirty stray would break branch switch's cannot-fail-dirty guarantee and void flush-before-overwrite for every file the model can't see). The composer's input extends accordingly: beside the snapshot diff it receives the changed-path list, and non-snapshot paths compose **path-shaped events** — `CLAUDE.md` composes "Update agent guide (vN)", the version read from the guide's marker first line (a pure function of file content, *not* write-site tagging — the no-interception rule stands); any other non-snapshot path composes "Update '⟨path⟩'", folding plural ("Update 3 files"). Model events keep the subject when present; non-snapshot changes then ride as body bullets — recorded, never silently absorbed under an unrelated subject. Attribution needs no new rule: the EchoLedger (02-architecture.md) is path-keyed, so the app's guide write classifies app-mediated by its receipt (the mechanism behind the guide-attribution exception below) and a stray edit classifies foreign, the two-commit split applying per file as everywhere else.
|
||||
- **Healing mutations commit separately** (ruled 2026-07-29 — 01-storage-format.md ▸ Validation and healing): a debounce window holding a scheduled heal's changes alongside anyone else's splits the heal's paths into their own commit — the two-commit split's mechanism with a third class, keyed by the Writer's heal-marked receipts in the EchoLedger; a three-way window commits **foreign → heal → user** (blessed 2026-07-31 — causal: what was found, the response to it, the newest overwrite); split staging starts by **resetting the shared index to HEAD** (blessed 2026-07-31 — another writer's staged-but-uncommitted picks are discarded, their *content* still committing on the app's classified terms; the exposed race is only the gap between a foreign `add` and its `commit`, since a held `index.lock` already backs the app off, and a private in-memory index beneath the wrapper is the named upgrade if cohabitation friction ever shows) (attribution machinery, like the author split; the composer stays a pure diff reader and names the heal commit from its own diff shape — a loose-file relocation reads as its Attach-shaped event, a legacy migration's move into `.trash/` as Delete, the guide as "Update agent guide (vN)"; that the shape vocabulary doesn't say "healed" is accepted, the banner already told the user). Mostly redundant — each scheduled healer runs its own bracket at the reload tail, normally its own window — but the split makes separation a guarantee rather than a timing accident. **Heal commits are authored `Lanework Integrity <[email protected]>`** (ruled 2026-07-31 — the third pinned synthetic, joining Lanework External and the agent-slug family; strings are API): a heal is a third origin — not the user's gesture, not a foreign writer — and the separation exists for audit, so the trail filters by author like every origin; the committer stays the user (the recorded-by convention above). On-touch and inline heals are structurally exempt: each lives inside a host write or its triggering gesture and rides that commit, the backfilled `kind` composing no event per the bookkeeping rule above.
|
||||
- **Titles truncate in subjects only** (~40 chars, keeping `git log --oneline` sane); body lines carry full titles. An untitled item reads "(untitled)" — never a bare `""` (a pathfinder edge fixed, not carried). Undo/redo restores commit as "Undo: ⟨subject⟩" / "Redo: ⟨subject⟩"; the undo-menu labels are the *crossed* commit's subject, so labels never nest. **Subjects don't nest either — crossing a restore composes the inverse** (settled 2026-07-31): when the crossed commit's subject already carries a restore prefix — the post-relaunch case, where the reseed has made old restore commits ordinary steps — the composer emits the *inverse* label instead of stacking: crossing "Undo: S" yields "Redo: S", crossing "Redo: S" yields "Undo: S". This is the truer label, not a euphemism — undoing the restore that undid a move *re-applies* the move — and it caps prefixes at one across any number of relaunches. The sniff is on the subject string, so a foreign commit that happens to open with a prefix gets the inverse label too; that's cosmetic — the restore itself is unaffected. **Restore commits are user-authored, whatever they cross** (blessed 2026-07-31): the commit records the user's decision to put things back, not the crossed change — a foreign or agent-authored commit undone by ⌘Z yields a restore under the user's identity, keeping `--author` filtering truthful (an agent never appears to revert itself). **Menu enablement reads a cached stack picture that refreshes asynchronously after a landed commit** (blessed 2026-07-31): a crossing always awaits settlement — ⌘Z acts on true history, never the cache — so the lag can only mislabel or mis-grey a menu row for one beat, matching AppKit's own validation cadence; a synchronous walk on the commit-report path was declined as cost without an observable win.
|
||||
|
||||
**The external gap, closed** (the pathfinder weakness this section exists to fix): the composer is origin-agnostic, but external writers routinely touch what the pathfinder's diff never modeled — `labels`, `assignees`, `due`, custom frontmatter keys — so their commits degraded to a generic fallback even though the attribution machinery knew plenty. The rewrite:
|
||||
|
||||
- **The diff models the full schema-1 surface — plus the reserved metadata trio, deliberately**: label, assignee, and due changes compose ("Relabel card 'X'", "Assign card 'X'", "Set due date on card 'X'") even though 01-storage-format.md reserves those keys out of this version's UI — external writers (pathfinder-era boards, agents) are exactly who touches them, and naming three known keys in a pure diff function costs nothing. A change to any other unmodeled or custom key composes a named generic ("Update card 'X'") — never a board-level shrug when the touched item is identifiable.
|
||||
- **The diff models the full schema-1 surface — plus the reserved metadata trio, deliberately**: label, assignee, and due changes compose ("Relabel card 'X'", "Assign card 'X'", "Set due date on card 'X'") even though 01-storage-format.md reserves those keys out of this version's UI — external writers (pathfinder-era boards, agents) are exactly who touches them, and naming three known keys in a pure diff function costs nothing. A change to any other unmodeled or custom key **says what it is** (re-ruled 2026-07-31 — first lines self-describe; generics are a last resort, kept very rare): **"Change custom key on card 'X'"** (board and lane likewise — "Change custom key on board '⟨title⟩'"; several keys fold plural), the body naming each key with its old → new values. The bare named generic ("Update card 'X'") survives only for a change in a known file that is neither a vocabulary event nor a key change — a shape that should almost never occur; never a board-level shrug when the touched item is identifiable.
|
||||
- **Foreign commits speak the same vocabulary.** Origin lives in the author field (structural attribution below), not in message prose — a foreign move reads "Move card …" exactly like an app-mediated one, and any git client filters by author.
|
||||
- **The launch catch-up commit composes too**: changes found pending at board open diff HEAD's tree against the working tree through the same composer, instead of committing blind.
|
||||
|
||||
## Branch switching
|
||||
|
||||
Switching (or creating-and-switching) a branch from the board popover:
|
||||
Switching a branch from the popover's picker, or creating-and-switching from the **New Branch…** reveal behind that picker's divider (03-board-ui.md ▸ Git tab; the field moved to the settings sheet with the 2026-07-31 split and came back with the 2026-08-07 reversal, the sequence untouched by either move) — **create-and-switch runs the identical settle → flush → stamp → switch sequence, no at-HEAD fast path** (blessed 2026-07-31): "creating at HEAD can't change the tree" fails as a proof under concurrent writers (a foreign commit can land between the check and the create), and flushing pending work onto the branch it was made on is the honest cadence regardless — the occasional save-or-discard beat on a create buys one sequence with no special case:
|
||||
|
||||
- **Settle the editors first — explicitly, never silently.** Branch switch neither silently commits nor silently abandons an open card-body Edit session: if any open card window has one (unsaved keystrokes, or on-disk ~700 ms saves the session hasn't committed — the mid-session state Auto-commit above deliberately leaves uncommitted), the switch presents a **save-or-discard step**: **Save All** ends every session with its normal commit (each card's Edit→Preview flip), **Discard** reverts buffers and uncommitted saves to HEAD, **Cancel** keeps the current branch and the sessions. Silently flushing the commit alone would be wrong twice over: the tree can be clean precisely because a save hasn't landed, and a later debounced save would write old-branch text onto the new branch's card. With sessions settled, the pending auto-commit flushes (flush-before-overwrite above) and checkout runs on a truly settled tree: it cannot fail dirty, and no in-flight work is lost or dragged across branches. (Inline title editors need no step of their own: reaching the popover's branch controls commits them — click-away commits, and board-scoped commands disable while one is focused — 04-interactions.md ▸ Grammar.)
|
||||
- **Settle the editors first — explicitly, never silently.** Branch switch neither silently commits nor silently abandons an open card-body Edit session: if any open card window has one (unsaved keystrokes, or on-disk ~700 ms saves the session hasn't committed — the mid-session state Auto-commit above deliberately leaves uncommitted), the switch presents a **save-or-discard step**: **Save All** ends every session with its normal commit (each card's Edit→Preview flip), **Discard** reverts buffers and uncommitted saves to HEAD, **Cancel** keeps the current branch and the sessions. **Open raw-source buffers are settled by the same step** (settled — an unsettled raw buffer is the worse hazard: its Apply later writes the *entire* pre-switch `index.md` byte-for-byte onto the new branch's card): Save All *applies* each raw buffer — and since Apply validates, a buffer that fails validation cancels the whole switch with focus on the offending window, nothing half-switched; Discard exits raw source without writing; Cancel keeps everything. External checkouts the app can't gate are the accepted last-writer-wins case, same as the Edit buffer (05-card-window.md's dirty-buffer rule; on git boards the overwritten version is a commit, one revert away). Silently flushing the commit alone would be wrong twice over: the tree can be clean precisely because a save hasn't landed, and a later debounced save would write old-branch text onto the new branch's card. With sessions settled, the pending auto-commit flushes (flush-before-overwrite above) and checkout runs on a truly settled tree: it cannot fail dirty, and no in-flight work is lost or dragged across branches. **The settle also clears each open card window's fine undo stack** (ruled 2026-07-31): pre-switch steps describe the branch being left — Save All and Discard alike end with every window's stack empty, the board-stack discard-and-reseed precedent one level down; the windows stay open, following their cards onto the new branch with fresh stacks. **The flush is Save-All-shaped** (blessed 2026-07-31): after Discard, the session's reverted bytes are *reconciled, never flushed* — the pending window for that folder drops, since disk again agrees with HEAD and committing the discarded saves would betray the button; pending changes elsewhere on the board still flush normally. (Inline title editors need no step of their own: reaching the popover's picker or its New Branch… field commits them — click-away commits, and board-scoped commands disable while one is focused — 04-interactions.md ▸ Grammar.)
|
||||
- **The undo/redo stack does not survive a switch.** It is discarded and reseeded from the new HEAD's first-parent ancestry — the relaunch rule applied at switch time; redo starts empty. (Replaying a restore commit from the previous branch onto the new one would be wrong.)
|
||||
- **Everything remote-facing tracks the current branch**: ahead/behind, Pull/Push, and push-on-commit all operate against the current branch's upstream. On a branch with no upstream yet, the first push — manual or push-on-commit — **creates it on the remote quietly** (`push -u` semantics): creating a remote branch is non-destructive, and quiet is consistent with push-failures-never-nag (07-sync-collab.md). Genuine failures queue with the badge as usual.
|
||||
- The switch itself is bracketed (02-architecture.md): watcher suspended, one full reload at the end. If that final reload fails, the board locks read-only until a successful reload — see 02's live-reload resilience; the on-screen snapshot is from the previous branch and must not be edited over the new one.
|
||||
|
||||
## Interaction with external writers
|
||||
|
||||
Agent and hand edits arrive through the watcher like any change and get auto-committed on the same debounce — so agent work is undoable, attributed, and *described* in the same trail: foreign changes compose through the same message engine as app-mediated ones (Commit messages above — the pathfinder's generic "External edit: 2 cards changed" fallback is gone), with origin carried by the author field. One attribution exception: the app's own agent-guide writes (08-agent-integration.md) are the app's own Writer operations — app-mediated by the echo machinery, carrying the guide's version-marker first line — and committed as "Update agent guide (vN)", not "External edit". Known quirk, not a bug: undoing an "Update agent guide (vN)" commit restores an older guide that the app immediately re-upgrades — a one-bounce no-op undo (restore commit + fresh upgrade commit). Harmless; the guide is app-owned and self-healing by design.
|
||||
Agent and hand edits arrive through the watcher like any change and get auto-committed on the same debounce — so agent work is undoable, attributed, and *described* in the same trail: foreign changes compose through the same message engine as app-mediated ones (Commit messages above — the pathfinder's generic "External edit: 2 cards changed" fallback is gone), with origin carried by the author field. One attribution exception: the app's own agent-guide writes (08-agent-integration.md) are the app's own Writer operations — app-mediated by the echo machinery, carrying the guide's version-marker first line — and committed as "Update agent guide (vN)", not "External edit". The guide-commit undo quirk is the heal-transparency rule's oldest case (Rules ▸ Heal commits are transparent): in-session the upgrade commit never enters the stack; only a relaunch-old guide commit bounces — once, restore commit plus fresh upgrade commit, the fresh one transparent. Harmless; the guide is app-owned and self-healing by design.
|
||||
|
||||
**Commit attribution is structural, not just a message convention.** The Writer/echo machinery lets the auto-committer classify every observed change as **app-mediated** (the user acting through the app) or **foreign** (anything else). User-driven commits carry the user's git identity; foreign changes are committed under the pinned synthetic author **`Lanework External <[email protected]>`** — so any git client can filter, log, and blame by origin. The strings are API (users script against them; the `.invalid` TLD honestly marks a non-routable synthetic identity) — they change with the deliberateness of a schema change.
|
||||
**Commit attribution is structural, not just a message convention.** The Writer/echo machinery (the **EchoLedger** — 02-architecture.md ▸ Components, where its matching rule and race cases are settled) lets the auto-committer classify every observed change, per file, as **app-mediated** (the user acting through the app) or **foreign** (anything else). User-driven commits carry the user's git identity; foreign changes are committed under the pinned synthetic author **`Lanework External <[email protected]>`** — so any git client can filter, log, and blame by origin. **The committer field is always the user's identity** (blessed 2026-07-31 — git's own `am`/cherry-pick convention: author = whose change, committer = who recorded it): every commit the app makes, foreign-authored included, records the user's app as its committer. The strings are API (users script against them; the `.invalid` TLD honestly marks a non-routable synthetic identity) — they change with the deliberateness of a schema change.
|
||||
|
||||
**Where the user's git identity comes from** (no git install is assumed, and the sandbox doesn't read `~/.gitconfig` — honest limits, not bugs): **repo-local `.git/config` wins when present** — standard git semantics, readable in-sandbox because it lives under the board root, and the natural state of adopted/cloned boards. The board popover's git section exposes name/email fields that **write that repo-local config** — the setting *is* the file, portable to any git client, per-board by nature (work and personal boards can differ). Absent repo config, the **derived default** applies: the macOS account's full name plus `shortname@hostname` — git's own no-config fallback shape, zero ceremony. Commits pushed to a forge under the derived email won't link to a forge account; the popover fields are the fix when that matters. A debounce window containing both kinds is **split into two commits**, never mixed (flush-before-overwrite already orders them: foreign first, then the user's overwrite). Honest limit: the app distinguishes app-mediated from foreign, not human from agent — a hand edit in a text editor and an agent write look identical *unless the writer says otherwise via `modified-by` (below)*. Agents wanting precise attribution are encouraged (via the agent guide, 08-agent-integration.md) to commit their own changes; the app follows along.
|
||||
**Where the user's git identity comes from** (no git install is assumed, and the sandbox doesn't read `~/.gitconfig` — honest limits, not bugs): **repo-local `.git/config` wins when present** — standard git semantics, readable in-sandbox because it lives under the board root, and the natural state of adopted/cloned boards. The **popover's Git tab** carries an identity section (03-board-ui.md — the 2026-07-31 split moved the fields onto a settings sheet and the 2026-08-07 reversal brought them back) exposing name/email fields that **write that repo-local config** — the setting *is* the file, portable to any git client, per-board by nature (work and personal boards can differ). **The fields re-read the config at 2 s while they are visible** (blessed 2026-07-31; container amended 2026-08-07 — the poll rides with the fields, so it now lives and dies with the popover's Git tab rather than with the retired sheet): the watcher never delivers `.git`, so no board event can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and a visibility-scoped poll is the 15 s paused-state re-read's shape at form cadence (a focused field keeps its keystrokes; dismissing the surface stops the poll). **Writes append, reads take the last** (blessed 2026-07-31): the writer appends a plain `[user]` section and never edits existing sections or `[user "…"]` subsections (their semantics are tool-specific); the reader — like git itself — takes the last plain-section value, which is exactly what an append produces. The asymmetry lets the write always win without the writer ever reformatting what it didn't create — the frontmatter engine's never-reformat instinct applied to git config; the worst case is a slightly redundant file git reads correctly. A write whose keys already read back at their target values is skipped whole, so revisiting the fields never grows the file. **Clearing a key is the one sanctioned in-place edit** (ruled 2026-08-06): an empty field means "no repo-local opinion", and the config format spells absence one way only — the key not being there. The append-shaped alternative, an empty `email =` line, is an opinion in the wrong direction: a repo-level empty value *overrides* the user's global `~/.gitconfig` in their own terminal and fails their commits with git's empty-ident error. So a clear deletes every plain-section line for that key — deleting fewer than all of them changes nothing under last-wins — and drops any plain `[user]` header left with no keys under it; clearing both fields leaves the file with no plain-section identity at all, the state a never-configured repo is in. Subsections stay untouchable in both directions. Absent repo config, the **derived default** applies: the macOS account's full name plus `shortname@hostname` — git's own no-config fallback shape, zero ceremony. Commits pushed to a forge under the derived email won't link to a forge account; the identity fields are the fix when that matters. **The derived default is passed as an explicit per-commit signature, never written into repo config** (ruled 2026-07-31 — the signature-capable commit path gates the pro-m1 ship): repo config is the record of the user's popover edits and of adopted repos' own state, and an app-written identity there would outrank the user's global `~/.gitconfig` for their *own terminal commits* in that board. The build-time interim that materializes identity into a fresh repo's `.git/config` (SwiftGitX 0.4.0's signatureless commit + the sandbox's unreadable global config) is tolerated in-tree during pro-m1 construction and must die before release — the attribution rules above (per-commit author variation) require explicit signatures anyway. A debounce window containing both kinds is **split into two commits**, never mixed (flush-before-overwrite already orders them: foreign first, then the user's overwrite). Honest limit: the app distinguishes app-mediated from foreign, not human from agent — a hand edit in a text editor and an agent write look identical *unless the writer says otherwise via `modified-by` (below)*. Agents wanting precise attribution are encouraged (via the agent guide, 08-agent-integration.md) to commit their own changes; the app follows along.
|
||||
|
||||
**`modified-by` refines foreign attribution** (the self-reported provenance key — 01-storage-format.md): when every file changed in a foreign debounce window carries the same `modified-by: X`, that commit is authored as **X** with the synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part slugified; the domain marks self-reported identity, distinct from both the user and the generic external author). Any disagreement between stamps, any unstamped changed file, or any true deletion in the window falls back to `Lanework External` — a deletion leaves no file to stamp. **A folder move is not a deletion**: items match by id across the whole board (Commit messages above — the same matching that reads a move as a move, not delete+add), so a moved card attributes by its stamp like any changed file. But a bare `mv` rewrites nothing — the moved `index.md` still carries whatever the app last wrote (no stamp) and demotes the window under the unstamped-file rule — so the agent guide teaches re-stamping on move (08-agent-integration.md). Same trust level as self-committing — it's what the writer claims, accepted as such; the stale-stamp hand-edit case (01) is the known misattribution edge. Self-committing remains the precise path; the stamp is the lightweight middle.
|
||||
|
||||
**Two writers, one repository — the designed situation, not an edge case.** Self-committing agents mean the auto-committer shares the repo with concurrent `git` processes, and it must be graceful about it:
|
||||
|
||||
- **`index.lock` contention is never an error.** If the auto-committer finds the index locked (an agent's commit in flight), it backs off briefly and retries; if the lock persists, it simply re-debounces — the pending changes are still pending, and the next quiet moment commits them. No banner, no log-worthy failure: a held lock is another writer doing its job. (Genuine commit failures — disk full, repo corruption — are different: files stay safe on disk but history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried on the next debounce.) **The same posture covers every app-initiated operation** (settled): pull, push, branch switch, and undo restore meeting a held lock wait and retry briefly, silently; contention outlasting the brief retry surfaces as a *waiting* state in the operation's in-progress banner row ("waiting for another writer's git lock"), retrying on its cadence — never an error dialog, never a hammer — and a wait that persists implausibly long names the lock path (a crashed writer's leftover is the user's to clear; the never-mutate rule's one exemption is the app's own leftovers, Abnormal repo states above). An operation that fails *cleanly* — disk error, refused checkout; network and auth are 07-sync-collab.md's pause-and-badge story — surfaces as a one-shot banner failure naming the operation and the error, the tree left as it was; failure after the tree changed wholesale is instead 02-architecture.md's failed-final-reload lock.
|
||||
- **`index.lock` contention is never an error.** If the auto-committer finds the index locked (an agent's commit in flight), it backs off briefly and retries; if the lock persists, it simply re-debounces — the pending changes are still pending, and the next quiet moment commits them. No banner, no log-worthy failure: a held lock is another writer doing its job. (Genuine commit failures — disk full, repo corruption — are different: files stay safe on disk but history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried on the next debounce.) **The same posture covers every app-initiated operation** (settled): pull, push, branch switch, and undo restore meeting a held lock wait and retry briefly, silently; contention outlasting the brief retry surfaces as a *waiting* state in the operation's in-progress banner row ("waiting for another writer's git lock"), retrying on its cadence — never an error dialog, never a hammer — and a wait that persists implausibly long names the lock path (a crashed writer's leftover is the user's to clear; the never-mutate rule's one exemption is the app's own leftovers, Abnormal repo states above). **The wait is bounded — 30 s, then a clean failure** (blessed 2026-07-31; injectable): a genuine writer finishes in seconds, so only a stale lock ever reaches the bound, and an eternal spinner holding the wholesale bracket — and with it the board — is worse than a failure that names the path to delete. The bound expiring is the clean-failure case below, tree untouched. An operation that fails *cleanly* — disk error, refused checkout; network and auth are 07-sync-collab.md's pause-and-badge story — surfaces as a one-shot banner failure naming the operation and the error, the tree left as it was; failure after the tree changed wholesale is instead 02-architecture.md's failed-final-reload lock. **Form-anchored operations answer at the form first** (ruled 2026-07-31; container updated twice — by the popover/sheet split, then back by the 2026-08-07 reversal, so these forms live in the popover's Git tab): add-git — and later form-asked operations like verify-remote — fail into an inline caption in the tab's relevant posture while the popover is up (the user asked from a form still under their eye; dismissing the popover dismisses the stale error, retry is right there, VoiceOver reads it from the focused surface); if the popover has been dismissed before the answer arrives, the failure falls back to the one-shot banner above — inline is the primary surface, never a silence trap. The banner enumeration stays the posture for board-wholesale brackets that outlive any one surface — branch switch and undo restore included (blessed 2026-07-31: a switch's bounded lock wait can expire long after the popover dismissed). **The two rules compose rather than conflict** (ruled 2026-08-06, the create-and-switch overlap; simplified 2026-08-07 by the reversal): a board-wholesale operation asked from a form is both at once, and the form rule wins while the asking surface stands — create-and-switch and a plain switch are now asked from the same surface and answer at the same caption, the popover's, so there is one inline slot for the branch affordance rather than two that had to be kept from showing at once. The banner remains the posture for whatever outlives the asking surface — the form rule's own fallback generalized: **inline while the asking surface is up, banner once it is gone.** Banner-only-everywhere was weighed and set aside — it would answer a form's question away from the form still under the user's eye. Which window's strip carries the row is 02-architecture.md's hosted-by-the-window-of-origin rule — the card window hosts its own strip, and a ⌘Z pressed there surfaces its failure there (re-homing to the board window if the card window closes first, per 02).
|
||||
- **A clean tree is the happy path, not a malfunction.** When the debounce fires and the tree has nothing to commit — the agent already committed its own work — the auto-committer no-ops silently. The agent's commit, under the agent's own authorship, *is* the record; that is precisely what the self-commit recommendation is for.
|
||||
- **An agent's `git add -A` can sweep up the user's not-yet-committed app-mediated changes** under the agent's authorship, muddying structural attribution for that window. Accepted limit — the app cannot police another process's staging; the agent guide (08-agent-integration.md) tells agents to commit only their own paths, which keeps well-behaved agents honest.
|
||||
|
||||
## Repository hygiene
|
||||
|
||||
- **`.gitignore` seeded at init, never touched after.** Adding git to a board writes a minimal `.gitignore` (`.DS_Store`) if none exists; the app never edits an existing one and never manages the file afterward — it's the user's from then on. (Repo-nested boards have no app-managed git, so no app `.gitignore` either.)
|
||||
- **Repo growth is accepted.** Unbounded history is the price of never-rewrite, and every attachment version lives in the repo forever. The app may run safe libgit2 housekeeping (repacking loose objects) periodically — it rewrites nothing. Content-removing compaction is explicitly out (it would rewrite history); size tooling joins the wishlist if growth ever bites in practice.
|
||||
- **Deleting never forgets.** On a git board, deleting a card removes it from the board but never from history — every version of its content and attachments stays reachable in any git client, and even the future tombstone purge (01-storage-format.md) only cleans the working tree. This is part of the design; users should learn it here, not from a repo browser.
|
||||
- **`.gitignore` seeded on every board, never touched after** (re-ruled 2026-07-31 — the file outgrew git: it is the one noise definition the loose-file relocation heal obeys, 01-storage-format.md ▸ Rules, so every board carries it, git or not). Board creation writes the minimal seed — `.DS_Store` plus the writer's temp pattern (`.*.lanework-*`) — and a board missing the file gains it by scheduled heal at open (the guide-refresh cadence; deletion is answered by re-seeding, and the escape hatch for wanting no exclusions is an *empty* file, which the app honors and never rewrites). The app never edits an existing `.gitignore` — it's the user's from the seed on, and fine-tuning what counts as noise over time means fine-tuning the seed. Repo-nested boards are seeded too (re-ruling the old no-app-`.gitignore` posture): the file serves the heal there, not any app-managed git; the visible untracked file in the user's repository is accepted on the agent-guide precedent, with the honest side effect that the enclosing repo's git reads it for paths under the board.
|
||||
- **Repo growth is accepted.** Unbounded history is the price of never-rewrite, and every attachment version lives in the repo forever. The app may run safe libgit2 housekeeping (repacking loose objects) periodically — it rewrites nothing. **"Periodically" has numbers** (blessed 2026-07-31, all injectable): the pack runs when the loose-object count crosses **6,700** — git's own `gc.auto` default, borrowed for the identical reason — **8 s after session activation** (outlasting the committer's launch catch-up), at background priority, skipped under a pause, a lock, or an in-flight commit, and **attempted at most once per session** — hygiene must never become a hot loop; a skipped or failed pack waits for the next open. **Packs accumulate — the pass never consolidates, rewrites, or deletes existing packs** (blessed 2026-07-31): it deletes only loose files it proved redundant against the pack it just wrote, so a long-lived board accrues roughly one pack per threshold's worth of objects, forever — the never-rewrite posture's accepted cost (git tolerates many packs gracefully; a terminal `git gc` consolidates at the user's discretion). **SHA-256 repositories are unsupported, safely** (blessed 2026-07-31): an adopted SHA-256 repo the engine cannot open takes the corrupt-repo loud-failure path — never a silent fall to mode-none; should one ever open, hygiene abstains by construction — its loose-object filter matches the SHA-1 filename shape only, so its work list is empty and the repo is left untouched (recognizing nothing and touching nothing is the fail-safe direction; a looser match that deleted half-recognized files would be the dangerous one). Content-removing compaction is explicitly out (it would rewrite history); size tooling joins the wishlist if growth ever bites in practice.
|
||||
- **Deleting never forgets.** On a git board, deleting a card removes it from the board but never from history — every version of its content and attachments stays reachable in any git client, and even a trash purge (Empty Trash, or the future age-based auto-purge — 01-storage-format.md) only cleans the working tree. This is part of the design; users should learn it here, not from a repo browser.
|
||||
|
||||
## Changes from Kanban
|
||||
|
||||
- **Commit granularity resettled to Edit sessions**: body commits land at the Edit→Preview flip (the effective Save button), not at typing-settle; the committer stages around open sessions (Rules ▸ Auto-commit).
|
||||
- **Branch switch gates on explicit save-or-discard** for open Edit sessions instead of silently flushing (Branch switching).
|
||||
- **Commit messages upgraded for external writers**: full schema-1 diff surface, foreign commits in the same vocabulary (no "External edit" prose), semantic launch catch-up, untitled-item rendering (Commit messages).
|
||||
- The rewrite should extract the old repo's AI-ANALYSIS-git-operations.md conclusions (forward-restore model, C3/C8) into a short normative doc rather than re-deriving them.
|
||||
- The old repo's AI-ANALYSIS-git-operations.md conclusions (forward-restore model, C3/C8) are extracted into [14-git-operations.md](14-git-operations.md) rather than re-derived — cite it, not the pathfinder file.
|
||||
|
||||
## Out of scope
|
||||
|
||||
|
||||
+14
-12
@@ -1,5 +1,7 @@
|
||||
# Sync & Collaboration
|
||||
|
||||
**Tier scope: Lanework Pro** (12-editions.md). The free tier ships mode:none only — the state machine below never leaves its first state there, `.git` encountered on disk is inert (12), and the Mode: none section's old no-undo caveat is superseded in every tier by native undo (13-native-undo.md — the provider follows the board, re-ruled 2026-07-31). Teams adds tracker-backed sync behind the same seam (deferred).
|
||||
|
||||
Every board has exactly one **collab mode** at a time, but the mode is not fixed at creation — it can evolve over the board's lifetime:
|
||||
|
||||
```
|
||||
@@ -12,7 +14,7 @@ A board may be created plain — **without any git repository** (a pivot from th
|
||||
|
||||
## Mode: none (local-only)
|
||||
|
||||
Plain folders on local disk. **No git repository at all** — and therefore, since git is the undo substrate, no undo/redo (06-history-undo.md). FSEvents live-reload works as on any board. Adding git later initializes the repo and moves the board to git mode. Honest caveat: without git there is no commit-before-overwrite protection, so on a no-git board **real data loss is possible** (e.g. concurrent or external overwrites) — accepted; adding git is the remedy. Repo-nested boards share this caveat: a repo exists, but the app manages no git there (06-history-undo.md), so its protections never run — committing is the user's own workflow. Deletion is the exception: the tombstone trash (03-board-ui.md) makes deletes recoverable even without git — overwrites are the lossy case, and Empty Trash is deliberate.
|
||||
Plain folders on local disk. **No git repository at all** — and therefore no git history; undo/redo binds the native stack in every tier (13-native-undo.md, re-ruled 2026-07-31). FSEvents live-reload works as on any board. Adding git later initializes the repo and moves the board to git mode. Honest caveat: without git there is no commit-before-overwrite protection, so on a no-git board **real data loss is possible** (e.g. concurrent or external overwrites) — accepted; adding git is the remedy. Repo-nested boards share this caveat: a repo exists, but the app manages no git there (06-history-undo.md), so its protections never run — committing is the user's own workflow. Deletion is the exception: the materialized trash (03-board-ui.md) makes card and lane deletes recoverable even without git — overwrites are the lossy case, and Empty Trash is deliberate.
|
||||
|
||||
## Mode: git
|
||||
|
||||
@@ -20,27 +22,27 @@ The board is a git repository (the 06-history-undo.md substrate — undo/redo, a
|
||||
|
||||
- Push/pull becomes a sharing mechanism between machines/people at file-level granularity; the fractal one-item-one-file design keeps conflicts rare and small (a reorder touches one file).
|
||||
- **Remote tracking lives in the board popover** (03-board-ui.md): ahead/behind indicator plus manual **Pull** and **Push** controls — which are also Board-menu items (no default chord, remappable — 11-command-nexus.md; the configuration carve-out is 04-interactions.md's).
|
||||
- **Which remote is the board's remote** (settled — adopted clones can carry several): resolution is git's own defaulting — the current branch's upstream remote; else `origin`; else the repo's sole remote. The popover names the remote it tracks, its change-remote control edits exactly that one, add-remote on a remote-less repo creates `origin`, and 06-history-undo.md's quiet first `push -u` targets the resolved remote (recording it as the upstream, which pins resolution thereafter). The unresolvable case — several remotes, no upstream, none named `origin` — is surfaced honestly: remote operations disable and the popover offers a one-time remote picker, whose choice becomes the branch's upstream on the next push.
|
||||
- **Optional push-on-commit**: a per-board setting (stored app-side in the board registry — 02-architecture.md's per-board app state); when enabled, every auto-commit is pushed immediately.
|
||||
- **Which remote is the board's remote** (settled — adopted clones can carry several): resolution is git's own defaulting — the current branch's upstream remote; else `origin`; else the repo's sole remote. The settings sheet names the remote it tracks (the popover's tracking badge shows it at a glance — the 2026-07-31 popover/sheet split, 03-board-ui.md), its change-remote control edits exactly that one, add-remote on a remote-less repo creates `origin`, and 06-history-undo.md's quiet first `push -u` targets the resolved remote (recording it as the upstream, which pins resolution thereafter). The unresolvable case — several remotes, no upstream, none named `origin` — is surfaced honestly: remote operations disable and the sheet offers a one-time remote picker, whose choice becomes the branch's upstream on the next push.
|
||||
- **Optional push-on-commit**: a per-board setting (stored app-side in the board registry — 02-architecture.md's per-board app state); when enabled, **every commit the app makes** is pushed immediately — auto-commits and undo/redo restore commits alike (06-history-undo.md's restores are commits like any other; a shared board never shows a phantom lag after an undo).
|
||||
- **Push failures never nag.** A push rejected as non-fast-forward (another machine pushed first) triggers an automatic **fetch → rebase → push**, with bounded retries — the same rebase machinery as Pull, so it cannot block and cannot conflict. Manual Push behaves identically. Stated plainly: enabling push-on-commit implicitly accepts that remote commits may land in the live board whenever pushes race — consistent with the board's live-reload nature, but it should be learned from the design, not discovered. All other push failures stay quiet: pushes queue, the ahead/behind indicator in the board popover carries the pending count and the last error, and pushing resumes automatically on the next commit or manual Push. No modals, no per-commit errors. The one refinement: **authentication failures pause rather than retry** — see Remote authentication below.
|
||||
- **There can be no conflicts — and no data loss.** Every edit becomes a commit before anything can overwrite it (auto-commit settles local changes; the tree is clean by the time a pull runs). A pull — manual, or the automatic fetch-rebase after a rejected push — runs only at **interaction rest**: it queues behind an in-flight drag or open inline editor (the same settled-change notion the auto-commit debounce uses), flushes the pending auto-commit (06-history-undo.md's flush-before-overwrite), then runs bracketed (02-architecture.md's live-reload resilience) — never an error dialog, never a board yanked mid-drag. An open card-body **Edit session neither blocks a pull nor is interrupted by one**: the flush commits the session's on-disk saves as-is (06-history-undo.md's mechanical exception to session-granularity commits) and the rebase runs; when the pulled commits touch the very card being edited, the card window **signposts** the remote change (a transient banner, no modal, no merge UI) while the dirty buffer stays put and wins per 05-card-window.md — the losing remote version is a commit, one revert away. A pull fetches the remote's commits and **rebases local commits on top of them**; where a rebase hits a genuinely conflicting hunk, the **local side wins** — always, with no configuration. (This is the one deliberate history rewrite in the app, and it only ever touches unpushed local commits — see 06-history-undo.md's undo-never-rewrites rule.) Crucially, resolution discards nothing: the losing remote version survives intact in the fetched commits below, so an edit that "lost" the rebase is visible in any git client and one revert away. What may *appear* as data loss is always recoverable. No interactive merge UI, no conflict markers written by the app, sync never blocks. A pull that cannot start or fails cleanly follows 06-history-undo.md's app-initiated-operation posture — lock contention shows as a waiting state in the operation's banner row, clean failures as one-shot banner errors, an interrupted rebase is aborted-and-reported via the own-leftovers exemption; push alone keeps the richer queue-and-badge story (below). Conflict markers encountered in files (from git activity *outside* the app) fail fast only where they break parsing — markers in or around the frontmatter make the file the malformed-input case the loader rejects loudly with the offending path. Markers wholly inside a Markdown body are, honestly, valid input: they load fine and render as body text, and the app deliberately doesn't police body content to detect them (stated stance, not an oversight). (The old repo's AI-THINKING-merge-conflicts.md explored this territory; mine it when specifying the rebase mechanics.)
|
||||
- **There can be no conflicts — and no data loss.** Every edit becomes a commit before anything can overwrite it (auto-commit settles local changes; the tree is clean by the time a pull runs). The clean-tree premise is why **abnormal repo states pause the remote half too**: a detached HEAD or in-progress merge/rebase holds Pull, Push, and push-on-commit alongside auto-commit (06-history-undo.md ▸ Rules ▸ Abnormal repo states — the whole git surface pauses; the ahead/behind badge keeps counting, a fetch being a read). A pull — manual, or the automatic fetch-rebase after a rejected push — runs only at **interaction rest**: it queues behind an in-flight drag or open inline editor (the same settled-change notion the auto-commit debounce uses), flushes the pending auto-commit (06-history-undo.md's flush-before-overwrite), then runs bracketed (02-architecture.md's live-reload resilience) — never an error dialog, never a board yanked mid-drag. An open card-body **Edit session neither blocks a pull nor is interrupted by one**: the flush commits the session's on-disk saves as-is (06-history-undo.md's mechanical exception to session-granularity commits) and the rebase runs; when the pulled commits touch the very card being edited, the card window **signposts** the remote change (a transient banner, no modal, no merge UI) while the dirty buffer stays put and wins per 05-card-window.md — the losing remote version is a commit, one revert away. An open **raw-source buffer gets the same treatment** (settled): a pull neither blocks on it nor invalidates it — the bracket's write lock merely disables Apply while the pull runs — and the same-card signpost shows in source mode too (the banner strip is window furniture, not part of the swapped content area). A later Apply is last-writer-wins across the *whole file*, frontmatter included, with the overwritten pulled version a commit one revert away — on the same branch this is exactly the Edit-buffer race. (Branch switch and undo restore must gate raw buffers on save-or-discard instead — 06-history-undo.md ▸ Branch switching — because there a stale Apply would write onto a *different tree's* card, not merely race a newer version of the same one.) A pull fetches the remote's commits and **rebases local commits on top of them**; where a rebase hits a genuinely conflicting hunk, the **local side wins** — always, with no configuration. (This is the one deliberate history rewrite in the app, and it only ever touches unpushed local commits — see 06-history-undo.md's undo-never-rewrites rule.) Crucially, resolution discards nothing: the losing remote version survives intact in the fetched commits below, so an edit that "lost" the rebase is visible in any git client and one revert away. What may *appear* as data loss is always recoverable. No interactive merge UI, no conflict markers written by the app, sync never blocks. A pull that cannot start or fails cleanly follows 06-history-undo.md's app-initiated-operation posture — lock contention shows as a waiting state in the operation's banner row, clean failures as one-shot banner errors, an interrupted rebase is aborted-and-reported via the own-leftovers exemption; push alone keeps the richer queue-and-badge story (below). Conflict markers encountered in files (from git activity *outside* the app) fail fast only where they break parsing — markers in or around the frontmatter make the file the malformed-input case the loader rejects loudly with the offending path. Markers wholly inside a Markdown body are, honestly, valid input: they load fine and render as body text, and the app deliberately doesn't police body content to detect them (stated stance, not an oversight). (The old repo's AI-THINKING-merge-conflicts.md explored this territory; mine it when specifying the rebase mechanics.)
|
||||
|
||||
## Remote authentication (settled)
|
||||
|
||||
Everything above assumes credentials exist; this is where they come from. Constraints first, stated as honest limits: a sandboxed app with bundled libgit2 cannot read `~/.ssh` (no entitlement grants it — silent access to every key is exactly what the sandbox exists to prevent), cannot reach `ssh-agent` (a unix socket outside the container; this also rules out 1Password/Secretive/YubiKey agents), cannot run the user's credential helpers, and gains nothing by shelling out (children inherit the sandbox). The network-client entitlement is assumed. Auth is therefore app-native, and **the Keychain is the only credential store** — credentials never live in board files or repo config. That is the deliberate inversion of files-are-truth: secrets are the one thing that must never be a file in the board.
|
||||
|
||||
- **Transports: HTTPS and SSH, both Keychain-backed.**
|
||||
- **HTTPS (primary)**: username + token (forge PATs; plain basic auth for generic hosts), stored as a Keychain internet password keyed by **host + username** — git's own scoping model, shared across boards: one GitHub token serves every board, and two accounts on one host coexist as two usernames. **Which username a board uses is the remote URL's business** (git's own answer, and the HTTPS analogue of the SSH per-host table): a username in the URL (`https://alice@host/…`) selects the Keychain item `host + alice`, and the popover's credential capture stamps the entered username into the remote URL in repo config — the URL is the assignment record, no app-side state (the secret itself stays in the Keychain). A URL naming no username resolves to the host's sole stored username; when a host has several, the popover's username field becomes a picker and saving stamps the choice into the URL, while background operations treat the ambiguity as **Authentication needed** (pause and badge, never guess — the same posture as auth failure).
|
||||
- **SSH — Keychain-resident keys, never key files.** Each Mac has a **Lanework key**: an app-generated ed25519 keypair whose private half lives as an ACL-protected Keychain item and is handed to libssh2 from memory — it never exists on disk. The board popover shows the public key with a Copy affordance; the user adds it to their forge like any machine key. An **existing key imports by paste or drag** (a one-time read under user intent): copied into the Keychain — passphrase entered once at import, stored under Keychain protection thereafter — and the original file is never referenced again. Per-machine identity, per-Mac revocable on the forge — the ssh-idiomatic shape. (Secure Enclave-backed keys — non-exportable, custom sign callback, P-256 — are a possible later hardening, not v1.)
|
||||
- **Key scope: app-level objects, per-host assignment.** Keys are never board state — the machine key plus any imports live app-wide (Keychain), and each SSH host maps to one of them: default the machine key; importing a key during a host's setup assigns it to that host. A "host" is `hostname[:port]` parsed from the remote URL — the same endpoint identity the TOFU fingerprint store uses (OpenSSH's own `[host]:port` convention); the URL's username (`git@`) disambiguates nothing and stays out of it. The assignment table holds **only overrides** — no entry means the machine key, so the default costs zero records and removing an override self-heals to it. The popover's key picker is labeled per-host ("key for github.com"), which teaches the one cross-board consequence: switching a host's key switches it for every board on that host — the same rotate-once-follow-everywhere behavior as HTTPS tokens. Housekeeping stays small: an import referenced by no host row can be removed; the machine key only regenerates (confirm-gated — it invalidates the old public half on every forge), and that is the entire rotation story. The board popover is only the surface — it shows the key for *that remote's host*, the way the commit-identity fields front repo-local config. Known limit, accepted: two accounts on the *same* host can't be told apart by key (forges bind key→account globally; git's own answer is ssh-config aliases, which live in files the sandbox can't read) — a per-remote key override joins the wishlist if it ever bites.
|
||||
- **Host verification is trust-on-first-use**: with no `~/.ssh/known_hosts` readable, the first connection to an SSH host confirms its fingerprint with the user; accepted fingerprints live app-side in Application Support (02-architecture.md's app-wide state home, host-scoped). A later mismatch **hard-blocks with an explanation** — that mismatch is the attack the check exists for.
|
||||
- **Setup verifies right there.** Adding or changing a remote (board popover — 03-board-ui.md) probes with authentication immediately (ls-remote): missing or rejected credentials surface **inline in the popover** — HTTPS shows username + token fields with a forge-appropriate hint; SSH shows the machine key to copy plus Verify. The user leaves the popover with a remote that demonstrably works, or knowingly not. Boards adopted from a terminal clone (whose auth lives outside the sandbox and can't be reused) hit the same inline flow at the first in-app operation that needs credentials.
|
||||
- **Auth failures pause; they never nag and never hammer.** A push or pull rejected for authentication (expired token, revoked key) is not retried — a dead credential cannot succeed, and hammering invites rate limits and lockouts. The push queue pauses and the popover badge switches to a distinct **Authentication needed** state carrying the error; the popover presents the same inline fields, prefilled where possible. Updating the credential (or fixing forge-side and hitting Verify) resumes the queue. Network failures keep the quiet auto-resume above — only auth pauses.
|
||||
- **Background operations never prompt.** Push-on-commit and the automatic fetch-rebase-push stay silent through auth trouble (badge only); credential capture happens exclusively in the popover, where the user already is when it matters (manual Pull/Push live there too).
|
||||
- **HTTPS (primary)**: username + token (forge PATs; plain basic auth for generic hosts), stored as a Keychain internet password keyed by **host + username** — git's own scoping model, shared across boards: one GitHub token serves every board, and two accounts on one host coexist as two usernames. **Which username a board uses is the remote URL's business** (git's own answer, and the HTTPS analogue of the SSH per-host table): a username in the URL (`https://alice@host/…`) selects the Keychain item `host + alice`, and the settings sheet's credential capture stamps the entered username into the remote URL in repo config — the URL is the assignment record, no app-side state (the secret itself stays in the Keychain). A URL naming no username resolves to the host's sole stored username; when a host has several, the sheet's username field becomes a picker and saving stamps the choice into the URL, while background operations treat the ambiguity as **Authentication needed** (pause and badge, never guess — the same posture as auth failure).
|
||||
- **SSH — Keychain-resident keys, never key files.** Each Mac has a **Lanework key**: an app-generated ed25519 keypair whose private half lives as an ACL-protected Keychain item and is handed to libssh2 from memory — it never exists on disk. The board settings sheet shows the public key with a Copy affordance; the user adds it to their forge like any machine key. An **existing key imports by paste or drag** (a one-time read under user intent): copied into the Keychain — passphrase entered once at import, stored under Keychain protection thereafter — and the original file is never referenced again. Per-machine identity, per-Mac revocable on the forge — the ssh-idiomatic shape. (Secure Enclave-backed keys — non-exportable, custom sign callback, P-256 — are a possible later hardening, not v1.)
|
||||
- **Key scope: app-level objects, per-host assignment.** Keys are never board state — the machine key plus any imports live app-wide (Keychain), and each SSH host maps to one of them: default the machine key; importing a key during a host's setup assigns it to that host. A "host" is `hostname[:port]` parsed from the remote URL — the same endpoint identity the TOFU fingerprint store uses (OpenSSH's own `[host]:port` convention); the URL's username (`git@`) disambiguates nothing and stays out of it. The assignment table holds **only overrides** — no entry means the machine key, so the default costs zero records and removing an override self-heals to it. The sheet's key picker is labeled per-host ("key for github.com"), which teaches the one cross-board consequence: switching a host's key switches it for every board on that host — the same rotate-once-follow-everywhere behavior as HTTPS tokens. Housekeeping stays small: an import referenced by no host row can be removed; the machine key only regenerates (confirm-gated — it invalidates the old public half on every forge), and that is the entire rotation story. The board settings sheet is only the surface — it shows the key for *that remote's host*, the way the commit-identity fields front repo-local config. Known limit, accepted: two accounts on the *same* host can't be told apart by key (forges bind key→account globally; git's own answer is ssh-config aliases, which live in files the sandbox can't read) — a per-remote key override joins the wishlist if it ever bites.
|
||||
- **Host verification is trust-on-first-use**: with no `~/.ssh/known_hosts` readable, the first connection to an SSH host confirms its fingerprint with the user; accepted fingerprints live app-side (02-architecture.md's app-wide state home, host-scoped). A later mismatch **hard-blocks with an explanation** — that mismatch is the attack the check exists for.
|
||||
- **Setup verifies right there.** Adding or changing a remote (board settings sheet — 03-board-ui.md, the 2026-07-31 popover/sheet split) probes with authentication immediately (ls-remote): missing or rejected credentials surface **inline in the sheet** — HTTPS shows username + token fields with a forge-appropriate hint; SSH shows the machine key to copy plus Verify. The user leaves the sheet with a remote that demonstrably works, or knowingly not. Boards adopted from a terminal clone (whose auth lives outside the sandbox and can't be reused) hit the same inline flow at the first in-app operation that needs credentials.
|
||||
- **Auth failures pause; they never nag and never hammer.** A push or pull rejected for authentication (expired token, revoked key) is not retried — a dead credential cannot succeed, and hammering invites rate limits and lockouts. The push queue pauses and the popover badge switches to a distinct **Authentication needed** state carrying the error (the badge points at the settings sheet); the sheet presents the same inline fields, prefilled where possible. Updating the credential (or fixing forge-side and hitting Verify) resumes the queue. Network failures keep the quiet auto-resume above — only auth pauses.
|
||||
- **Background operations never prompt.** Push-on-commit and the automatic fetch-rebase-push stay silent through auth trouble (badge only); credential capture happens exclusively in the settings sheet, one click behind the badge that says it's needed (manual Pull/Push stay in the popover — daily operations, the split's other half).
|
||||
|
||||
## iCloud Drive — not supported (decided)
|
||||
|
||||
Boards should not live in iCloud Drive. The app makes **no iCloud accommodations**: no NSMetadataQuery watching, no eviction handling, no download triggering, no NSFileVersion conflict resolution. When the user opens or creates a board at a path inside iCloud Drive, the app **warns with a thorough explanation and recommends git integration instead** — it does not hard-block (the user is always right), but the warning must genuinely teach why this is a bad idea:
|
||||
Boards should not live in iCloud Drive. The app makes **no iCloud accommodations**: no NSMetadataQuery watching, no eviction handling, no download triggering, no NSFileVersion conflict resolution. When the user opens or creates a board at a path inside iCloud Drive, the app **warns with a thorough explanation and recommends git integration instead** — it does not hard-block (the user is always right), but the warning must genuinely teach why this is a bad idea. (Base Lanework keeps the warning without the recommendation — there is no git to recommend and no Pro pitch in a warning; it recommends a local folder on the eviction/silent-fork grounds alone — 12-editions.md ▸ Edition naming in base.)
|
||||
|
||||
- **Git and iCloud corrupt each other.** A board with git enabled (the undo substrate) has a `.git` inside; iCloud syncs `.git` internals — thousands of small object files and constantly-rewritten refs/packs — poorly and non-atomically; partial or reordered sync can corrupt the repository. Two Macs auto-committing the same board produce divergent histories iCloud cannot merge.
|
||||
- **Eviction breaks fail-fast loading.** iCloud may evict any file's contents to free space, leaving a placeholder. An evicted `index.md` is unreadable; with no download-trigger machinery the board simply fails to load with an I/O error until the user manually re-downloads it.
|
||||
|
||||
@@ -15,20 +15,24 @@ AI agents are first-class users of Lanework boards — not through an API, but t
|
||||
|
||||
The app silently maintains a `CLAUDE.md` in every board — a condensed, agent-facing rendition of the schema teaching any agent how to operate on the board directly:
|
||||
|
||||
- Creating cards (mkdir UUID, write `index.md`, ordering rules).
|
||||
- Creating cards (mkdir UUID, write `index.md`, ordering rules) — plain UTF-8, no BOM, preserving each file's existing line endings (01-storage-format.md's encoding contract).
|
||||
- Moving between lanes (folder move), reordering (gapped ranks, only touch the moved item).
|
||||
- Tombstone deletes, colors/icons.
|
||||
- New in the rewrite: **attachments** (the `attachments/` convention, importing files).
|
||||
- New in the rewrite: **`modified-by` self-stamping** — stamp files you write; re-stamp every write *and every move* (the app clears it on its own writes; a bare folder move leaves the card unstamped); self-commit instead when you need exact authorship.
|
||||
- New in the rewrite: a pointer to the optional **`CLAUDE.user.md`** (see below), instructing agents to read it when present.
|
||||
- Deletes — move the card **or lane** folder into `<root>/.trash/` and restamp `modified` (the trash sorts newest-first by that stamp — re-ruled 2026-07-31, no rank to mint; never delete a folder outright unless permanence is meant); colors/icons.
|
||||
- **`kind`** — common schema, written at creation of every object: include `kind: lane` / `kind: card` when creating anything (`kind: board` at board root), and stamp `kind: lane` when trashing a lane that lacks it. The *value* is what tells a trashed lane from a card inside the flat `.trash/` (01-storage-format.md ▸ Deletion); the app backfills a missing key on touch (01 ▸ Validation and healing), so omitting it is healable, never fatal.
|
||||
- **Attachments** (the `attachments/` convention, importing files) — including the rule that **card files belong in `attachments/`**: a loose file written beside `index.md` will be relocated there by the app with a notice (01-storage-format.md's loose-file carve-out), so agents should put it there in the first place — and the card-level **`attachments` claimed name**: `attachments` inside a card folder is the app's (the card's file folder); never create a *file* by that name.
|
||||
- **`modified-by` self-stamping** — stamp files you write; re-stamp every write *and every move* (the app clears it on its own writes; a bare folder move leaves the card unstamped); self-commit instead when you need exact authorship.
|
||||
- **The stamp discipline** of 01 ▸ `modified`'s scope: **reordering within a lane rewrites only `order`** — leave `modified` and `modified-by` alone — while **a move between lanes, between boards, or into/out of `.trash/` updates both** (stamp `modified`, re-stamp `modified-by`).
|
||||
- A pointer to the optional **`CLAUDE.user.md`** (see below), instructing agents to read it when present.
|
||||
|
||||
This list is **present-tense and normative for the current guide** (re-ruled 2026-07-30): it states what the shipped guide teaches *now*, and editing it means editing the guide literal in the same change. Per-version changelog bullets are deliberately gone — a hand-maintained version history beside the literal drifted within days of v6 (claiming teachings the shipped body lacked), the same failure class as README's hand-enumerated deferred list; version history lives in git.
|
||||
|
||||
Mechanics: version marker in the first line (`lanework-agent-guide vN`); rewritten when missing or older, never downgraded, left untouched when current or newer. To the loader it's just another ignored file.
|
||||
|
||||
**Ownership**: the board-root `CLAUDE.md` is **app-owned and not available for user editing** — its header says so, and user edits do not survive upgrades. This holds on every board, including repo-nested ones: the guide is auto-written there too (the untracked file in the user's repository is accepted — a board is agent-facing wherever it lives). A board-root `CLAUDE.md` found *without* the marker is displaced user content, not clobbered: its content is moved to `CLAUDE.user.md` if that name is free (otherwise the guide write is skipped with a log — user content is never destroyed), and the guide is then written.
|
||||
|
||||
**`CLAUDE.user.md`** is the user's extension point: an optional, user-authored file at board root carrying board-specific agent instructions. The generated guide tells agents to read it when present, so it lands in agent context without the app ever touching it — the app never writes, upgrades, or validates it.
|
||||
**`CLAUDE.user.md`** is the user's extension point: an optional, user-authored file at board root carrying board-specific agent instructions. The generated guide tells agents to read it when present, so it lands in agent context without the app ever touching it — the app never writes, upgrades, or validates an *existing* `CLAUDE.user.md`; the one exception is its creation, once, to rescue displaced content (the markerless-`CLAUDE.md` relocation above, which only runs when the name is free).
|
||||
|
||||
On git boards, a guide write rides the normal watcher → auto-commit path with an honest message ("Update agent guide (v3)"), not "External edit" (06-history-undo.md).
|
||||
On git boards, a guide write rides the normal watcher → auto-commit path with an honest message ("Update agent guide (v3)"), not "External edit" (06-history-undo.md ▸ Commit messages — the non-snapshot-files rule: the committer stages the whole root, and the composer reads the subject's version from the guide's marker line, a pure function of file content).
|
||||
|
||||
## Agent conventions worth specifying (new in the rewrite)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ All ten pathfinder templates carry over: Basic, Classic Kanban, Software Project
|
||||
|
||||
### Instantiation
|
||||
|
||||
Creating a board from a template: copy the tree — **skipping tombstoned items** (Save as Template already strips them, but hand-dropped user templates can carry them; a new board isn't born with trash) — **mint fresh GUIDs** for every lane/card folder, stamp `created`/`modified` fresh (the stated exception to 01-storage-format.md's copies-keep-`created` rule — a new board is born today, not forked from the template), seed the save panel's suggested name from the template title, and **set the new board's `title` to the user-chosen document name** (per 01-storage-format.md's board-naming rule, so display name and folder name start out matching). The `template:` key is kept — inert on an ordinary board. **`.git` is never copied** — a template is content, not history, and a hand-dropped user template that carries one must not produce boards that are silently in git mode (06-history-undo.md's no-silent-auto-init — the principle is *never give the user a repo they didn't ask for*, and it scopes to instantiation: File ▸ Duplicate deliberately carries `.git`, because a duplicate of a git board is a fork of its history — 03-board-ui.md): every instantiated board starts at mode `none`. Beyond the `template:` residue, the result is indistinguishable from a hand-built board.
|
||||
Creating a board from a template: copy the tree — **skipping `.trash/`** (Save as Template already strips it, but hand-dropped user templates can carry one; a new board isn't born with trash) — **mint fresh GUIDs** for every lane/card folder, stamp `created`/`modified` fresh (the stated exception to 01-storage-format.md's copies-keep-`created` rule — a new board is born today, not forked from the template), seed the save panel's suggested name from the template title, and **set the new board's `title` to the user-chosen document name** (per 01-storage-format.md's board-naming rule, so display name and folder name start out matching). The `template:` key is kept — inert on an ordinary board. **`.git` is never copied** — a template is content, not history, and a hand-dropped user template that carries one must not produce boards that are silently in git mode (06-history-undo.md's no-silent-auto-init — the principle is *never give the user a repo they didn't ask for*, and it scopes to instantiation: File ▸ Duplicate deliberately carries `.git`, because a duplicate of a git board is a fork of its history — 03-board-ui.md): an instantiated board is never in *git* mode — its actual mode follows 06's nearest-`.git`-wins detection at the destination the save panel chose: mode `none` in a plain folder, repo-nested when saved inside an existing repository (no app-managed git, no add-git — the popover explains; native undo still serves, 06-history-undo.md). Beyond the `template:` residue, the result is indistinguishable from a hand-built board.
|
||||
|
||||
### Why this format
|
||||
|
||||
@@ -38,14 +38,14 @@ Creating a board from a template: copy the tree — **skipping tombstoned items*
|
||||
|
||||
## Save as Template
|
||||
|
||||
A "Save as Template" function copies the current board into the user templates store; the chooser lists user templates after the bundled ones. **The copy is preceded by the close flush** (02-architecture.md ▸ Windows: editor saves, then the pending auto-commit — with the pull-style mechanical exception committing an open Edit session's on-disk saves as-is, sessions staying open), so the template never misses the last keystrokes; the same rule covers File ▸ Duplicate (03-board-ui.md), where the flush also keeps the copied `.git`'s history from lagging its tree. Because a template *is* a board, the copy is nearly literal: **`.git` is not copied** (a template is content, not history — copying it would embed the board's full repo, every attachment version included, in the template store; see 06-history-undo.md's repo-growth note), tombstoned items are dropped, a `template:` key is added — or, when the board already carries one (e.g. it was itself instantiated from a template), its stale `order` is overwritten — with an order appended after existing user templates; GUIDs are left as-is (instantiation mints fresh ones anyway), and timestamps are kept per 01-storage-format.md's copies-keep-`created` rule (equally inert — instantiation restamps them).
|
||||
A "Save as Template" function copies the current board into the user templates store; the chooser lists user templates after the bundled ones. **The copy is preceded by the close flush** (02-architecture.md ▸ Windows: editor saves, then the pending auto-commit — with the pull-style mechanical exception committing an open Edit session's on-disk saves as-is, sessions staying open), so the template never misses the last keystrokes; the same rule covers File ▸ Duplicate (03-board-ui.md), where the flush also keeps the copied `.git`'s history from lagging its tree. Under the read-only lock, Save as Template disables with one exception — the unwritable-location state, where it stays live unless an open Edit or raw-source session holds unsaved content the suspended saves can't flush (reads the board, writes the app-side store; 02-architecture.md ▸ Live-reload resilience has the settled scoping). Because a template *is* a board, the copy is nearly literal: **`.git` is not copied** (a template is content, not history — copying it would embed the board's full repo, every attachment version included, in the template store; see 06-history-undo.md's repo-growth note), `.trash/` is dropped, a `template:` key is added — or, when the board already carries one (e.g. it was itself instantiated from a template), its stale `order` is overwritten — with an order appended after existing user templates; GUIDs are left as-is (instantiation mints fresh ones anyway), and timestamps are kept per 01-storage-format.md's copies-keep-`created` rule (equally inert — instantiation restamps them).
|
||||
|
||||
Two edges, settled:
|
||||
|
||||
- **Store collisions auto-rename, Finder-style** (`Board.kanban` → `Board 2.kanban`) — the 01 import precedent: saving never overwrites an existing template and never refuses.
|
||||
- **Strays copy through.** The copy is literal apart from the stated exclusions (`.git`, tombstoned items) — `CLAUDE.user.md`, a seeded `.gitignore`, and other non-schema files carry through Save as Template *and* instantiation alike. Deliberate: a template is the folder, and `CLAUDE.user.md` carrying a board's custom agent instructions into boards born from it is a feature. The app-owned `CLAUDE.md` copies inertly and self-heals to the current guide version when the new board is opened (08-agent-integration.md).
|
||||
- **Strays copy through.** The copy is literal apart from the stated exclusions (`.git`, `.trash/`) — `CLAUDE.user.md`, a seeded `.gitignore`, and other non-schema files carry through Save as Template *and* instantiation alike. Deliberate: a template is the folder, and `CLAUDE.user.md` carrying a board's custom agent instructions into boards born from it is a feature. The app-owned `CLAUDE.md` copies inertly and self-heals to the current guide version when the new board is opened (08-agent-integration.md).
|
||||
|
||||
**Storage (settled): Application Support** (`…/Lanework/Templates/`, inside the app container) as the canonical store — friction-free sandbox writes, no location ceremony — kept honest by a **Reveal in Finder** affordance in the template chooser: revealed, it's plain board folders, hand-editable and agent-writable, and a board folder dropped in becomes a template — **no `template:` key required**. Chooser order: bundled templates by `template.order`, then keyed user templates by `template.order`, then keyless user boards last, sorted by display name (`title` ?? folder name — 01-storage-format.md's board naming). The app never stamps a key into store files it didn't write itself — a hand-dropped board is never touched, and adding a key by hand is how its user picks a position; the one writer of keyed files is Save as Template, whose own copies arrive keyed (above). A user-visible or user-configurable location was considered and set aside as ceremony disproportionate to a secondary feature; revisit if template sharing becomes a real workflow.
|
||||
**Storage (re-homed 2026-07-30 with the one-app collapse): the app's Application Support container** (`…/Application Support/<bundle id>/Templates/`, beside the registry per 02-architecture.md ▸ Per-board app state's app-wide-state rule) as the canonical store — friction-free sandbox writes, no location ceremony. Templates are plain board folders — no bookmark grant ceremony, the container is the app's own. Kept honest by a **Reveal in Finder** affordance in the template chooser: revealed, it's plain board folders, hand-editable and agent-writable, and a board folder dropped in becomes a template — **no `template:` key required**. Chooser order: bundled templates by `template.order`, then keyed user templates by `template.order`, then keyless user boards last, sorted by display name (`title` ?? folder name — 01-storage-format.md's board naming). An unloadable user template sorts with the keyless tier, by folder name — the failed load can supply neither `template.order` nor `title`, so the folder name is the only identity it has (and the one its unloadable row already shows). The app never stamps a key into store files it didn't write itself — a hand-dropped board is never touched, and adding a key by hand is how its user picks a position; the one writer of keyed files is Save as Template, whose own copies arrive keyed (above). A user-visible or user-configurable location was considered and set aside as ceremony disproportionate to a secondary feature; revisit if template sharing becomes a real workflow.
|
||||
|
||||
## Rejected alternative
|
||||
|
||||
@@ -57,4 +57,4 @@ Keeping the Swift-struct catalog (pathfinder approach). Simpler to ship, but it'
|
||||
|
||||
## Open questions
|
||||
|
||||
None currently — the storage location is settled (Application Support as the canonical store, kept honest by Reveal in Finder; a user-visible or configurable location was set aside as ceremony, revisit if template sharing becomes a real workflow).
|
||||
None currently — the storage location is settled (the app's Application Support container as the canonical store, re-homed 2026-07-30 with the one-app collapse; kept honest by Reveal in Finder; a user-visible or configurable location was set aside as ceremony, revisit if template sharing becomes a real workflow).
|
||||
|
||||
+20
-17
@@ -10,47 +10,50 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
|
||||
|
||||
## The board through VoiceOver
|
||||
|
||||
- **Tree shape**: window → lanes (accessibility containers, in lane `order`) → cards (leaf elements, in card `order`). A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like the visible badge (04-interactions.md). The lane header's new-card button is a labeled child ("New card in ⟨lane⟩"). A card is **one flattened element**: label = title (or the untitled placeholder), value carries the attachment count when present, selected state via trait. Face icon and chips are decorative — folded into the element, never separately focusable. The sole-selection **attachment carousel** (03-board-ui.md) is decorative too — page dots and paging included, nothing focusable: the flattened element already carries the attachment count in its value, and the accessible attachment surface is the card window's keyboard-native section (below).
|
||||
- **Logical order, not masonry position** (decided): within a wide lane, VoiceOver reads cards by `order` — the interior grid columns are presentation only. This deliberately diverges from on-screen geometry; the spatial arrow-key model (04-interactions.md) remains available alongside, since board keyboard navigation keeps working with VoiceOver running.
|
||||
- **VO cursor and app selection are independent** (Finder-style): moving the VoiceOver cursor never mutates selection. VO-Space on a card toggles its selection (the ⌘-click analogue — a toggle, never plain click's replace, 04-interactions.md ▸ Selection); ⌘↩ opens the card window; arrow keys and ⇧-arrows drive selection exactly as without VoiceOver. Selection state is always readable from the element (trait), and cut cards expose their dimmed pending state in the value ("cut, pending paste").
|
||||
- **Actions come from the context menu.** Context menus are the single inventory of per-item actions (Open, Rename, Delete, Put Back, Delete Immediately, width stepper, …), reachable the standard way (VO-⇧-M); where SwiftUI additionally surfaces menu items as custom accessibility actions, that's free improvement, not a separate design surface.
|
||||
- **Rotor**: lane titles are headings, so the headings rotor jumps lane-to-lane — on a one-dimensional board that *is* structural navigation; no custom rotors unless practice shows the need.
|
||||
- **Trash quasi-lane**: when shown (View ▸ Show Trash), it is the last container, labeled as Trash with its count; toggling visibility is announced. Tombstoned cards read their deletion state and expose Put Back / Delete Immediately via the context menu; there is no Open (03-board-ui.md's no-editing-in-the-trash). A tombstoned lane's single entry reads "⟨title⟩, deleted lane, N cards" and exposes the same actions; keyboard reachability follows 04-interactions.md's homogeneous-by-kind trash rules.
|
||||
- **Tree shape**: window → lanes (accessibility containers, in lane `order`) → cards (leaf elements, in card `order`). A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like the visible badge (04-interactions.md). The lane header's new-card button is a labeled child ("New card in ⟨lane⟩"). A card is **one flattened element**: label = title (or the untitled placeholder), value carries the attachment count when present, selected state via trait. Face icon and chips are decorative — folded into the element, never separately focusable: the flattened element carries the attachment count in its value, and the accessible attachment surface is the card window's keyboard-native section (below); the face itself has no media presentation (03-board-ui.md's no-carousel resettlement).
|
||||
- **Logical order, not masonry position** (decided): within a wide lane, VoiceOver reads cards by `order` — the interior grid columns are presentation only. This deliberately diverges from on-screen geometry (narrowed by the 2026-07-31 column-major masonry: walking down one column now *is* consecutive `order`; the divergence that remains is a geometry-sorted reading-order sweep — left-to-right, then down — which interleaves the columns); the spatial arrow-key model (04-interactions.md) remains available alongside, since board keyboard navigation keeps working with VoiceOver running.
|
||||
- **VO cursor and app selection are independent** (Finder-style): moving the VoiceOver cursor never mutates selection. VO-Space on any selectable element — card or lane header — toggles its selection (the ⌘-click analogue — a toggle, never plain click's replace, 04-interactions.md ▸ Selection; ruled 2026-07-29: a replace would silently wipe a multi-element selection, and one uniform VO-Space rule means the user never has to know the element kind to predict Space); ⌘↩ opens the card window; arrow keys and ⇧-arrows drive selection exactly as without VoiceOver. Selection state is always readable from the element (trait), and cut cards expose their dimmed pending state in the value ("cut, pending paste").
|
||||
- **Actions come from the context menu.** Context menus are the single inventory of per-item actions (Open, Rename, Delete, width stepper, …), reachable the standard way (VO-⇧-M); where SwiftUI additionally surfaces menu items as custom accessibility actions, that's free improvement, not a separate design surface. **The custom-action cut** (confirmed 2026-07-29): every plain button row of an item's context menu becomes a custom action; rows that open their own accessible surface (Style…'s popover) and non-action controls (the quick-style swatch picker) stay menu-only — the context menu remains the full inventory either way. Each action must call the same method as its menu row, so the two surfaces cannot drift.
|
||||
- **Rotor**: lane titles are headings, so the headings rotor jumps lane-to-lane — on a one-dimensional board that *is* structural navigation; no custom rotors unless practice shows the need. **The title doubling is accepted** (ruled 2026-07-29): entering a lane reads the container label ("Doing, lane, 3 cards") and then the heading ("Doing, heading") — two elements, two purposes: the label gives boundary-crossing context, the heading feeds the rotor. This is the platform-standard landmark-plus-heading pattern; collapsing it would cost the on-entry announcement, the more valuable half.
|
||||
- **Trash lane**: when shown (View ▸ Show Trash), it is the last container, labeled as Trash with its count; toggling visibility is announced — "Trash shown" / "Trash hidden" (confirmed 2026-07-29: resulting state, not the action, so a mis-hit tells the user where the board ended up and the toolbar toggle reads like the menu checkmark; the count stays on the container label, which VO reads on arrival, rather than duplicated into the announcement). Its cards are ordinary card elements (resettled 2026-07-28 — the materialized trash: no special states beyond the container) exposing Delete and Reveal in Finder via the context menu; there is no Open (03-board-ui.md's no-editing-in-the-trash), and restore is drag-out or the ⌘X/⌘V keyboard path; keyboard reachability follows 04-interactions.md ▸ The trash. A trashed **lane** (rejoined 2026-07-29) is one flattened opaque element — "⟨title⟩, deleted lane, N cards" — never a container: its cards are not in the tree (the opaque unit, 03 ▸ Trash), its actions are the same Delete / Reveal in Finder, and its restore paths are the same drag-out or ⌘X/⌘V.
|
||||
- **Search**: filtered-out cards leave layout and the accessibility tree together — the filter is the single source of truth for "what's on the board" (04-interactions.md), and the tree is one of its readers. Lane counts announced reflect the filter.
|
||||
|
||||
## Moving without dragging
|
||||
|
||||
- **Cards**: clipboard. ⌘X the selection, move selection to the destination (arrows), ⌘V — between lanes, within a lane (paste lands after the anchor card), and across boards (04-interactions.md's staged clipboard). This is the committed drag-free move story; it needs no VoiceOver-specific machinery because selection and paste targeting are already keyboard-native.
|
||||
- **Lanes**: the defect this doc originally named (lanes had no keyboard-move path) is closed by the keyboard map — with a lane selected, ⌘←/⌘→ move it (Board ▸ Move Left / Move Right, 04-interactions.md); cards gain ⌥⌘↑/⌥⌘↓ within-lane sorting, and cross lanes drag-free via cut/paste (04-interactions.md's clipboard rules). Lanes carry the clipboard too (resettled, 04-interactions.md ▸ Clipboard), so cross-board lane copy/move — once drag-only, the contract's last gap — is ⌘C/⌘X, then ⌘V with the destination board frontmost.
|
||||
- **Lane resize**: the header context menu's width stepper (03-board-ui.md) — and its keyboard face, the Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘←, 04-interactions.md) — is the accessible path; edge drag is enhancement only.
|
||||
- **Lane resize**: the header context menu's width stepper (03-board-ui.md) — and its keyboard face, the Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘←, 11-command-nexus.md) — is the accessible path; edge drag is enhancement only.
|
||||
- **Attachments**: the card window's sidebar items expose Open / Reveal in Finder / Remove via context menu, and the section is keyboard-navigable outright (arrows, Space-QuickLook, Return, ⌫ — 05-card-window.md); adding files drag-free is File ▸ Add Attachment… (⇧⌘A, 11-command-nexus.md) alongside Finder-drop.
|
||||
- **Comments** (05-card-window.md ▸ The comments column): the pane is a labeled container ("Comments, N"); each comment is **one flattened element** — author, date, edited state, body — with its context-menu rows (Edit / Delete / Reveal in Finder) riding as custom actions per the cut; the composer is a labeled text field (⌘↩ posts) and the header's sort control is Tab-reachable. Foreign comment arrivals announce path-shaped ("New comment on '⟨card⟩'") — the window-scoped read never blocks the announcement, which composes from the path alone. **Edits and deletes speak the same family** (pinned 2026-07-31): "Edit comment on '⟨card⟩'" / "Delete comment on '⟨card⟩'" — 06-history-undo.md's path-shaped verb family verbatim, arrivals leading by precedence, plurals folding ("3 new comments on 'X'"); the exact strings live in AccessibilityPhrases, one home.
|
||||
|
||||
## Live board announcements
|
||||
|
||||
- **Foreign changes announce, app-mediated echoes never do.** The auto-committer already classifies every observed change as app-mediated or foreign and synthesizes diff summaries for commit messages (06-history-undo.md); announcements reuse that summarizer — one polite (non-interrupting) digest per reload debounce ("Board changed: 2 cards edited, 1 card added"), never per-file chatter. On no-git boards the same classifier runs without the committer — announcements don't depend on git mode.
|
||||
- **A vanishing focus is called out specifically.** If the selected or VO-focused card disappears in a reload (deleted externally, or hidden by a lane tombstone), the announcement names it ("Card 'Fix login' was deleted externally") and focus recovers to the card's lane (mirroring selection's reload-survival rules, 02-architecture.md).
|
||||
- **Bracketed operations announce once, at completion** ("Pulled 3 commits", "Switched to branch 'redesign'") — never their internal churn (02-architecture.md's bracketing). The live-reload-resilience banner (02-architecture.md) is an accessibility element and is announced when it appears and when it clears — including the read-only lock after a failed bracketed reload.
|
||||
- **Foreign changes announce, app-mediated echoes never do.** The auto-committer already classifies every observed change as app-mediated or foreign (the EchoLedger — 02-architecture.md ▸ Components) and synthesizes diff summaries for commit messages (06-history-undo.md); announcements reuse that summarizer — one polite (non-interrupting) digest per reload debounce ("Board changed: 2 cards edited, 1 card added"), never per-file chatter. **The announcer consumes the ledger's per-file facts on every reload origin, reconciling included** (ruled 2026-07-29 — the EchoLedger builds pre-release in base, not with Pro's committer): files changed during a blind window (sleep, deactivation) carry no receipts, so they classify foreign and announce — the launch-catch-up doctrine ("the app never vouches for changes it didn't witness") applied to speech; a reconciling reload that reveals external changes is never silent. On no-git boards the same classifier runs without the committer — announcements don't depend on git mode. **The digest covers the trash only while the trash lane is shown** (ruled 2026-07-29): with View ▸ Show Trash on, trash cards are ordinary elements of the visible board (▸ Trash lane above), so a foreign purge, restore, or Empty Trash joins the digest like any lane's churn — a user working in the shown trash must hear it emptied under them; while hidden, trash churn stays silent, matching "no layout side effects from a foreign edit". **A shown trashed-lane row's held count speaks when it changes** (ruled 2026-07-31): foreign churn *inside* a trashed lane's subtree is invisible to the snapshot, but the row's count is part of its visible face — a sighted user sees the number move, so the digest says it ("Deleted lane 'Doing' now holds 6 cards", plural-folded as usual), sourced from the disk-side held count. The opacity doctrine holds: the digest narrates the row's visible value, never the interior.
|
||||
- **A vanishing focus is called out specifically.** If the selected or VO-focused card disappears in a reload (deleted externally, or gone with its deleted lane), the announcement names it ("Card 'Fix login' was deleted externally") and focus recovers to the card's lane (mirroring selection's reload-survival rules, 02-architecture.md). **Naming and recovery are independent axes** (ruled 2026-07-29): when the head of a multi-selection vanishes but co-selected cards survive, the announcement still names the vanished head — the thing under the cursor was deleted, and that is what the rule exists to say — while the focus move is vetoed by the survivors (02's re-resolution rule: the head re-anchors within the surviving selection; a reload never edits a selection the user still partly holds). Vanished non-head members stay unnamed and fall to the digest's counts. **When the lane itself vanished, recovery walks up then sideways** (settled): focus lands on the lane now occupying the vanished lane's position — the next lane by `order`, else the previous one — and on the board container only when no lanes remain (the ⌫-successor pattern, 04-interactions.md, applied to external change; never into the trash, which stays hidden — no layout side effects from a foreign edit). The announcement then names the *lane*, not the card ("Lane 'Doing' was deleted externally, with 5 cards") — the implied-events-don't-steal-the-subject discipline of 06-history-undo.md's composer, applied to speech.
|
||||
- **Bracketed operations announce once, at completion** ("Pulled 3 commits", "Switched to branch 'redesign'") — never their internal churn (02-architecture.md's bracketing). The live-reload-resilience banner (02-architecture.md) is an accessibility element and is announced when it appears and when it clears — including the read-only lock after a failed bracketed reload. **Banner transitions are origin-independent** (confirmed 2026-07-29): "app-mediated echoes never announce" governs the change digest — never narrate the user's own edits — but a banner appearing or clearing is surface liveness, visible to a sighted user regardless of cause, so it speaks on any reload origin (an app write whose reload clears a breakage is exactly a moment the user should hear "cleared"). Precedence ladder: raised condition > bracket completion > cleared condition > vanished focus > digest.
|
||||
|
||||
## Card window, welcome, template chooser, popover
|
||||
|
||||
- **Card window**: standard controls, standard labels. The attributes sidebar is a labeled container of labeled sections; attachment rows are elements labeled by filename; the Details section's unknown-key rows read as static text ("⟨key⟩, ⟨value⟩"); the bottom actions are ordinary buttons. **Preview renders to the accessibility tree as structured text** — headings navigable by rotor, lists and tables read as such; task-list checkboxes are real accessible checkboxes, toggleable without the pointer (05-card-window.md's live checkboxes); body images use Markdown alt text when present, else the filename. Edit and the raw-source outlet are ordinary accessible text editors; the Preview/Edit toggle (⌘E) announces its state.
|
||||
- **Welcome window**: recents rows are elements labeled "⟨name⟩, ⟨location⟩, N lanes, M cards" (registry-cached counts — 02-architecture.md); row actions (Open / Reveal in Finder / Forget) via context menu; unavailable rows say so ("unavailable — board not found").
|
||||
- **Welcome window**: recents rows are elements labeled "⟨name⟩, ⟨location⟩, N lanes, M cards" (name, icon, and counts all registry-cached — 02-architecture.md; the row never reads a board's files); row actions (Open / Reveal in Finder / Forget) via context menu; unavailable rows say so ("unavailable — board not found").
|
||||
- **Template chooser**: templates are elements labeled by title; the mini per-lane previews are decorative and hidden from the tree.
|
||||
- **Board popover**: labeled controls throughout; the ahead/behind indicator's information — counts, queued pushes, last error — must be readable as text, never conveyed by color or shape alone.
|
||||
- **Board settings sheet** — *retired 2026-08-07* (03 ▸ Board settings sheet; the 2026-07-31 popover/sheet split reversed). Its bar carries over to the popover's Git tab, which now hosts what it held: headers VoiceOver can navigate by (the Commit Identity block keeps its heading trait); labeled controls throughout; inline probe/verify outcomes announced from the focused surface; every control Tab-reachable under Full Keyboard Access. The original objection stands as the standing test — Tab-walking two dozen controls in an *untitled, unsectioned* surface fails this bar — and the tab strip plus per-block headings are what answer it now that the controls are back in the popover.
|
||||
- **Style editor** (card sidebar section, board popover, Style… popover — 03-board-ui.md ▸ Styling ▸ Controls): grids are arrow-navigable, every well Tab-reachable and labeled by name (palette color, symbol name; leading wells "None" / "Default"); the current value is stated by trait, and a batch selection's mixed state reads as "mixed", never conveyed by highlight alone.
|
||||
|
||||
## Text scaling & visual accommodations
|
||||
|
||||
- **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.
|
||||
- **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. Increase Contrast strengthens borders and the selection indicator.
|
||||
- **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). The 2026-08-06 color-combo reversal (03 ▸ Styling ▸ Controls) changes none of this: a panel-picked color is stored as the palette name when it lands on one, else as hex, and either spelling renders through the same runtime ink seam — in-app picking gained the freedom hand-editing always had, and the ink math was already waiting for it. No warning surface exists or is owed; the app's answer to a low-contrast pick is to choose readable ink, not to argue. An `#RRGGBBAA` background with alpha computes against the color **composited over its effective backdrop** in the active appearance (the board's over the window background; light and dark resolve differently), recomputed on appearance change. Palette names and hex share one ink-selection code path — the palette's AA claim is pinned by a computed-contrast test over all 12 backgrounds in both appearances (ratified 2026-07-29). **The AA obligation binds the primary label tier** (ruled 2026-07-29): the ink seam moves the whole label hierarchy with the primary, and subordinate tiers (.secondary, .quaternary) inherit the system vocabulary's own contrast posture, which sits below 4.5:1 on any background including the system's — the platform-standard reading; the strict path for users who need more is Increase Contrast, which raises accents and washes to full alpha (▸ Visual accommodations). Increase Contrast strengthens borders and the selection indicator.
|
||||
- **State is never color-alone**: selection is a ring plus trait, cut-pending is dim plus stated value, the trash header is hatched plus labeled — all already patterned; kept as a rule.
|
||||
- **Reduce Motion**: reflow-on-drag, search animate-out, the drag replica, rubber-band feedback, and trash animations all get reduced variants (crossfade or instant). **Reduce Transparency**: glass underlays (carousel page dots) go solid.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Automated audits are test failures**: Xcode's accessibility audit (`performAccessibilityAudit`) runs in UI tests over every surface — board (trash shown and hidden), card window (Preview, Edit, raw source), welcome, template chooser, board popover.
|
||||
- **A manual VoiceOver smoke script** lives with the test plan: create lane → create card → rename → cut/paste to another lane → external edit lands (announcement heard) → delete → Put Back → Empty Trash. Run per release; it is the canonical "does the board actually work blind" check.
|
||||
- **Automated audits are test failures**: Xcode's accessibility audit (`performAccessibilityAudit`) runs in UI tests over every surface — board (trash shown and hidden), card window (Preview, Edit, raw source), welcome, template chooser, board popover. *(Amended 2026-08-07: the board settings sheet retired and its controls rehomed into the popover's Git tab, so the inventory is one surface shorter; auditing that tab specifically — the popover opens on Info — is an open card, tracked with the manual pass in `KanbanUITests/AccessibilityVerification.md`.)* **Pro surfaces audit through a fixture-gated tier override** (ruled 2026-08-06): the audit's every-surface claim reaches tier-gated UI (the settings sheet's Pro sections, remote/credentials, the card History section in its present state) via a launch flag honored **only when `UITestLaunch.isFixtureLaunch` is also set** — the fixture flag already redirects registry and app state to a scratch container and builds a throwaway board, so the override grants Pro on a disposable sandbox and never over real boards; a bare tier flag in the shipping binary would be a subscription bypass, and this one is not (a Terminal user gains a Pro-looking toy, not a working subscription). The weighed alternatives lose on the audit's own terms: a permanent manual-checklist carve-out would asterisk the every-surface claim, and a debug-build-only override would leave release builds unauditable. Surfaces whose reachable-state audit is genuinely partial pin what the free fixture can reach (the disabled settings row, the absent History section) *and* audit the full surface under the override — both states are shipping states, both audit.
|
||||
- **A manual VoiceOver smoke script** lives with the test plan: create lane → create card → rename → cut/paste to another lane → external edit lands (announcement heard) → delete → restore from the trash (⌘X/⌘V) → Empty Trash. Run per release; it is the canonical "does the board actually work blind" check.
|
||||
|
||||
## Changes from Kanban
|
||||
|
||||
@@ -58,4 +61,4 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
|
||||
|
||||
## Open questions
|
||||
|
||||
None currently — the lane-move gap and the Add Attachment menu path are both closed by the keyboard map (04-interactions.md).
|
||||
None currently — the lane-move gap and the Add Attachment menu path are both closed by the keyboard map (04-interactions.md's contract, inventoried in 11-command-nexus.md).
|
||||
|
||||
+41
-24
@@ -10,7 +10,7 @@ The single source of truth for **every command and action the app can perform**
|
||||
| **M−** | Menu command, effectively fixed | Undo/Redo only: NSUndoManager rewrites their titles dynamically, which defeats title-matched remapping (04). |
|
||||
| **G** | Fixed grammar key | Platform grammar, deliberately not remappable — Finder's own Return/arrows aren't either (04 ▸ Grammar). |
|
||||
| **P** | Pointer grammar | Clicks, drags, modifiers — not customizable. |
|
||||
| **C** | Configuration control | Form-like controls (board popover, chooser, welcome); the keyboard path is reachability (Board Info ⌘I + Tab-reachable controls), not bindings — 04's configuration carve-out. |
|
||||
| **C** | Configuration control | Form-like controls (board popover, chooser, welcome); the keyboard path is reachability (Board Info ⌘I + Tab-reachable controls), not bindings — 04's configuration carve-out. *(Container amended 2026-08-07: the board settings sheet and its Board ▸ Board Settings… path retired with the reversal of the 2026-07-31 popover/sheet split — 03 ▸ Board settings sheet.)* |
|
||||
|
||||
**Toolbar presence is a separate axis**, orthogonal to the classes: toolbar items mirror menu commands, their presence per window is user-customizable (Customize Toolbar — 03-board-ui.md ▸ Toolbar; defaults and catalogs live there), and a toolbar is never a function's only home. Labels match menu titles except Undo/Redo, whose toolbar labels stay static (03).
|
||||
|
||||
@@ -18,37 +18,46 @@ The single source of truth for **every command and action the app can perform**
|
||||
|
||||
| Menu | Command | Default | Context |
|
||||
|---|---|---|---|
|
||||
| App | Settings… | ⌘, | Everywhere; the app-wide preferences pane. v1 holds one control: "Restore open boards at launch" (02 ▸ Launch and window lifecycle) |
|
||||
| File | New Card | ⌘N | Board window; disabled on a zero-lane board. Target rule: 04 |
|
||||
| File | New Lane | ⇧⌘N | Board window |
|
||||
| File | New Board… (opens the template chooser) | ⌥⌘N | Everywhere |
|
||||
| File | Open… | ⌘O | Everywhere; standard open panel (boards = `.kanban` packages and extension-less board folders — 01) |
|
||||
| File | Open Recent ▸ (with Clear Menu) | — | Everywhere; reads the board registry (02) |
|
||||
| File | Board Info (opens the board popover) | ⌘I | Board window |
|
||||
| File | Board Info (toggles the board popover — opens it closed, closes it open) | ⌘I | Board window |
|
||||
| File | Duplicate (the board — a Finder-style "copy" sibling, 03 ▸ Welcome; never the selection) | ⇧⌘S | Board window |
|
||||
| File | Save as Template | — (no default) | Board window; 09-templates.md |
|
||||
| File | Reveal in Finder | — (no default) | Board window: the selection's folder(s), or the board root with nothing selected; card window: the card's folder — the selected attachment's file instead when the attachments section is focused |
|
||||
| File | Reveal in Finder | — (no default) | Board window: the selection's folder(s), or the board root with nothing selected; card window: the card's folder — the selected attachment's file instead when the attachments section is focused; welcome: the selected recent's folder (disabled on unavailable rows) — the context-menu entry's required twin |
|
||||
| File | Add Attachment… | ⇧⌘A | Card window |
|
||||
| File | Delete | ⌘⌫ | Board window, live selection (chord twin of Put Back — validation enables exactly one). Deliberately **not** extended to the card window: an enabled ⌘⌫ key equivalent would steal delete-to-line-start from the window's text surfaces, so there the card's delete is the sidebar Actions button (05) |
|
||||
| File | Put Back | ⌘⌫ | Board window, tombstoned selection (chord twin of Delete) |
|
||||
| File | Delete Immediately | ⌥⌘⌫ | Board window, tombstoned selection; confirmed on boards without git history (mode none / repo-nested), immediate on git boards — 03 ▸ Trash |
|
||||
| File | Empty Trash… (confirmed) | ⇧⌘⌫ | Board window, trash non-empty |
|
||||
| File | Add Comment | — (no default) | Card window (all tiers — 12); if Show Comments is off, turns it on (persisted, the same user choice) and focuses the composer — 05 ▸ The comments column |
|
||||
| File | Delete | ⌘⌫ | Board window, any card or lane selection — staged by place (resettled 2026-07-28; lanes rejoined 2026-07-29): board cards and lanes move to `.trash/`, trash selections delete permanently (03's recoverability confirm — freight-counting for lanes). Deliberately **not** extended to the card window: an enabled ⌘⌫ key equivalent would steal delete-to-line-start from the window's text surfaces, so there the card's delete is the sidebar Actions button (05). **Delete Immediately (⌥⌘⌫) is deliberately absent** (removed 2026-07-30): permanence is only reachable inside the trash — 03 ▸ Trash |
|
||||
| File | Empty Trash… (confirmed) | ⇧⌘⌫ | Board window, trash shown and non-empty (whole-trash scope, search-independent — 03 ▸ Trash) |
|
||||
| File | Close | ⌘W | Any window; flushes per 02 ▸ Windows |
|
||||
| Edit | Undo / Redo (M−) | ⌘Z / ⇧⌘Z | Focus-routed (06 ▸ Undo routing): text undo in a focused editor, git undo otherwise; git undo disabled on no-git and repo-nested boards |
|
||||
| Edit | Cut / Copy / Paste | ⌘X / ⌘C / ⌘V | Board window: cards and lanes (cards-XOR-lanes selections; lane paste lands after the anchor lane — 04 ▸ Clipboard; on a zero-lane board only a lane payload pastes — 04 ▸ ⌘N target rule); in the trash, ⌘C copy-out only (card and lane entries), ⌘X disabled (04 ▸ The trash); text editors: standard text clipboard |
|
||||
| Edit | Select All | ⌘A | Board: all visible live cards (filter-respecting); text editors: the text |
|
||||
| Edit | Undo / Redo (M−) | ⌘Z / ⇧⌘Z | Focus-routed (06 ▸ Undo routing): text undo in a focused editor, git undo otherwise; git undo disabled on no-git and repo-nested boards, during 06's abnormal-state pause (detached HEAD, in-progress merge/rebase), and under the read-only lock (02) |
|
||||
| Edit | Cut / Copy / Paste | ⌘X / ⌘C / ⌘V | Board window: cards and lanes (cards-XOR-lanes selections; lane paste lands after the anchor lane — 04 ▸ Clipboard; on a zero-lane board only a lane payload pastes — 04 ▸ ⌘N target rule); in the trash, ⌘C copies out and ⌘X/⌘V is the keyboard restore path (resettled 2026-07-28 — 04 ▸ The trash); paste never targets the trash; text editors: standard text clipboard |
|
||||
| Edit | Select All | ⌘A | Board: all visible cards on the active board side (filter-respecting); on the active trash side it selects all visible trash rows, both kinds — the container boundary decides which "all" is meant, and trash selection is kind-blind (04 ▸ The trash); text editors: the text |
|
||||
| Edit | Find | ⌘F | Board window: board search (04 ▸ Search); card window: find-in-text (05) |
|
||||
| Edit | Find Next / Find Previous | ⌘G / ⇧⌘G | Card window: the find bar's stepping; disabled in the board window — board search is a live filter, not a cursor. **Use Selection for Find (⌘E) is deliberately absent**: the chord belongs to View ▸ Edit Body, which outranks the text view's binding; a user who wants it back remaps Edit Body system-natively |
|
||||
| Edit | Find Next / Find Previous | ⌘G / ⇧⌘G | Card window: the find bar's stepping — the rows enable only while the comments-thread find bar is up and step *that* bar; otherwise they disable and the chord falls through the responder chain to the focused text surface's own NSTextFinder stepping (pinned 2026-07-31 — routing by focus applied to find); disabled in the board window — board search is a live filter, not a cursor. **Use Selection for Find (⌘E) is deliberately absent**: the chord belongs to View ▸ Edit Body, which outranks the text view's binding; a user who wants it back remaps Edit Body system-natively |
|
||||
| Board | Open Card | ⌘↩ | Board window, sole selected live card; during an inline title edit (placeholder or rename), commits it and opens — the one board command enabled mid-edit (04 ▸ Grammar) |
|
||||
| Board | Rename | — (cards: Return in place) | Board window, sole selected card/lane; a lane's only rename path (Return on a lane creates); exists for completeness and remapping |
|
||||
| Board | Style… (the style editor; selection-aware) | ⌥⌘S | Board window: selected cards or lane; nothing selected = the board |
|
||||
| Board | Move Up / Move Down | ⌥⌘↑ / ⌥⌘↓ | Card selection within one lane (within-lane sort, logical order; non-contiguous selections gather behind their first card on the first press); disabled when the selection spans lanes; inert on lanes and on tombstoned cards |
|
||||
| Board | Move Left / Move Right | ⌘← / ⌘→ | Lane selection only (one slot; never into the trash) — cards cross lanes by drag or Cut/Paste, not ⌘-arrows |
|
||||
| Board | Increase Lane Width / Decrease Lane Width (the stepper's re-divide semantics, never the window's size — 03 ▸ Lane) | ⌥⌘→ / ⌥⌘← | Selected lane |
|
||||
| Board | Pull / Push | — (no default) | Remote-backed boards only (07); popover twins exist |
|
||||
| View | Show Trash (checkmark toggle) | ⇧⌘T | Board window |
|
||||
| Board | Move Up / Move Down | ⌥⌘↑ / ⌥⌘↓ | Card selection within one lane (within-lane sort, logical order; non-contiguous selections gather behind their first card on the first press); disabled when the selection spans lanes; inert on lanes and on trash cards |
|
||||
| Board | Move Left / Move Right | ⌘← / ⌘→ | Lane selection only (one slot; never into the trash) — cards cross lanes by drag or Cut/Paste, not ⌘-arrows; disabled while any text control is focused (04 ▸ Grammar, caret-chords rule) |
|
||||
| Board | Increase Lane Width / Decrease Lane Width (the stepper's re-divide semantics, never the window's size — 03 ▸ Lane) | ⌥⌘→ / ⌥⌘← | Selected lane(s) — batches over a multi-lane selection like style (03 ▸ Lane); disabled while any text control is focused (04 ▸ Grammar, caret-chords rule) |
|
||||
| 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 |
|
||||
| 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 | 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 | 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 | 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) |
|
||||
|
||||
## Fixed grammar keys (G)
|
||||
|
||||
@@ -61,10 +70,14 @@ All board grammar keys are inert while a title editor is focused, and menu dispa
|
||||
| Return | Board, lane selected | Create card at its bottom (placeholder; Return commits and re-selects the lane) |
|
||||
| Return | Board, sole selected card | Inline rename; committing empty removes `title`; inert on multi-card selections |
|
||||
| Escape | Board window | One layer per press: abandon editor, else clear search (focus to board), else deselect |
|
||||
| ⌫ | Board, live selection | Tombstone — plain-key synonym of File ▸ Delete, kept grammar so no second "Delete" title exists (04) |
|
||||
| ⌫ | Board, any selection | Delete, staged by place — plain-key synonym of File ▸ Delete (board side moves to `.trash/`, trash side deletes permanently with 03's confirm), kept grammar so no second "Delete" title exists (04) |
|
||||
| Return | Card window, Preview | Enter Edit |
|
||||
| Escape | Card window, Edit | Return to Preview |
|
||||
| Return | Card window, title field | Commit title, focus into body |
|
||||
| ⌘↩ | Card window, comment composer or inline comment editor focused | Post the draft (rename + restamp, one commit) / end the edit session at its commit point — 05 ▸ The comments column; twinned by the Comment / Save buttons |
|
||||
| Escape | Card window, comment composer focused | Focus moves out, draft file untouched — Escape never discards a draft (ruled 2026-07-29; 05 ▸ The comments column) |
|
||||
| Escape | Card window, inline comment editor focused | Cancel — revert to session-start bytes and end the session, the Cancel button's keyboard twin (05) |
|
||||
| Escape | Card window, title field | Abandon: revert to the on-disk title, focus into body — routes by focus, winning over Edit-mode's Escape while the field is focused (05) |
|
||||
| Escape / ⌘↩ | Card window, source mode | Cancel / Apply (leaving-by-toggle is Apply too — 05) |
|
||||
| Arrows / Space / Return / ⌫ | Card window, attachments section focused | Row navigation / QuickLook / open / Remove to *system* Trash (05) |
|
||||
|
||||
@@ -74,31 +87,35 @@ All board grammar keys are inert while a title editor is focused, and menu dispa
|
||||
- **Drag & drop — the locality model** (04): within-board move / cross-board copy; **⌥ always forces copy, ⌘ always forces move**; multi-drag; lane header is the lane drag surface; drag-to-restore from the trash.
|
||||
- **Lane edge drag** (03 ▸ Lane): window-growing resize between integer widths — the one width control that moves the window.
|
||||
- **Finder file drops** (04): onto a card = attach; onto lane empty space = one card per file; anywhere on the card window = attach (05's payload-split precedence).
|
||||
- **Preview** (05): task-list checkbox toggle (the one interactive exception), link opens, text selection; carousel paging on the card face (03).
|
||||
- **Preview** (05): task-list checkbox toggle (the one interactive exception), link opens, text selection. **In-content controls aren't P-only** (settled): checkboxes and links are real controls in the focus/accessibility tree, so Full Keyboard Access + Space and VO-Space reach them (05 ▸ Task-list checkboxes) — content rides the system focus model rather than earning command rows; "a command absent here doesn't exist" scopes to commands, not content.
|
||||
- **Lane header new-card button** (03, labeled "New card in ⟨lane⟩" — 10): creates in **the button's lane**, appended at the bottom — the click names its target, overriding 04's selection-derived ⌘N target rule (settled); placeholder and abandon semantics exactly as ⌘N. The card window's attachments-section **quiet add affordance** (05) is the same class: a pointer twin of File ▸ Add Attachment…, no separate behavior.
|
||||
- **Attachment rows** drag out their file URL (05). **Welcome rows**: single click selects, double click opens (03).
|
||||
|
||||
## Context menus
|
||||
|
||||
Context menus are the per-item action inventory VoiceOver reads (10 ▸ Actions). Every entry is a twin of a menu command, a fixed grammar key, or a configuration control — no function's only home:
|
||||
Context menus are the per-item action inventory VoiceOver reads (10 ▸ The board through VoiceOver). Every entry is a twin of a menu command, a fixed grammar key, or a configuration control — no function's only home:
|
||||
|
||||
| Surface | Entries |
|
||||
|---|---|
|
||||
| Card / lane | Open (cards), Rename, Style…, quick-style recents row (03), Delete |
|
||||
| Trash entries | Put Back, Delete Immediately, Reveal in Finder (inspection before a purge; twin of File ▸ Reveal in Finder, which is not edit-shaped and stays enabled on tombstoned selections — 04 ▸ The trash) |
|
||||
| Lane header | Width control (stepper — menu twins Increase/Decrease Lane Width) |
|
||||
| Card | Open, Rename, Style…, quick-style recents row (03), Delete (the ⌥-alternate Delete Immediately row retired with the command, 2026-07-30) |
|
||||
| Lane | One menu, invoked on the header or lane empty space (settled — a full lane still has its header): Rename, Style…, quick-style recents row (03), Width control (stepper — menu twins Increase/Decrease Lane Width), Delete |
|
||||
| Trash selection | Delete (permanent — 03's recoverability confirm), Reveal in Finder (inspection before a purge; twin of File ▸ Reveal in Finder, not edit-shaped, enabled on trash selections — 04 ▸ The trash) |
|
||||
| Attachment row | Open, Remove (system Trash) — twins of the focused section's grammar keys (Return / ⌫ — 05); Reveal in Finder — twin of File ▸ Reveal in Finder in its attachments-focused context |
|
||||
| Comment | Edit (inline session — 05 ▸ The comments column), Delete (immediate, undoable — 01), Reveal in Finder |
|
||||
| Welcome recent | Open, Reveal in Finder, Forget (C — registry management, welcome-scoped) |
|
||||
|
||||
## Configuration controls (C)
|
||||
|
||||
- **Board popover** (Board Info ⌘I — 03 ▸ Board popover): board rename; embedded style editor; add-git (mode none) / repo-nested explanation (06); branch display, switch, create; commit-identity name/email (06); add/change remote, credential fields, machine SSH key Copy + Verify, Authentication-needed state (07); ahead/behind with Pull/Push buttons and the push-on-commit toggle.
|
||||
- **Board popover** (Board Info ⌘I — 03 ▸ Board popover; **the board's one configuration surface** since the 2026-08-07 reversal of the 2026-07-31 popover/sheet split): board rename and the board glyph; the Theme tab's preset backgrounds; the Info tab's dossier; and the Git tab — posture lines (repo-nested explanation, unreadable/paused states — 06); branch display and switch picker with its New Branch… reveal; add-git (mode none, 06); commit-identity name/email (06); ahead/behind with Pull/Push buttons and the status badges.
|
||||
- **Board settings sheet** — *retired 2026-08-07* (03 ▸ Board settings sheet). Its contents rehomed into the popover above, and Board ▸ Board Settings… left this document's menu inventory with it. 07-sync-collab.md's own surfaces — add/change remote with inline verify, credential fields, the SSH key surface (machine key Copy + Verify, key import by paste or drag, the per-host key picker, removal of an unreferenced import, confirm-gated machine-key regeneration), the Authentication-needed capture and TOFU confirms, the push-on-commit toggle — need a home ruled on that card; they were inventoried here as the sheet's and are homeless until then.
|
||||
- **Style editor** (three anchors — 03 ▸ Styling ▸ Controls): grids arrow-navigable, every well Tab-reachable.
|
||||
- **Template chooser** (09): template selection; Reveal in Finder for the user store.
|
||||
- **Welcome** (03): recents list; Forget.
|
||||
- **Comments header** (05 ▸ The comments column): the sort-direction control (ascending/descending, app-wide persisted) — Tab-reachable beside the count.
|
||||
|
||||
## Standard macOS furniture
|
||||
|
||||
System-provided; the app adds nothing beyond convention: App menu (About, Hide, Quit — **no Settings pane in v1**: the only app-wide preferences, quick-style recents and `NSUserKeyEquivalents`, need no UI; ⌘, unused), Window menu, Help (carries the one line teaching the System Settings remap path — 04). **No Print story in v1** (⌘P unused). Customize Toolbar… per system convention (03).
|
||||
System-provided: App menu (About, Hide, Quit), Window menu, Help (carries the one line teaching the System Settings remap path — 04). The app's own additions to this furniture are inventoried in Menu commands above — App ▸ Settings… and Window ▸ Welcome to Lanework; the remaining app-wide preferences, quick-style recents and `NSUserKeyEquivalents`, need no UI. **No Print story in v1** (⌘P unused). Customize Toolbar… per system convention (03). **Window tabbing stays enabled** (settled): the system's Show Tab Bar / tab items appear with their standard chords — ⇧⌘T is the system's, which is why Show Trash ships without a default (Menu commands above); tabbed board windows are ordinary system behavior, each tab still a full board window (a tab's saved per-board frame applies when it stands alone — 02-architecture.md).
|
||||
|
||||
## Changes from Kanban
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Tiers
|
||||
|
||||
Lanework ships as **one Mac App Store app** — `dev.rzen.indie.Kanban`, free, 2.0 updating the existing record — built from one codebase and one on-disk format, with **Lanework Pro as an auto-renewable subscription** unlocking the git tier. This doc owns the tier axis: what each tier is, how the gate is engineered (the provider seam, the entitlement), and which features land where. Individual docs stay tier-agnostic where they can — they conditionalize on **board mode** (none / git / git+remote — 07-sync-collab.md), and this doc defines which modes each tier ships.
|
||||
|
||||
**PIVOT 2026-08-07 — git leaves the paywall.** Git integration — detection, adoption, auto-commit, git-backed undo/history, branches (06-history-undo.md), and remotes/auth when they ship (07-sync-collab.md) — is **tier-independent**: every tier composes the git stack on git-mode boards exactly as Pro did. The base/Pro feature split is being re-decided, and git isn't going to be it. Until the new split is ruled: the subscription **machinery stays built and tested but dormant** — the entitlement's mechanics (local read, composition-time, offline grace, the recorded session tier) are unchanged and correct for whatever the next split gates, but the Settings Pro section is not rendered and no surface names or sells Pro. The free-only git postures are **retired**: the inert-`.git` stance and the popover's Pro pointer describe a gate that no longer exists — a `.git` at a board root is live in every tier, detection runs at every board open, and every board carries the popover's Git tab (the mode-driven postures: no-repository door, repo-nested, unverifiable, branch). What the pivot does **not** change: git stays **opt-in per board** (06 — creating a local repository is the user's deliberate choice, never auto-initialized), and a board without app-managed git binds conventional native undo/redo (13) exactly as before — the provider still follows the board. The sections below describing the git gate (the tier matrix's git rows, the inert posture, no-grandfathering) stand as record of the pre-pivot design and are not restated; read them through this note.
|
||||
|
||||
**Re-ruled 2026-07-30 — the one-app collapse.** This supersedes the 2026-07-27 two-app split (separate base and Pro targets) and the 2026-07-29 App Group ruling that served it. The split's compile-time purity (base never links libgit2, no network entitlement) dragged permanent coexistence machinery behind it: a shared App Group, per-edition grant slots (security-scoped bookmarks never cross sandboxes), registry freshness stamping between two live processes, UTI-ownership twins, a both-apps-installed rulebook — a tax on every layer that generated a steady stream of design findings, all serving a state (two sandboxed apps sharing app-side state) that existed only because the packaging created it. One app makes that state unrepresentable. Costs accepted with eyes open: libgit2 rides dormant in the free download, and the one app declares the network-client entitlement (exercised only under Pro) — the "free app provably has no network access" story is traded for "no network use until you subscribe," which is honest but weaker.
|
||||
|
||||
## The tiers
|
||||
|
||||
- **Lanework** (free) — no git integration. Boards are plain folders (mode `none` everywhere); undo/redo is macOS-native (13-native-undo.md). The full board experience: lanes, cards, styling, trash, attachments, card window, templates, comments (when they ship), agents, accessibility.
|
||||
- **Lanework Pro** (subscription) — git integration as designed in 06-history-undo.md and 07-sync-collab.md: opt-in init, adoption, git-backed undo/history, branches, remotes, pull/push, auth. Plus Pro-only differentiators (matrix below).
|
||||
- **Lanework Teams** — tracker integration over the reserved enhanced schema (`remote`/`remote-state`, tracker-*synced* comment threads — comments themselves ship in every tier). **Deferred** — no design pass; probably a separate app when it comes. Whatever shape it takes, it will **never share an app group or any cross-app state** with Lanework (ruled 2026-07-30) — files are the only interchange this family recognizes.
|
||||
|
||||
The strategic reason for the seam stands unchanged (settled): Teams' card sync must be **backend-agnostic** — it has to work over git and over a range of trackers — so history and sync sit behind a genuine provider seam. The free tier's native undo is the first proof the seam is real: two working history providers before a third arrives.
|
||||
|
||||
## Distribution (re-ruled 2026-07-30)
|
||||
|
||||
One record: `dev.rzen.indie.Kanban`, free, all territories, 2.0 as an update — the 1.x listing simply grows the subscription. The `.kanban` package UTI (`dev.rzen.indie.kanban-board`) is declared and exported once, by the one app — no ownership twins, no default-claim choreography. **Lanework Pro is an auto-renewable subscription** (StoreKit 2), purchased and managed in a **Pro section of Settings (⌘,)** — subscribe, manage, restore purchases. Teams' eventual monetization is deferred with Teams. **2.0 ships only when both tiers are ready** (ruled 2026-07-31 — RELEASE.md): the Settings Pro section never faces a store without its product, so its unreachable state is only ever a true sentence.
|
||||
|
||||
**No grandfathering** (ruled 2026-07-30): 1.x shipped git-backed undo free; 2.0's free tier is native undo over the inert-`.git` posture (below). Existing users' boards keep working untouched, their histories stay intact and inspectable in any git client — the app just stops *extending* them until Pro is subscribed, and git resumes exactly where it left off (the committer's whole-root staging collapses the gap into one catch-up commit). No receipt-date logic exists.
|
||||
|
||||
## The entitlement (ruled 2026-07-30)
|
||||
|
||||
- **A local read, never a network call.** Pro state is read from StoreKit's signed on-device transaction store at **board-session composition** — the open path gains no network dependency (02-architecture.md's hang-avoidance doctrine extends here). Offline with an active subscription is indistinguishable from online.
|
||||
- **Subscribe takes effect at each board's next open** — the provider binding is a composition-time fact, the design the seam was built for. The purchase flow offers to reopen open boards so the upgrade feels immediate.
|
||||
- **A lapse never interrupts an open session**: an open board finishes with the provider it composed; the next open composes the native stack over inert `.git`. Unsubscribed and lapsed are **one state** — the inert posture, nothing lost, histories frozen not forfeited.
|
||||
- **Offline grace resolves toward the paying user**: an on-disk expiry passing while offline, with the last known state *active and auto-renew on*, holds the entitlement until StoreKit actually refreshes and answers. A cancellation (auto-renew off) lapses at expiry, offline or not. Either wrong-for-a-window direction costs nothing: a wrong lapse pauses auto-commits into one catch-up commit; a wrong hold gives away days of local commits — Apple's own billing grace makes the same trade.
|
||||
- **A fresh install that has never been online** has no cached transactions and reads as the free tier until the first refresh — honest and self-correcting.
|
||||
|
||||
## The provider seam
|
||||
|
||||
History (and later sync) is a provider behind one protocol boundary, bound per board session at composition from the entitlement:
|
||||
|
||||
- **HistoryProviding** — the undo/redo substrate. **The provider follows the board** (re-ruled 2026-07-31): gitless boards bind the native undo stack (13-native-undo.md: NSUndoManager over inverse `WriteOperation`s) in every tier — an upgrade never removes undo — while Pro binds the git provider on git boards (06-history-undo.md: undo as forward restore commits over HEAD's first-parent ancestry); repo-nested boards bind native too (re-ruled 2026-07-31 — the stack touches no git, so what matters is the absence of *app-managed* git, and the upgrade story is exceptionless). Teams inherits Pro's. Add-git swaps native → git mid-session by the branch-switch discard-and-reseed precedent (13).
|
||||
- **Sync/tracker providers** — deferred with Teams; the reserved schema keys and the one-way file flow (02-architecture.md) are the format-level seam already in place.
|
||||
|
||||
What is shared across providers (settled): **06's Undo routing is tier-independent** — focus decides text-undo vs board-undo; only the substrate behind board-undo differs. The command surface is identical (⌘Z/⇧⌘Z, dynamically retitled menu items — both providers use NSUndoManager's title rewriting); menu titles draw on the same semantic vocabulary (06 ▸ Commit messages). A user subscribing (or lapsing) relearns nothing.
|
||||
|
||||
## The free tier and `.git` — the inert posture (settled; now also the lapsed posture)
|
||||
|
||||
The free tier generalizes the repo-nested stance to every `.git` it meets: **any `.git` is inert**. Opening a board that has one (a formerly-subscribed user's board, a 1.x board, a repo-nested board) works normally — files read and write as on any board, native undo runs, the trash works — but the app never reads history, never commits, never touches `.git` in any way. To the free tier, `.git` at the board root is a stray like any other, preserved verbatim. Pro's external-writer machinery (06 ▸ Interaction with external writers) already reconciles the uncommitted drift a free-tier session leaves behind — a free-tier edit is just a foreign change to the git provider's next composition. The watcher's `.git` event filtering is unconditional (it exists to ignore git churn, which lapsed-and-resumed boards will produce).
|
||||
|
||||
The free tier's popover git slot (03-board-ui.md ▸ Board popover) does not offer add-git. **Its posture is contextual** (settled — ruled 2026-07-27, carried through the collapse): on ordinary boards the section is simply absent — the popover is rename + style, complete in itself. Only when the board carries an inert `.git` does a calm info line appear: "This board has a git history. Lanework Pro works with it." — an honest explanation of what the folder is, surfacing exactly where the question arises, never a standing ad; it is also the one in-context pointer to Settings' Pro section. The card window's absent History section follows the same pattern: absent, no placeholder.
|
||||
|
||||
## Tier matrix
|
||||
|
||||
The feature sort. Everything not listed rides with "board experience" and is identical everywhere.
|
||||
|
||||
| Feature | Lanework (free) | Pro | Teams |
|
||||
|---|---|---|---|
|
||||
| Board experience: lanes, cards, drag & drop, keyboard map, clipboard, search, styling, trash, attachments, card window, templates, welcome screen | ✓ | ✓ | ✓ |
|
||||
| Agent integration: agent guide, `modified-by` attribution, tolerance rules | ✓ | ✓ | ✓ |
|
||||
| Accessibility (10-accessibility.md, all of it) | ✓ | ✓ | ✓ |
|
||||
| Comments (designed 2026-07-29 — 01 ▸ Enhanced schema + 05 ▸ The comments column; ships post-2.0) | ✓ | ✓ | ✓ + tracker-synced threads |
|
||||
| Undo/redo | native (13) | git on git boards, native otherwise — repo-nested included (06/13, re-ruled 2026-07-31) | inherits Pro |
|
||||
| Undo of foreign/agent edits | — (honest gap, 13) | ✓ (stack absorbs foreign commits) | ✓ |
|
||||
| Overwrite protection (flush-before-overwrite, both-versions-as-commits) | — (07's accepted caveat is permanent here; trash + native undo are the safety story) | ✓ | ✓ |
|
||||
| History surfaces: card History sidebar (05), View ▸ History (11) | — | ✓ | ✓ |
|
||||
| Git: init/adoption, branches, commit identity, repository hygiene | — | ✓ | ✓ |
|
||||
| Remotes: pull/push, push-on-commit, auth (Keychain, SSH, TOFU) | — | ✓ | ✓ |
|
||||
| "While you were away" digest (WISHLIST item 1, requires git) | — | ✓ (future) | ✓ (future) |
|
||||
| Tracker integration (`remote`/`remote-state` sync, tracker-backed boards) | — | — | ✓ (future) |
|
||||
|
||||
Docs 06 and 07 are **Pro-tier docs**; every other doc applies to all tiers, with mode-conditioned passages (undo availability, popover git surface, the permanent-delete confirmation branch) resolving per the modes the tier ships. The free tier ships exactly one mode: `none` (with the inert-`.git` posture above); Pro ships the full state machine.
|
||||
|
||||
## The target (re-ruled 2026-07-30)
|
||||
|
||||
**One app target.** The 2026-07-27 target split retires wholesale: the `KanbanPro` target, scheme, bundle id, module-alias test arrangement, `verify-editions.sh`, and the edition-twin files (EditionAbout, EditionTypes — Info.plist-posture twins existed only because two bundles claimed different ownership) all come out; the UTI is exported once. libgit2 links into the one target when the git provider is built (pro-m1) — dormant code behind the entitlement gate, not a second binary. Entitlements: the current minimal set plus `network-client` (exercised only under Pro; Keychain needs no access group — groups exist for sharing across apps). The pro-m1/pro-m2 milestones are unchanged in content — the git HistoryProvider and remote sync, built behind the seam — they now compile into the one target and activate by subscription.
|
||||
|
||||
## App-side state (re-ruled 2026-07-30)
|
||||
|
||||
One sandbox: the board registry and its Application Support peers (02-architecture.md ▸ Per-board app state) home in the app's **ordinary sandbox container** — the App Group is removed wholesale, superseding the 2026-07-29 group ruling. No group entitlement, no per-edition grant slots (one bookmark per record), no per-edition open-now flags (one flag), no cross-process freshness stamping (one process — macOS apps are single-instance), no "Also open in…" awareness line, no group-id provisioning risk. The clipboard staging store, template store, and scalar defaults follow the same collapse.
|
||||
|
||||
## Tier naming in the free app (settled — ruled 2026-07-27, carried through the collapse)
|
||||
|
||||
**Quiet signposts.** The free tier presents as a complete app, not a demo: Pro is named in exactly three places — one line in the About box, the contextual popover line on `.git` boards (above), and the Settings Pro section where the subscription actually lives. Nothing on the welcome screen, nothing in banners. The iCloud/network-volume warning (07-sync-collab.md) is written for the free tier without a git recommendation — it warns on its own merits (eviction, silent forks) and recommends a local folder; no Pro pitch in a warning (a warning that sells reads as manufactured).
|
||||
|
||||
## Open questions
|
||||
|
||||
None currently — the one-app collapse, subscription shape, entitlement semantics, App Group removal, and no-grandfathering were ruled 2026-07-30; the popover slot posture and quiet signposts carry from 2026-07-27.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Native Undo
|
||||
|
||||
The undo/redo substrate for **every board without app-managed git** (re-ruled 2026-07-31 — the provider follows the board, not the tier alone; formerly free-tier-only, which made a Pro upgrade *remove* undo from mode-none boards). Since the 2026-08-07 pivot (12-editions.md — git left the paywall) the tier axis is gone entirely, and one thing the pivot does **not** change: git remains **opt-in per board** — creating a local repository is the user's deliberate choice (add-git, 06), never something the app initializes for them — so a board whose user never opted in keeps this conventional stack for good. The substrate is the board's mode alone: mode-none **and repo-nested** boards bind it (the repo-nested no-undo case retired 2026-07-31: leave-strictly-alone concerns *git*, and this stack never touches git — memory-only, journal-free, session-scoped — so what repo-nested denies is app-managed history, never ⌘Z; the upgrade story is thereby exceptionless), switching to the git provider (06-history-undo.md) where the board's own git exists. **Add-git swaps the substrate mid-session** — the commanded flip discards the in-session native stack and seeds the git trail from the root commit, the branch-switch discard-and-reseed precedent applied; a subscription lapse still never interrupts (12). The design problem is not NSUndoManager itself — it is native undo over **files-are-truth**: the disk can change underneath the stack, because the app is not the only writer.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Two levels: one stack per board, one per open card window** (re-ruled 2026-07-31 — the session-coarsening model, superseding the pure one-stack rule): the **board stack** is owned by the board session and shared by board surfaces; a **card window owns its own stack** for the session it represents — every gesture issued in that window (comment post/delete/edit, body Edit sessions, style/details changes, attachment ops where undoable) registers there at fine grain, and `window.undoManager` answers with it (standard per-window AppKit scoping). Disk stays live throughout — files-first untouched; this is history granularity only. **Window close coarsens**: the session's net effect registers on the board stack as **one coarse step named "Changes to '⟨card⟩'"** (ruled 2026-07-31 — the board row reads "Undo Changes to 'Fix login'": plural and scope-flavored, distinct from every fine verb, honest about folding many kinds; the fine body-edit wording never leaks onto the board menu), values-based, whose undo restores the card subtree to its session-start state — deleted comments included — and whose redo reapplies the net effect; a session with no net change registers nothing. The coarse step is transactional at apply time: staleness validation runs per component (the field-level predicate below), and any stale component skips the whole step — never a partial session revert. **Session steps anchor by card identity, never by path** (ruled 2026-07-31): the coarse step — and the window's fine steps it folds — stores the card's UUID plus expected values, and apply-time validation resolves the card's *current* folder exactly the way the window itself always resolves its card (the per-snapshot UUID walk; `writeCardBody` already resolves trash locations on purpose). A tracked relocation — a lane move mid-session or after close, a trash move — therefore never stales the step; only genuine content changes do, which is what the validation exists to catch. A card that resolves nowhere (purged, or moved out of the board) is the honest skip. 06 ▸ Undo routing applies unchanged — text-editing surfaces get their session-scoped text undo above either stack. **Two stacks over one open card are the blessed shape** (2026-07-31): a board-issued gesture on a card whose window is open registers on the board stack while the window's own gestures register on the window stack — no ordering relation between the two, interleaving decided by ⌘Z focus (06 ▸ Undo routing); routing board gestures into the open window's stack was considered and rejected, since board ⌘Z must never see card-session steps.
|
||||
- **Registration at the Writer boundary.** Every app-mediated mutation already passes through the Writer as a `WriteOperation` (02-architecture.md) — that closed enum is the exact inventory of undoable operations. Each Writer call site registers the inverse operation, computed from the pre-write snapshot the store already holds: move → move back (original lane, original `order`); reorder → restore original `order`; rename → restore title; restyle → restore prior style; resize → restore prior width; Edit-session body save → restore prior body bytes; card or lane delete (⌫) → move back out of `.trash/` (lanes rejoined the trash 2026-07-29 — the recreate-from-capture inverse retires with the last destructive delete); restore-by-move → move back in; create → remove the created folder.
|
||||
- **What is not undoable** (settled): **Permanently delete** (the trash's Delete, Empty Trash) — `purgeIsUnrecoverable` stays true in base, and the existing confirmation rule (03-board-ui.md) already fires on all base boards, since none have git history: the confirm *is* the safety. **The duplicate-id remint** (01-storage-format.md — a silent scheduled heal since 2026-07-29, formerly the user-gated Repair) — heals aren't user gestures, so nothing enters the stack, and undoing one would recreate the duplicate id it exists to remove. Permanently delete matches its existing "destructive, confirmed, final" posture; the remint sits outside undo as all heals do. **Raw Source Apply** (blessed 2026-07-31): the hatch writes byte-for-byte outside every contract — no `modified` stamp, no attribution clear, and no history step at either level; an Apply-only session folds to no coarse step, and an Apply mixed into a session is invisible to the fold. The hatch's story is "you edited the file," and files-are-truth covers it — on git boards the write commits like any disk change (05-card-window.md's carve-outs are the same statement from the stamping side).
|
||||
- **Coalescing follows commit granularity** (settled; window scoping added 2026-07-31): one gesture, one undo step — a multi-card move is one step with a plural title; an Edit session is one step, registered at the Edit→Preview flip (the effective Save — 05-card-window.md) **on the card window's stack**, like every window gesture; the window close registers the one coarse session step on the board stack (Rules above); a styling batch is one step (03's one-gesture-one-commit rule, substrate swapped). The 06 vocabulary supplies menu titles ("Undo Move 3 Cards"), via NSUndoManager's dynamic retitling — the same naming machinery both editions use.
|
||||
- **Session-only persistence** (settled): the stack lives with the board session and dies at close/quit — standard macOS behavior. Git undo's survive-relaunch property is a Pro difference, stated honestly (12's matrix).
|
||||
- **Foreign writes never join the stack** (settled): NSUndoManager can only undo what the app mediated. An agent's or hand edit is not a step — the honest capability gap vs Pro (12's matrix). Foreign changes also do not clear the stack wholesale; collisions are handled lazily, per step, by validation:
|
||||
- **Staleness validation before every apply** (settled): an inverse operation re-checks its target against the disk — a fresh read of the target at ⌘Z time (blessed 2026-07-29: not the store snapshot, which is by construction one reload behind the app's own writes; a rapid ⌘Z run validated against the snapshot would compare pre-state and false-skip every step). **The predicate is field-level** (settled — ruled 2026-07-27): each step registers both sides of its write anyway (the before-value is the inverse; the after-value is what its write set), so validation compares the targeted field's current value against the expected after-value — nearly free, and truer to never-surprise-the-file than an existence-only check (an inverse rename must not clobber a foreign rename on a still-existing card; body steps compare bytes). Target folder gone, or the field no longer holding the step's after-value → the step is **skipped, not applied**: popped from the stack with an info-tone banner ("Undo skipped — 'Fix login' changed outside Lanework"), and ⌘Z falls through to the next step. Never apply a stale inverse on top of someone else's newer write. **Delete steps validate their undo by existence only** (blessed 2026-07-31): a delete's forward write sets nothing but the `modified` stamp (the arrival rank mint retired 2026-07-31), and a clock reading is not a choosable after-value — pinning it would false-skip the restore whenever an agent touched the trashed card; the undo therefore expects only that the trashed folder still exists, while the redo stays field-level via the restore's `order` write. **Invalidation is lazy** (settled — ruled 2026-07-27): staleness is discovered at ⌘Z time, never by background pruning — the EchoLedger's foreign diffs do not eagerly drop colliding steps. The stack always looks full; with the field-level predicate a skip fires only on a genuine per-field collision, and a skipped step's banner explains itself, where eager pruning would shrink the stack invisibly mid-session.
|
||||
- **Locks disable the stack** (settled): every read-only lock (vanished root, failed reload after wholesale ops, unwritable location — 02-architecture.md) disables Undo/Redo with the other mutating commands; the stack itself survives the lock and resumes when it clears. Steps landed before a lock validate like any other at apply time.
|
||||
|
||||
## Interaction with the trash
|
||||
|
||||
⌫'s undo is the move back — a delete is a move into `.trash/` (cards resettled 2026-07-28; lanes rejoined 2026-07-29), so its undo is the ordinary inverse move, returning a card to its source lane and rank, a lane to its strip position (subtree intact — it never left the folder); a restore-by-move undoes the same way in reverse. The stack and the trash never conflict — they are the same folder moves addressed by recency instead of by selection. The old lane-delete recreate-from-capture inverse is **retired** — no destructive delete remains outside a trash, so nothing needs byte capture. A **permanent delete registers no step** — the trash's Delete and Empty Trash are not undoable (Rules above), lanes and their freight included; the confirm is the safety.
|
||||
|
||||
**Comments keep the no-capture rule true — on the window stack** (re-ruled 2026-07-31, superseding the board-stack routing): a comment delete is a move into the card's `comments/.trash/` (01-storage-format.md ▸ Enhanced schema — the materialized-trash pattern one level down), its inverse the ordinary move back, and the step lives on the **card window's own stack** (Rules above) — the board stack never carries a granular comment step, so the old stale-after-close skip scenario cannot arise. **The purge of `comments/.trash/` defers with the coarse step** (re-ruled 2026-07-31, superseding purge-at-close): the coarse close step's undo restores deleted comments, so their backing lives as long as the step does — the purge runs when the coarse step leaves the board stack **cleanly** — undone-and-superseded, or dropped off the end — or when the board session ends; **a stale-skipped step's backing instead survives to board-session end** (ruled 2026-07-31, decoupling skip from purge): the skip banner says nothing was applied, and an irreversible purge riding that gesture would be surprise loss — the skip is exactly when the user may want to inspect what the collision left; crash residue still sweeps at the next card-window open — **and residue is defined by the purge-deferral condition itself** (ruled 2026-07-31): `comments/.trash/` content referenced by a live coarse step on the board stack is a step's backing, not residue — the open-time sweep consults the stack and skips owned content, re-arming when the owning step leaves the stack (which is exactly when the deferred purge wanted to run; one condition, two consumers). Reopening a window can therefore never destroy its prior session's undo backing. Unowned content sweeps as before, armed-then-cleared like every heal memo. **Every purge of `comments/.trash/` is per-entry behind the ownership gate** (ruled 2026-08-06 — the container-whole retirement purge retires): a step's retirement and a no-step close remove only entries no live step still backs — the same `backedContent` inventory the sweep consults, making it one condition, *three* consumers. The container-whole purge assumed one owning step per card's comment trash, and two sessions over the same card broke it: the second step's retirement — or a mere reopen-and-close that registered nothing — emptied the first step's backing out from under it, silently killing an undo the stack still promised. Under the gate a purge cannot stale a live step by construction; an entry that outlives its owner is collected by whichever consumer runs next (the next retirement, close, open-time sweep, or session end — convergence, not a leak). One carve-out: **an open card window is itself an owner of its card's comment trash** — a retirement firing while the card's window is open defers its purge to that window's close, because entries deleted in the live session are backed by the window's fine steps, which the board-stack inventory cannot see; the close then settles by the same gate (its coarse step becomes the owner, or the no-step close purges the unowned). On Pro the substrate is history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge rides the close flush there as before — purge timing follows the undo substrate's need.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Attachment operations, v1** (deferred — ratified 2026-07-27): attach → remove is a clean inverse, but remove-attachment → re-add requires the removed file to survive somewhere (a staging area with a lifecycle — App Support, bounded, its own cleanup rules; possibly shared with 04 ▸ Clipboard's staging). The deferral is the ruling: attachment add/remove registers **no undo step** in v1 (the operations remain, as today, confirmed-or-benign); the staging design pass reopens post-2.0.
|
||||
- **EchoLedger-synthesized foreign undo** (deferred, wishlist — WISHLIST.md item 6): the ledger already classifies foreign diffs for announcements; it could synthesize inverse operations and push foreign steps onto the stack, narrowing the gap to Pro. Real design needed (ordering vs app steps, attribution, user expectations) — not assumed by this doc.
|
||||
|
||||
## Open questions
|
||||
|
||||
None currently — the staleness predicate (field-level) and invalidation timing (lazy) were ruled 2026-07-27 and are settled in Rules above.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Git Operations — Extracted Conclusions
|
||||
|
||||
**Tier scope: Lanework Pro** (12-editions.md). This doc extracts the settled conclusions from the pathfinder's git-operations analysis (`../../Kanban/AI-ANALYSIS-git-operations.md`, 2026-07-23) so the git milestone has a citable in-repo source. It resolves the design corpus's outstanding tbd — the git-operations doc extraction the Implementation board's root index names as one of two TBDs graduating into work items. The source file catalogued issues and deliberately made no decisions; the decisions were made in 06-history-undo.md and 07-sync-collab.md, and this doc records which of the source's conclusions those docs build on — and which of its leanings later design deliberately settled otherwise. Issue tags (A1, C3, …) are the source file's.
|
||||
|
||||
## The forward-restore model (C3, C9) — the load-bearing extraction
|
||||
|
||||
**Every restorative operation moves history forward. Nothing the app does ever rewrites a published commit: no reset, no force-push, no revert-by-rewrite.**
|
||||
|
||||
- Undo (⌘Z) and redo (⇧⌘Z) restore earlier states as **new forward commits** ("Undo: ⟨subject⟩"). Reset-based undo is forbidden once any second observer exists — a remote, another machine, an agent reading the repo — because it rewrites history that observer may already have seen (C3). And the observer assumption is not optional: agents-as-concurrent-editors is a product requirement, so the safe case for reset (a never-pushed, never-read local tip) is never provable. Forward-only sidesteps the entire class.
|
||||
- Under sync, forward-only undo makes an undo **just another change for the sync loop to carry** — nothing special, no divergent realities for anyone who saw the abandoned state (C9). Reset-based undo would instead manufacture divergence with every ⌘Z.
|
||||
- The **one deliberate rewrite** in the app is pull's rebase of **unpushed local** commits (07-sync-collab.md) — local-only history, never published, rebased so the shared trail stays linear. Published history is never touched, by anyone, for any reason.
|
||||
|
||||
This is the conclusion 06-history-undo.md's "Undo never rewrites history" rule builds on, and the reason a rewritten remote (an outside agent force-pushed) is treated as fresh divergence to merge — never mimicked locally, never force-pushed back (B7).
|
||||
|
||||
## Persistence across relaunch (C8) — as later design settled it
|
||||
|
||||
The source left C8 open between an in-memory stack that dies with the app and a git-enabled "restore to any point" browser. Later design settled it past both options, and the settled form is what m7 builds:
|
||||
|
||||
- **No sidecar undo state, ever.** The undo stack **reseeds from HEAD's first-parent ancestry on load**; redo starts empty. In-session it behaves as classic dual stacks; after relaunch, past restore commits reappear as ordinary undoable steps (06-history-undo.md ▸ Rules). Nothing is stored beside the repo, so nothing can drift from it.
|
||||
- **The board-wide history browser / timeline is explicitly out of scope** — it is a tracker-integration-era feature (07-sync-collab.md's deferred story), not part of the git milestone. The commit trail remains its natural substrate whenever it happens; the forward-restore model is all the preparation it needs.
|
||||
- **The one deliberate carve-in**: the card window's read-only per-card History section (05-card-window.md) — a scoped log view, not a browser. Per-row restore and lane history stay on the wishlist.
|
||||
|
||||
## What did not carry over
|
||||
|
||||
Three of the source's leanings were settled **differently** by 06/07; they are recorded here so this doc cannot be read as endorsing them:
|
||||
|
||||
- **C1, the undo substrate**: the source leaned toward a hybrid — model-layer inverse operations for ⌘Z, with git as a recorder. Settled differently: on git boards **the commit trail itself is the substrate** — the stack *is* HEAD's first-parent ancestry, and undo applies forward restore commits (06-history-undo.md). Inverse operations exist, but as the **free tier's** native undo substrate (13-native-undo.md) behind the same HistoryProviding seam — a tier seam, not a hybrid inside one system.
|
||||
- **A1, iCloud coexistence**: the source's lean (via the pathfinder wishlist) was dual mode per document — cloud-synced boards silently get no git. Settled differently: **iCloud Drive is not supported at all** — no accommodations, a thorough once-per-board warning that recommends local disk plus git remote, and no hard block (07-sync-collab.md ▸ iCloud Drive). Mode is decided by `.git` presence alone, never by the board's location.
|
||||
- **B6, sync cadence as the divergence budget**: the source framed cadence as the decision that determines how good a silent merge engine must be. Settled differently: **there is no merge engine** — auto-commit keeps the tree clean by pull time, pull rebases with local-wins on conflicting hunks, and rejected pushes fetch-rebase-push automatically, so conflicts are structurally impossible rather than managed (07-sync-collab.md). Cadence survives only as 06's history-legibility constraint (a shared board's log must stay bearable), not as a divergence budget.
|
||||
|
||||
## Already absorbed — pointers, not restatements
|
||||
|
||||
The rest of the source's conclusions that motivated 06/07 live there in settled form; cite those docs, not this one: repo maintenance and growth posture (A2 → 06 ▸ Repository hygiene), the commit-failure taxonomy and lock handling (A3 → 06 ▸ Interaction with external writers), the repo-state gate for foreign states (A4 → 06 ▸ Rules ▸ Abnormal repo states), auth and transport limits (B5 → 07 ▸ Remote authentication), history as a read surface and per-mutation semantic messages (B8, C2, and the source's strongest cross-cutting signal → 06 ▸ Commit messages), undo scope and focus routing (C6 → 06 ▸ Undo routing), and the connect-time privacy sentence (B9 → owed by the remote-setup surface; 07 ▸ Mode: git).
|
||||
|
||||
## Open questions
|
||||
|
||||
None — this doc extracts settled material only. The source file's eight "open decisions" are all closed: 1 (A1), 3 (B6), 4 (C1) as recorded above; 2 (A4), 5 (C2/B8), 6 (C4), 7 (C5) in 06-history-undo.md's Rules and Commit messages; 8 (C8) as recorded above.
|
||||
+10
-5
@@ -1,6 +1,6 @@
|
||||
# Lanework — Design Documents
|
||||
|
||||
A ground-up rewrite of the Kanban app, to ship as **Lanework** on the Mac App Store. The old repo (`../Kanban`) was a pathfinder — it never shipped, but it is the reference implementation and the source of hard-won decisions. This design starts from a clean slate and keeps only what earned its place. The rewrite keeps the internal codename `Kanban` (bundle id `dev.rzen.indie.Kanban`).
|
||||
A ground-up rewrite of the Kanban app, to ship as **Lanework** on the Mac App Store. The old repo (`../../Kanban`, one level above this repository — not the rewrite's own `Kanban/` source folder, which shares the codename) was a pathfinder — it never shipped, but it is the reference implementation and the source of hard-won decisions. This design starts from a clean slate and keeps only what earned its place. The rewrite keeps the internal codename `Kanban` (bundle id `dev.rzen.indie.Kanban`).
|
||||
|
||||
Each document covers one aspect of the design. Within each:
|
||||
|
||||
@@ -13,21 +13,26 @@ Each document covers one aspect of the design. Within each:
|
||||
| Doc | Aspect |
|
||||
|---|---|
|
||||
| [00-vision.md](00-vision.md) | What Lanework is, who it's for, goals and non-goals |
|
||||
| [01-storage-format.md](01-storage-format.md) | On-disk contract: folders, frontmatter, ordering, tombstones |
|
||||
| [01-storage-format.md](01-storage-format.md) | On-disk contract: folders, frontmatter, ordering, the materialized trash |
|
||||
| [02-architecture.md](02-architecture.md) | App structure: source of truth, stores, watchers, concurrency |
|
||||
| [03-board-ui.md](03-board-ui.md) | Board window: layout, lanes, cards, styling, templates |
|
||||
| [04-interactions.md](04-interactions.md) | Selection, drag & drop, keyboard, clipboard, search |
|
||||
| [05-card-window.md](05-card-window.md) | The card window: Markdown preview/edit, attachments |
|
||||
| [06-history-undo.md](06-history-undo.md) | Git-backed undo/redo and history |
|
||||
| [07-sync-collab.md](07-sync-collab.md) | Board modes: local-only, git; iCloud Drive warned against |
|
||||
| [06-history-undo.md](06-history-undo.md) | Git-backed undo/redo and history — **Pro tier** |
|
||||
| [07-sync-collab.md](07-sync-collab.md) | Board modes: local-only, git; iCloud Drive warned against — **Pro tier** |
|
||||
| [08-agent-integration.md](08-agent-integration.md) | AI agents as first-class users of the board |
|
||||
| [09-templates.md](09-templates.md) | Board templates: inventory and definition format |
|
||||
| [10-accessibility.md](10-accessibility.md) | VoiceOver, text scaling, visual accommodations |
|
||||
| [11-command-nexus.md](11-command-nexus.md) | The command Nexus — every command and action: bindings, contexts, customizability |
|
||||
| [12-editions.md](12-editions.md) | The tiers (free / Pro subscription / Teams deferred): one-app distribution, entitlement, provider seam, feature matrix |
|
||||
| [13-native-undo.md](13-native-undo.md) | macOS-native undo/redo — the free tier's history substrate |
|
||||
| [14-git-operations.md](14-git-operations.md) | Extracted pathfinder git-operations conclusions: the forward-restore model — **Pro tier** |
|
||||
|
||||
## Deferred design iterations
|
||||
|
||||
None remaining — the card window (05-card-window.md), toolbar (03-board-ui.md ▸ Toolbar), and styling controls (03-board-ui.md ▸ Styling ▸ Controls) each had their focused pass and are settled.
|
||||
The card window (05-card-window.md), toolbar (03-board-ui.md ▸ Toolbar), styling controls (03-board-ui.md ▸ Styling ▸ Controls), and comments (01 ▸ Enhanced schema + 05 ▸ The comments column — designed 2026-07-29, shipping post-2.0) each had their focused pass and are settled.
|
||||
|
||||
**The authoritative list of open design passes is the findings board** (Lanework Redesign.kanban ▸ Issues to Resolve) — this section stopped enumerating by hand after drifting twice. Standing examples as of 2026-07-29: the attachment-undo staging design and the EchoLedger foreign-undo bridge (both flagged in 13-native-undo.md), Teams' tracker integration (no design yet), the fail-fast decision surface (01 ▸ Refuse), and the drop-release settle presentation (03 ▸ Motion — deliberately last in line).
|
||||
|
||||
## Wishlist
|
||||
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
<title>Board Backgrounds — Facets</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; text-wrap:balance; }
|
||||
.page-head .subtitle{ font-size:13px; color:var(--ink-2); margin:0; max-width:760px; }
|
||||
|
||||
.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; }
|
||||
|
||||
.gen-note{ font-size:12px; color:var(--ink-2); font-family:ui-monospace,"SF Mono",Menlo,monospace; }
|
||||
|
||||
.recipe-box{ margin-bottom:40px; }
|
||||
.section-label{ font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-2); margin:0 0 10px; }
|
||||
.recipe-list{ display:grid; grid-template-columns:1fr 1fr; gap:7px 32px; margin:0; }
|
||||
.recipe-item{ display:flex; gap:6px; font-size:13px; line-height:1.5; }
|
||||
.recipe-item dt{ margin:0; font-weight:600; color:var(--ink-2); flex:0 0 auto; }
|
||||
.recipe-item dd{ margin:0; color:var(--ink-2); }
|
||||
@media (max-width:720px){ .recipe-list{ grid-template-columns:1fr; } }
|
||||
|
||||
.family{ margin-bottom:44px; }
|
||||
.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; }
|
||||
|
||||
.colheads{
|
||||
display:grid; grid-template-columns:repeat(3, 1fr); gap:14px;
|
||||
margin-bottom:8px;
|
||||
position:sticky; top:56px; z-index:5;
|
||||
background:var(--paper-a85);
|
||||
-webkit-backdrop-filter:blur(8px);
|
||||
backdrop-filter:blur(8px);
|
||||
padding:4px 0;
|
||||
}
|
||||
.colheads span{
|
||||
font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase;
|
||||
color:var(--ink-2);
|
||||
}
|
||||
.grid{ display:grid; grid-template-columns:repeat(3, 1fr); gap:14px; }
|
||||
@media (max-width:720px){
|
||||
.colheads{ display:none; }
|
||||
.grid{ grid-template-columns:1fr; }
|
||||
}
|
||||
|
||||
.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 Backgrounds — Facets, round 2</h1>
|
||||
<p class="subtitle">The triangle mesh swept on the full axis set: vertex count (columns), color count (mono · complementary duo · contrasting trio), tone, and saturation. Brightness stays a narrow per-triangle jitter around the tone base. Hue wheel narrowed to four representatives this round — amber, forest, sky, rose — the full wheel returns for finals. 3 color counts × 2 tones × 4 hues × 3 saturations × 3 densities = 216 swatches. Reroll to reseed geometry and color assignment under the same recipes.</p>
|
||||
</header>
|
||||
|
||||
<div class="controlbar">
|
||||
<div class="chipgroup" id="strategy-chips" role="group" aria-label="Color-count filter">
|
||||
<button type="button" class="chip active" data-strategy="all" aria-pressed="true">All</button>
|
||||
<button type="button" class="chip" data-strategy="mono" aria-pressed="false">Mono</button>
|
||||
<button type="button" class="chip" data-strategy="duo" aria-pressed="false">Duo</button>
|
||||
<button type="button" class="chip" data-strategy="trio" aria-pressed="false">Trio</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>
|
||||
<button type="button" class="chip" id="reroll">↻ Reroll geometry</button>
|
||||
<span class="gen-note" id="gen-note">gen 1</span>
|
||||
</div>
|
||||
|
||||
<section class="recipe-box">
|
||||
<p class="section-label">Recipe parameters</p>
|
||||
<dl class="recipe-list">
|
||||
<div class="recipe-item"><dt>Vertices</dt><dd>coarse 5×3 cells (~20 triangles) · medium 9×6 (~97) · fine 14×9 (~230), grid-jittered then Delaunay-triangulated</dd></div>
|
||||
<div class="recipe-item"><dt>Colors</dt><dd>mono · duo = complement H+180° weighted 65/35 · trio = triad H±120° weighted 50/30/20, picked per triangle</dd></div>
|
||||
<div class="recipe-item"><dt>Saturation</dt><dd>soft · mid · rich row bands, jittered ±15% per triangle</dd></div>
|
||||
<div class="recipe-item"><dt>Brightness</dt><dd>narrow band — tone base (light ≈85–90%, dark ≈17–21%) ±4.5 per triangle; all hues in a swatch share it</dd></div>
|
||||
<div class="recipe-item"><dt>Hue</dt><dd>amber 38° · forest 140° · sky 215° · rose 335° (±3° per triangle)</dd></div>
|
||||
<div class="recipe-item"><dt>Randomness</dt><dd>geometry and per-triangle color assignment reseed on reroll; the swatch ID names the recipe</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="family" data-strategy="mono" data-tone="light">
|
||||
<h2>Mono — Light</h2>
|
||||
<p class="recipe">One hue; only the brightness jitter draws the mesh. Rows: each hue at soft, mid, rich saturation.</p>
|
||||
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
|
||||
<div class="grid" id="grid-mono-light"></div>
|
||||
</section>
|
||||
|
||||
<section class="family" data-strategy="duo" data-tone="light">
|
||||
<h2>Duo — Light</h2>
|
||||
<p class="recipe">Base hue plus its complement, weighted 65/35 — a dominant field with contrasting inclusions.</p>
|
||||
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
|
||||
<div class="grid" id="grid-duo-light"></div>
|
||||
</section>
|
||||
|
||||
<section class="family" data-strategy="trio" data-tone="light">
|
||||
<h2>Trio — Light</h2>
|
||||
<p class="recipe">A contrasting triad (H, H+120°, H−120°) weighted 50/30/20 — closest in spirit to the low-poly reference.</p>
|
||||
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
|
||||
<div class="grid" id="grid-trio-light"></div>
|
||||
</section>
|
||||
|
||||
<section class="family" data-strategy="mono" data-tone="dark">
|
||||
<h2>Mono — Dark</h2>
|
||||
<p class="recipe">The mono mesh on the dark base — facets catching light like slate.</p>
|
||||
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
|
||||
<div class="grid" id="grid-mono-dark"></div>
|
||||
</section>
|
||||
|
||||
<section class="family" data-strategy="duo" data-tone="dark">
|
||||
<h2>Duo — Dark</h2>
|
||||
<p class="recipe">Complementary pair on the dark base; at low brightness the hue contrast turns ember-like.</p>
|
||||
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
|
||||
<div class="grid" id="grid-duo-dark"></div>
|
||||
</section>
|
||||
|
||||
<section class="family" data-strategy="trio" data-tone="dark">
|
||||
<h2>Trio — Dark</h2>
|
||||
<p class="recipe">The triad on the dark base — stained glass at dusk.</p>
|
||||
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
|
||||
<div class="grid" id="grid-trio-dark"></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 (same as sweep-1 gallery) ----------
|
||||
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){
|
||||
return mulberry32(xmur3(id)());
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
function mod360(h){ return ((h % 360) + 360) % 360; }
|
||||
function clamp(v,min,max){ return v < min ? min : (v > max ? max : v); }
|
||||
function hsl(h,s,l){ return "hsl(" + f1(mod360(h)) + "," + f1(clamp(s,0,100)) + "%," + f1(clamp(l,0,100)) + "%)"; }
|
||||
function rnd(rng,min,max){ return min + rng() * (max - min); }
|
||||
function rndInt(rng,min,max){ return Math.floor(rnd(rng,min,max+1)); }
|
||||
function f1(n){ return n.toFixed(1); }
|
||||
|
||||
// ---------- Delaunay (Bowyer–Watson) ----------
|
||||
function circumcircle(a,b,c){
|
||||
const ax=a[0],ay=a[1],bx=b[0],by=b[1],cx=c[0],cy=c[1];
|
||||
const d = 2*(ax*(by-cy)+bx*(cy-ay)+cx*(ay-by));
|
||||
if(Math.abs(d) < 1e-9) return null;
|
||||
const a2 = ax*ax+ay*ay, b2 = bx*bx+by*by, c2 = cx*cx+cy*cy;
|
||||
const ux = (a2*(by-cy)+b2*(cy-ay)+c2*(ay-by))/d;
|
||||
const uy = (a2*(cx-bx)+b2*(ax-cx)+c2*(bx-ax))/d;
|
||||
const dx = ax-ux, dy = ay-uy;
|
||||
return { x:ux, y:uy, r2:dx*dx+dy*dy };
|
||||
}
|
||||
function triangulate(points){
|
||||
const pts = points.slice();
|
||||
const st = pts.length;
|
||||
pts.push([-3000,-3000],[3500,-3000],[240,3600]);
|
||||
let tris = [{ i:[st,st+1,st+2], cc:circumcircle(pts[st],pts[st+1],pts[st+2]) }];
|
||||
for(let pi=0; pi<st; pi++){
|
||||
const p = pts[pi];
|
||||
const bad = [];
|
||||
for(let t=0;t<tris.length;t++){
|
||||
const cc = tris[t].cc;
|
||||
if(cc){
|
||||
const dx = p[0]-cc.x, dy = p[1]-cc.y;
|
||||
if(dx*dx+dy*dy < cc.r2) bad.push(tris[t]);
|
||||
}
|
||||
}
|
||||
const edgeCount = new Map();
|
||||
for(let b=0;b<bad.length;b++){
|
||||
const idx = bad[b].i;
|
||||
for(let e=0;e<3;e++){
|
||||
const u = idx[e], v = idx[(e+1)%3];
|
||||
const key = u < v ? u+"_"+v : v+"_"+u;
|
||||
edgeCount.set(key,(edgeCount.get(key)||0)+1);
|
||||
}
|
||||
}
|
||||
const badSet = new Set(bad);
|
||||
tris = tris.filter(function(t){ return !badSet.has(t); });
|
||||
for(let b=0;b<bad.length;b++){
|
||||
const idx = bad[b].i;
|
||||
for(let e=0;e<3;e++){
|
||||
const u = idx[e], v = idx[(e+1)%3];
|
||||
const key = u < v ? u+"_"+v : v+"_"+u;
|
||||
if(edgeCount.get(key) === 1){
|
||||
const cc = circumcircle(pts[u],pts[v],p);
|
||||
if(cc) tris.push({ i:[u,v,pi], cc:cc });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for(let t=0;t<tris.length;t++){
|
||||
const idx = tris[t].i;
|
||||
if(idx[0] < st && idx[1] < st && idx[2] < st) out.push(idx);
|
||||
}
|
||||
return { pts: pts, tris: out };
|
||||
}
|
||||
|
||||
// ---------- color ----------
|
||||
// Weighted per-triangle hue pick; weights sum to 1.
|
||||
function pickHue(rng, hueList){
|
||||
let x = rng();
|
||||
for(let i=0;i<hueList.length;i++){
|
||||
if(x < hueList[i].w) return hueList[i].h;
|
||||
x -= hueList[i].w;
|
||||
}
|
||||
return hueList[hueList.length-1].h;
|
||||
}
|
||||
function triangleColor(rng, hueList, spec){
|
||||
const h = pickHue(rng, hueList) + rnd(rng,-3,3);
|
||||
const s = spec.sat * rnd(rng,0.85,1.15);
|
||||
const l = spec.lBase + rnd(rng,-spec.lJit,spec.lJit);
|
||||
return hsl(h,s,l);
|
||||
}
|
||||
|
||||
// ---------- facets generator ----------
|
||||
// Points scatter beyond the 480x300 viewBox so the mesh covers the frame edge-to-edge.
|
||||
function genFacets(rng, hueList, spec, density){
|
||||
const pts = [];
|
||||
const x0 = -36, y0 = -36, x1 = 516, y1 = 336;
|
||||
const cols = density.cols, rows = density.rows;
|
||||
const cw = (x1-x0)/cols, ch = (y1-y0)/rows;
|
||||
for(let c=0;c<cols;c++){
|
||||
for(let r=0;r<rows;r++){
|
||||
pts.push([ x0 + (c + rnd(rng,0.08,0.92))*cw, y0 + (r + rnd(rng,0.08,0.92))*ch ]);
|
||||
}
|
||||
}
|
||||
const mesh = triangulate(pts);
|
||||
let s = '<rect width="480" height="300" fill="' + hsl(hueList[0].h, spec.sat, spec.lBase) + '"/>';
|
||||
for(let t=0;t<mesh.tris.length;t++){
|
||||
const idx = mesh.tris[t];
|
||||
const a = mesh.pts[idx[0]], b = mesh.pts[idx[1]], c = mesh.pts[idx[2]];
|
||||
const color = triangleColor(rng, hueList, spec);
|
||||
const ptsAttr = f1(a[0])+","+f1(a[1])+" "+f1(b[0])+","+f1(b[1])+" "+f1(c[0])+","+f1(c[1]);
|
||||
s += '<polygon points="' + ptsAttr + '" fill="' + color + '" stroke="' + color + '" stroke-width="0.7"/>';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------- board overlay (same as sweep-1 gallery) ----------
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------- registry ----------
|
||||
const HUES = [
|
||||
{ code:"am", name:"amber", h:38 },
|
||||
{ code:"fo", name:"forest", h:140 },
|
||||
{ code:"sk", name:"sky", h:215 },
|
||||
{ code:"ro", name:"rose", h:335 }
|
||||
];
|
||||
|
||||
const STRATEGIES = [
|
||||
{ id:"mono", hues:function(H){ return [{h:H, w:1}]; } },
|
||||
{ id:"duo", hues:function(H){ return [{h:H, w:0.65},{h:H+180, w:0.35}]; } },
|
||||
{ id:"trio", hues:function(H){ return [{h:H, w:0.5},{h:H+120, w:0.3},{h:H-120, w:0.2}]; } }
|
||||
];
|
||||
|
||||
const DENSITIES = [
|
||||
{ id:"c", label:"coarse", cols:5, rows:3 },
|
||||
{ id:"m", label:"medium", cols:9, rows:6 },
|
||||
{ id:"f", label:"fine", cols:14, rows:9 }
|
||||
];
|
||||
|
||||
// Saturation rows and brightness bases per tone. Rich rows get a touch of
|
||||
// brightness headroom so the saturation actually shows.
|
||||
const TONES = {
|
||||
light: { lJit:4.5, levels:[ {label:"soft", sat:20, lBase:90}, {label:"mid", sat:42, lBase:88}, {label:"rich", sat:68, lBase:85} ] },
|
||||
dark: { lJit:4.5, levels:[ {label:"soft", sat:16, lBase:17}, {label:"mid", sat:34, lBase:19}, {label:"rich", sat:52, lBase:21} ] }
|
||||
};
|
||||
|
||||
// ---------- lightbox ----------
|
||||
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();
|
||||
});
|
||||
|
||||
// ---------- render ----------
|
||||
let gen = 1;
|
||||
|
||||
function renderAll(){
|
||||
closeLightbox();
|
||||
const toneKeys = ["light","dark"];
|
||||
for(let si=0; si<STRATEGIES.length; si++){
|
||||
const strategy = STRATEGIES[si];
|
||||
for(let ti=0; ti<toneKeys.length; ti++){
|
||||
const tone = toneKeys[ti];
|
||||
const toneSpec = TONES[tone];
|
||||
const grid = document.getElementById("grid-" + strategy.id + "-" + tone);
|
||||
grid.innerHTML = "";
|
||||
// rows: hue × saturation; columns: density
|
||||
for(let hi=0; hi<HUES.length; hi++){
|
||||
for(let li=0; li<toneSpec.levels.length; li++){
|
||||
const level = toneSpec.levels[li];
|
||||
for(let di=0; di<DENSITIES.length; di++){
|
||||
const density = DENSITIES[di];
|
||||
const id = "facets-" + HUES[hi].code + "-" + strategy.id + "-" + density.id + "-" + tone.charAt(0) + (li+1);
|
||||
const rng = seededRng(id + "/g" + gen);
|
||||
const spec = { sat:level.sat, lBase:level.lBase, lJit:toneSpec.lJit };
|
||||
const hueList = strategy.hues(HUES[hi].h);
|
||||
const inner = genFacets(rng, hueList, spec, density);
|
||||
const overlay = buildOverlay(rng, 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";
|
||||
|
||||
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 = HUES[hi].name + " · " + level.label;
|
||||
caption.appendChild(idSpan);
|
||||
caption.appendChild(noteSpan);
|
||||
|
||||
card.appendChild(frame);
|
||||
card.appendChild(caption);
|
||||
card.addEventListener("click", function(){ openLightbox(svgEl, id); });
|
||||
|
||||
grid.appendChild(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
document.getElementById("gen-note").textContent = "gen " + gen;
|
||||
}
|
||||
|
||||
// ---------- filters ----------
|
||||
let activeStrategy = "all", activeTone = "all";
|
||||
const strategyChips = document.querySelectorAll("#strategy-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 strat = sec.dataset.strategy, tone = sec.dataset.tone;
|
||||
const visible = (activeStrategy === "all" || activeStrategy === strat) && (activeTone === "all" || activeTone === tone);
|
||||
sec.style.display = visible ? "" : "none";
|
||||
}
|
||||
}
|
||||
strategyChips.forEach(function(chip){
|
||||
chip.addEventListener("click", function(){
|
||||
strategyChips.forEach(function(c){ c.classList.remove("active"); c.setAttribute("aria-pressed","false"); });
|
||||
chip.classList.add("active");
|
||||
chip.setAttribute("aria-pressed","true");
|
||||
activeStrategy = chip.dataset.strategy;
|
||||
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);
|
||||
});
|
||||
document.getElementById("reroll").addEventListener("click", function(){
|
||||
gen++;
|
||||
renderAll();
|
||||
});
|
||||
|
||||
renderAll();
|
||||
})();
|
||||
</script>
|
||||
@@ -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,72 @@
|
||||
# Board background image set — exploration notes
|
||||
|
||||
Status: **sweep 1 + faceted gallery 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%.
|
||||
|
||||
## Faceted gallery — user-designed originals (2026-08-07)
|
||||
|
||||
Separate from the sweep-1 axes model: patterns designed to the user's own criteria — canvas tiled by shapes, per-shape **brightness jitter within a narrow band** doing the drawing, base brightness set by light/dark tone. Geometry is random on every render (that's part of the concept), so the gallery has a **Reroll** button; a swatch ID names the recipe, not a fixed layout.
|
||||
|
||||
- **Artifact**: https://claude.ai/code/artifact/d27ad77f-9298-4cf1-9093-19d13a186752 (republished in place each round)
|
||||
- **Generator source**: `board-backgrounds-faceted.html` (this folder) — same seeded-PRNG + board-overlay-toggle infrastructure as sweep 1; seed = swatch ID + generation counter.
|
||||
- **facets**: grid-jittered dot scatter over an extended canvas → Bowyer–Watson Delaunay triangulation, each triangle stroked with its own fill color to kill antialiasing seams. Reference image: low-poly example on the Redesign board.
|
||||
- **bubbles**: same color logic on ~64 opaque circles, power-law radii, drawn large-first. **Set aside after round 1** ("we'll need more work on that") — round-1 recipe kept in git history of the generator file.
|
||||
|
||||
**Round 2 (current, facets only)** — axes per the user: vertex count, color count, tone, saturation; brightness stays the narrow in-image jitter. 216 swatches = 3 color strategies × 2 tones × 4 hues × 3 saturations × 3 densities. Hue wheel narrowed to 4 representatives (amber/forest/sky/rose) to keep the cross reviewable; full wheel returns for finals.
|
||||
|
||||
- Densities (columns): coarse 5×3 cells ≈20 triangles · medium 9×6 ≈97 · fine 14×9 ≈230.
|
||||
- Color strategies (sections): mono · duo = complement H+180° weighted 65/35 · trio = triad H±120° weighted 50/30/20, hue picked per triangle.
|
||||
- IDs `facets-<hue>-<strategy>-<density>-<tone><sat>`, e.g. `facets-sk-duo-f-l2` = sky duo fine light mid.
|
||||
- Jitters: hue ±3°, sat ±15%, brightness ±4.5 L around tone base (light 85–90, dark 17–21, rich rows get slight headroom shifts).
|
||||
|
||||
**Shipped in-app (2026-08-07)**: the recipe is live in the board popover's Background tab (03-board-ui.md § Board popover ▸ Background tab) — Swift port in `Kanban/UI/Board/Backgrounds/`, rendered to a static `facets.png` at pick time (never a live view — the perf/sync ruling). One deliberate deviation from the gallery: the scatter grid gained a sacrificial boundary ring per density (coarse 7×5 @ margin 0.305, medium 10×7 @ 0.14, fine 15×10 @ 0.12 — interior cell size unchanged) because the gallery's fixed 0.075 margin let the ground notch the frame edge (coarse by up to 0.163 of the width — visible flat borders on the reviewed coarse swatches; medium 0.044, fine 0.004). Full-bleed coverage is now a tested invariant (`margin ≥ 0.92 × cell` both axes). Packaging question from sweep 1 is thereby answered for facets: generated on demand, not bundled.
|
||||
|
||||
## 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.
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
# Drag-Reorder Model
|
||||
|
||||
How dragging reorders items on a board. Ported from the pathfinder's document of the same name and rewritten for Lanework's two layouts: the **lane strip** (horizontal, mixed widths via the `width` unit multiplier) and the **card masonry** inside every lane (as many interior columns of standard width as the lane has units, each column stacking independently). The pathfinder wrote its model for columns and kept a "Generalizing to 2D" coda for the day cards could differ in size; in Lanework that day is the first one, so the coda is the present tense and lives inline.
|
||||
|
||||
Implementation, in two halves.
|
||||
|
||||
**The arithmetic**: `Kanban/UI/Board/DropSlotMath.swift` (pure zone math), `Kanban/UI/Board/MasonryLayout.swift` (`MasonryPlacement`, the resting grid), `Kanban/UI/Board/DragAutoScrollMath.swift` (edge autoscroll geometry), `Kanban/UI/Board/LaneLayoutMath.swift` (the strip's analytic resting layout), and the store's drop commits in `Kanban/LiveStore/BoardStore.swift` (`moveCards`, `copyCards`, `moveLanes`, `receiveCards`, `receiveLanes`, `restoreByDrag`, `receiveRestoredCards`). Tests in `KanbanTests/DropSlotMathTests.swift`, `KanbanTests/DragAutoScrollMathTests.swift` and `KanbanTests/DragWriteTests.swift`.
|
||||
|
||||
**The session**: `Kanban/UI/Board/DragPayload.swift` (the exported UTTypes and the pasteboard JSON), `Kanban/UI/Board/DragSession.swift` (the app-wide session state, `DragLocality`, the `CommittedHold`, the watchdog), `Kanban/UI/Board/BoardDrops.swift` (`BoardDropContext`'s shared retargets and commit, plus the three drop delegates and the measured-geometry registry), `Kanban/UI/Board/DragAutoScroller.swift` (the ticking driver), and `Kanban/UI/Board/DragShadow.swift` (the shadow and the multi-drag count badge). The gestures live where the design puts the handles — `LaneView` (the header's `.onDrag`, the card face's, and each lane's drop target) and `TrashLaneView` (a row's). Tests in `KanbanTests/DragSessionTests.swift`; the delegates and gestures are deliberately thin over the tested values.
|
||||
|
||||
## The pieces
|
||||
|
||||
A drag session involves four visual actors:
|
||||
|
||||
1. **Drag handle** — the affordance that starts the drag (a lane's title bar and empty space, 03-board-ui.md ▸ Lane; a card's whole face). Only the handle initiates; everything else about the session is about the item, not the handle.
|
||||
2. **Drag replica** — the image travelling under the cursor. A faithful, full-size replica of the dragged item (the whole lane, not the strip of title bar that was grabbed), fanned with ghosts and a count badge for multi-drags. Its tracking is verbatim input echo and never animates; only its bracketing transitions do — the pickup lift and the release's fly-to-slot or fly-back (03-board-ui.md § Motion, settled).
|
||||
3. **Shadow placeholder** — a dashed outline occupying the item's proposed landing spot in the layout. At drag start it replaces the item's original space; thereafter it marks wherever the current proposal is. A multi-drag shows **N contiguous shadows**. The drop always lands exactly where the shadows show.
|
||||
4. **The reflow** — siblings animating aside to make room when the proposal moves ("pre-drop"), under the structural-voice spring keyed on the proposal and nothing broader (03-board-ui.md § Motion).
|
||||
|
||||
## Resting-layout zones
|
||||
|
||||
While a drag is in flight, the proposal (an insertion index) is computed geometrically against the **resting layout**: the visible siblings laid out with the dragged items removed and no placeholder inserted. Each slot `i` owns a *zone*: item `i`'s entire resting extent plus half the inter-item gap on each side. Zones tile the container with no dead space between them; before the first item and past the last item lie the outermost slots.
|
||||
|
||||
The proposal is a pure function of the cursor over these fixed zones. This is deliberately **not** derived from per-item hover events, which feed back off the very reflow they cause (items move under the cursor → a different item fires → the proposal moves again) and jitter. Because the zones are reconstructed analytically rather than measured, they do not move when the placeholder does.
|
||||
|
||||
**The resting layout follows the effective operation: a move lifts the dragged run out, a copy leaves it in.** 04-interactions.md says the originals stay for both copies — the within-board ⌥-drag and the cross-board default — so the source board draws what it will still hold: the originals stand in place, dimmed (`ClipboardTreatment.dimmedOpacity`, the same treatment a trash row being dragged out wears), and the shadow run opens beside them. A move's originals are lifted out at pickup and stay out until the echo lands. The single expression is `DragSession.hiddenMembers(onBoardRooted:)`, which every surface that builds a resting layout goes through.
|
||||
|
||||
Pressing or releasing ⌥/⌘ mid-drag therefore reflows the source board **once**, and that one-shot reflow is the point rather than a cost: the modifier's whole job is to change what the drop will leave behind, and the board answering the question is the feedback being asked for. (This retires an earlier carve-out that lifted the run out for every operation to keep the board still. The stillness was real; it was bought by never answering the question.) The flip lands on the next `dropUpdated`, because that is where the operation is re-resolved — a modifier pressed with the mouse perfectly still waits for the next motion. Once a release has settled, the operation is frozen for the duration of the committed hold (`DragSession.resolveOperation` guards on `hold == nil`, exactly as `propose` does), so a settled copy keeps its originals on screen and a settled move keeps them lifted until the echo snapshot lands.
|
||||
|
||||
The index-space consequence is real and is carried by the writers rather than papered over: **the drop index means different things for `moveCards` and `copyCards`**, and each one is counted in the layout its own gesture was showing. A move's index counts slots among the cards the run vacates; a copy's counts them among the lane's full rendered set, originals included. Neither writer has to guess — the operation picks the writer and the operation picked the layout — and a within-board ⌥-copy therefore lands exactly at the shadow's slot, including when the shadow sits below the dragged run in the same lane (see **The drop commits** below).
|
||||
|
||||
Lanes get this for free and the invariant is worth stating: a within-board lane drag never resolves to `.copy` (⌥ is ignored there), so the strip's zones never see a re-admitted lane under the cursor; a **cross-board** lane copy re-admits the lane into the *source* strip while the cursor is over the foreign board, where none of the source strip's zones are being consulted. `moveLanes` and `receiveLanes` keep the index space they always had. The strip's lane rendering has no dim of its own, so a re-admitted lane reads as an ordinary lane rather than as the source of the drag — the card face and the trash rows are the only surfaces wearing that treatment today.
|
||||
|
||||
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.
|
||||
- **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 strip has no scroller and no measured frames worth reading: every lane is always on screen because the window's width divides across the lanes' width units (03-board-ui.md § Layout — full visibility). So the resting layout is a closed-form expression of `standardWidth(stripWidth:totalUnits:gap:)`, `slotWidth(units:standard:gap:)` and the unit counts of the visible lanes *minus the dragged run* — `LaneLayoutMath`'s own arithmetic, reused rather than restated (`DropSlotMath.laneExtents`). The first slot starts at `gap`, because the strip's outer margin is one gap wide — the same origin `LaneLayoutMath.laneIndex` hit-tests against and the session's cursor conversion lands in.
|
||||
|
||||
`standard` is **not** recomputed with the dragged lanes removed. It is a function of the board's unit total, and a lane in flight is still a lane on the board — the shadow occupies its units. Recomputing would re-divide the whole strip at pickup and again at release, which is the "motion feeds back into logic" failure this model exists to avoid. For a cross-board arrival the destination board's own `standard` is the one that counts, and the arriving run is measured in the destination's units.
|
||||
|
||||
The trash quasi-lane consumes one unit while shown and is never a landing spot for anything (04-interactions.md ▸ The trash: no move or paste ever targets the trash). It is excluded from the strip's slot list, and the terminal slot's uncapped reach past the last real lane is clamped by the session rather than by the arithmetic.
|
||||
|
||||
## Span-capped trigger regions (mixed sizes)
|
||||
|
||||
When items can differ in size, "hovering anywhere over an item" is the wrong trigger. Dropping a 1× lane before a 3× lane puts the 1× lane at the 3× lane's *leading edge* — so a cursor over the 3× lane's far side is nowhere near where the dragged lane would actually land, and reflowing there feels wrong and twitchy. 04-interactions.md ▸ Drag and drop asks for exactly this: "no reflow until the cursor reaches where the dragged lane would actually land".
|
||||
|
||||
The rule: slot `i` triggers only while the cursor is over the span the dragged run would **actually occupy** once dropped there —
|
||||
|
||||
```
|
||||
trigger(i) = [ leading(i) − gap/2, leading(i) + draggedSpan + gap/2 ]
|
||||
```
|
||||
|
||||
where `leading(i)` is item `i`'s resting leading edge and `draggedSpan` is the total extent of the dragged run (sum of the dragged items' slot widths plus the gaps between them — `DropSlotMath.laneRunSpan`). Note this is exactly where the shadows will sit if the proposal is accepted: the trigger region *is* the shadow run's future footprint.
|
||||
|
||||
The remainder of a wider item's zone — beyond the cap — is a **dead region**.
|
||||
|
||||
Two slots are never capped, because nothing beyond them could be confused for a different target:
|
||||
|
||||
- the **end slot** (past the last item): appending is the only reading;
|
||||
- the region **before the first item**: slot 0 is the only reading (it falls out of the arithmetic — the cap only ever truncates a zone's far side).
|
||||
|
||||
If the dragged run is at least as large as the item whose zone it crosses, the cap covers the whole zone and behavior is identical to the uncapped model.
|
||||
|
||||
## Hysteresis
|
||||
|
||||
A dead region changes nothing: the current proposal — and therefore the shadow — **holds** until the cursor enters another slot's live trigger region. Combined with the tiling zones (a zone is left only by entering another) this gives the drag its hysteresis: the shadow never bounces while the cursor drifts through ambiguous territory, it only moves when a genuinely new landing spot is reached.
|
||||
|
||||
The math says this by **returning `nil`**, not by returning the caller's own value back to it: `nil` is "hold", and a session that has no proposal yet still has none. That is the difference between the hold and the fresh-entry case below, and it is why the API is `Int?`.
|
||||
|
||||
Edge case: if a dead region is hovered with **no valid prior proposal** — a fresh cross-board entry, or the first sample after a reload invalidated the last proposal — the containing slot is proposed anyway. A drag in flight over a live target must always have *some* landing spot.
|
||||
|
||||
## The card masonry (2D)
|
||||
|
||||
A lane lays its cards out with `MasonryLayout`, dealing **column-major** (ruled 2026-07-31, replacing the pathfinder's round-robin): with `n` cards and `C` columns, `base = n / C` and the first `n % C` columns take one more, so column `c` holds the contiguous run `[start(c), start(c+1))` and logical order runs *down* each column before crossing to the next. Each column stacks its cards top-aligned and independently, with no row alignment across columns (03-board-ui.md § Lane). Card widths are uniform — the column width — and heights vary, so the grid is genuinely two-dimensional and the span-cap applies on the vertical axis.
|
||||
|
||||
The resting grid is **re-run, not measured**: `MasonryPlacement.frames(heights:)` replays the same placement over the lane's rendered cards minus the dragged ones, from `(columnCount, columnWidth, spacing, heights)`. `MasonryLayout` itself places subviews through that one function, so the resting grid a drag reasons about and the grid SwiftUI draws cannot drift apart.
|
||||
|
||||
**Heights are frozen at drag start** and passed in, never measured mid-flight — the animation-proof rule below, and the reason a lane whose cards are reflowing under a ~0.18s spring still resolves stable proposals.
|
||||
|
||||
Cursor → proposal, in three steps (`DropSlotMath.cardSlot`):
|
||||
|
||||
1. **Column.** The interior columns' x-bands tile the lane's card area — column `c` plus half a spacing on each side — and the cursor's band picks `c`. Outside the outermost bands the cursor clamps inward, so the lane's padding and its header target the nearest column rather than nothing. Exact-boundary ties keep the current proposal's column, as in 1D.
|
||||
2. **Row.** Column `c`'s cards are the contiguous logical range `[start(c), start(c+1))`; their vertical extents feed the *same* 1D span-capped machinery the strip uses, with `draggedSpan` = the **first dragged card's frozen height** (the run's footprint at the landing spot; the remaining shadows stack below it, and the trigger rect that matters is the one the cursor is over). Dead regions hold, the tail slot below the column's last card is uncapped, and the region above the first card is uncapped.
|
||||
3. **Logical index.** Column `c`, row `r` is logical position `start(c) + r` — no clamp needed, since `r` never exceeds the column's own card count. A non-final column's tail slot is a **genuine mid-list position** (`start(c+1)`, immediately before the next column's first card); only the last column's tail is the end slot — appending. This is a landing spot the round-robin deal could not offer, where every column's tail collapsed to the end.
|
||||
|
||||
The insertion index is therefore always a position in the lane's **logical card order**, which is what the store writes and what 10-accessibility.md's logical-order rule requires. Because the columns re-deal on every count change, inserting at index `k` slides later cards down within their columns and moves at most one card across each column boundary — far gentler than the round-robin deal this replaced, which sent every later card sideways. `MasonryLayout` is a `Layout` over a single `ForEach` precisely so the moves that do happen animate as positional slides rather than as remove/insert blinks. One presentation consequence of the re-deal, accepted with the ruling: a *tail* proposal's shadow draws at the head of the **next** column — exactly where the card will sit once the columns re-deal around it — so the shadow is not always directly under the cursor; the drop still lands exactly where the shadows show.
|
||||
|
||||
Everything else carries over unchanged: resting-layout reconstruction, boundary ties, own-slot no-op, uncapped terminal slots.
|
||||
|
||||
## Multi-drag
|
||||
|
||||
Dragging any member of a multi-selection drags the whole selection (04-interactions.md ▸ Drag and drop). Three rules follow, and the first two are the whole of what the math has to know:
|
||||
|
||||
- **One proposal for the whole run.** A multi-drag proposes a single insertion index and inserts contiguously there. There is no per-item targeting and no interleaving.
|
||||
- **The run's span is the run's span.** `draggedSpan` sums the dragged items' extents plus the gaps between them, so a two-lane drag has to travel twice as far before a wider neighbour's slot triggers. In the masonry the vertical cap uses the first dragged card's frozen height, since that is the shadow the cursor is over.
|
||||
- **Preserved relative order** is *flatten order* — "lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, top-to-bottom)" — the same order the ⌘N target rule and paste anchor on. `SelectionGrammar.liveCards` is its single definition; the drop commits sort their members through it rather than through the `Set`'s iteration order, which has none.
|
||||
|
||||
N contiguous shadows are rendered by the session; the *index* is all this document's arithmetic produces.
|
||||
|
||||
## Cross-board sessions and the locality model
|
||||
|
||||
**Locality picks the default — the Finder volume model** (04-interactions.md ▸ Drag and drop, settled). Within a board a drag is a **move**: rearranging. Between boards it is a **copy**: transferring, with the system copy badge showing over the foreign board. **⌥ always forces copy** and **⌘ always forces move** — Finder's exact modifier grammar — and each is a no-op where its behavior is already the default. The badge tracks the effective operation live as the cursor crosses a board boundary, which means the operation is a function of (source board, board under the cursor, modifiers) sampled every frame, not a decision taken at pickup.
|
||||
|
||||
Two carve-outs:
|
||||
|
||||
- **Lane drags never copy within their board.** ⌥ is simply ignored there: the drag stays a clean reorder and the badge never shows copy. The within-board lane duplicate exists, but its home is the clipboard (04-interactions.md ▸ Clipboard, Lane paste) — the usual shape, where the keyboard path is canonical and the drag is the enhancement.
|
||||
- **A lane copy strips tombstoned cards**; a lane **move** carries them whole, and they land in the destination's trash by rendering. Copies transfer content, and trash isn't content (09-templates.md's instantiation precedent).
|
||||
|
||||
Geometry does not change across the boundary. The destination board's own resting layout answers the proposal, in the destination's own `standard` and gap; the arriving run's span is its unit counts measured against the destination's standard. What changes is only which commit runs and on which store — see below.
|
||||
|
||||
**"Sampled every frame" means sampled on every drop callback, and drop callbacks arrive only while the mouse moves.** A modifier pressed against a perfectly still pointer therefore reaches nothing: the operation decides what the *source* board's resting layout holds (a copy leaves its originals standing — see Resting-layout zones), whether the trash column takes the drop at all, and which index space the shadows are counted in, and all three used to wait for the next twitch of the mouse. So a `.flagsChanged` watch runs for exactly the drag's lifetime — armed at `begin`, stopped at `end`, which is where every way a drag can finish already funnels — and publishes a counter; the board window under the cursor turns that counter back into **the one shared retarget** (`BoardDropContext.retargetAfterModifierFlip`), the same seam the autoscroll driver's every scroll step goes through. The flip carries no location and needs none: the retargets read the physical mouse, so a stationary pointer is simply the cursor they already read. The window that answers is the one whose surface resolved the standing proposal — recorded per retarget as a `LaneDropRegistry`, which is per board *window* — so a cross-board drag re-proposes against the board being hovered and never against the one it came from. A settled release ignores flips entirely, the same freeze the committed-overlay hold applies to `propose` and to the operation itself.
|
||||
|
||||
## The drop commits
|
||||
|
||||
The commit is the store's, and it is one `performWrite` bracket per gesture whatever the set's size: one app-mediated reload, and (on git boards) one commit rather than N. Every one of them takes an index counted **against the destination's rendered items as the resting layout showed them** — so the number the geometry produced is the number the writer consumes, unrewritten. For every move that means "with the dragged run removed"; for the within-board ⌥-copy it means "with the originals still there", because a copy leaves them there and the zones counted them (see **Resting-layout zones**). Cross-board arrivals never face the question: the destination never held the originals.
|
||||
|
||||
| Gesture | Store method | Writer |
|
||||
| --- | --- | --- |
|
||||
| Within-board card drag | `moveCards(_:toLane:at:)` | `moveItem` per card — same parent degrades to a rank rewrite |
|
||||
| Within-board ⌥-drag | `copyCards(_:toLane:at:)` | `copyItem` per card, `.fork` stamps |
|
||||
| Within-board lane drag | `moveLanes(_:toIndex:)` | `moveItem`, same-parent reorder |
|
||||
| Cross-board cards | `receiveCards(_:operation:toLane:at:)` on the **destination** store | `copyItem` / `moveItem` |
|
||||
| Cross-board lanes | `receiveLanes(_:operation:at:)` on the **destination** store | `copyItem` + tombstone strip / `moveItem` |
|
||||
| Trash → live lane, same board | `restoreByDrag(cardID:intoLane:at:)` | `restoreItem` then `moveItem` |
|
||||
| Trash → another board | `receiveRestoredCards(_:operation:toLane:at:)` | `copyItem`/`moveItem` then `restoreItem` |
|
||||
|
||||
Three properties of that table are load-bearing:
|
||||
|
||||
- **Ranks are inserted, never permuted.** A drop writes only the dragged items' `order` — the siblings' files are not touched, so `modified` (and a git commit) stays honest about what actually moved. `Ranks.insertionRanks(amongVisible:at:count:)` produces the N ranks the contiguous run needs; `nil` from it is the renumber trigger, exactly as an exhausted midpoint is everywhere else, and the fallback compacts the destination and places again (`moveLane`'s and `sortSelection`'s pattern).
|
||||
- **A copy's ranks are computed against the destination's *full* rendered set**, because the originals stay and are still on disk holding their ranks. A move's are computed against the set the moving members vacate. One expression covers both: the ranks are placed among the rendered cards minus whatever will actually leave — which is also, exactly, the layout each gesture's zones were built over, so the index needs no translation on the way in.
|
||||
- **Cross-board writes are executed by the destination store**, inside *its* bracket. The source board's tree changes outside its own store's bracket, which is correct and needs no coordination: the source store's watcher sees a foreign change and reloads, which is what a foreign change is.
|
||||
|
||||
Identity follows 01-storage-format.md exactly. A copy mints fresh UUIDs at every level and keeps `created` (a copy is a fork). A move keeps the UUID; only the **import boundary** remints, per folder, at the finest grain — a lane arriving with one colliding card is still a lane move with one reminted card.
|
||||
|
||||
## The mid-drag re-grounding trio
|
||||
|
||||
**A foreign reload mid-drag re-grounds the drag, never corrupts the drop** (04-interactions.md ▸ Drag and drop, settled — a two-second drag racing agent edits is the designed concurrency). Three rules compose, and each one is a property of something already in this document:
|
||||
|
||||
1. **Geometry re-derives.** The only inputs frozen at drag start are the *dragged items'* sizes and nothing else; the resting zones are recomputed against each new snapshot. A foreign lane add or tombstone re-divides the strip, the zones move with it, and the next proposal targets the board as it now is. Nothing is cached across a reload because nothing needs to be.
|
||||
2. **Proposals re-validate by liveness.** A proposal whose target lane was tombstoned or vanished in the reload is invalidated — tombstoned lanes are never drop targets — the shadow withdraws, and no proposal stands until the pointer reaches a live target. **Release with no valid proposal cancels**: items return, nothing is written, and a card is never filed under a `deleted:` parent. The store's commits enforce the same rule independently (a destination lane that is gone or tombstoned is a silent no-op), so the gesture and the write cannot disagree.
|
||||
3. **An emptied drag cancels itself.** Drag membership is a UUID set that vanished items leave silently (02-architecture.md); when the *last* dragged item leaves it, the replica dissolves and release is a no-op. Partial vanishing drops the survivors, matching the pending-cut precedent.
|
||||
|
||||
## The committed-overlay hold
|
||||
|
||||
At release the write goes to disk and the *snapshot does not change*. The watcher's bracket closes, a reload runs, and only then does the board show the new order — one-way flow, deliberately (02-architecture.md). In between, for one round trip, the snapshot still describes the pre-drop arrangement.
|
||||
|
||||
That gap is what makes "the replica flies to its slot" hard to reconcile with the one-way flow: the slot it should fly to is a position that does not exist yet, and dropping the drag state at release would snap every sibling back to the pre-drop layout for a frame before the reload lands.
|
||||
|
||||
The resolution is the **committed-overlay hold**, and it is the new-card placeholder's `awaitingArrival` precedent applied to the drag: at release the session flips from *proposing* to *committed*, keeps rendering the arrangement it was showing, and stands until the app-mediated reload that carries the write arrives — then hands off and dissolves. The hand-off condition is the same shape as the placeholder's: the overlay watches the snapshot for the state it is standing in for, and discards itself the moment the snapshot has it, because holding a moment longer would draw the arrangement twice.
|
||||
|
||||
Like the placeholder, it is store-transient overlay state and a **named exception** to the one-way flow rather than a hole in it: it renders nothing that is not already on disk or already refused, and every failure path — a write that throws, a reload that fails, a session emptied mid-flight — dissolves it and lets the snapshot be the authority again. The banner says what went wrong; the board shows what is true.
|
||||
|
||||
This is the drag session's mechanism, not the math's — it belongs to the same milestone's second half.
|
||||
|
||||
## Animation-proof inputs (implementation constraint)
|
||||
|
||||
**Motion never feeds back into logic** (03-board-ui.md § Motion, a hard constraint, inherited from the pathfinder's rule of the same name). Every proposal change animates a reflow (~0.18s). During that window, anything *measured* is mid-flight: item frames, the placeholder's frame, and even the drop location reported by the system (it is expressed in the drop target's space, and that view may itself be moving). Retargeting from measured values while the board animates produces garbage zones and a proposal that thrashes — the shadow chases the cursor and all hysteresis is lost. Three rules follow:
|
||||
|
||||
- **Compute resting zones analytically, never from measured frames.** The strip's layout is a closed form over `(stripWidth, gap, unit counts)`; the masonry's is a closed form over `(columnCount, columnWidth, spacing, frozen heights)`. Both are stable no matter what is animating.
|
||||
- **Read the cursor from the physical mouse** (`NSEvent.mouseLocation`, converted through the window), not from the drop callback's location. This is also what `LaneResizeSession` and `MarqueeSession` already do.
|
||||
- **Freeze the dragged items' sizes at drag start.** The pickup transition fires geometry updates while the dragged item lifts and scales; its lingering "last measured frame" is a few per cent off, which would mis-size the shadow and the span-cap.
|
||||
|
||||
One lifecycle trap in the same family, recorded because the pathfinder paid for it: a finished session's phase events can be delivered *after the user has already started the next drag*, and a naive cleanup handler wipes the new session's state (no shadow, drop dead). Cleanup on session-phase events must be gated on the physical button being up; a mouse-polling watchdog remains the guaranteed termination path.
|
||||
|
||||
## Single-target dispatch (implementation constraint)
|
||||
|
||||
SwiftUI/macOS delivers a drag session to the **deepest drop region under the cursor — with no fall-through**, not even when that target's declared content types don't match the session's payload. A region whose topmost target only understands one drag type is therefore a *dead zone* for the other type: no hover callbacks, and a release there snaps back instead of committing.
|
||||
|
||||
Consequence: every drop delegate must accept **all session types** — card, lane, and external Finder file drags (04-interactions.md ▸ Drag and drop: files onto a card become attachments, files onto lane empty space become cards) — and route internally. Without file support at every fall-through layer, the same dead-region hit-testing bug would strand a file session: it would fall through to a target that only declares the board's own types, get no hover callbacks, and refuse the drop outright, with no highlight and no snapback to explain why.
|
||||
|
||||
The lane body's delegate resolves card sessions against its masonry zones, forwards lane sessions (cursor converted to strip space) to the strip's logic, and resolves file sessions against the same masonry zones; the strip delegate (gaps, margins, placeholder regions) retargets lane sessions, retargets card and file sessions against an analytically reconstructed per-lane grid — the safety net for a lane whose own drop region goes dead — and commits the current proposal on release: the drop always lands where the shadows show. Shadow placeholders are hit-transparent, so the strip target stays live beneath them.
|
||||
|
||||
## Edge autoscroll
|
||||
|
||||
A lane's cards live in a scroll view, so a lane taller than its viewport has landing spots below the fold. Nothing in the model above can reach them — the proposal is a function of the cursor over the *visible* resting layout — so a card session hovering near either end of a lane's scroll area scrolls it, continuously, until the pointer leaves the band or the drag ends. Implementation: `Kanban/UI/Board/DragAutoScrollMath.swift`, tests in `KanbanTests/DragAutoScrollMathTests.swift`.
|
||||
|
||||
The geometry is a pure function of viewport-local coordinates: each end of the visible extent owns a 56pt **activation band**, and a pointer inside one scrolls that way at a speed ramping linearly from 90pt/s at the band's inner edge to 800pt/s at (and beyond) the viewport's own edge. Outside both bands the velocity is exactly zero, so a drag crossing a lane's middle never scrolls it. The floor at the band boundary is deliberate — entering a band should produce visible motion, not an imperceptible crawl.
|
||||
|
||||
The pointer may also sit outside the visible area and still drive it: generously above and below (a lane's header and the strip's padding are still "this lane"), but only ~12pt sideways, so a drag over the neighbouring lane never scrolls this one.
|
||||
|
||||
Three constraints shape the driver, which is the session's half of the work:
|
||||
|
||||
- **The pointer is the physical mouse**, partly for the general reason above, but mostly because drop callbacks only arrive while the mouse *moves*, and holding still against an edge is exactly the gesture that must keep scrolling. A frame clock plus `NSEvent.mouseLocation` needs no events at all.
|
||||
- **Every scroll step re-resolves the proposal.** The cursor is stationary in the lane's space while the *content* moves under it, so without this the shadow would freeze at whatever slot the last mouse movement proposed and the drop would land there. The lane's drop delegate and the autoscroll driver must go through one shared retarget, so they can never disagree.
|
||||
- **Termination is structural**, like the rest of the session lifecycle: the driver is a `.task(id:)` keyed on the session, so it is cancelled the moment the session ends — and the watchdog guarantees that flag clears no matter how the drag finished.
|
||||
|
||||
The clock is the display's. Frames arrive from a `CADisplayLink` on whichever screen the lane is drawn on (`NSView.displayLink(target:selector:)`, bridged to an `AsyncStream` the `.task` above consumes), and each step integrates the gap between two `targetTimestamp`s, clamped at 50ms so a link resuming from an occluded window steps once rather than teleporting the lane. The 16ms `Task.sleep` loop this replaced had two defects that reach the user as judder: `Task.sleep` guarantees only a *lower* bound on the wake-up, and a fixed ~60Hz cadence beats against a 120Hz panel instead of landing on it. Both arrive at the shadow as well as at the content, because every scroll step drags a retarget behind it.
|
||||
|
||||
The board strip itself has nothing to autoscroll: every lane is always visible (the window width divides across the lanes' width units) and the strip fills the window height, so there is no board-level scroller in either axis. The geometry above is axis-agnostic and would serve one unchanged if that ever changes.
|
||||
|
||||
## Adjacent interaction: the lane resize drag (not a drag session)
|
||||
|
||||
Dragging a lane's trailing edge resizes it between whole unit counts — see `LaneResizeSession.swift` and `LaneLayoutMath`. It deliberately lives OUTSIDE the drag-session machinery above: the handle is a plain `DragGesture`, carries no drop target, and refuses to start while a card/lane session is in flight. Its layout trick inverts this document's premise: instead of reflowing siblings around a shadow, the session freezes the strip's standard width and resizes the *window* by one standard-plus-gap per snap tick, so every other lane keeps its exact pixels and the release settles the dragged lane into a slot that's already in place. Snapping is asymmetric ("shadow leads"): tick up the instant the live edge clears the inter-lane gap; tick down only after retreating 10pt back into it — the 10pt re-entry band is the only hysteresis, cousin to the dead-region hold above.
|
||||
|
||||
It does borrow one thing from the machinery it lives outside: **the release gets its own hold** (`LaneWidthHold`). The release writes the new unit count, and that write takes the one-way flow's round trip, so a session that cleared there would hand the strip back to a standard divided from the already-grown window by the snapshot's still-stale unit total — a visible two-step, once into the wrong arrangement and again when the echo lands. So the frozen standard and the *written* unit count keep governing until the snapshot carries that width, with the same timeout-and-dissolve guarantee as the committed-overlay hold. The condition is the only real difference, and it follows from the subject: that hold stands in for an arrangement and any landing on the board retires it, while this one stands in for a value and only a landing that actually carries it will do.
|
||||
@@ -0,0 +1 @@
|
||||
there is no board without an index.md
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: card
|
||||
order: 1024
|
||||
title: Intact Card
|
||||
---
|
||||
Nothing wrong with this one. It is here so the broken sibling below is a *card* defect rather
|
||||
than the only thing in its lane.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 2
|
||||
kind: card
|
||||
order: 2048
|
||||
title: From A Newer Lanework
|
||||
---
|
||||
Defect #2: a schema newer than this app, below the root — the one `schema` rule the optional-key
|
||||
ruling left alone. Unfixable, so the surface offers only Skip.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: lane
|
||||
order: 1024
|
||||
title: Intact Lane
|
||||
---
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 3
|
||||
kind: card
|
||||
order: 1024
|
||||
title: Hidden Behind The Broken Lane
|
||||
---
|
||||
Broken too, and deliberately *not* in the aggregate: its lane was skipped before this folder was
|
||||
ever listed. Fix the lane and the next walk reports this one.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: lane
|
||||
order: 2048
|
||||
title: A Lane That Will Not Parse
|
||||
labels: [red, green
|
||||
---
|
||||
Defect #3: unparseable YAML — an unterminated flow sequence, the same shape
|
||||
`unparseable-yaml.kanban` uses one level up. The card below is never enumerated: a broken lane
|
||||
takes its subtree with it, and repair-then-re-check is what reveals what it was hiding.
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: card
|
||||
order: 1024
|
||||
title: Last Card
|
||||
---
|
||||
The walk reaches here, which is the point: a defect in an earlier lane does not end the walk.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: lane
|
||||
order: 3072
|
||||
title: Also Intact
|
||||
---
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Many Defects
|
||||
created: 2026-07-31T09:00:00Z
|
||||
---
|
||||
The root's own `schema` is missing — the this-really-is-a-board gate, and defect #1.
|
||||
|
||||
The walk does not stop here: lanes are enumerated by folder shape, so everything below is still
|
||||
read and reported in the same aggregate.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: A Board Missing Its Schema
|
||||
---
|
||||
No 'schema' field here — the loader fails fast before looking at anything
|
||||
else.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
schema: 2
|
||||
title: From The Future
|
||||
---
|
||||
This board was written by a newer version of the app than this one.
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: card
|
||||
order: 1024
|
||||
title: Intact Card
|
||||
---
|
||||
Stays on the board whatever is skipped — a skip omits the item it names, never its siblings.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 2
|
||||
kind: card
|
||||
order: 2048
|
||||
title: From A Newer Lanework
|
||||
---
|
||||
Unfixable, so Skip is the only choice the surface offers for it. Skipped, the card leaves the
|
||||
board and this file stays on disk untouched.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: lane
|
||||
order: 1024
|
||||
title: Intact Lane
|
||||
---
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: card
|
||||
order: 1024
|
||||
title: Perfectly Fine, Behind A Broken Lane
|
||||
---
|
||||
Nothing at all is wrong with this card. It is never in the snapshot regardless: its lane is either
|
||||
a defect or a skip, and both take the subtree.
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: lane
|
||||
order: 2048
|
||||
title: A Lane That Will Not Parse
|
||||
labels: [red, green
|
||||
---
|
||||
Unparseable YAML. Skipping it takes the whole lane out of the board — the intact card below
|
||||
included, which is the honest cost of the tolerance and why the notice names what left.
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: card
|
||||
order: 1024
|
||||
title: Last Card
|
||||
---
|
||||
Proof the walk kept going past the broken lane.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: lane
|
||||
order: 3072
|
||||
title: Also Intact
|
||||
---
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Skippable Defects
|
||||
created: 2026-07-31T09:00:00Z
|
||||
---
|
||||
Every defect on this board is **below the root**, which is what makes it the skip channel's golden
|
||||
case: the root is never skippable, so a board whose only defects are skippable has to have an
|
||||
intact root.
|
||||
|
||||
Skip both and it opens — without the broken lane, without the newer-schema card, and without the
|
||||
perfectly good card that lives under the broken lane.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
schema: 1
|
||||
bad: [1, 2
|
||||
---
|
||||
Body text.
|
||||
+36
-1
@@ -2,4 +2,39 @@
|
||||
|
||||
Golden fixture boards for the storage-contract test suite — real on-disk folder trees, not inline strings, so the same fixtures can later drive XCUITests via the `--open-board` launch hook.
|
||||
|
||||
Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`.
|
||||
Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`. Tests live in `KanbanTests/FixtureBoardTests.swift`.
|
||||
|
||||
Hidden fixture files are named `.hidden-*` rather than `.DS_Store`: the repo's `.gitignore` ignores `.DS_Store` everywhere, so a fixture spelled that way would exist on the authoring machine and vanish from a fresh clone. The loader's rule is `.skipsHiddenFiles` — it is about the leading dot, not the name.
|
||||
|
||||
Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never generated at test time), chosen so their lexicographic order matches the expected tie-break order — usually a leading digit (`10000000-...`, `20000000-...`, …) so folder order reads the same as array-index order in the tests. That is a fixture-authoring convention, not the loader's gate: the identity predicate is shape-only (`8-4-4-4-12` hex, **any case, any version** — 01-storage-format.md § Fractal layout ▸ Rules), and the case/version coverage lives in `KanbanTests/BoardLoaderTests.swift` rather than here.
|
||||
|
||||
## Valid/ — one board per tolerated/valid case
|
||||
|
||||
| Board | Case |
|
||||
|---|---|
|
||||
| `rich-board.kanban` | A full-breadth well-formed board: 2 lanes, 3 cards, bodies, styling (background/icon/iconColor/width), unknown + reserved frontmatter keys, `attachments/` and `comments/` with real content. Its `attachments/` also carries all four listing shapes — two ordinary files, a hidden one, and a subfolder with a file — so `Card.attachments`' flat rule (01-storage-format.md § Attachments) is asserted against a real tree. Also the board every `index.md` in the tree is round-tripped against. |
|
||||
| `interrupted-create.kanban` | The motivating skip-not-error case: a UUID-shaped lane folder and a UUID-shaped card folder, each with no `index.md` yet (folder created, write not yet landed). |
|
||||
| `non-uuid-strays.kanban` | Non-UUID-shaped folders at both lane and card depth, with and without `index.md` — name shape gates candidacy before the file is ever read. |
|
||||
| `stray-files.kanban` | Stray (non-directory) files at board, lane, and card level — never level candidates, never warned about. The **card-level** one (`scratch.md`) is also the loose-file carve-out's golden case: tolerated everywhere else, it is reported in `LoadResult.looseCardFiles` for the app to relocate into `attachments/` (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28). Detection is read-only, so the file stays put on disk. |
|
||||
| `tombstones.kanban` | A tombstoned lane and a tombstoned card, both still on disk and still in the snapshot, flagged (`isDeleted`) rather than removed. Also proves a tombstoned lane doesn't recursively flag its own un-deleted children. |
|
||||
| `duplicate-order-tie-break.kanban` | Three cards sharing one `order` in one lane, and two lanes sharing one `order` — both broken by folder name, ascending. |
|
||||
| `unknown-key-order.kanban` | Unknown/reserved frontmatter keys interleaved with schema-owned ones at board, lane, and card level — `document.unknownFields` must preserve exactly the order they were written in. |
|
||||
| `coercion.kanban` | Lenient-field coercion and fallback: wrong-type scalars that coerce (`title: 2048`, `iconColor: 42`, `width: "3"`) versus ones with no sensible reading that fall back to the default (`title: [a, b]`, `width: 1.5`, and `background: 12345` — a bare scalar, which `background` no longer has a reading for at all), plus `background: {x: 1}` — a legal mapping naming neither subkey, so no color and no trace — and a `deleted` with an unusable timestamp that still tombstones. |
|
||||
| `duplicate-top-level-keys.kanban` | A top-level key written twice — at board, lane (the strict `order` field), and card level. Last occurrence wins; **not** a fail-fast case (settled, newer than the original card text). Round-tripped to prove the earlier occurrence survives on disk, invisible only to reads. |
|
||||
| `board-level-deleted.kanban` | A board-level `deleted:` key — legal per the frontmatter table but meaningless; ignored + warned, rest of the board loads normally. |
|
||||
| `optional-keys.kanban` | `order` and `schema` optional below the board root (re-ruled 2026-07-31). One lane holds a ranked card plus every order-less shape — no key, an explicit null, `order: banana`, `order: .nan` — which all read as append-at-end in folder-name order; the strip holds a ranked lane, a `schema`-less one, and an order-less one. Also the golden case for the minimum agent card: a card whose whole frontmatter is a title. |
|
||||
|
||||
## Malformed/ — one board per fail-fast case
|
||||
|
||||
Each board is minimal: one broken thing. The two multi-defect boards at the bottom are the deliberate exceptions — they exist precisely because the loader collects rather than stops (01-storage-format.md § Malformed input, settled 2026-07-31), which is a claim no one-broken-thing board can make.
|
||||
|
||||
| Board | Case |
|
||||
|---|---|
|
||||
| `unparseable-yaml.kanban` | An unterminated flow sequence in the board's frontmatter. |
|
||||
| `missing-schema.kanban` | Board root `index.md` has no `schema` key — the **root only**; below it a missing `schema` reads as 1 (`Valid/optional-keys.kanban`). |
|
||||
| `schema-newer-than-app.kanban` | Board root `schema: 2`, newer than `BoardLoader.supportedSchema`. |
|
||||
| `board-root-missing-index.kanban` | The board root folder itself has no `index.md`. |
|
||||
| `many-defects.kanban` | **The collect-all board.** A root with no `schema`, a card with `schema: 2`, and a lane whose frontmatter will not parse — three fail-fast classes on one board, reported as one `BoardLoadFailure` in walk order (root, then lanes by folder name with their cards inside them). The broken lane also holds a broken card, which is deliberately **not** in the aggregate: a broken lane takes its subtree with it, and the repair's re-check is what reveals what it was hiding. Two further lanes load fine, which is what proves the walk kept going. |
|
||||
| `skippable-defects.kanban` | **The skip channel's board.** `many-defects.kanban` minus the root defect — the root is never skippable, so a board whose defects are all skippable must have an intact root. Skip both (the newer-schema card and the unparseable lane) and it opens: without them, without the broken lane's own perfectly valid card (the subtree goes too), and with a `LoadWarning.userSkipped` per skip as the loud mark the opened board's notice is written from. |
|
||||
|
||||
The four `order` boards that used to live here — `missing-order-lane`, `missing-order-card`, `explicit-null-order`, `non-numeric-order` — were retired on 2026-07-31, when `order` became optional below the board root. Their shapes all live on in `Valid/optional-keys.kanban` as coercion cases.
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: Also Business As Usual
|
||||
---
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: Business As Usual
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Board With A Stray 'deleted' Key
|
||||
deleted: 2026-01-01T00:00:00Z
|
||||
---
|
||||
'deleted' is legal per the common frontmatter table but meaningless at
|
||||
board level — a board can't tombstone itself out of its own window. The
|
||||
loader ignores it (and logs a warning) rather than acting on it; the rest
|
||||
of the board loads completely normally.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: 2048
|
||||
---
|
||||
An unquoted integer title coerces to its source text, "2048".
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 2048
|
||||
title: [a, b]
|
||||
---
|
||||
A sequence has no sensible string reading — malformed, falls back to the
|
||||
untitled placeholder.
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 3072
|
||||
iconColor: 42
|
||||
---
|
||||
An integer iconColor coerces to its source text, "42" — not a real color,
|
||||
but the value round-trips rather than being rejected.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 4096
|
||||
background: {x: 1}
|
||||
---
|
||||
A mapping is a legal `background` — it carries `color` and `image` subkeys
|
||||
— but this one names neither, so there is no color to read and the card
|
||||
falls back to none.
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 5120
|
||||
background: 12345
|
||||
---
|
||||
`background` is a mapping and only a mapping, so a bare scalar has no
|
||||
reading at all — malformed, no color, bytes preserved.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 6144
|
||||
title: Deleted With An Unusable Timestamp
|
||||
deleted: definitely-not-a-date
|
||||
---
|
||||
The user's intent to delete outranks the broken date — this card still
|
||||
tombstones, its deletion date just unknown.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: Width Coerces From A Quoted Number
|
||||
width: "3"
|
||||
---
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 2048
|
||||
title: Width Is Unusable — Not An Integer
|
||||
width: 1.5
|
||||
---
|
||||
A malformed width is preserved on disk and simply not used — never
|
||||
rejected, never rewritten.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Coercion And Lenient-Field Fallback
|
||||
---
|
||||
Schema-owned display fields coerce where a sensible reading exists (a
|
||||
wrong-type scalar reads as its source text) and fall back to their default
|
||||
where none does (a sequence/mapping, a non-integer width). A present but
|
||||
unusable 'deleted' timestamp still tombstones — presence outranks validity.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: Should Render First (folder starts with 1)
|
||||
---
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: Should Render Second (folder starts with 2)
|
||||
---
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: Should Render Third (folder starts with 3)
|
||||
---
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: Lane With Three Tied Cards
|
||||
---
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 2048
|
||||
title: Should Render Before Lane bbb... (same order, 'a' < 'b')
|
||||
---
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 2048
|
||||
title: Should Render After Lane aaa... (same order, 'b' > 'a')
|
||||
---
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Duplicate Order Values, Tie-Broken By Folder Name
|
||||
---
|
||||
Two siblings that share an 'order' render in a deterministic order —
|
||||
ascending 'order' first, folder name (lexicographic) as the tiebreak.
|
||||
This board exercises the tiebreak at both lane level and card level.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 2048
|
||||
title: First Title
|
||||
title: Second Title
|
||||
---
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
order: 4096
|
||||
title: Dup Order Lane
|
||||
---
|
||||
Duplicate keys aren't special-cased by field — 'order' is a strict,
|
||||
schema-owned field and still resolves via last-wins at the document layer,
|
||||
before the loader ever validates it. This is NOT a fail-fast case.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Draft Name
|
||||
title: Final Name
|
||||
---
|
||||
A hand-edit that types 'title' twice — strict YAML would reject the file
|
||||
outright. The engine reads the last occurrence and keeps both on disk.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: A Real Card
|
||||
---
|
||||
Body.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: A Real Lane
|
||||
---
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Interrupted Two-Step Create
|
||||
---
|
||||
The app creates a folder first and writes index.md a moment later — a crash
|
||||
or a killed app between those two steps must not brick the board.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: Real Card
|
||||
---
|
||||
Body.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 9999
|
||||
title: This looks like a card but isn't named like one
|
||||
---
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: Real Lane
|
||||
---
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Non-UUID Strays At Every Depth
|
||||
---
|
||||
Name shape gates level detection — a folder that isn't UUIDv4-shaped is a
|
||||
stray, whether or not it holds an index.md, and whether it sits at lane
|
||||
depth or card depth.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 9999
|
||||
title: This looks like a lane but isn't named like one
|
||||
---
|
||||
Never rendered — 'todo-notes' isn't UUIDv4-shaped.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: card
|
||||
order: 1024
|
||||
title: Ranked Card
|
||||
---
|
||||
The only card in this lane that says where it goes. Every sibling below reads as
|
||||
append-at-end, in folder-name order.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Minimum Agent Card
|
||||
---
|
||||
The whole legal minimum: one `mkdir` and one write, no `order` and no `schema`
|
||||
(08-agent-integration.md — "filing a card must need nothing but the schema").
|
||||
Reads as schema 1, at the bottom of the lane.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: card
|
||||
order:
|
||||
title: Null Order Card
|
||||
---
|
||||
The hand-editor started the key and never gave it a value — reads as missing,
|
||||
which below the root is the append-at-end reading.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: card
|
||||
order: banana
|
||||
title: Non-Numeric Order Card
|
||||
---
|
||||
Present but unusable — the same reading a missing key gets, recorded as a
|
||||
coercion with the text as written.
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: card
|
||||
order: .nan
|
||||
title: Non-Finite Order Card
|
||||
---
|
||||
NaN has no place in the total order the tie-break and midpoint math assume, so
|
||||
it is unusable exactly like `banana` — a coercion since 2026-07-31, not a
|
||||
rejection.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: lane
|
||||
order: 1024
|
||||
title: Ranked Lane
|
||||
---
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: card
|
||||
order: 1024
|
||||
title: Card In The Orderless Lane
|
||||
---
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: lane
|
||||
title: Orderless Lane
|
||||
---
|
||||
No rank at all: sorts right of every ranked lane in the strip.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
kind: lane
|
||||
order: 2048
|
||||
title: Schemaless Lane
|
||||
---
|
||||
No `schema` below the root reads as 1 — the walk validating this file against
|
||||
schema 1 is what makes the reading reliable.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: board
|
||||
title: Optional Keys
|
||||
---
|
||||
The board root keeps its `schema` — the this-really-is-a-board gate. Everything
|
||||
below it may leave `order` and `schema` out entirely (01-storage-format.md
|
||||
§ Frontmatter and § Ordering, re-ruled 2026-07-31).
|
||||
+1
@@ -0,0 +1 @@
|
||||
Hidden entries are never attachments (skipsHiddenFiles).
|
||||
+1
@@ -0,0 +1 @@
|
||||
Not a real .txt either — a stand-in attachment.
|
||||
+1
@@ -0,0 +1 @@
|
||||
not-a-real-png-just-a-placeholder-attachment
|
||||
+1
@@ -0,0 +1 @@
|
||||
A subfolder file: tolerated, preserved, never surfaced.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 1
|
||||
author: rzen
|
||||
created: 2026-07-21T08:30:00Z
|
||||
---
|
||||
Comments are enhanced-schema and out of scope for this version — this
|
||||
folder is here only to prove it's preserved verbatim and never descended
|
||||
into (cards are leaves; the loader never scans past index.md).
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
schema: 1
|
||||
title: "Design the fixture taxonomy"
|
||||
order: 1024
|
||||
created: 2026-07-20T10:15:00Z
|
||||
modified: 2026-07-26T20:07:30Z
|
||||
modified-by: claude
|
||||
source: DESIGN/01-storage-format.md # inline comment on a plain scalar
|
||||
project: lanework
|
||||
sphere: work
|
||||
labels: [m1-storage-read]
|
||||
---
|
||||
Enumerate every fail-fast and tolerated case from the storage format and
|
||||
build one golden fixture board per case.
|
||||
|
||||
- attachments/ holds a sketch
|
||||
- comments/ holds a placeholder future-comment folder
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Wire up the loader's stray tolerance
|
||||
order: 2048
|
||||
background: {color: coral}
|
||||
icon: flag.fill
|
||||
iconColor: orange
|
||||
---
|
||||
Plain, unquoted title this time — mixing styles on purpose.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: Doing
|
||||
width: 2
|
||||
background: {color: '#3478F6'}
|
||||
icon: hammer.fill
|
||||
iconColor: blue
|
||||
---
|
||||
WIP limit: 3 cards. Pull from Backlog only when there's room.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: 'Ship v1'
|
||||
created: 2026-06-01T12:00:00Z
|
||||
modified: 2026-07-15T09:00:00Z
|
||||
---
|
||||
Shipped. 🎉
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user