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
+22
View File
@@ -285,6 +285,10 @@ struct CardWindowHost: View {
/// reason: two card windows on one board have two different selections, and the menu bar reaches
/// the frontmost one through the focus system.
@State private var attachments = CardAttachments()
/// This window's printable subject the card, its lane, its board and its thread, as File Print
/// needs them (`CardPrintSubject`). Window-scoped for `CardBodyPresentation`'s reason: two card
/// windows are two documents, and the menu bar reaches the frontmost one through the focus system.
@State private var cardPrint = CardPrintSubject()
/// This window's thumbnail memory. Held here rather than in the section so it survives every
/// snapshot the store applies a cache that died with the view would regenerate every thumbnail
/// on every reload (`AttachmentThumbnailCache`).
@@ -381,6 +385,9 @@ struct CardWindowHost: View {
// know a card window is in front at all (11-command-nexus.md scopes all three to the card
// window).
.focusedSceneValue(\.cardComments, session.comments)
// File Print (P) reaches the frontmost card window the same way the card-window scope
// of a row the board window answers with its whole board (11-command-nexus.md).
.focusedSceneValue(\.cardPrint, cardPrint)
// The raw-source outlet's detailed alert, presented over this window a validation
// refusal on Apply, or a file that could not be opened as source. It hangs *here* rather
// than inside the editor because the second of those fires while source mode is still
@@ -473,6 +480,21 @@ struct CardWindowHost: View {
// would open nothing.
attachments.cardFolder = folder
session.comments.cardFolder = folder
cardPrint.cardFolder = folder
}
// **File Print's subject, re-derived from every snapshot** for the folder's and the
// announcer's reason: a card renamed, restyled, relabelled or moved to another lane prints as
// it is now (`CardPrintSubject`). The thread is a closure rather than a value, so P reads the
// comments at the moment it is pressed rather than whatever the pane last saw.
.onChange(of: placement.card, initial: true) { _, card in
cardPrint.card = card
cardPrint.readThread = { store.commentThread(inCard: card.id) }
}
.onChange(of: placement.lane.title.value, initial: true) { _, title in
cardPrint.laneTitle = title
}
.onChange(of: AppModel.displayName(of: store), initial: true) { _, title in
cardPrint.boardTitle = title
}
// The announcer's subject, re-derived from every snapshot for the folder's reason: a card
// renamed mid-session is announced under its new name ("New comment on 'card'").