diff --git a/DESIGN/03-board-ui.md b/DESIGN/03-board-ui.md index 76408c1..3db9f97 100644 --- a/DESIGN/03-board-ui.md +++ b/DESIGN/03-board-ui.md @@ -27,7 +27,7 @@ Toolbars are **pure enhancement**: every function they host already has a menu i - Leading icon + title. Two face chips are in scope, both present-only and the same secondary-tinted, decorative styling family: **attachments** (a quiet glyph when the card has files — the title dominates) and **comments** (ruled 2026-08-09, card e729e30a — a quiet bubble glyph plus its count when the card has one or more, joining the attachments chip at the row's trailing edge). Metadata chips (labels/assignees/due) went to the enhanced schema with their fields — out of scope. - **No body excerpt, but not "closed with no growth"** (re-settled 2026-08-09 — that old phrasing is retired as stale): the face still never draws a preview of the card's own prose, and that stays closed for good. What *has* grown since is exposure of facts the card already carries structurally, never content: the hero banner (below) and the comments chip both draw from a fact — a named attachment, a folder count — that a sighted glance or a screen reader already had another way to learn, not from anything a body excerpt would have shown. -- **Hero image** (ruled 2026-08-09): a card whose `hero` key names one of its own attachments (a bare filename — 01-storage-format.md § Frontmatter's card table) draws it as a **banner across the full plate width above the icon-and-title row**, aspect-fill cropped to a fixed em-scaled band (2.75 em — 36pt at the standard body, so it scales with system text size and board zoom like every other face figure). Opt-in and hand-written: **no in-app setter this version** — "Set as Hero" from the attachment row is future work. A key that is absent, malformed, or names a file that is missing, unreadable or not an image renders **exactly as no key at all** — the band has no height until a picture actually decodes, so the degrade is structural rather than a branch. Everything else the face draws is unchanged and sits outside the band: the accent stripe runs the plate's full leading edge over it, the selection and file-hover strokes ring the whole plate, the cut/drag dims cover it, and the drop model registers the plate's real height — a hero card is simply a taller card. The trash draws it too (the one-face rule). The drag replica carries the band when the picture is already decoded, which it is for any card the user can see to drag. +- **Hero image** (ruled 2026-08-09): a card whose `hero` key names one of its own attachments (a bare filename — 01-storage-format.md § Frontmatter's card table) draws it as a **banner across the full plate width above the icon-and-title row**, aspect-fill cropped to a fixed em-scaled band (2.75 em — 36pt at the standard body, so it scales with system text size and board zoom like every other face figure). Set from the card window's **attachment row context menu** — "Set as Hero" on any image row, "Remove Hero" on the current one (re-ruled 2026-08-09, promoting the day-one "future work" note; 05-card-window.md ▸ Attachments) — and hand-written or agent-written exactly as before. One hero per card: Set on a card that already has one **replaces** it, and the row that holds it shows Remove Hero instead, so there is no Remove-then-Set dance. Image rows only, by extension: offering the row on a `.zip` would let a user set a hero that can never draw. A key that is absent, malformed, or names a file that is missing, unreadable or not an image renders **exactly as no key at all** — the band has no height until a picture actually decodes, so the degrade is structural rather than a branch. Everything else the face draws is unchanged and sits outside the band: the accent stripe runs the plate's full leading edge over it, the selection and file-hover strokes ring the whole plate, the cut/drag dims cover it, and the drop model registers the plate's real height — a hero card is simply a taller card. The trash draws it too (the one-face rule). The drag replica carries the band when the picture is already decoded, which it is for any card the user can see to drag. - **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 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. @@ -35,7 +35,7 @@ Toolbars are **pure enhancement**: every function they host already has a menu i ### Capabilities (settled) -- **`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. +- **`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. **The image has exactly one in-app control, and it writes a file rather than browsing for one**: Edit ▸ Paste as Board Background (ruled 2026-08-09) writes the pasteboard's picture into the board folder as `Pasted Background.` and points `image` at it — the generated-background gesture's shape, minus the colour, because a picture off the pasteboard carries no ground colour to degrade to, so `color` is left exactly as it was. Re-pasting overwrites the board's own file; a file of that name the app did not write is stepped around, Finder-style. There is still **no image picker** — naming a picture that is already somewhere in the folder is the raw file's job, and it stays the escape hatch (the stance custom hex held until the 2026-08-06 combo reversal; for browsing 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). diff --git a/DESIGN/04-interactions.md b/DESIGN/04-interactions.md index ad0cac3..f15f5e9 100644 --- a/DESIGN/04-interactions.md +++ b/DESIGN/04-interactions.md @@ -28,6 +28,7 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s - ⌘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". +- **⌘V's image-data branch** (ruled 2026-08-09): when the pasteboard carries **raw image data and no file URL** — a screenshot, a browser's Copy Image, Preview's ⌘C — ⌘V lands the picture as a new file in a card's `attachments/`. **Precedence, in one order**: the app's own clipboard type wins outright (a Lanework copy still pastes as cards or lanes); a **file URL** on the pasteboard suppresses the branch entirely (a Finder copy is a reference to a file the user already filed — a different gesture, and this one declines rather than guessing); raw image data is the fallback. **Target**: the board window pastes onto the **anchor card** (the existing paste-target grammar — the last selected card in flatten order), so a lane, an empty and a trash selection all grey the row out rather than the app guessing a card; a card window pastes onto **its own card**, and a focused text field still wins ⌘V natively, as everywhere else. **Format**: a file-shaped flavor is kept byte for byte — PNG, JPEG, GIF, HEIC, WebP, extension its own — and everything else (TIFF, BMP: interchange encodings, not files people want in a folder) is re-encoded to PNG; PNG is preferred when several flavors are offered, which is the screenshot's case. **Name**: Finder's own — `Pasted Image.png`, then `Pasted Image 2.png` — minted by the existing import path's own collision ladder. It **is** that import path (the one Finder file drops and ⇧⌘A take), so the bracket, the read-only lock, the banners, the echo receipts and "no undo step for an attachment arrival" all hold unchanged, and the arrival announces exactly as a drop's does. A pasteboard that declares a flavor it cannot back up writes nothing at all. **Edit ▸ Paste as Board Background** is the same payload's other destination and a named row rather than a ⌘V branch — a backdrop has no selection to target (03-board-ui.md § Styling ▸ Capabilities). - **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 diff --git a/DESIGN/05-card-window.md b/DESIGN/05-card-window.md index cf601a5..de315bd 100644 --- a/DESIGN/05-card-window.md +++ b/DESIGN/05-card-window.md @@ -58,7 +58,8 @@ Stacked sections under small-caps headers, in this order; quiet rows, read-optim - 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. **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. +- Row interactions: double-click or Return opens; context menu Open / Reveal in Finder / **Set as Hero** or **Remove Hero** / 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. **The hero row is one slot with two words** (ruled 2026-08-09, 03-board-ui.md § Card face ▸ Hero image): "Set as Hero" on any image row that is not already the hero, "Remove Hero" on the one that is, and **nothing at all** on a row that can be neither — present-or-absent rather than greyed, because a disabled "Set as Hero" on a `.zip` would claim a capability that will never arrive (Remove, just below it, greys under the lock as before). Removing the hero removes the *key*, never the file. The row that is the hero says so to VoiceOver as its value. +- **⌘V pastes a picture onto this card** (04-interactions.md ▸ Clipboard's image-data branch): the whole-window drop's keyboard twin, landing in the same `attachments/` through the same import path. A focused text field — the body editor, the composer, an inline comment edit — consumes ⌘V natively and stays a text paste, which is the drop precedence's rule arriving on the other input. - **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 diff --git a/DESIGN/11-command-nexus.md b/DESIGN/11-command-nexus.md index c3bc209..0cdbdf9 100644 --- a/DESIGN/11-command-nexus.md +++ b/DESIGN/11-command-nexus.md @@ -30,13 +30,16 @@ The single source of truth for **every command and action the app can perform** | File | Share… (the board, staged as a `.zip`, `NSSharingServicePicker` anchored to the board window's toolbar or its center) | — (no default) | Board window; design ruling 2026-08-09, card 72691b11 — a faithful copy like Duplicate's (`.git` the sole exclusion; attachments, comments and `.trash/` carried), never gated on the read-only lock (a share is a read, `Print…`'s own posture), disabled only while an inline title editor 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 | Set as Hero | — (no default) | Card window; the attachments section's selected row, when it is an image the card is not already using (2026-08-09 — 03 ▸ Card face ▸ Hero image). Twin of the row's own context entry; **two rows rather than one that renames itself**, since titles are the remapping key | +| File | Remove Hero | — (no default) | Card window; the selected row when it *is* the card's hero — the key goes, the file stays. See Set as Hero for why the pair is two rows | | 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 | Print… | ⌘P | Board window: prints **the board** as a linear document — lanes in left-to-right order, each lane's cards top-to-bottom, never a graphical snapshot of the strip; the trash is unreachable (it is a sibling container of `lanes`, not a lane — 01 ▸ Deletion). Card window: prints **that card**. **The sandbox needs an entitlement to print at all** (owner-reported 2026-08-09, board window frontmost, row enabled): `Kanban.entitlements` carried no `com.apple.security.print`, so a sandboxed `NSPrintOperation` was denied with the system alert "This application does not support printing" regardless of which code path invoked it — the fix is the entitlement key, not app logic. **Also revised the same day, as hardening rather than as the fix**: the row is now **always enabled** — a print is a read, so neither the read-only lock nor the focused-editor rule ever closed it (Reveal in Finder's posture), and the row claims ⌘P unconditionally rather than disabling, because a disabled `Button` still owns its key equivalent — a window with neither scope (welcome, the template chooser, Settings, the restore-bootstrap window, a card window whose board hasn't joined) used to drop the unclaimed chord into AppKit's own nil-target print handling, whose stock failure happens to be the identical alert text by a wholly separate mechanism. Scope now resolves at the moment ⌘P is pressed instead: board, then card, then — with neither — a one-sentence app-drawn refusal ("Nothing to Print" / "Open a board or a card to print it."), the same voice the empty-document refusal already uses. Finder's own `printFiles` Apple Event (File ▸ Print… on a selected board, a drag onto a printer queue, a print-and-open service) is implemented rather than left unhandled — its own separate route to the same stock alert: `AppDelegate` loads the path headless through `BoardLoader` and either prints it or gives the identical one-sentence refusal. Options ride in a **print-panel accessory** with the system's live preview: which components (title, icon+labels line, rendered body, comments — off by default, oldest- or newest-first), page breaks (continuous / between lanes / between cards, real sheet boundaries), one base font face and size every other size derives from, and a toggleable header/footer (board title, print date, page numbers, custom line). The option sets persist app-side as **named print profiles** with a reserved "Last Used" pseudo-profile that auto-captures the most recent settings — `UserDefaults`, never board data (02 ▸ Per-board app state), the zoom level's own argument. **Page Setup… is deliberately absent**: the paper questions are answered in the print panel's own page-setup group, so a second dialog would be a second place to set one margin | | 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, 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 | 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; **⌘V's image-data branch** (2026-08-09 — 04 ▸ Clipboard) lands a pasteboard picture in the anchor card's `attachments/`, and in a card window in that window's card; text editors: standard text clipboard | +| Edit | Paste as Board Background | — (no default) | Board window; enabled with image data on the pasteboard and a writable board — no selection needed, since a backdrop has one value per board (2026-08-09 — 03 ▸ Styling ▸ Capabilities) | | 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 — 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 | @@ -103,7 +106,7 @@ Context menus are the per-item action inventory VoiceOver reads (10 ▸ The boar | Card | Open, Copy Link (folder link to the pasteboard — twin of Board ▸ Copy Link, 2026-08-09), 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 | +| 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; **Set as Hero / Remove Hero** (2026-08-09 — 03 ▸ Card face ▸ Hero image) — one slot with two words, present on image rows only and absent rather than greyed where neither applies; twins of the two File rows below, which address the section's *selected* row | | 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) | diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 2c3ff1c..5eae7e6 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -474,6 +474,23 @@ struct CardWindowHost: View { .onChange(of: placement.card.attachments, initial: true) { _, names in attachments.names = names } + // **The hero is the snapshot's too**, and republished the same way, so the row that + // offers "Remove Hero" is the row the card face is actually banding from (05 ▸ + // Attachments; 03-board-ui.md § Card face ▸ Hero image). A malformed key reads as no + // hero, which is exactly what the face draws. + .onChange(of: placement.card.hero.value, initial: true) { _, hero in + attachments.hero = hero + } + // **⌘V in this window pastes a picture onto this card** (04-interactions.md ▸ Clipboard's + // image-data branch). Here rather than inside `CardWindowView` because the availability + // is the clipboard's observable reading and this is where a store, a card id and the + // app-wide clipboard are all in scope at once — the same join `configureAttachments` + // makes for the other two writes. + .cardWindowImagePaste( + store: store, + cardID: placement.card.id, + clipboard: appModel.clipboard + ) .onChange(of: Self.cardFolder(root: store.rootURL, placement: placement), initial: true) { _, folder in // Re-derived from the store's *current* root, `cardFolder`'s rule: a mid-session // folder rename moves the board, and rows resolving against where it used to be @@ -693,7 +710,7 @@ struct CardWindowHost: View { session.rawSourceIsActive = { [rawSource] in rawSource.isActive } session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() } session.rawSourceCancel = { [rawSource] in rawSource.cancel() } - Self.configureAttachments(attachments, store: store, cardID: cardID) + Self.configureAttachments(attachments, store: store, cardID: cardID, undo: session.undo) Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo) } @@ -811,13 +828,24 @@ struct CardWindowHost: View { /// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason: /// the target resolution is invisible in a running window until it is wrong, and this shape is /// what lets a test drive the real wiring rather than a re-typed copy of it. - static func configureAttachments(_ attachments: CardAttachments, store: BoardStore, cardID: ItemID) { + static func configureAttachments( + _ attachments: CardAttachments, + store: BoardStore, + cardID: ItemID, + undo: CardWindowUndo + ) { attachments.importFiles = { [weak store] urls in store?.importAttachments(urls, toCard: cardID) } attachments.removeFile = { [weak store] name in store?.removeAttachment(named: name, fromCard: cardID) } + // **The window's own stack**, like every other gesture issued in this window + // (13-native-undo.md ▸ Rules ▸ two levels): Set as Hero is a restyle, and a restyle made here + // folds into this session's close step rather than landing on the board's. + attachments.setHeroFile = { [weak store] name in + store?.setHero(name, onCard: cardID, on: undo) + } } /// Points the raw-source outlet at its card — the outlet's three seams (05-card-window.md ▸ Raw diff --git a/Kanban/App/ClipboardManifest.swift b/Kanban/App/ClipboardManifest.swift index 7e4cb3f..e76be1c 100644 --- a/Kanban/App/ClipboardManifest.swift +++ b/Kanban/App/ClipboardManifest.swift @@ -201,6 +201,20 @@ public protocol ClipboardPasteboard: AnyObject { /// resulting `changeCount`. @discardableResult func write(manifest: Data, text: String) -> Int + + /// **Every type identifier the pasteboard currently carries** — what the image-data branch + /// classifies (04-interactions.md ▸ Clipboard, ruled 2026-08-09; `PastedImage.flavor`). + /// + /// A list rather than a set of yes/no questions, because the *rule* is a decision over a list and + /// belongs in one place: adding a flavor to `PastedImage.verbatimTypes` must not also mean adding + /// a method here. It subsumes `manifestData()`'s question too, and does not replace it — the + /// manifest is read as bytes and decoded, which a type list cannot answer. + func availableTypes() -> [String] + + /// The bytes under one type, whatever it is — the general read behind `manifestData()`'s + /// specific one, added for the image branch (which knows its type only at runtime, off the + /// classification above). + func data(forType type: String) -> Data? } /// The real pasteboard. @@ -226,6 +240,19 @@ public final class SystemPasteboard: ClipboardPasteboard { pasteboard.data(forType: Self.type) } + /// `NSPasteboard.types` — the **first item's** types, which is what "the clipboard's payload" + /// means for every producer this branch cares about: a screenshot, a browser's Copy Image, a + /// Finder copy, and this app's own write are all single-item writes. A multi-item pasteboard's + /// later items are deliberately not consulted; pasting the second image of a five-image copy is a + /// gesture nobody has asked for and would need a target grammar of its own. + public func availableTypes() -> [String] { + (pasteboard.types ?? []).map(\.rawValue) + } + + public func data(forType type: String) -> Data? { + pasteboard.data(forType: NSPasteboard.PasteboardType(type)) + } + @discardableResult public func write(manifest: Data, text: String) -> Int { pasteboard.clearContents() diff --git a/Kanban/App/ClipboardStore.swift b/Kanban/App/ClipboardStore.swift index 2f4d5fd..91afe7b 100644 --- a/Kanban/App/ClipboardStore.swift +++ b/Kanban/App/ClipboardStore.swift @@ -66,6 +66,21 @@ public final class ClipboardStore { /// happened to invalidate the menu. public private(set) var payload: ClipboardManifest? + /// **The image-data branch's reading of the same pasteboard**, as of the same `refresh()` — + /// `nil` when there is no picture to paste, when a board payload outranks one, or when the + /// pasteboard carries file URLs (04-interactions.md ▸ Clipboard, ruled 2026-08-09; + /// `PastedImage.flavor(hasBoardItems:types:)` holds the precedence and this holds its answer). + /// + /// Observed beside `payload` and refreshed in the same breath, for `payload`'s exact reason: the + /// three surfaces that turn on it — the board's ⌘V fallback, the card window's ⌘V, and Edit ▸ + /// Paste as Board Background — are menu-validated, and a computed pasteboard read would leave + /// every one of them stale until something else happened to rebuild the menu. + /// + /// **Two readings, never two reads**: one `refresh()` reads the pasteboard once and fills both, + /// which is what makes "a board payload wins" a property of the code rather than an ordering two + /// call sites have to remember. + public private(set) var imagePayload: PastedImage.Flavor? + /// The staging directory — public because the tests assert on what it holds after a copy, a /// paste and a sweep, exactly as `BoardRegistry.storageURL` is public for its tests. @ObservationIgnored public let stagingRoot: URL @@ -271,6 +286,153 @@ public final class ClipboardStore { } } + // MARK: - Paste ▸ the image-data branch + + // **⌘V's fallback, not a second command** (04-interactions.md ▸ Clipboard, ruled 2026-08-09). + // A pasteboard carrying raw image data and no file URL pastes the picture into a card's + // `attachments/` — the screenshot, the browser's Copy Image, Preview's ⌘C. Everything about it + // is deliberately the *existing* machinery seen from one branch over: + // + // - **The precedence is `refresh()`'s**, which fills `payload` and `imagePayload` from one read + // and can therefore never let a picture divert a board paste. + // - **The write is `BoardStore.importAttachments(_:toCard:)`** — the same call a Finder file drop + // and the card window's ⇧⌘A make. That is what buys the Finder-style collision rename, the + // `performWrite` bracket (one app-mediated reload, the read-only lock, the banner on failure), + // the echo ledger's receipt, and the staging rules, without a second import path in the app to + // keep in step with the first. + // - **The name is minted by writing a temp file** rather than by asking the Writer for a free + // name and then writing under it: `importFiles` takes source URLs and climbs its own ladder, so + // handing it a file already called "Pasted Image.png" is how a paste gets Finder's answer + // rather than a second implementation of it. + // - **It registers no undo step**, exactly like every other attachment arrival (13-native-undo.md + // ▸ Out of scope: "attachment add/remove registers no undo step in v1"). A paste that landed a + // file is an import, and imports are not on the stack — half a pair would be worse than none. + // - **It announces exactly as a file drop does**, which is to say the arrival is silent: the + // write is app-mediated, so the reload it produces carries receipts and the announcer's ladder + // is quiet by construction (10-accessibility.md — "app-mediated echoes never do"). What the + // user gets is what a drop gives them: the row appearing in the attachments section and the + // card face's chip counting one higher. + + /// Whether ⌘V would paste a picture into `store`'s **anchor card** — the board window's branch. + /// + /// Three clauses. The board accepts board mutations (the lock and the focused-editor rule, exactly + /// as `canPaste(into:)` reads them); there is a picture on the pasteboard; and the selection + /// anchors a *card*, because an attachment belongs to one. A lane selection, an empty selection + /// and a trash selection all anchor no card and therefore offer nothing here — which is + /// `PasteTarget.card`'s answer, so the item's availability and the paste's own refusal are the + /// same expression. + public func canPasteImage(into store: BoardStore) -> Bool { + guard store.acceptsBoardMutations, imagePayload != nil else { return false } + return PasteTarget.card(selection: store.selection, snapshot: store.snapshot) != nil + } + + /// ⌘V's image branch on the board — resolves the anchor card and pastes into it. + @discardableResult + public func pasteImage(into store: BoardStore) -> Bool { + refresh() + guard canPasteImage(into: store), + let cardID = PasteTarget.card(selection: store.selection, snapshot: store.snapshot) + else { return false } + return pasteImage(intoCard: cardID, in: store) + } + + /// Whether ⌘V would paste a picture into this **named** card — the card window's branch, where + /// the target is the window's own card rather than a selection's anchor. + /// + /// The lock clause is `!store.isReadOnly` rather than `acceptsBoardMutations`, which is + /// `CardAttachments.isEditable`'s reading and the right one here: the focused-editor half of + /// `acceptsBoardMutations` is about *this board window's* inline title editor, and a card window + /// has no business going dead because a board window behind it is mid-rename. A focused text + /// field in the card window still wins ⌘V natively, which is the rule that actually matters here + /// and needs no arithmetic (`ClipboardCommands`' focused-editor note). + /// + /// The card must be on the **board side**: `BoardStore.importAttachments` refuses a trashed card + /// outright, and offering a row that would no-op is exactly what the codebase's named predicates + /// exist to prevent. + public func canPasteImage(intoCard cardID: ItemID, in store: BoardStore) -> Bool { + guard !store.isReadOnly, imagePayload != nil else { return false } + return BoardStore.boardItem(cardID, in: store.snapshot)?.cardID != nil + } + + /// Pastes the pasteboard's picture into `cardID`'s `attachments/`. + /// + /// - Returns: whether a file was handed to the import path. `false` is every way this can decline + /// — no picture, a card that is not there, a pasteboard that declared a type it could not back + /// up, or a temp file that would not write — and every one of them writes nothing at all. + @discardableResult + public func pasteImage(intoCard cardID: ItemID, in store: BoardStore) -> Bool { + refresh() + guard canPasteImage(intoCard: cardID, in: store), let flavor = imagePayload else { return false } + guard let raw = pasteboard.data(forType: flavor.type), + let bytes = PastedImage.encode(raw, as: flavor) + else { + // The pasteboard named a flavor it cannot produce, or produced bytes that are not an + // image. Nothing is written and nothing is said: the honest outcome of a pasteboard that + // lied is the one where the card is untouched. + Self.logger.debug("image paste declined — the declared flavor produced no usable bytes") + return false + } + guard let staged = Self.stageForImport(bytes, named: flavor.fileName) else { return false } + defer { try? FileManager.default.removeItem(at: staged.deletingLastPathComponent()) } + + store.importAttachments([staged], toCard: cardID) + return true + } + + /// Writes `bytes` to a private temp folder under `name`, and answers the file's URL. + /// + /// **A folder per paste, not a shared scratch directory**: the file has to carry the exact name + /// the import ladder will start from ("Pasted Image.png"), so two pastes in flight would collide + /// on it — and the folder is what the caller removes afterwards, which is one `removeItem` + /// instead of a file plus whatever else ended up beside it. + /// + /// The app's own container temp directory, so this needs no sandbox grant and no bookmark: the + /// bytes came off the pasteboard, they are going into the board the app already holds, and this + /// is the few milliseconds in between. + private static func stageForImport(_ bytes: Data, named name: String) -> URL? { + let folder = FileManager.default.temporaryDirectory + .appendingPathComponent("PastedImage-\(UUID().uuidString)", isDirectory: true) + guard (try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)) != nil + else { return nil } + let url = folder.appendingPathComponent(name) + guard (try? bytes.write(to: url)) != nil else { + try? FileManager.default.removeItem(at: folder) + return nil + } + return url + } + + // MARK: - Paste ▸ the board backdrop + + /// Whether Edit ▸ Paste as Board Background applies to `store` (03-board-ui.md § Styling ▸ + /// Capabilities; the `background` mapping's `image` subkey). + /// + /// Two clauses and no third: a board that accepts mutations, and a picture on the pasteboard. + /// There is no target to resolve — a board has exactly one backdrop — which is what makes this + /// the one image-paste surface that stays live on a zero-lane board. + public func canPasteBoardBackground(into store: BoardStore) -> Bool { + store.acceptsBoardMutations && imagePayload != nil + } + + /// Edit ▸ Paste as Board Background — the picture into the board folder, `background.image` + /// pointed at it. + /// + /// The bytes are prepared exactly as the attachment branch's are (same classification, same + /// format rule) and then handed to `BoardStore.applyPastedBackground(data:fileExtension:)`, which + /// owns the naming, the one bracket and the undo step. Nothing about the *file* is decided here. + @discardableResult + public func pasteBoardBackground(into store: BoardStore) -> Bool { + refresh() + guard canPasteBoardBackground(into: store), let flavor = imagePayload else { return false } + guard let raw = pasteboard.data(forType: flavor.type), + let bytes = PastedImage.encode(raw, as: flavor) + else { + Self.logger.debug("background paste declined — the declared flavor produced no usable bytes") + return false + } + return store.applyPastedBackground(data: bytes, fileExtension: flavor.fileExtension) + } + // MARK: - Paste /// Where this paste is going, resolved **now** — from the selection as it stands when ⌘V is @@ -467,6 +629,12 @@ public final class ClipboardStore { guard count != lastChangeCount else { return } lastChangeCount = count payload = pasteboard.manifestData().flatMap(ClipboardManifest.init(data:)) + // The image branch's whole precedence, applied here so it is applied once: a board payload + // outranks a picture, and a file URL means this is not the image branch's pasteboard at all. + imagePayload = PastedImage.flavor( + hasBoardItems: payload != nil, + types: pasteboard.availableTypes() + ) if let cut = armedCut, payload?.copyID != cut.copyID { voidCut() } diff --git a/Kanban/App/PastedImage.swift b/Kanban/App/PastedImage.swift new file mode 100644 index 0000000..3113ce8 --- /dev/null +++ b/Kanban/App/PastedImage.swift @@ -0,0 +1,210 @@ +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers +import os + +// MARK: - PastedImage + +/// **A picture on the pasteboard, as a file this app could write** (04-interactions.md ▸ Clipboard, +/// the image-data branch ruled 2026-08-09) — which flavor to take, what the landed file is called, +/// and whether the bytes travel verbatim or are re-encoded on the way. +/// +/// ### Why this is a pure rule with no pasteboard in it +/// +/// `NewCardTarget` and `PasteTarget`'s reason, one layer over: every clause below is a *decision* +/// about a list of type identifiers, and a decision that can be a value function should be one — the +/// menu item's `disabled`, the paste's own refusal, and the tests all read the same answer instead of +/// three hand-kept-in-sync conditions. The bytes are fetched by whoever owns the pasteboard seam +/// (`ClipboardStore`); nothing here touches `NSPasteboard`. +/// +/// ### The precedence, which is the whole of the classification +/// +/// 1. **The app's own clipboard type wins outright.** A Lanework copy on the pasteboard is a board +/// payload and pastes as cards or lanes exactly as it always did — an image flavor riding beside +/// it (there is none today, but a future manifest could carry a preview) must never divert ⌘V. +/// 2. **File URLs are somebody else's branch.** A Finder copy puts `public.file-url` down, sometimes +/// with an image flavor beside it, and "the pasteboard's payload is IMAGE DATA (no file URL)" is +/// the ruling's own parenthesis. A file URL is a *reference* to something the user already has +/// filed; taking a second copy of it into `attachments/` behind their back is a different gesture +/// with a different answer, and this branch declines rather than guessing at it. +/// 3. **Raw image data is the fallback**, which is the screenshot (⌃⇧⌘4), the browser's Copy Image, +/// and Preview's ⌘C. +/// +/// ### The format rule +/// +/// **A file-shaped flavor is kept byte for byte** — PNG, JPEG, GIF, HEIC, WebP. These are the +/// encodings a file on disk is already written in, so re-encoding one would cost either fidelity (a +/// JPEG round-tripped through PNG is bigger *and* still carries the original's artefacts) or the +/// picture itself (a GIF's animation does not survive a single-frame decode). The extension is that +/// type's own preferred one, so the landed file opens in Preview and QuickLooks in the sidebar with +/// no ceremony. +/// +/// **Everything else is re-encoded to PNG** — in practice `public.tiff`, AppKit's lossless +/// interchange flavor, which is what a screenshot and a Preview copy put down beside their PNG and +/// what a great many apps offer *instead* of one. TIFF is an interchange encoding rather than a file +/// people want sitting in a card's folder: a 5K screenshot is tens of megabytes as TIFF and about a +/// tenth of that as PNG, both lossless. `.bmp` rides the same branch for the same reason. +/// +/// The preference order is PNG first and TIFF last, so the overwhelmingly common paste — a screenshot +/// offering `public.png` and `public.tiff` together — lands as the PNG it already is, with no decode +/// and no re-encode at all. +public enum PastedImage { + + // MARK: The name + + /// **The name a pasted image lands under** — Finder's own shape for a file that arrives with no + /// name of its own ("Pasted Image.png", then "Pasted Image 2.png", …). + /// + /// Only the stem is here: the extension is the flavor's (`Flavor.fileExtension`), and the + /// collision ladder is `BoardWriter.freshName`'s, reached by handing the import path a temporary + /// file with this name on it. Nothing in this app climbs a second ladder — "Finder-style rename + /// on collision is one rule wherever the app has to find a free name". + public static let baseName = "Pasted Image" + + /// **The board backdrop's own stem** — the same rule one level up (03-board-ui.md § Styling ▸ + /// Capabilities), so a pasted background is as recognizable in a board folder as a pasted + /// attachment is in a card's. + /// + /// Its own constant rather than `baseName` reused: the two files land in different folders for + /// different reasons, and a board folder holding something called "Pasted Image.png" would say + /// nothing about what it is for. `FacetsGenerator.fileName` is the sibling this is modeled on. + public static let backgroundBaseName = "Pasted Background" + + // MARK: The flavor + + /// One readable image payload on the pasteboard: where to read it from, what to write, and what + /// to call it. + public struct Flavor: Equatable, Sendable { + + /// The pasteboard type identifier the bytes come from. + public let type: String + + /// The landed file's extension — the flavor's own for a verbatim write, `png` for a + /// converted one. + public let fileExtension: String + + /// Whether the bytes are re-encoded on the way (see the type comment's format rule). + public let convertsToPNG: Bool + + /// The file name a paste of this flavor mints, before the Finder ladder ever sees it. + public var fileName: String { "\(PastedImage.baseName).\(fileExtension)" } + + /// The board-backdrop file name for the same flavor. + public var backgroundFileName: String { "\(PastedImage.backgroundBaseName).\(fileExtension)" } + } + + // MARK: Classification + + /// The flavors kept verbatim, **in this app's preference order** — not the pasteboard's, which + /// is the *owner's* ranking of what it thinks a taker wants and has no idea a file is about to + /// be written. + /// + /// PNG leads because it is lossless, universally readable, and the flavor a screenshot already + /// carries. JPEG follows so a photograph copied out of a browser lands as the JPEG it is rather + /// than as a PNG several times its size. GIF, HEIC and WebP are here so that a source offering + /// only one of them is still a paste rather than a refusal. + public static let verbatimTypes: [UTType] = [.png, .jpeg, .gif, .heic, .webP] + + /// The flavors re-encoded to PNG — the interchange bitmaps (see the type comment). + public static let convertedTypes: [UTType] = [.tiff, .bmp] + + /// What this pasteboard offers the image branch, or `nil` when the branch does not apply. + /// + /// - Parameter hasBoardItems: whether the app's own clipboard type is present and readable — + /// clause 1 of the precedence. Passed in rather than read here because deciding *that* is + /// `ClipboardManifest`'s job and this type has no pasteboard. + /// - Parameter types: every type identifier the pasteboard currently carries. + public static func flavor(hasBoardItems: Bool, types: [String]) -> Flavor? { + guard !hasBoardItems, !carriesFileURL(types) else { return nil } + + let offered = Set(types) + for type in verbatimTypes where offered.contains(type.identifier) { + // A registered type with no preferred extension is not something a file can be named + // after; skipping it lets the ladder fall through to the converted branch rather than + // minting "Pasted Image." with nothing after the dot. + guard let ext = type.preferredFilenameExtension else { continue } + return Flavor(type: type.identifier, fileExtension: ext, convertsToPNG: false) + } + for type in convertedTypes where offered.contains(type.identifier) { + return Flavor(type: type.identifier, fileExtension: "png", convertsToPNG: true) + } + return nil + } + + /// Whether the pasteboard is carrying a file reference — clause 2 of the precedence. + /// + /// Conformance rather than equality with `public.file-url`, for `FinderDrop.isDirectory + /// (typeIdentifiers:)`'s reason: a source is free to declare a subtype of it, and the rule is + /// about what the value *is*. A type the system does not know is not a file URL, which is the + /// same optimistic reading a drag's unknown types get. + public static func carriesFileURL(_ types: [String]) -> Bool { + types.contains { UTType($0)?.conforms(to: .fileURL) ?? false } + } + + // MARK: The bytes + + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "paste-image") + + /// The bytes this flavor writes, given what the pasteboard handed over — `raw` itself for a + /// verbatim flavor, a PNG re-encoding for a converted one, and `nil` when the payload turns out + /// not to be a decodable image at all. + /// + /// A `nil` here is the honest refusal: the pasteboard *declared* a type it cannot back up, and + /// writing an unreadable file into somebody's card folder is worse than doing nothing. The + /// caller treats it exactly as it treats an empty pasteboard. + public static func encode(_ raw: Data, as flavor: Flavor) -> Data? { + guard flavor.convertsToPNG else { return raw.isEmpty ? nil : raw } + return pngData(from: raw) + } + + /// A bitmap payload re-encoded as PNG, through ImageIO. + /// + /// **ImageIO rather than `NSBitmapImageRep`**, which is the same call `BoardBackdrop.decode` + /// makes and for the same reasons: it is the framework that actually owns the codecs, it is + /// `Sendable`-clean and main-actor-free, and it needs no AppKit image cache in the middle. The + /// full image is decoded rather than a thumbnail — this is a *conversion*, and downsampling a + /// picture the user pasted would silently cost them resolution they never agreed to lose. + /// + /// Alpha survives, because a PNG destination writing a CGImage with an alpha channel keeps it — + /// which matters for exactly the payload this branch sees most, a screenshot of a rounded window. + public static func pngData(from data: Data) -> Data? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let image = CGImageSourceCreateImageAtIndex( + source, 0, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary + ) + else { + logger.debug("pasteboard bitmap could not be decoded") + return nil + } + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + output, UTType.png.identifier as CFString, 1, nil + ) else { return nil } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + logger.debug("pasteboard bitmap could not be re-encoded as PNG") + return nil + } + return output as Data + } + + // MARK: Which attachments can be a hero + + /// Whether an attachment named `name` is one **"Set as Hero" may point at** — an image, by its + /// name's own extension (05-card-window.md ▸ Attachments; 03-board-ui.md § Card face ▸ Hero + /// image). + /// + /// **By extension rather than by opening the file**, deliberately. The context menu is built + /// while the pointer is going down on a row, and the sidebar may be showing a hundred of them; + /// a per-row `CGImageSourceCreateWithURL` to decide whether a menu row is offered is exactly the + /// kind of disk touch the card window keeps out of a body evaluation. The cost of being wrong is + /// nothing either way: a name whose extension lies renders as no band at all (`CardHeroImage`'s + /// structural degrade), and an image the system does not recognize by extension is simply not + /// offered the row — the `hero` key is still hand-writable, which is what it was born as. + public static func isImageName(_ name: String) -> Bool { + let ext = (name as NSString).pathExtension + guard !ext.isEmpty, let type = UTType(filenameExtension: ext) else { return false } + return type.conforms(to: .image) + } +} diff --git a/Kanban/History/CardWindowUndo.swift b/Kanban/History/CardWindowUndo.swift index c0ef4d8..e6244eb 100644 --- a/Kanban/History/CardWindowUndo.swift +++ b/Kanban/History/CardWindowUndo.swift @@ -273,7 +273,7 @@ public final class CardWindowUndo { } private static let fieldOrder: [ExpectedField.Kind] = [ - .title, .order, .width, .background, .backgroundImage, .icon, .iconColor, .body, + .title, .order, .width, .background, .backgroundImage, .hero, .icon, .iconColor, .body, ] } } diff --git a/Kanban/History/HistoryStaleness.swift b/Kanban/History/HistoryStaleness.swift index 9ca7f3c..020dffa 100644 --- a/Kanban/History/HistoryStaleness.swift +++ b/Kanban/History/HistoryStaleness.swift @@ -46,6 +46,15 @@ public enum ExpectedField: Sendable, Equatable { /// afterwards. `nil` is the absent subkey, exactly as everywhere else here. case backgroundImage(String?) + /// `hero` — the card's banner picture, named by the attachment row's "Set as Hero" / "Remove + /// Hero" (`BoardStore.setHero(_:onCard:on:)`, ruled 2026-08-09). + /// + /// Its own case beside `.icon` rather than a reading of it, for `.backgroundImage`'s reason: they + /// are independent values on one card, and a step that named the hero must not stale because + /// somebody picked a symbol afterwards. `nil` is the removed key — Remove Hero — exactly as + /// everywhere else here. + case hero(String?) + /// `icon` — the styling gesture's symbol dimension. case icon(String?) @@ -69,6 +78,7 @@ public enum ExpectedField: Sendable, Equatable { case .collapsed: .collapsed case .background: .background case .backgroundImage: .backgroundImage + case .hero: .hero case .icon: .icon case .iconColor: .iconColor case .body: .body @@ -84,6 +94,7 @@ public enum ExpectedField: Sendable, Equatable { case collapsed case background case backgroundImage + case hero case icon case iconColor case body @@ -348,6 +359,7 @@ public enum HistoryStaleness { case let .collapsed(expected): equal(document.collapsed, expected) case let .background(expected): equal(document.background, expected) case let .backgroundImage(expected): equal(document.backgroundImage, expected) + case let .hero(expected): equal(document.hero, expected) case let .icon(expected): equal(document.icon, expected) case let .iconColor(expected): equal(document.iconColor, expected) case let .body(expected): document.body == expected diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index ceac34e..7c2f3ab 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -219,6 +219,11 @@ struct KanbanApp: App { ShareBoardCommand(appModel: appModel) RevealInFinderCommand() AddAttachmentCommand() + // The attachment row's hero pair, as the menu-bar twins 11-command-nexus.md's + // context-menu contract requires ("no function's only home"). Two rows rather than one + // that renames itself, for titles-are-API's reason (`SetAsHeroCommand`). + SetAsHeroCommand() + RemoveHeroCommand() AddCommentCommand() Divider() @@ -233,12 +238,22 @@ struct KanbanApp: App { // (`UndoCommands.swift`). UndoRedoCommands() - // The Edit menu: Find (⌘F), then its card-window stepping twins, placed after the standard - // Cut/Copy/Paste/Select All group, which is where macOS puts Find. The clipboard items above - // them stay the system's, answered by the board as a responder (`ClipboardCommands.swift`) — - // a second item sharing one of those titles is what titles-are-API forbids. Undo and Redo - // were the system's too until the latch (the group just above). + // The Edit menu: Paste as Board Background, then Find (⌘F) and its card-window stepping + // twins, placed after the standard Cut/Copy/Paste/Select All group, which is where macOS puts + // Find. The clipboard items above them stay the system's, answered by the board as a + // responder (`ClipboardCommands.swift`) — a second item sharing one of those titles is what + // titles-are-API forbids. Undo and Redo were the system's too until the latch (the group just + // above). + // + // **Paste as Board Background joined 2026-08-09** with the image-data paste branch: it sits + // directly under the system's Paste because it is the one paste this app has that ⌘V could + // not carry — a backdrop has no selection to target, so it needs a name rather than a + // modifier (`PasteBoardBackgroundCommand`). No default chord, like Welcome's row. CommandGroup(after: .pasteboard) { + PasteBoardBackgroundCommand(clipboard: appModel.clipboard) + + Divider() + FindCommand() FindSteppingCommands() } diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index d1f0dd3..ea5dc0e 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -296,15 +296,21 @@ public final class BoardStore: HealHost { /// refreshes nothing — the pre-skip behaviour of `snapshotGeneration` exactly, kept exactly. public private(set) var landedReloads: Int = 0 - /// **The generated background this store wrote, and the reload count it was written at** — the - /// reroll's echo (`generatedBackgroundName(replacing:inRoot:)`, which is the only reader and - /// carries the whole reasoning). + /// **The board image this store wrote, and the reload count it was written at** — the reroll's + /// echo (`boardImageName(base:replacing:inRoot:)`, which is the only reader and carries the whole + /// reasoning). + /// + /// **`base` is carried beside the name** because there are two producers now — the generator's + /// `facets.png` and a pasted `Pasted Background.png` (ruled 2026-08-09) — and the echo's claim is + /// "this store already owns *that family's* name since the last reload". Without it, a paste + /// following a reroll inside one reload window would read `facets.png` as its own and overwrite a + /// file it never wrote. /// /// `@ObservationIgnored` because nothing renders it: it is bookkeeping about a file name, and a /// view that redrew when it changed would be redrawing for the write it is already going to be /// told about by the reload. @ObservationIgnored - var generatedBackgroundEcho: (name: String, reloads: Int)? + var generatedBackgroundEcho: (name: String, base: String, reloads: Int)? /// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless /// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always @@ -2055,7 +2061,9 @@ public final class BoardStore: HealHost { let root = rootURL let priorImage = snapshot.backgroundImage let priorColor = snapshot.background - let name = generatedBackgroundName(replacing: priorImage.value, inRoot: root) + let name = boardImageName( + base: FacetsGenerator.fileName, replacing: priorImage.value, inRoot: root + ) let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in try BoardWriter.writeBoardImage( @@ -2070,7 +2078,7 @@ public final class BoardStore: HealHost { } } guard landed != nil else { return false } - generatedBackgroundEcho = (name: name, reloads: landedReloads) + generatedBackgroundEcho = (name: name, base: FacetsGenerator.fileName, reloads: landedReloads) // restyle → prior style (13-native-undo.md ▸ Rules). The board's own stack, never a window's: // there is no card here to have a session. @@ -2172,13 +2180,14 @@ public final class BoardStore: HealHost { document.setBackgroundImage(nil) } - /// The name a generated background is written under: **ours to overwrite**, or the next free one. + /// The name a board image is written under: **ours to overwrite**, or the next free one. /// - /// `current` is what `background.image` says now. When that is already the generated name the - /// file is this board's own output and is replaced in place — including when it has been deleted - /// from the folder by hand, which is a board whose backdrop is broken and is exactly what - /// regenerating fixes. Otherwise the Finder ladder decides, which yields the plain name when - /// nothing holds it and `facets 2.png` when something does. + /// `base` is the producer's own file name — `facets.png` for the generator, `Pasted + /// Background.` for a paste — and `current` is what `background.image` says now. When that + /// is already `base` the file is this board's own output and is replaced in place — including + /// when it has been deleted from the folder by hand, which is a board whose backdrop is broken + /// and is exactly what regenerating (or re-pasting) fixes. Otherwise the Finder ladder decides, + /// which yields the plain name when nothing holds it and `facets 2.png` when something does. /// /// ### The reroll's echo /// @@ -2194,10 +2203,91 @@ public final class BoardStore: HealHost { /// is the better authority. Once a reload lands, the snapshot's `background.image` takes over and /// this memory stops being consulted — including when a hand edit pointed the board somewhere /// else in the meantime. - private func generatedBackgroundName(replacing current: String?, inRoot root: URL) -> String { - if current == FacetsGenerator.fileName { return FacetsGenerator.fileName } - if let echo = generatedBackgroundEcho, echo.reloads == landedReloads { return echo.name } - return BoardWriter.freshName(for: FacetsGenerator.fileName, in: root) + /// + /// **The echo is only consulted for its own family** (`base`), which is what keeps two producers + /// off each other: a paste landing inside the reroll's echo window must not read `facets.png` as + /// a name it owns. + private func boardImageName(base: String, replacing current: String?, inRoot root: URL) -> String { + if current == base { return base } + if let echo = generatedBackgroundEcho, echo.base == base, echo.reloads == landedReloads { + return echo.name + } + return BoardWriter.freshName(for: base, in: root) + } + + // MARK: - Pasted background + + /// **Applies a pasted picture as this board's backdrop** — Edit ▸ Paste as Board Background + /// (03-board-ui.md § Styling ▸ Capabilities; 04-interactions.md ▸ Clipboard's image-data branch, + /// ruled 2026-08-09). + /// + /// `applyGeneratedBackground` line for line — the same two-writes-one-bracket ordering (picture + /// first, so a failure never leaves the board naming a file that is not there), the same + /// `.setBoardBackground` operation, the same swallowed failure, the same restyle step — with + /// exactly two differences, both of them deliberate: + /// + /// - **The colour is not touched.** The generator writes `background.color` because it *knows* + /// its render's ground colour and wants the board to degrade to it; a picture off the + /// pasteboard has no such figure, and inventing one (an average, a corner sample) would be this + /// gesture quietly restyling a board the user only asked to give a backdrop. So the mapping's + /// other subkey survives untouched, which is `setBackgroundImage`'s whole per-subkey contract. + /// - **The name carries the payload's own extension** (`Pasted Background.png`, `.jpeg`, …), + /// because the format rule keeps a file-shaped flavor verbatim (`PastedImage`). A board pasted + /// twice in two formats therefore leaves the first file behind — the same quiet leftover + /// choosing a solid colour over a generated background already leaves, and for the same reason: + /// undo restores the *field*, and a field cannot point an undo back at bytes this gesture + /// deleted. + /// + /// **The undo restores the field, not the bytes** — `applyGeneratedBackground`'s own note, + /// unchanged and for its reason: a re-paste over this board's own `Pasted Background.png` + /// overwrites pixels nothing kept a copy of. + /// + /// - Returns: whether bytes reached disk, which is the same question as "is an echo reload + /// coming". + @discardableResult + public func applyPastedBackground(data: Data, fileExtension: String) -> Bool { + let root = rootURL + let priorImage = snapshot.backgroundImage + let base = "\(PastedImage.backgroundBaseName).\(fileExtension)" + let name = boardImageName(base: base, replacing: priorImage.value, inRoot: root) + + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in + try BoardWriter.writeBoardImage( + data: data, named: name, inRoot: root, operation: .setBoardBackground + ) + try BoardWriter.updateIndex( + inItemFolder: root, kind: .board, operation: .setBoardBackground + ) { document in + document.setBackgroundImage(name) + } + } + guard landed != nil else { return false } + generatedBackgroundEcho = (name: name, base: base, reloads: landedReloads) + + // restyle → prior image (13-native-undo.md ▸ Rules). The board's own stack, never a window's: + // there is no card here to have a session. **Only the image subkey is declared**, which is + // the field-level predicate at its narrowest: a colour chosen after this paste must not stale + // the step, because this gesture never wrote a colour. + registerStep( + HistoryPhrase.name(.restyle, kind: .board), + undoExpects: [.present(root, .backgroundImage(name))], + redoExpects: [.present(root, .backgroundImage(priorImage.value))] + ) { _ in + try BoardWriter.updateIndex( + inItemFolder: root, kind: .board, operation: .setBoardBackground + ) { document in + // A malformed prior reads as a removal, `restore(_:to:in:)`'s own rule and the one + // the redo expectation above is written against. + document.setBackgroundImage(priorImage.value) + } + } redo: { _ in + try BoardWriter.updateIndex( + inItemFolder: root, kind: .board, operation: .setBoardBackground + ) { document in + document.setBackgroundImage(name) + } + } + return true } /// Both subkeys, written into the mapping rather than over it (BackgroundField.swift) — spelled @@ -3667,6 +3757,112 @@ public final class BoardStore: HealHost { } } + // MARK: - The hero image + + /// **Points a card's `hero` key at one of its attachments, or removes it** — the card window + /// attachment row's "Set as Hero" and "Remove Hero" (03-board-ui.md § Card face ▸ Hero image, + /// whose "no in-app setter this version" line this ruling retires, 2026-08-09; 05-card-window.md + /// ▸ Attachments). + /// + /// ### It is a style write, and is shaped like one + /// + /// One key on one card's `index.md`, through `updateIndex` inside one `performWrite` bracket, + /// registering one **restyle** step — `applyStyle`'s shape with the batch collapsed to a single + /// subject, because there is one: a hero belongs to a card, and the row that sets it names + /// exactly one file. It takes `.style` as its operation for the same reason it takes the restyle + /// phrase: nothing here writes a file, which is the whole of what made + /// `WriteOperation.setBoardBackground` split off from `.style` one level up. + /// + /// **It does register an undo step**, unlike its neighbours in this section. That is not an + /// inconsistency with "attachment add/remove registers no undo step" (13-native-undo.md ▸ Out of + /// scope): the reason that rule exists is that a removed *file* has nowhere to come back from, and + /// this gesture moves no file at all — it edits a key whose before-value the step carries, which + /// is the ordinary invertible frontmatter write every other style dimension already is. + /// + /// ### Replacement, not refusal + /// + /// "Set as Hero" on a card that already has one **replaces** it, and the row is simply absent on + /// the row already holding the key (`CardAttachments.canSetHero(_:)`) — a card has one hero, the + /// user picked a different picture, and making them Remove first would be ceremony. A call that + /// would write what is already there is a no-op: `effective(_:against:)` is the same predicate + /// `applyStyle` uses to decide a dimension changed nothing, so a redundant set costs no write, no + /// reload and no undo step. + /// + /// ### The guards are `importAttachments`' + /// + /// **The board container and only it** — a trashed card's hero is not editable from a window that + /// is dismissing itself — and a lane id is refused because a lane has no hero. `name` is not + /// checked against the card's listing here: the surface only ever offers a row it is showing, and + /// the schema's own reading refuses anything that is not a bare filename anyway + /// (`FrontmatterDocument.hero`), so a name that has since gone lands a key that renders as no band + /// — which is exactly what 03 says a hero naming a missing file does. + /// + /// - Parameter name: the attachment's file name, or `nil` to remove the key. + /// - Parameter window: the card window whose stack the step belongs on, when the gesture came + /// from one (13-native-undo.md ▸ Rules ▸ two levels). A window-issued gesture also anchors by + /// **card identity** rather than by path, `applyStyle`'s rule verbatim, so a lane move under an + /// open window never stales it. + /// - Returns: whether bytes reached disk. + @discardableResult + public func setHero(_ name: String?, onCard cardID: ItemID, on window: CardWindowUndo? = nil) -> Bool { + guard let item = Self.boardItem(cardID, in: snapshot), + let card = item.cardID, + let subject = Self.card(cardID, in: snapshot) + else { return false } + + let prior = subject.hero + let change: StyleChange = name.map { .set($0) } ?? .remove + guard Self.effective(change, against: prior) != .keep else { return false } + + let folder = rootURL + .appendingPathComponent(item.laneID.rawValue, isDirectory: true) + .appendingPathComponent(card.rawValue, isDirectory: true) + let anchor: HistoryAnchor = window != nil ? .card(cardID) : .path(folder) + + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in + // `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a failure + // names the card by the title it still has. + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in + document.setStyleValue(name, for: FrontmatterKeys.hero) + } + } + guard landed != nil else { return false } + + registerStep( + HistoryPhrase.name(.restyle, kind: .card), + subject: item.title, + on: window, + undoExpects: [.present(anchor, .hero(name))], + redoExpects: [.present(anchor, .hero(prior.value))] + ) { store in + try BoardWriter.updateIndex( + inItemFolder: try store.requiredFolder(for: anchor, .style(title: nil)), + operation: .style(title: nil) + ) { document in + // A malformed prior reads as a removal, `restore(_:to:in:)`'s own rule and the one + // the redo expectation above is written against. + Self.restore(prior, to: FrontmatterKeys.hero, in: &document) + } + } redo: { store in + try BoardWriter.updateIndex( + inItemFolder: try store.requiredFolder(for: anchor, .style(title: nil)), + operation: .style(title: nil) + ) { document in + document.setStyleValue(name, for: FrontmatterKeys.hero) + } + } + return true + } + + /// One live board-side card off a snapshot, by identity — `boardItem`'s sibling for a caller that + /// needs the card's *fields* rather than its position. + nonisolated static func card(_ id: ItemID, in snapshot: BoardModel) -> Card? { + for lane in snapshot.lanes { + if let card = lane.cards.first(where: { $0.id == id }) { return card } + } + return nil + } + // MARK: - The loose-file carve-out /// Moves every loose file the last applied snapshot found beside a card's `index.md` into that diff --git a/Kanban/Storage/FrontmatterDocument.swift b/Kanban/Storage/FrontmatterDocument.swift index 24a54b5..4183702 100644 --- a/Kanban/Storage/FrontmatterDocument.swift +++ b/Kanban/Storage/FrontmatterDocument.swift @@ -585,9 +585,12 @@ public enum FrontmatterKeys { /// carrying a separator is not a hero image spelled awkwardly, it is a value with no reading at /// all (`FrontmatterDocument.hero`). /// - /// **Opt-in, and hand-written**: there is no in-app setter this version (ruled 2026-08-09), so - /// the key is written by an author or an agent and the app only ever reads it. It joins - /// `schemaOwned` all the same — it is Lanework's to interpret, not an unknown key riding along. + /// **Opt-in, and written from exactly one control**: the card window attachment row's "Set as + /// Hero" / "Remove Hero" (re-ruled 2026-08-09, promoting the key's own "future work" note the day + /// it landed; `BoardStore.setHero(_:onCard:on:)`). The row can only name a file the card already + /// has, which is the key's grammar enforced by the surface rather than validated after the fact; + /// hand-writing and agent-writing stay exactly as legal as they were. It joins `schemaOwned` for + /// the reason it always did — it is Lanework's to interpret, not an unknown key riding along. public static let hero = "hero" public static let created = "created" diff --git a/Kanban/UI/AccessibilityPhrases.swift b/Kanban/UI/AccessibilityPhrases.swift index 2cf2863..5a373aa 100644 --- a/Kanban/UI/AccessibilityPhrases.swift +++ b/Kanban/UI/AccessibilityPhrases.swift @@ -128,6 +128,16 @@ enum AccessibilityPhrases { /// the dim is the sighted signal, this is the other one. static let cutPending = "cut, pending paste" + /// **The attachment row that is the card's hero** (05-card-window.md ▸ Attachments; ruled + /// 2026-08-09) — carried as the row's *value*, beside its filename label. + /// + /// The same reasoning `cutPending` states one field over: a sighted user sees the banner on the + /// card face and knows which file made it, and without this the sidebar gives a VoiceOver user no + /// way to tell one of five images from the other four. It is also what makes the row's own + /// context menu legible — "Remove Hero" appearing on exactly one row is otherwise a menu that + /// changes for no announced reason. + static let heroAttachment = "hero image" + /// A card element's value — the attachment count and the comment count when the card has either /// (design ruling 2026-08-09, card e729e30a — the comments chip's face value gains "N comments" /// beside the existing attachment wording), the cut-pending phrase when it is staged for paste, diff --git a/Kanban/UI/Board/ClipboardCommands.swift b/Kanban/UI/Board/ClipboardCommands.swift index cff235e..c777220 100644 --- a/Kanban/UI/Board/ClipboardCommands.swift +++ b/Kanban/UI/Board/ClipboardCommands.swift @@ -45,9 +45,94 @@ extension View { .onCommand(#selector(NSText.copy(_:)), perform: clipboard.canCopy(from: store) ? { clipboard.copy(from: store) } : nil) - .onCommand(#selector(NSText.paste(_:)), perform: clipboard.canPaste(into: store) ? { - clipboard.paste(into: store) - } : nil) + .onCommand(#selector(NSText.paste(_:)), perform: Self.pasteAction(store: store, clipboard: clipboard)) + } + + /// **⌘V's two branches as one optional handler** (04-interactions.md ▸ Clipboard, the image-data + /// branch ruled 2026-08-09). + /// + /// The precedence is expressed as the order of these two `if`s and nowhere else, which is the + /// same discipline the rest of this file states: availability *is* the handler's presence, so a + /// board payload winning over a picture is one expression rather than a condition on one item and + /// a matching negation on another. `ClipboardStore.refresh` has already made the two readings + /// mutually exclusive at the source (`imagePayload` is `nil` whenever a board payload is + /// readable), so this ordering is belt over braces — but it is the ordering a reader will look + /// for, and stating it here costs one line. + /// + /// `nil` — neither branch applies — greys the standard Paste row out exactly as before. + private static func pasteAction(store: BoardStore, clipboard: ClipboardStore) -> (() -> Void)? { + if clipboard.canPaste(into: store) { + return { clipboard.paste(into: store) } + } + if clipboard.canPasteImage(into: store) { + return { clipboard.pasteImage(into: store) } + } + return nil + } +} + +// MARK: - The card window's ⌘V + +extension View { + + /// **⌘V in a card window pastes a picture into that card** (04-interactions.md ▸ Clipboard, the + /// image-data branch; 05-card-window.md ▸ Attachments). + /// + /// The board's own responder shape, one window over and with one branch instead of two: there is + /// no board payload a card window could paste — cards and lanes land on a *board* — so the card + /// window answers `paste:` only for the image branch, and only while there is a picture to take. + /// + /// **A focused text field still wins, with nothing here doing the arithmetic.** `NSTextView` + /// consumes `paste:` natively, so ⌘V in the body editor, the comment composer or an inline + /// comment edit stays a text paste and this responder never sees the selector — which is the + /// board's own "focused-editor rule, twice over" arriving in the window where it matters most. + /// It is also why this hangs on the window's whole content rather than on the attachments + /// section: 05 makes the *window* the drop surface for files, and the paste is that sentence's + /// keyboard twin. + func cardWindowImagePaste(store: BoardStore, cardID: ItemID, clipboard: ClipboardStore) -> some View { + onCommand( + #selector(NSText.paste(_:)), + perform: clipboard.canPasteImage(intoCard: cardID, in: store) ? { + clipboard.pasteImage(intoCard: cardID, in: store) + } : nil + ) + } +} + +// MARK: - Edit ▸ Paste as Board Background + +/// **Edit ▸ Paste as Board Background** — the pasteboard's picture into the board folder, with +/// `background.image` pointed at it (03-board-ui.md § Styling ▸ Capabilities; ruled 2026-08-09). +/// +/// ### Why this is a row of its own rather than another ⌘V branch +/// +/// ⌘V has a target: the anchor card, or the card window's card. A board's backdrop is not on that +/// path at all — it is one value per board, reachable with nothing selected — so folding it into the +/// paste selector would mean either a modifier nobody could discover or ⌘V meaning two different +/// things depending on the selection. A named row says what it does, and the Nexus's "no default +/// chord" posture covers the rest: it remaps like any other item. +/// +/// **Board window only**, which needs no clause: the row reads `\.boardStore`, and a card window or +/// the welcome window in front means there is no focused board store and the item is disabled. +/// +/// The title is API (04-interactions.md ▸ Configurable bindings) and is unique across the menu bar. +struct PasteBoardBackgroundCommand: View { + + let clipboard: ClipboardStore + + @FocusedValue(\.boardStore) private var store + + var body: some View { + Button("Paste as Board Background") { + guard let store else { return } + clipboard.pasteBoardBackground(into: store) + } + .disabled(!isEnabled) + } + + private var isEnabled: Bool { + guard let store else { return false } + return clipboard.canPasteBoardBackground(into: store) } } diff --git a/Kanban/UI/Board/PasteTarget.swift b/Kanban/UI/Board/PasteTarget.swift index 025b0d9..2421430 100644 --- a/Kanban/UI/Board/PasteTarget.swift +++ b/Kanban/UI/Board/PasteTarget.swift @@ -60,6 +60,25 @@ enum PasteTarget { return Cards(laneID: lane.id, index: index) } + /// **Which card an image paste lands on** — the anchor card, and only a card (04-interactions.md + /// ▸ Clipboard, the image-data branch ruled 2026-08-09: "paste targets a card … per the existing + /// paste-target grammar"). + /// + /// `flattenAnchor` again rather than a rule of its own, because the ruling says "the existing + /// paste-target grammar" and this is it: the last selected card in flatten order, which is + /// already what a card payload anchors after and what ⌘N creates after. + /// + /// **`nil` wherever the anchor is not a card**, which is the whole of the difference from + /// `cards(selection:lastActiveLaneID:snapshot:)` — and the reason there is no last-active-lane + /// fallback here. A board payload can *append to a lane*, so "nothing selected" still has an + /// answer; a picture has to land in some card's `attachments/`, and there is no card the app + /// could pick without inventing one. So an empty selection, a lane selection and a trash + /// selection all answer `nil`, the menu greys out, and the user selects a card — rather than a + /// screenshot silently arriving on whichever card the app guessed at. + static func card(selection: ItemReferenceSet, snapshot: BoardModel) -> ItemID? { + NewCardTarget.flattenAnchor(selection: selection, snapshot: snapshot)?.anchorCardID + } + /// The lane payload's slot among the board's live lanes — **always an answer**, zero-lane board /// included, because lane paste "stays enabled and lands at the board's right end" whatever the /// board holds. That is what makes it the other way out of a board with no lanes. diff --git a/Kanban/UI/Card/CardAttachments.swift b/Kanban/UI/Card/CardAttachments.swift index ec8f772..00b8c72 100644 --- a/Kanban/UI/Card/CardAttachments.swift +++ b/Kanban/UI/Card/CardAttachments.swift @@ -48,6 +48,15 @@ public final class CardAttachments { /// stabler identity than an index across a reload that inserted a file above it. public var selected: String? + /// **The file this card's `hero` key names**, republished from the snapshot exactly as `names` + /// is (`Card.hero`) — `nil` for a card with no hero, and for one whose key is malformed, which is + /// the same "no band" the face renders (03-board-ui.md § Card face ▸ Hero image). + /// + /// Here rather than derived in the section for `names`' reason: the sidebar must not be a second + /// reading of the card able to disagree with the board face's. It drives one thing only — which + /// of the two hero rows a row's context menu offers. + public var hero: String? + /// Whether the section currently holds keyboard focus. Read by File ▸ Reveal in Finder, whose /// card-window scope is "the card's folder — the selected attachment's file instead when the /// attachments section is focused" (11-command-nexus.md). @@ -62,6 +71,11 @@ public final class CardAttachments { /// .removeAttachment(named:fromCard:)`. public var removeFile: ((String) -> Void)? + /// Points the card's `hero` key at a file, or removes it — filled by the host with `BoardStore + /// .setHero(_:onCard:on:)`, this window's undo stack attached, so the step lands on the stack the + /// gesture was issued on (13-native-undo.md ▸ Rules ▸ two levels). + public var setHeroFile: ((String?) -> Void)? + public init() {} // MARK: - Derived @@ -105,6 +119,58 @@ public final class CardAttachments { removeFile?(name) } + /// **Set as Hero** — the row's file becomes the card's banner picture (05 ▸ Attachments, ruled + /// 2026-08-09; 03-board-ui.md § Card face ▸ Hero image). + public func setAsHero(_ name: String) { + guard Self.canSetHero(name, hero: hero, names: names, isEditable: isEditable) else { return } + setHeroFile?(name) + } + + /// **Remove Hero** — the key goes, the file stays. Removing the *hero* is not removing the + /// attachment: the picture is still one of the card's files and is still in the list, which is + /// what keeps this row distinct from the Remove one sitting below it. + public func removeHero() { + guard isEditable, hero != nil else { return } + setHeroFile?(nil) + } + + // MARK: - The hero rows' rules + + /// Whether a row offers **Set as Hero** — the row is an image, the section can write, the file is + /// actually in the listing, and the card's hero is not already this very file. + /// + /// **Image-type only** (`PastedImage.isImageName`), because the key means a picture: offering the + /// row on a `.zip` would let a user set a hero that can never draw, and 03's structural degrade + /// would leave them with a key and no band and nothing to explain it. + /// + /// **Absent, not disabled, on the current hero's row**: that row shows Remove Hero instead, which + /// is the same slot saying the true thing. Everywhere else "Set as Hero" *replaces* whatever hero + /// the card had — one hero per card, and a Remove-then-Set dance would be ceremony (see + /// `BoardStore.setHero(_:onCard:on:)`). + /// + /// A pure static for `moved`/`settle`'s reason: the menu's two branches become lines of test + /// rather than a context menu somebody has to open. + public nonisolated static func canSetHero( + _ name: String, + hero: String?, + names: [String], + isEditable: Bool + ) -> Bool { + guard isEditable, names.contains(name), hero != name else { return false } + return PastedImage.isImageName(name) + } + + /// Whether a row offers **Remove Hero** — it is the card's current hero, and the section can + /// write. The image test is deliberately *not* repeated: a hero somebody hand-wrote to a + /// non-image file is exactly the state this row exists to get out of. + public nonisolated static func canRemoveHero( + _ name: String, + hero: String?, + isEditable: Bool + ) -> Bool { + isEditable && hero == name + } + // MARK: - Row actions that are not writes /// Double-click, Return, and the context menu's Open: the file's default app (05 ▸ Attachments). @@ -238,6 +304,78 @@ struct AddAttachmentCommand: View { } } +// MARK: - File ▸ Set as Hero / Remove Hero + +/// **The attachment row's hero pair, as menu rows** — card window only, acting on the attachments +/// section's *selected* row (11-command-nexus.md; 05-card-window.md ▸ Attachments; 03-board-ui.md +/// § Card face ▸ Hero image). +/// +/// ### Why the menu rows exist at all +/// +/// 11's context-menu contract: "Every entry is a twin of a menu command, a fixed grammar key, or a +/// configuration control — **no function's only home**". The row's pointer path is the context menu; +/// these are its required twins, and they are also what makes the gesture keyboard-reachable in a +/// section 05 went out of its way to make keyboard-native. +/// +/// ### Two rows, not one row with two titles +/// +/// **Titles are API** (04-interactions.md ▸ Configurable bindings) — a user's custom binding is +/// stored against the title — so a single row that renamed itself would silently drop that binding +/// every time the selection moved. Two rows is `Collapse Lane`/`Expand Lane`'s answer to the same +/// shape, and for the same reason. In the *context* menu the two share one slot, because a context +/// menu is built fresh per row and carries no bindings. +/// +/// The subject is the **selected** row rather than a row under a pointer, which is what a menu-bar +/// item can address at all — File ▸ Reveal in Finder's card-window scope reads the same selection. +struct SetAsHeroCommand: View { + + @FocusedValue(\.cardAttachments) private var attachments + + /// The row's validation as a value a test can hold — `AddAttachmentCommand.isEnabled`'s shape, + /// for its reason. It is `CardAttachments.canSetHero` applied to the selected row, so the menu + /// row and the context row can never disagree about what "an image that is not already the hero" + /// means. + static func isEnabled(_ attachments: CardAttachments?) -> Bool { + guard let attachments, let selected = attachments.selected else { return false } + return CardAttachments.canSetHero( + selected, + hero: attachments.hero, + names: attachments.names, + isEditable: attachments.isEditable + ) + } + + var body: some View { + Button("Set as Hero") { + guard let attachments, let selected = attachments.selected else { return } + attachments.setAsHero(selected) + } + .disabled(!Self.isEnabled(attachments)) + } +} + +/// Set as Hero's other direction — see it for why the pair is two rows. +struct RemoveHeroCommand: View { + + @FocusedValue(\.cardAttachments) private var attachments + + static func isEnabled(_ attachments: CardAttachments?) -> Bool { + guard let attachments, let selected = attachments.selected else { return false } + return CardAttachments.canRemoveHero( + selected, + hero: attachments.hero, + isEditable: attachments.isEditable + ) + } + + var body: some View { + Button("Remove Hero") { + attachments?.removeHero() + } + .disabled(!Self.isEnabled(attachments)) + } +} + // MARK: - The focused value /// The focused card window's attachments section, beside `FocusedValues.cardBody` — see diff --git a/Kanban/UI/Card/CardAttachmentsSection.swift b/Kanban/UI/Card/CardAttachmentsSection.swift index 1153cba..5993879 100644 --- a/Kanban/UI/Card/CardAttachmentsSection.swift +++ b/Kanban/UI/Card/CardAttachmentsSection.swift @@ -159,6 +159,7 @@ struct CardAttachmentsSection: View { name: name, url: url, isSelected: isSelected, + isHero: attachments.hero == name, isSectionFocused: isFocused, thumbnails: thumbnails, pointSize: pointSize, @@ -194,17 +195,48 @@ struct CardAttachmentsSection: View { .contextMenu { menu(for: name) } } - /// The attachment row's context menu — **Open, Reveal in Finder, Remove** (11-command-nexus.md ▸ - /// Context menus), twins of the focused section's grammar keys (Return / ⌫) and of File ▸ Reveal - /// in Finder in its attachments-focused context. No new store method, no parallel - /// implementation: every row here calls exactly what the keyboard calls. + /// The attachment row's context menu — **Open, Reveal in Finder, the hero row, Remove** + /// (11-command-nexus.md ▸ Context menus), twins of the focused section's grammar keys (Return / + /// ⌫) and of File ▸ Reveal in Finder in its attachments-focused context. No new store method, no + /// parallel implementation: every row here calls exactly what the keyboard calls. /// /// It acts on **its own row**, not on the selection, which is what makes a right-click on an /// unselected row unambiguous without a select-first dance. + /// + /// ### The hero row is one slot with two words + /// + /// **"Set as Hero"** on any image row that is not already the hero, **"Remove Hero"** on the one + /// that is, and nothing at all on a row that can be neither — a non-image file, or any row while + /// the board is locked (03-board-ui.md § Card face ▸ Hero image; the rules themselves are + /// `CardAttachments.canSetHero`/`canRemoveHero`, so the menu and the tests read one answer). + /// + /// **Present-or-absent rather than enabled-or-disabled**, which is the opposite of Remove just + /// below it — and the difference is what the row would *mean* greyed out. A disabled Remove says + /// "this file cannot be removed right now", which is true and useful under the lock. A disabled + /// "Set as Hero" on a `.zip` would say "this file could be the hero, but not now", which is not + /// true and never will be. The lock case follows the row rather than splitting it: a menu whose + /// hero slot appears and disappears by file type and *also* greys by lock would be two rules + /// where the section has one. + /// + /// It sits between the read-only pair and Remove, on the divider grammar the Board menu uses + /// (`KanbanApp`): reads first, then the edit-shaped rows, with the destructive one last. @ViewBuilder private func menu(for name: String) -> some View { Button("Open") { attachments.open(name) } Button("Reveal in Finder") { attachments.reveal(name) } + + if CardAttachments.canSetHero( + name, hero: attachments.hero, names: names, isEditable: attachments.isEditable + ) { + Divider() + Button("Set as Hero") { attachments.setAsHero(name) } + } else if CardAttachments.canRemoveHero( + name, hero: attachments.hero, isEditable: attachments.isEditable + ) { + Divider() + Button("Remove Hero") { attachments.removeHero() } + } + Divider() Button("Remove") { attachments.remove(name) } .disabled(!attachments.isEditable) @@ -249,6 +281,9 @@ private struct AttachmentRow: View { let name: String let url: URL? let isSelected: Bool + /// Whether this row's file is the card's hero — spoken, and nothing more: the row draws no badge + /// of its own, because the banner *is* the sighted signal and it is right there on the card face. + let isHero: Bool let isSectionFocused: Bool let thumbnails: AttachmentThumbnailCache let pointSize: CGFloat @@ -279,6 +314,7 @@ private struct AttachmentRow: View { .help(name) .accessibilityElement(children: .combine) .accessibilityLabel(name) + .accessibilityValue(isHero ? AccessibilityPhrases.heroAttachment : "") .accessibilityAddTraits(isSelected ? .isSelected : []) .task(id: url?.path) { guard let slot, let url else { return } diff --git a/KanbanTests/CardAttachmentsTests.swift b/KanbanTests/CardAttachmentsTests.swift index c72032f..9a006c7 100644 --- a/KanbanTests/CardAttachmentsTests.swift +++ b/KanbanTests/CardAttachmentsTests.swift @@ -248,7 +248,7 @@ struct AddAttachmentTargetTests { let store = try BoardStore(rootURL: fixture.root) let attachments = CardAttachments() - CardWindowHost.configureAttachments(attachments, store: store, cardID: card1) + CardWindowHost.configureAttachments(attachments, store: store, cardID: card1, undo: CardWindowUndo()) attachments.importFiles?([shot]) #expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50])) @@ -266,7 +266,7 @@ struct AddAttachmentTargetTests { let store = try BoardStore(rootURL: fixture.root) let attachments = CardAttachments() - CardWindowHost.configureAttachments(attachments, store: store, cardID: card1) + CardWindowHost.configureAttachments(attachments, store: store, cardID: card1, undo: CardWindowUndo()) attachments.removeFile?(doomed) #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments").isEmpty) @@ -285,7 +285,7 @@ struct AddAttachmentTargetTests { let store = try BoardStore(rootURL: fixture.root) let attachments = CardAttachments() - CardWindowHost.configureAttachments(attachments, store: store, cardID: card1) + CardWindowHost.configureAttachments(attachments, store: store, cardID: card1, undo: CardWindowUndo()) attachments.cardFolder = cardFolder(fixture) attachments.names = ["shot.png"] diff --git a/KanbanTests/ClipboardTests.swift b/KanbanTests/ClipboardTests.swift index 8db9a95..7c388a9 100644 --- a/KanbanTests/ClipboardTests.swift +++ b/KanbanTests/ClipboardTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import UniformTypeIdentifiers @testable import Kanban /// `ClipboardStore`'s own machinery — the manifest, the staging lifecycle, the sweep, the @@ -25,6 +26,12 @@ final class FakePasteboard: ClipboardPasteboard { private(set) var text: String? private var data: Data? + /// **What some other app put down**, by type — the raw material the image branch classifies + /// (`PastedImage.flavor(hasBoardItems:types:)`). Ordered so `availableTypes()` can answer in a + /// stable order, which is what makes "our preference order wins over the pasteboard's" a claim a + /// test can actually make. + private var foreign: [(type: String, data: Data)] = [] + func manifestData() -> Data? { data } @discardableResult @@ -32,14 +39,34 @@ final class FakePasteboard: ClipboardPasteboard { changeCount += 1 data = manifest self.text = text + // A real write clears the pasteboard first, so anything another app left is gone. + foreign = [] return changeCount } + func availableTypes() -> [String] { + (data != nil ? [UTType.laneworkClipboard.identifier] : []) + foreign.map(\.type) + } + + func data(forType type: String) -> Data? { + if type == UTType.laneworkClipboard.identifier { return data } + return foreign.first { $0.type == type }?.data + } + /// Another app copied: ownership moves, our type is gone, the counter advanced. func takeOver() { changeCount += 1 data = nil text = nil + foreign = [] + } + + /// Another app put *these* flavors down — a screenshot, a browser's Copy Image, a Finder copy. + /// `takeOver`'s shape with a payload: the counter advances and our own type goes, because that is + /// what `clearContents()` does to it. + func seed(_ payloads: [(type: String, data: Data)]) { + takeOver() + foreign = payloads } } diff --git a/KanbanTests/PasteImageTests.swift b/KanbanTests/PasteImageTests.swift new file mode 100644 index 0000000..aefa305 --- /dev/null +++ b/KanbanTests/PasteImageTests.swift @@ -0,0 +1,771 @@ +import CoreGraphics +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers +@testable import Kanban + +/// **Pasting a picture** (04-interactions.md ▸ Clipboard's image-data branch, ruled 2026-08-09) — +/// the pasteboard classification and its precedence, the format rule, the Finder-style name and its +/// ladder, the hero key's write, and the three surfaces' validation. +/// +/// Like every other write suite here the landing tests drive a **real store over a real temp board** +/// and read back through the loader or the raw bytes, never through a snapshot the store handed out. +/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`; `FakePasteboard`, +/// `ClipboardHarness` and the board fixture come from `ClipboardTests.swift`. + +// MARK: - Making pictures + +/// A tiny opaque bitmap, encoded under `type` — real bytes, because the format rule's whole claim is +/// about what ImageIO can and cannot read, and a `Data("png".utf8)` stand-in would make the +/// conversion tests vacuous. +func encodedImage(_ type: UTType, side: Int = 4) -> Data { + let space = CGColorSpaceCreateDeviceRGB() + let context = CGContext( + data: nil, + width: side, + height: side, + bitsPerComponent: 8, + bytesPerRow: 0, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + context.setFillColor(CGColor(red: 0.2, green: 0.6, blue: 0.9, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: side, height: side)) + let image = context.makeImage()! + + let output = NSMutableData() + let destination = CGImageDestinationCreateWithData(output, type.identifier as CFString, 1, nil)! + CGImageDestinationAddImage(destination, image, nil) + _ = CGImageDestinationFinalize(destination) + return output as Data +} + +/// The type identifier of whatever `data` actually is, as ImageIO reads it — the only honest way to +/// assert "the bytes on disk are still a PNG". +func imageType(of data: Data) -> String? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + return CGImageSourceGetType(source) as String? +} + +/// One app-mediated reload landed — `GeneratedBackgroundTests`' own helper, because the echo-window +/// tests here are about exactly the same memo and have to open and close that window the same way. +@MainActor +private func settle(_ store: BoardStore) async { + store.handleWatcherEvent(.treeChanged(.appMediated)) + await store.awaitQuiescence() +} + +// MARK: - Classification + +@Suite("PastedImage ▸ classification") +struct PastedImageClassificationTests { + + @Test("The app's own clipboard type wins outright — a picture beside it never diverts ⌘V") + func boardItemsWin() { + let flavor = PastedImage.flavor( + hasBoardItems: true, + types: [UTType.laneworkClipboard.identifier, UTType.png.identifier, UTType.tiff.identifier] + ) + #expect(flavor == nil) + } + + /// The ruling's own parenthesis: "IMAGE DATA (no file URL)". A Finder copy of a PNG puts a file + /// URL down, often with an image flavor beside it, and that is a different gesture's payload. + @Test("A file URL suppresses the branch, whatever else rides beside it") + func fileURLsSuppress() { + #expect(PastedImage.flavor( + hasBoardItems: false, + types: [UTType.fileURL.identifier, UTType.png.identifier] + ) == nil) + // Conformance, not equality: a subtype of `public.file-url` is still a file reference. + #expect(PastedImage.carriesFileURL([UTType.fileURL.identifier])) + #expect(!PastedImage.carriesFileURL([UTType.png.identifier, UTType.tiff.identifier])) + // A type the system does not know is not a file URL — the optimistic reading a drag's + // unknown types get. + #expect(!PastedImage.carriesFileURL(["com.example.nothing-at-all"])) + } + + @Test("Raw image data is the fallback, and an empty pasteboard offers nothing") + func imageDataIsTheFallback() throws { + let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.png.identifier])) + #expect(flavor.type == UTType.png.identifier) + #expect(PastedImage.flavor(hasBoardItems: false, types: []) == nil) + #expect(PastedImage.flavor(hasBoardItems: false, types: [UTType.plainText.identifier]) == nil) + } + + /// The screenshot's exact pasteboard: PNG and TIFF together. Our order wins over the + /// pasteboard's, so the common paste costs no decode and no re-encode at all. + @Test("PNG beats TIFF however the pasteboard orders them") + func pngBeatsTIFF() throws { + for types in [ + [UTType.tiff.identifier, UTType.png.identifier], + [UTType.png.identifier, UTType.tiff.identifier], + ] { + let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: types)) + #expect(flavor.type == UTType.png.identifier) + #expect(!flavor.convertsToPNG) + #expect(flavor.fileName == "Pasted Image.png") + } + } + + @Test("A file-shaped flavor keeps its own extension; TIFF and BMP become PNG") + func theFormatRule() throws { + let jpeg = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.jpeg.identifier])) + #expect(!jpeg.convertsToPNG) + #expect(jpeg.fileExtension == UTType.jpeg.preferredFilenameExtension) + + let gif = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.gif.identifier])) + #expect(!gif.convertsToPNG, "a GIF's animation does not survive a single-frame decode") + #expect(gif.fileName == "Pasted Image.gif") + + for interchange in [UTType.tiff, UTType.bmp] { + let flavor = try #require( + PastedImage.flavor(hasBoardItems: false, types: [interchange.identifier]) + ) + #expect(flavor.convertsToPNG) + #expect(flavor.fileExtension == "png") + #expect(flavor.type == interchange.identifier, "the bytes still come from the offered type") + } + } + + @Test("The two names are the two folders' — a card's attachment and a board's backdrop") + func theTwoNames() throws { + let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.jpeg.identifier])) + #expect(flavor.fileName == "Pasted Image.jpeg") + #expect(flavor.backgroundFileName == "Pasted Background.jpeg") + } +} + +// MARK: - The bytes + +@Suite("PastedImage ▸ encoding") +struct PastedImageEncodingTests { + + @Test("A verbatim flavor is handed back byte for byte — no decode, no re-encode") + func verbatimIsUntouched() throws { + let png = encodedImage(.png) + let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.png.identifier])) + #expect(PastedImage.encode(png, as: flavor) == png) + } + + @Test("TIFF is re-encoded, and what comes out really is a PNG") + func tiffBecomesPNG() throws { + let tiff = encodedImage(.tiff) + let flavor = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.tiff.identifier])) + let encoded = try #require(PastedImage.encode(tiff, as: flavor)) + #expect(encoded != tiff) + #expect(imageType(of: encoded) == UTType.png.identifier) + } + + /// A pasteboard that declares a flavor it cannot back up. Nothing is written, which is the honest + /// outcome — see `ClipboardStore.pasteImage(intoCard:in:)`. + @Test("Bytes that are not an image encode to nothing rather than to a broken file") + func undecodableBytesRefuse() throws { + let tiff = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.tiff.identifier])) + #expect(PastedImage.encode(Data("not a picture".utf8), as: tiff) == nil) + + let png = try #require(PastedImage.flavor(hasBoardItems: false, types: [UTType.png.identifier])) + #expect(PastedImage.encode(Data(), as: png) == nil, "an empty payload is not a file") + } +} + +// MARK: - Which attachments can be a hero + +@Suite("PastedImage ▸ image names") +struct PastedImageNameTests { + + @Test("An image is decided by extension, and anything else is not one") + func imageNames() { + #expect(PastedImage.isImageName("Pasted Image.png")) + #expect(PastedImage.isImageName("photo.JPEG"), "extensions are case-insensitive") + #expect(PastedImage.isImageName("clip.gif")) + #expect(!PastedImage.isImageName("notes.txt")) + #expect(!PastedImage.isImageName("archive.zip")) + #expect(!PastedImage.isImageName("README"), "no extension, no reading") + #expect(!PastedImage.isImageName("")) + } +} + +// MARK: - Where a picture lands on the board + +@MainActor +@Suite("PasteTarget ▸ the image branch") +struct PasteImageTargetTests { + + private func snapshot() throws -> BoardModel { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + return try BoardLoader.load(boardRoot: fixture.root).model + } + + @Test("The anchor card, which is the last selected in flatten order") + func theAnchorCard() throws { + let model = try snapshot() + #expect(PasteTarget.card( + selection: ItemReferenceSet(ids: [clipboardCard1], container: .board), + snapshot: model + ) == clipboardCard1) + // Two selected: the last in flatten order, the shared anchor rule. + #expect(PasteTarget.card( + selection: ItemReferenceSet(ids: [clipboardCard1, clipboardCard4], container: .board), + snapshot: model + ) == clipboardCard4) + } + + /// A picture has to land in *some* card's `attachments/`, and there is no card the app could pick + /// without inventing one — so these three all answer nothing and the menu greys out. + @Test("A lane, an empty and a trash selection all anchor no card") + func nothingToAnchorOn() throws { + let model = try snapshot() + #expect(PasteTarget.card( + selection: ItemReferenceSet(ids: [clipboardLane1], container: .board), + snapshot: model + ) == nil) + #expect(PasteTarget.card(selection: .empty, snapshot: model) == nil) + #expect(PasteTarget.card( + selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash), + snapshot: model + ) == nil) + } +} + +// MARK: - The paste itself + +@MainActor +@Suite("Paste ▸ an image into a card") +struct PasteImageWriteTests { + + /// Attachment names as the loader sees them — never the store's snapshot, which a write + /// deliberately does not touch (the one-way flow). + private func attachments(_ card: String, in fixture: WriterFixture) throws -> [String] { + let model = try BoardLoader.load(boardRoot: fixture.root).model + for lane in model.lanes { + if let match = lane.cards.first(where: { $0.id.rawValue == card }) { return match.attachments } + } + return [] + } + + @Test("A screenshot lands as 'Pasted Image.png', byte for byte, in the anchor card") + func aScreenshotLands() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + let png = encodedImage(.png) + harness.pasteboard.seed([ + (UTType.png.identifier, png), + (UTType.tiff.identifier, encodedImage(.tiff)), + ]) + harness.store.select([clipboardCard2], in: .board) + + #expect(harness.clipboard.pasteImage(into: harness.store)) + + #expect(try attachments(Ident.card2, in: harness.fixture) == ["Pasted Image.png"]) + #expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/Pasted Image.png") == png) + #expect(harness.store.banners.oneShots.isEmpty) + } + + @Test("A second paste climbs the Finder ladder rather than overwriting") + func theFinderLadder() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))]) + harness.store.select([clipboardCard2], in: .board) + + #expect(harness.clipboard.pasteImage(into: harness.store)) + #expect(harness.clipboard.pasteImage(into: harness.store)) + #expect(harness.clipboard.pasteImage(into: harness.store)) + + #expect(try attachments(Ident.card2, in: harness.fixture) + == ["Pasted Image 2.png", "Pasted Image 3.png", "Pasted Image.png"]) + } + + /// The name is minted against what is on disk, so a hand-placed file of the same name is the + /// user's and is never written through — `BoardWriter.freshName`'s rule, inherited whole. + @Test("A file already holding the name is stepped around, never overwritten") + func anExistingNameIsRespected() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + let mine = Data("hand placed".utf8) + try harness.fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/Pasted Image.png", mine) + harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))]) + harness.store.select([clipboardCard2], in: .board) + + #expect(harness.clipboard.pasteImage(into: harness.store)) + + #expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/Pasted Image.png") == mine) + #expect(try attachments(Ident.card2, in: harness.fixture) == ["Pasted Image 2.png", "Pasted Image.png"]) + } + + @Test("A TIFF-only pasteboard lands a PNG") + func tiffLandsAsPNG() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + harness.pasteboard.seed([(UTType.tiff.identifier, encodedImage(.tiff))]) + harness.store.select([clipboardCard2], in: .board) + + #expect(harness.clipboard.pasteImage(into: harness.store)) + + #expect(try attachments(Ident.card2, in: harness.fixture) == ["Pasted Image.png"]) + let landed = try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/Pasted Image.png") + #expect(imageType(of: landed) == UTType.png.identifier) + } + + @Test("A pasteboard that declared a flavor it cannot back up writes nothing at all") + func aLyingPasteboardWritesNothing() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + harness.pasteboard.seed([(UTType.tiff.identifier, Data("not a picture".utf8))]) + harness.store.select([clipboardCard2], in: .board) + + #expect(!harness.clipboard.pasteImage(into: harness.store)) + #expect(try attachments(Ident.card2, in: harness.fixture).isEmpty) + #expect(harness.store.banners.oneShots.isEmpty) + } + + /// The card window's branch: the target is the window's own card, whatever the board's selection + /// happens to be. + @Test("The card window pastes onto its own card, not onto the board's selection") + func theCardWindowsOwnCard() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))]) + harness.store.select([clipboardCard2], in: .board) + + #expect(harness.clipboard.pasteImage(intoCard: clipboardCard4, in: harness.store)) + + #expect(try attachments(Ident.card4, in: harness.fixture) == ["Pasted Image.png"]) + #expect(try attachments(Ident.card2, in: harness.fixture).isEmpty) + } + + @Test("A board payload on the pasteboard is never diverted into an attachment") + func aBoardPayloadStillPastesAsCards() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + harness.store.select([clipboardCard1], in: .board) + harness.clipboard.copy(from: harness.store) + + #expect(harness.clipboard.imagePayload == nil) + #expect(!harness.clipboard.canPasteImage(into: harness.store)) + #expect(harness.clipboard.canPaste(into: harness.store)) + } +} + +// MARK: - Validation + +@MainActor +@Suite("Paste ▸ image menu validation") +struct PasteImageValidationTests { + + @Test("The board branch needs a picture and a card to put it on") + func theBoardBranchsClauses() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + + harness.store.select([clipboardCard1], in: .board) + #expect(!harness.clipboard.canPasteImage(into: harness.store), "nothing on the pasteboard") + + harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))]) + harness.clipboard.refresh() + #expect(harness.clipboard.canPasteImage(into: harness.store)) + + // A lane anchors no card, so the row greys out — and so does the paste. + harness.store.select([clipboardLane1], in: .board) + #expect(!harness.clipboard.canPasteImage(into: harness.store)) + #expect(!harness.clipboard.pasteImage(into: harness.store)) + + harness.store.clearSelection() + #expect(!harness.clipboard.canPasteImage(into: harness.store)) + } + + @Test("A file URL on the pasteboard offers nothing to any of the three surfaces") + func fileURLsOfferNothing() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + harness.pasteboard.seed([ + (UTType.fileURL.identifier, Data("file:///tmp/shot.png".utf8)), + (UTType.png.identifier, encodedImage(.png)), + ]) + harness.clipboard.refresh() + harness.store.select([clipboardCard1], in: .board) + + #expect(harness.clipboard.imagePayload == nil) + #expect(!harness.clipboard.canPasteImage(into: harness.store)) + #expect(!harness.clipboard.canPasteImage(intoCard: clipboardCard1, in: harness.store)) + #expect(!harness.clipboard.canPasteBoardBackground(into: harness.store)) + } + + /// The board's backdrop has no target to resolve, which is what makes it the one image-paste + /// surface that stays live with nothing selected. + @Test("The backdrop row needs only a picture and a writable board") + func theBackgroundRowsClauses() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))]) + harness.clipboard.refresh() + + harness.store.clearSelection() + #expect(harness.clipboard.canPasteBoardBackground(into: harness.store)) + #expect(!harness.clipboard.canPasteImage(into: harness.store), "no card selected") + } + + @Test("The card-window branch refuses a card that is not on the board side") + func theCardWindowBranchsClauses() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))]) + harness.clipboard.refresh() + + #expect(harness.clipboard.canPasteImage(intoCard: clipboardCard1, in: harness.store)) + #expect(!harness.clipboard.canPasteImage(intoCard: clipboardCard3, in: harness.store), "trashed") + #expect(!harness.clipboard.canPasteImage(intoCard: clipboardLane1, in: harness.store), "a lane") + } +} + +// MARK: - Set as Hero + +@MainActor +@Suite("The hero key ▸ set and remove") +struct SetAsHeroTests { + + private func heroKey(of card: String, lane: String, in fixture: WriterFixture) throws -> FieldValue { + try FrontmatterDocument.parse(fixture.indexText("\(lane)/\(card)")).hero + } + + /// The reload between the two halves is not ceremony: the no-op guard reads the **snapshot**, + /// which is one reload behind every write the app makes (the one-way flow), exactly as + /// `applyStyle`'s `effective(_:against:)` does. The two surfaces stay consistent because they read + /// the same snapshot — while it has not caught up, the row is still offering *Set* as Hero, so + /// there is no Remove for a user to press. + @Test("Set as Hero writes the bare filename; Remove Hero takes the key away") + func setThenRemove() async throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + #expect(store.setHero("photo.png", onCard: clipboardCard1)) + #expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png") + + await settle(store) + #expect(store.setHero(nil, onCard: clipboardCard1)) + #expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).isMissing) + #expect(store.banners.oneShots.isEmpty) + } + + /// One hero per card, and the user picked a different picture: Set replaces rather than refusing, + /// so no Remove-then-Set dance. + @Test("Set as Hero on a card that already has one replaces it") + func setReplaces() async throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + #expect(store.setHero("photo.png", onCard: clipboardCard1)) + await settle(store) + #expect(store.setHero("notes.txt", onCard: clipboardCard1)) + #expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "notes.txt") + } + + @Test("Writing the hero a card already has is a no-op — no write, no step") + func redundantSetsAreFree() async throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let history = NativeHistoryProvider() + store.history = history + + // Removing a hero that was never there changes nothing. + #expect(!store.setHero(nil, onCard: clipboardCard1)) + #expect(!history.canUndo, "nothing happened, so nothing is on the stack") + + #expect(store.setHero("photo.png", onCard: clipboardCard1)) + await settle(store) + #expect(!store.setHero("photo.png", onCard: clipboardCard1), "already says exactly this") + } + + @Test("The other keys and the body ride through untouched") + func everythingElseSurvives() throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + #expect(store.setHero("photo.png", onCard: clipboardCard1)) + let document = try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)")) + #expect(document.title.value == "First") + #expect(document.value(for: "project") != nil, "an unknown key is round-tripped") + #expect(document.body.contains("First body")) + } + + @Test("A trashed card and a lane are both refused") + func containerGuards() throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + #expect(!store.setHero("photo.png", onCard: clipboardCard3), "in the trash") + #expect(!store.setHero("photo.png", onCard: clipboardLane1), "a lane has no hero") + #expect(try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.card3)")).hero.isMissing) + } + + @Test("⌘Z puts the key back exactly as it was, under the restyle phrase") + func undoRestoresTheKey() throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let history = NativeHistoryProvider() + store.history = history + + #expect(store.setHero("photo.png", onCard: clipboardCard1)) + #expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png") + #expect(history.undoActionName == "Restyle Card") + + history.undo() + #expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).isMissing) + + history.redo() + #expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png") + } + + /// A prior hero comes back as itself rather than as an absence — `applyStyle`'s inverse reading. + @Test("Undoing a replacement restores the hero that was there") + func undoRestoresAPriorHero() async throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let history = NativeHistoryProvider() + store.history = history + + #expect(store.setHero("photo.png", onCard: clipboardCard1)) + await settle(store) + #expect(store.setHero("notes.txt", onCard: clipboardCard1)) + + history.undo() + #expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png") + } + + /// The wiring, driven through the real `configureAttachments` so the test breaks if the seam is + /// ever crossed or the id captured from the wrong place. + @Test("The attachment row's seam writes the window's own card") + func theRowsSeam() throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + let attachments = CardAttachments() + CardWindowHost.configureAttachments( + attachments, store: store, cardID: clipboardCard1, undo: CardWindowUndo() + ) + attachments.setHeroFile?("photo.png") + + #expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png") + } +} + +// MARK: - The hero rows' menu rules + +@Suite("The hero rows ▸ menu validation") +struct HeroMenuRulesTests { + + private let names = ["photo.png", "notes.txt", "shot.jpeg"] + + @Test("Set as Hero is offered on an image row that is not already the hero") + func setIsOffered() { + #expect(CardAttachments.canSetHero("photo.png", hero: nil, names: names, isEditable: true)) + #expect(CardAttachments.canSetHero("shot.jpeg", hero: "photo.png", names: names, isEditable: true)) + } + + @Test("It is absent on the current hero's row, which shows Remove Hero instead") + func theCurrentHerosRow() { + #expect(!CardAttachments.canSetHero("photo.png", hero: "photo.png", names: names, isEditable: true)) + #expect(CardAttachments.canRemoveHero("photo.png", hero: "photo.png", isEditable: true)) + #expect(!CardAttachments.canRemoveHero("shot.jpeg", hero: "photo.png", isEditable: true)) + } + + /// Offering the row on a `.zip` would let a user set a hero that can never draw. + @Test("A non-image row is never offered Set as Hero") + func nonImageRows() { + #expect(!CardAttachments.canSetHero("notes.txt", hero: nil, names: names, isEditable: true)) + } + + /// A hero somebody hand-wrote to a non-image file is exactly the state Remove Hero exists to get + /// out of, so that row does *not* repeat the image test. + @Test("Remove Hero is offered even where Set as Hero would not be") + func removeDoesNotRepeatTheImageTest() { + #expect(CardAttachments.canRemoveHero("notes.txt", hero: "notes.txt", isEditable: true)) + } + + @Test("The lock closes both rows, and a row not in the listing offers nothing") + func theLockAndTheListing() { + #expect(!CardAttachments.canSetHero("photo.png", hero: nil, names: names, isEditable: false)) + #expect(!CardAttachments.canRemoveHero("photo.png", hero: "photo.png", isEditable: false)) + #expect(!CardAttachments.canSetHero("gone.png", hero: nil, names: names, isEditable: true)) + } +} + +// MARK: - The menu-bar twins + +/// The two File rows the context entry's twin contract requires (11-command-nexus.md: "no function's +/// only home"), validated as values rather than by driving a menu — `AddAttachmentCommand`'s own +/// shape, for its reason. +@MainActor +@Suite("File ▸ Set as Hero / Remove Hero") +struct HeroCommandTests { + + private func section(selected: String?, hero: String?) -> CardAttachments { + let attachments = CardAttachments() + attachments.names = ["photo.png", "notes.txt", "shot.jpeg"] + attachments.hero = hero + attachments.selected = selected + attachments.isEditable = true + return attachments + } + + @Test("They read the section's selected row, and are dead with nothing selected") + func theyFollowTheSelection() { + #expect(SetAsHeroCommand.isEnabled(section(selected: "photo.png", hero: nil))) + #expect(!SetAsHeroCommand.isEnabled(section(selected: nil, hero: nil))) + #expect(!RemoveHeroCommand.isEnabled(section(selected: nil, hero: "photo.png"))) + #expect(!SetAsHeroCommand.isEnabled(nil), "no card window in front, no row") + #expect(!RemoveHeroCommand.isEnabled(nil)) + } + + /// Exactly one of the pair is ever live on a given row, which is what makes them read as one + /// gesture with two directions rather than as two independent commands. + @Test("Only one of the pair is live on any row") + func onlyOneIsLive() { + let onTheHero = section(selected: "photo.png", hero: "photo.png") + #expect(!SetAsHeroCommand.isEnabled(onTheHero)) + #expect(RemoveHeroCommand.isEnabled(onTheHero)) + + let onAnother = section(selected: "shot.jpeg", hero: "photo.png") + #expect(SetAsHeroCommand.isEnabled(onAnother)) + #expect(!RemoveHeroCommand.isEnabled(onAnother)) + + let onAFile = section(selected: "notes.txt", hero: "photo.png") + #expect(!SetAsHeroCommand.isEnabled(onAFile)) + #expect(!RemoveHeroCommand.isEnabled(onAFile)) + } + + @Test("The lock closes both rows") + func theLock() { + let locked = section(selected: "photo.png", hero: "photo.png") + locked.isEditable = false + #expect(!SetAsHeroCommand.isEnabled(locked)) + #expect(!RemoveHeroCommand.isEnabled(locked)) + } +} + +// MARK: - Paste as Board Background + +@MainActor +@Suite("Paste ▸ as board background") +struct PasteBoardBackgroundTests { + + private func background(_ fixture: WriterFixture) throws -> FrontmatterDocument { + try FrontmatterDocument.parse(fixture.indexText("")) + } + + @Test("The picture lands in the board folder and `background.image` names it") + func thePictureLands() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + let png = encodedImage(.png) + harness.pasteboard.seed([(UTType.png.identifier, png)]) + + #expect(harness.clipboard.pasteBoardBackground(into: harness.store)) + + #expect(try harness.fixture.data("Pasted Background.png") == png) + #expect(try background(harness.fixture).backgroundImage.value == "Pasted Background.png") + #expect(harness.store.banners.oneShots.isEmpty) + } + + /// A picture off the pasteboard carries no ground colour, so the gesture writes none — and a + /// colour the board already had survives, which is `setBackgroundImage`'s per-subkey contract. + @Test("The colour subkey is left exactly as it was") + func theColourSurvives() async throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + #expect(harness.store.applySolidBackground(colorHex: "#112233")) + await settle(harness.store) + harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))]) + + #expect(harness.clipboard.pasteBoardBackground(into: harness.store)) + + let document = try background(harness.fixture) + #expect(document.background.value == "#112233") + #expect(document.backgroundImage.value == "Pasted Background.png") + } + + /// The generator's overwrite-in-place rule, inherited: re-pasting must not leave a folder full of + /// abandoned pictures. + @Test("Re-pasting overwrites the board's own file rather than climbing the ladder") + func rePastingOverwrites() async throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png, side: 4))]) + #expect(harness.clipboard.pasteBoardBackground(into: harness.store)) + await settle(harness.store) + + let second = encodedImage(.png, side: 8) + harness.pasteboard.seed([(UTType.png.identifier, second)]) + #expect(harness.clipboard.pasteBoardBackground(into: harness.store)) + + #expect(try harness.fixture.data("Pasted Background.png") == second) + #expect(!harness.fixture.exists("Pasted Background 2.png")) + } + + /// A hand-placed file of that name is the user's, and is never written through — the ladder's + /// rule, the same one the generator follows. + @Test("A file already holding the name is stepped around") + func anExistingNameIsRespected() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + let mine = Data("hand placed".utf8) + try harness.fixture.file("Pasted Background.png", mine) + harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))]) + + #expect(harness.clipboard.pasteBoardBackground(into: harness.store)) + + #expect(try harness.fixture.data("Pasted Background.png") == mine) + #expect(try background(harness.fixture).backgroundImage.value == "Pasted Background 2.png") + } + + /// The two producers must not read each other's echo: a paste landing inside the reroll's window + /// must not overwrite `facets.png`. + @Test("A paste right after a generated background writes its own file") + func theTwoProducersStayApart() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + let generated = encodedImage(.png, side: 4) + #expect(harness.store.applyGeneratedBackground(png: generated, colorHex: "#445566")) + // Deliberately *no* reload — this is the echo window the generator's memo exists for. + harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png, side: 8))]) + + #expect(harness.clipboard.pasteBoardBackground(into: harness.store)) + + #expect(try harness.fixture.data(FacetsGenerator.fileName) == generated, "untouched") + #expect(try background(harness.fixture).backgroundImage.value == "Pasted Background.png") + } + + @Test("⌘Z puts the image subkey back and leaves the colour alone") + func undoRestoresTheSubkey() throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let history = NativeHistoryProvider() + store.history = history + + #expect(store.applyPastedBackground(data: encodedImage(.png), fileExtension: "png")) + #expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.value + == "Pasted Background.png") + #expect(history.undoActionName == "Restyle Board") + + history.undo() + #expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.isMissing) + // The file survives the undo — "the undo restores the field, not the bytes". + #expect(fixture.exists("Pasted Background.png")) + + history.redo() + #expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.value + == "Pasted Background.png") + } +}