Print boards and cards with configurable components and named print profiles

⌘P had no story: KanbanApp removed the platform's Print row outright on
11-command-nexus.md's "No Print story in v1 (⌘P unused)" line. That line
retires. File ▸ Print… now prints the board in front — lanes left to right,
each lane's cards top to bottom, as a linear document rather than a picture
of the strip — or, from a card window, that card. The trash is unreachable
by construction: it is a sibling container of `lanes`, not a lane.

The rules live in a pure layer nothing AppKit can reach. `PrintOptions` is
one Codable value carrying the printing card's five bullets — which
components (title, icon+labels line, rendered body, comments off by default
with either reading order), page breaks, one base face and size every other
size derives from, and a toggleable running head and foot. `PrintSource` is
what is being printed, frozen at ⌘P so the panel's repeated relayouts and a
board reloading underneath cannot disagree. `PrintDocumentBuilder` turns the
pair into a block list, which is where every decision a rendered page hides
becomes something a test can hold: component order, comment ordering, and
page-break markers that are markers rather than whitespace. Empty is empty
all the way up — a card with nothing to print consumes no page break, and a
lane whose cards all dropped out takes its heading with it.

A page break is a pagination fact, not a spacing one. TextKit has no
page-break character, so `PrintDocumentView` splits the document into
sections at its breaks and flows each into as many page-sized text
containers as it needs: a container boundary *is* a sheet boundary, at any
paper size with any margins. Bodies come from the app's one Markdown pass —
`BodyMarkup.parse` into `BodyMarkupRenderer` — re-faced run by run so the
chosen family reaches the text and fixed-pitch code keeps its own, and drawn
under a forced light appearance so the card window's dynamic label colours
do not print white.

Options ride in the print panel's own accessory rather than a pre-flight
sheet of ours, which buys the system's live preview of the real paginated
document; the preview refreshes through one KVO revision counter rather than
thirteen mirrored properties. Profiles persist app-side in UserDefaults,
never in board files — a print profile is how this user likes to read, not
what a board is (`BoardZoomStore`'s argument). A name is a profile's
identity, folded case-insensitively; "Last Used" is reserved in every
spelling, kept out of the stored list, and captured when an operation
actually ran, so a cancelled print rewrites nothing. Both decoders are
total: one unrecognized key must not cost a user every profile they saved.

DESIGN/11-command-nexus.md gains the Print row and loses the sentence
saying it would never have one.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 22:42:11 -04:00
parent cdc91d669d
commit 7651e40318
17 changed files with 3997 additions and 6 deletions
+24
View File
@@ -127,6 +127,23 @@ public enum AppPreferences {
/// stale-from-a-future-build value indistinguishable from a legal one downstream.
public static let boardZoomLevelKey = "boardZoomLevel"
// MARK: The print profiles
/// **The named print profiles** (11-command-nexus.md File Print; the printing card's closing
/// line, "these configurations probably good to persist (as named print profiles) and reused").
/// Read and written by `PrintProfileStore`, which owns their rules; the key is declared here with
/// its neighbours for `WindowID`'s reason.
///
/// The value is JSON `Data`, not a plist tree see that type's persistence note. It is app-side
/// and never board data: a print profile is how this user likes to read on paper, which no
/// collaborator and no agent has any business round-tripping.
public static let printProfilesKey = "printProfiles"
/// **The reserved "Last Used" pseudo-profile's content** the options the last print ran with, so
/// P opens on what the user did last. Absent until the first print, which reads as the factory
/// defaults (`PrintProfileStore.lastUsed`).
public static let printLastUsedKey = "printLastUsed"
// MARK: The appearance override
/// **View Appearance** (11-command-nexus.md) Auto / Light / Dark, app-wide and persisted
@@ -289,6 +306,12 @@ public final class AppModel {
/// one question.
public let appearance: AppearanceStore
/// The app-wide named print profiles (11-command-nexus.md File Print). Owned here for
/// `appearance`'s reason exactly: app-scoped, persisted beside its neighbours, and reached by File
/// Print a menu row, which lives outside every scene's environment and therefore receives this
/// object rather than looking a store up.
public let printProfiles: PrintProfileStore
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md Drag and drop).
///
/// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the
@@ -612,6 +635,7 @@ public final class AppModel {
styleRecents = StyleRecents(defaults: preferences)
zoom = BoardZoomStore(defaults: preferences)
appearance = AppearanceStore(defaults: preferences)
printProfiles = PrintProfileStore(defaults: preferences)
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
// Read once here rather than lazily, so File Open Recent is populated from the app's first
// menu pass a launch that restores boards never shows welcome, and a submenu that filled