Files
iccery-v2-mac/docs/27-roadmap-candidates.md
T
gronod f5163bccca
macOS CI / build-and-test (push) Successful in 8s
macOS CI / package (push) Successful in 3m17s
docs: add printing-workflow roadmap candidates
Outline features, defects, UI tweaks, and backend work that can
follow the shipped printer path. Display profiling and issue #16
stay out of scope.
2026-09-23 08:45:37 +01:00

62 KiB
Raw Permalink Blame History

27 — Roadmap candidates (printing workflows)

Status: proposal only. Nothing here is scheduled, estimated, or filed. Baseline: develop at marketing version 2.0.4 (project.yml). Milestones M1–M12 are closed. M13’s issues (#217 ticket restore, #218 paper source) are closed; the milestone record itself is still open on Gitea with no open issues. Open tracker: the only open issue is #16 (a separable Quartz / TargetPrint module). This document does not schedule it. See Out of scope. Date: 2026-09-23.

This is a list of work that can be added to the roadmap after the printer-profiling wizard already on develop. Each item is written so it can be pasted into a Gitea issue without the rest of the file. Identifiers (F1, D3, …) exist only so dependencies inside this document can be named. They are not issue numbers.

The product on develop already walks a printer profile from targen through unmanaged NSPrintOperation spooling, chartread, colprof, profcheck, optional printcal / applycal, a media library, project files, spot-read, and a printer-gamut viewer. Candidates below extend that loop. They do not open a second product.

Out of scope

  • Display profiling. No dispwin, dispread, display-calibration wizard, or soft-proof. The original won’t-fix (#90) still holds. Removing a display-class control that is already in the printer UI is in scope (D3); adding display measurement is not.
  • Issue #16 and a second print app. Do not extract ICCeryPrintKit, a TargetJob CLI, or a standalone TargetPrint.app. In-app printer settings and the native spooler have replaced that design. Proposals may change Sources/ICCery/Print/ and ICCeryCore/Print/. They must not reintroduce a fire-and-forget companion process.
  • Windows and Linux print trees. This repository is the macOS app. README.md already says those trees are not this product.
  • i18n, linking or dlopen of Argyll, and dropping the macOS 12 / Xcode 14.2 / Swift 5.7 floor. New SwiftUI must stay inside the 10-child ViewBuilder limit.

How the items relate

D1 Instrument catalog ──► D2 Seed Stage 3 from the default instrument
D3 Remove display colprof algorithm ──► U2 Group Stage 4 ──► F4 Printer colprof controls
D5 Clip warning is overwritten ──► F2 1:1 preflight
B4 Capabilities carry option keys ──► D8 Refuse a spool when those keys are missing
D4 Multi-key colour bypass ──► (no feature blocked; do it before more vendor work)
F1 Recipe stores semantic print setup ──► F8 Dry-down can show the recipe’s paper
F3 printtarg margin / scale / .cht ──► F2 (user can fix a clip) and F10 scanin
F9 Queue job watch ──► F8 starts the timer from “finished”, not from “accepted”
B1 Move pure ticket types into ICCeryCore — behaviour-preserving; do it before a second wave of D4/D6/D7 edits if more than one of those is scheduled

Anything not in that graph has no predecessor.


1. Features

F1 — Store the semantic print setup on the media recipe

Depends on: none. Blocks: nothing hard. F8 can name the recipe’s paper once this exists.

Problem

A media recipe remembers the queue, a free-text paper name, the last captured media-type string, the ink set, the preset, and an optional .cal. It does not remember the settings that actually determine an unmanaged target: paper-size token, quality key and value, tray key and value, or orientation. The native ticket that does remember them is deliberately session-only (PrintTicket in Sources/ICCery/Print/PrintTicket.swift: “Never written to settings.json or an .icceryproj”). Quit the app, or switch printers, and the next chart prints on driver defaults plus whatever Stage 2 happens to select.

The chart legend has the same hole from the other side. TargetWorkflowViewModel.labelMetadata is four text fields (metaPrinter, metaInkSet, metaDriverPaper, metaActualPaper). Nothing copies the recipe or the live queue into them, so printtarg -d often embeds Unspecified.

Current behaviour

  • MediaRecipe (Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaRecipe.swift) has printerID, paperName, driverMediaType, inkSet. No quality, tray, paper token, or orientation.
  • ICCeryProject stores printerID and mediaRecipeID only. Reopening a project restores the recipe id, not the Stage 2 pickers.
  • Stage 2 label fields are hand-edited (Stage2View.labelSection).

Proposal

Add optional, decodeIfPresent fields to MediaRecipe (unknown keys already ignored; old library files must still open):

Field Example Meaning
paper_size_token A4, Letter, Custom.595x842 CUPS PageSize token Stage 2 already writes
media_type_token 13 or PhotographicGlossy raw choice, not the localised label
media_type_label Epson Premium Glossy UI only
quality_key / quality_token / quality_label EPIJ_Qual / 305 / Best the pair TicketWriteResolver writes
tray_key / tray_token / tray_label EPIJ_FdSo / Rear / Rear tray same
orientation portrait or landscape must match the chart; see F2

Do not store printSettings / pageFormat XML. That blob is driver-version-specific; D7 is about the in-memory copy only.

Capture (existing Manage Media capture) copies the live Stage 2 selection when a queue is selected. Tokens that the current capabilities list does not contain are still stored, but marked stale on apply.

Apply (existing recipe apply), when printerID is in the enumerated queues:

  1. Select that queue and load capabilities (today’s path).
  2. Set paper, media, quality, tray, and orientation from the recipe when each token still exists. A missing token leaves that picker alone and raises an info notice naming the field (“Quality 305 is not on this driver”).
  3. Do not invent a PrintTicket. The next spool uses Stage 2 overrides on driver defaults until the user opens Printer Properties. Say that in the notice.
  4. If the chart label is still automatic (not labelIsCustom), fill empty metadata fields from the recipe: printer display name, ink set, driver media label, paper name. Never overwrite a non-empty field and never overwrite a custom label.

Applying a recipe with no print fields behaves exactly as today.

Acceptance

  • A library file written by 2.0.4 decodes, applies, and round-trips with the new keys absent.
  • Capture → quit → relaunch → apply restores paper, media, quality, tray, and orientation on the same queue, without a ticket blob on disk.
  • Apply onto a queue whose driver no longer has the quality token does not select a different quality silently.
  • Automatic printtarg -d contains the recipe’s printer, ink, and paper after apply when those metadata fields were empty.
  • CAL_ basenames still cannot be stored as the recipe’s .cal (existing rule).

Tests

  • MediaRecipe decode of a pre-field fixture and a full fixture.
  • Apply mapping test with a capabilities stub: hit, miss, and empty-field.
  • Label fill test: empty fields update; custom label and non-empty fields do not.

Out of scope

Opaque ticket persistence. Cross-queue replay of a token that only makes sense on one driver (apply already refuses a missing queue).


F2 — Refuse a profiling target that will not print 1:1

Depends on: D5. Ship D5 first so the warning cannot be hidden. Blocks: nothing. More useful once F3 can change margin and scale.

Problem

printtarg lays the chart out for a page in millimetres. Stage 2 then has a second paper picker and an orientation picker. The spooler draws the TIFF at scale 1.0, no centring, pagination .clip (NativeTargetSpooler, margins 0, scalingFactor = 1.0). If the raster is larger than the paper, the code logs a warning and prints anyway. A clipped or rotated chart cannot be read back. The profile then measures the wrong patches.

Current behaviour

NativeTargetSpooler.spool compares raster.pointSize to printInfo.paperSize with a 0.5 pt tolerance and emits a warning notice. It still calls NSPrintOperation.run(). Orientation is an independent "portrait" | "landscape" override (TicketWriteResolver writes orientation-requested 3 or 4, and the spooler also sets printInfo.orientation). workflow.pageSize (the chart) is explicitly not written back from the print side.

Proposal

Add a pure preflight value, computed before spool, with no Core Printing calls so it is unit-testable:

  • Chart size from the printtarg manifest (widthMm / heightMm on the page), converted at 72 pt = 1 in.
  • Paper size from the selected PageSize token’s known dimensions (the capabilities entry, or Custom.<w>x<h> which is already in points).
  • Orientation: the chart’s aspect decides. A landscape chart (widthMm > heightMm, including A4R / LetterR) requires landscape. Anything else requires portrait. A mismatch is a failure, not a second rotation on top of the TIFF.

Outcomes:

Result When Spool
ready raster fits inside the paper by ≥ 0 pt, aspect matches, drift ≤ 0.5 pt allowed
clipped raster exceeds paper by more than 0.5 pt on either edge blocked
rotated orientation picker disagrees with the chart aspect blocked
drift TIFF pixel size at its DPI disagrees with the manifest mm by more than 0.5 pt blocked

The print panel shows the result next to Print All, naming the chart mm and the paper token. Print anyway is a separate, non-default control, and the job title / notice must say “clipped” or “rotated” so a later verification failure is explainable. Default path does not print.

Do not scale to fit. Scaling is how a profiling target gets resampled. F3 is how the user shrinks the chart on purpose, by regenerating it.

Acceptance

  • A 210×297 mm chart on an A4 token spools without a prompt.
  • The same chart on a 4×6 token does not spool until Print anyway.
  • Landscape orientation on a portrait chart does not spool until Print anyway.
  • The preflight decision is a pure function of manifest mm, paper token, and orientation. Tests do not need a printer.

Tests

Table test for the four outcomes, including the 0.5 pt boundary and a Custom. token. UI test: the Print All button is disabled (or the confirm control is absent) in the clipped fixture.

Out of scope

Borderless imageable-area detection inside the driver. The first cut compares full paper size. A follow-up can subtract the driver’s reported hardware margins once a queue is available to measure them.


F3 — Expose the printtarg layout flags the bundled binary already accepts

Depends on: none. Blocks: F10. Soft help for F2.

Problem

Stage 2 can set instrument, page, bit depth, DPI, seed, label, and calibration. The bundled printtarg (Argyll 3.5.0 fork under Vendor/Argyll/macos-universal/) accepts several more flags that decide whether the chart fits the printer and the instrument. They are not in PrinttargConfig or PrinttargArgs.

From printtarg with no arguments, the relevant ones are:

Flag What it does
-a scale Scale patch size and spacers (for example 0.857, 1.5)
-A scale Extra spacer scale
-m mm Page margin in mm (binary default 6.0). Not included in the TIFF
-M mm Same margin, included in the TIFF
-h Hexagon patches for SS, double density for CM
-P Do not limit strip length
-L Suppress the left paper-clip border
-s / -S Write a scan-recognition .cht. -S skips the wide orientation strip

Proposal

Add the fields to PrinttargConfig and emit them from PrinttargArgs only when they differ from the binary default (no -m when the user leaves margin at 6; no -a at scale 1). Persist them on ProfilingPreset with decodeIfPresent so old presets still load.

Stage 2, inside the existing layout section, not the printer-driver row:

  • Margin (mm), stepper, default 6. Toggle “include margin in the TIFF” chooses -M instead of -m.
  • Patch scale, default 1. Disabled unless the user opens Advanced. Range: reject ≤ 0 and > 3.
  • Checkboxes: hexagon / double density (-h), don’t limit strip length (-P), no left clip border (-L). -h is enabled only for instruments SS and CM; the control explains that.
  • “Write scan chart (.cht)” with a second checkbox for narrow (-S). Default off. This is what F10 consumes.

Regenerating the target must stay explicit. Changing margin or scale never rewrites an existing TIFF in place.

Acceptance

  • Default argv is unchanged from today: -v -u -i … -p … -R 1 -t/-T dpi [label] [cal] basename.
  • -m 10, -a 0.9, -h, -s appear only when set, and round-trip through a preset.
  • A preset saved by 2.0.4 still applies.

Tests

Golden argv rows for each flag and for the all-defaults case. Preset decode of a fixture that lacks the new keys.

Out of scope

-e EPS, DeviceN fallback (-f), colour-space encodings (-w / -k / -o), TIFF quantise (-Q). Those are not needed to fit a chart to a printer.


F4 — Printer-side colprof controls

Depends on: D3 and U2. Blocks: nothing.

Problem

Stage 4 asks for algorithm, quality, FWA, illuminant, observer, viewing conditions, description, and copyright. For a printer profile the controls that actually change the separation are missing, and the ones that are present are free text.

The bundled colprof -? accepts, and the app does not surface:

  • -k / -K black generation (zero, 0.5 K, max, ramp, or the five-parameter curve). This is the CMYK black start. RGB printer profiles should leave it off.
  • -l total ink limit and -L black ink limit, overriding the .ti3. Stage 1’s targen -l limits the chart, not the profile.
  • -s / -S source gamut for the perceptual (and saturation) B2A. A proofing profile built against a house RGB or a reference CMYK needs this. It is not a display calibration.
  • -A manufacturer and -M model, which ColorSync shows in the profile list.
  • Illuminants the binary names: M0, M1, M2, A, C, D50, D50M2, D65, F5, F8, F10, or a .sp file. The UI offers None, Bare -f, D50, D65, and a custom path (ColprofFwaSelection).

ColprofConfig.intent and ProfilingPreset.colprofIntent already exist and ColprofArgs emits -t, but Stage4View has no intent control, so only a hand-edited preset can set it.

Proposal

After D3 removes X, the algorithm picker is Lab cLUT (l, default), XYZ cLUT (x), and Matrix (m). Matrix stays because some RGB printer targets are smooth enough; the label must say “Matrix (shaper)”, not a display name.

Add a Black and ink group, visible only when the Stage 1 colour space is CMYK:

  • Black generation: ramp (default, flag omitted), zero, 0.5 K, max, and Custom (the five colprof -k p numbers, each validated in range).
  • Total ink 0–400, black ink 0–100. Empty means omit the flag and keep the .ti3 value.

Add a Gamut mapping group:

  • Optional source .icc for -s (perceptual only) or -S (perceptual and saturation). File picker, .icc / .icm only. Empty omits the flag.
  • Intent picker bound to the existing intent field: omit / perceptual / relative / saturation / absolute, using the tokens colprof -t already accepts. Do not invent tokens.

Replace the illuminant, observer, and FWA text fields with pickers of the binary’s tokens, plus Custom file. “Bare (-f)” moves under a disclosure titled “Argyll default FWA” so it is not the first choice. Viewing-condition fields stay text but reject an empty string and the word none the way ColprofArgs already does; show the rejection inline.

Manufacturer and model are two optional text fields, emitted as -A and -M.

Preset keys are decodeIfPresent. CMYK-only fields are omitted from the argv when the colour space is RGB even if a preset still contains them.

Acceptance

  • RGB “Create Profile” argv gains no -k, -l, or -L.
  • CMYK with black “max” and total ink 300 emits -k x -l 300 (confirm the single-letter token against colprof -? during implementation; the usage text says zhxr).
  • Choosing a source profile emits one -s or one -S, not both.
  • A 2.0.4 preset still builds the same argv it does today.
  • Intent round-trips through the preset.

Tests

ColprofArgs golden rows for the new combinations, including RGB-ignores-ink-limit. Preset decode without the new keys.

Out of scope

Display algorithms (X, Y, g, G). Abstract-profile chaining (-p). Input-device flags (-ni, -ua, …).


F5 — Verify against a hold-out chart, not the profiling chart

Depends on: none. Blocks: nothing. F6 does not require this.

Problem

Stage 5 runs profcheck on the same .ti3 that built the profile (ProfcheckArgs.build is -v -k -s -u {ti3} {icc}). That number is optimistic: the profile has already seen those patches. A second, smaller chart printed and read after the profile exists is the usual check that the profile will hold on work it was not trained on.

Proposal

On Stage 5, next to the existing verification:

  1. New verification chart runs targen with a fixed, documented patch count (default 200, editable 50–600), the same colour space as the profile’s workflow, and a basename {profileBasename}-verify. It does not replace the profiling .ti1.
  2. The existing Stage 2 layout and print path prints it (same instrument, same recipe / print setup). Do not invent a second spooler.
  3. Stage 3’s chartread path reads it into {profileBasename}-verify.ti3. The profiling .ti3 stays where it is.
  4. Check hold-out runs the existing profcheck argv against that .ti3 and the current profile. The result is a VerificationRecord with a source of holdout (decodeIfPresent, default training for old history rows). The history list and the project snapshot show which source the number came from.
  5. The current “check this chart” action stays, and is labelled Check training chart so the two numbers are not confused.

Gating: hold-out check is enabled only when both the verify .ti3 and the profile exist. It does not unlock or lock Stage 4.

Acceptance

  • Running the training check still writes a history row and does not require a verify chart.
  • The hold-out .ti3 is a different file from the profiling .ti3. Average / snapshot of the profiling chart cannot delete it.
  • History CSV export includes the source column. Old history files load.

Tests

profcheck argv is unchanged given an explicit ti3 URL. History decode defaults source to training. A fixture asserts the verify basename cannot equal the profiling basename.

Out of scope

A second full wizard. Automatic judgment of pass/fail beyond the existing ΔE good/warning thresholds.


F6 — Iterate a profile with refine

Depends on: none. Uses the existing profile, print, and chartread paths.

Problem

Vendor/Argyll/macos-universal/refine is bundled and never spawned. After the first profile, Argyll’s iteration loop prints extra patches aimed at the regions the profile fits badly, reads them, and rebuilds. Shops do this when the first profcheck is not good enough. There is no way to do it without leaving the app.

Proposal

A Refine section on Stage 5, enabled when a profile and its canonical .ti3 both exist:

  1. Run refine with the profile and the .ti3 to produce {basename}-r{N}.ti1, where N is the next free index. Pass -v. Do not pass a flag this task has not checked against refine -? at implementation time; record the argv in the test.
  2. The user lays out, prints, and reads that .ti1 with the existing Stage 2 and Stage 3 actions, aimed at the refine basename. Do not auto-print.
  3. Rebuild from refined data runs colprof with the same ColprofConfig as the last build, on the refined .ti3, writing a new profile beside it ({basename}-r{N}.icc). It does not overwrite the previous profile.
  4. The user then runs the existing profcheck (training or F5) on the new profile and installs it with the existing installer if they want it.

One refine generation per click. No loop inside the app.

Acceptance

  • refine is resolved through BinaryResolver like the other sidecars. A missing binary is an error notice, not a crash.
  • The previous .icc is still on disk after a rebuild.
  • The wizard’s profiling basename does not change to the -rN name (artefact gating for Stages 1–4 stays on the original stem).

Tests

Argv builder golden test once the flags are confirmed from refine -?. Basename collision test for N. Resolver test with the sidecar absent.

Out of scope

Automatic repeated refine-until-ΔE. Merging the refined .ti3 back over the original.


F7 — Move between strips while chartread is running

Depends on: none.

Problem

During a strip read the only actions are Trigger (space), Done (d), Accept (return), Retry (also space), and Cancel (q). The bundled chartread also accepts f (forward), b (back), and n (next unread) in strip mode — the same family as d and q, documented in docs/15-stage3-chartread.md as f/b/n/d/q. A bad strip currently has to be re-read by triggering again, which is not the same as stepping back to a known row. The v1 UI’s Skip/Undo bytes (s / u) were wrong for real strip mode and have no buttons in this app; do not bring those bytes back.

Proposal

While chartread is in .awaitingStrip or .reading, show three more buttons:

Button Bytes Identifier
Back b\n btnChartreadBack
Forward f\n btnChartreadForward
Next unread n\n btnChartreadNextUnread

Add the cases to ChartreadInput next to .done and .quit. The button is disabled when no chartread child is running. XY-table states do not show them (the table flow uses place / align / scan, not strip letters).

The prompt line keeps showing chartread’s own text so a refused b (already on the first strip) is visible.

Acceptance

  • Pressing Back sends exactly b\n and nothing else.
  • s and u are not sent by any control.
  • Trigger and Retry are unchanged ( \n).

Tests

ChartreadInput byte test for the three cases. UI test that the buttons exist only in the strip states (use the existing chartread mock prompt fixtures).

Out of scope

Changing the classifier. Editing a single patch by hand.


F8 — Dry-down timer between print and measure

Depends on: none. If F9 has shipped, the timer starts when the job reaches Finished. Otherwise it starts when the spool notice says Sent, and the label must say “since the job was accepted”.

Problem

Ink has to settle before a spectrophotometer reading means anything. The app lets the user open Stage 3 the moment the TIFF exists, which is correct (they may have printed yesterday), but it never tells them how long it has been since this print.

Proposal

  • Settings: “Dry-down minutes”, integer 0–120, default 15. decodeIfPresent, so old settings.json gets 15. Zero turns the feature off.
  • After a successful spool of at least one page, Stage 2 shows a countdown “Settling — mm:ss” for that basename. It survives switching to Stage 3 and back, and it dies on quit (session-only; do not pretend we know about a print from yesterday).
  • Stage 3 Start Read stays enabled. A banner names the remaining time. A checkbox in Settings, default off, “Hold Start Read until dry-down ends”, gates the button when on. Skip is not required when the checkbox is off.
  • The banner names the paper when F1 has filled it; otherwise it names the basename.

Acceptance

  • Default settings do not block Start Read.
  • With the hold checkbox on and the timer running, Start Read is disabled and its accessibility value says why.
  • Minutes value 0 shows no banner.

Tests

Timer logic as a pure clock injected with a fixed Date. Settings decode without the new key. UI test only for the disabled-button case under the hold checkbox.

Out of scope

Per-paper dry-down tables. Estimating dry-down from the media type.


F9 — Tell the user when the printer has finished, not when the spooler accepted

Depends on: D5, so this status does not share one label with a clip warning. Blocks: a better start time for F8.

Problem

printAllPages sets the notice to “Sent N page(s) to {queue}” when NSPrintOperation.run() returns. That means the print system accepted the job. The printer may still be warming up, out of paper, or failed. Stage 3 is then a reasonable thing to click.

NSPrintOperation does not return a CUPS job id.

Proposal

Around the existing run() call:

  1. Snapshot lpstat -o (jobs) for the target queue immediately before run().
  2. Snapshot again immediately after a true return.
  3. The new job ids are the set difference. If the difference is empty or more than one id, the notice says “Accepted by the print system — job id not identified” and polling does not start.
  4. If there is exactly one new id, poll lpstat -o and lpstat -p on a 2-second timer while Stage 2 is visible, and keep the last state if the user leaves the stage.
  5. States shown in the print panel, separate from error/warning notices: Queued {id}, Printing {id}, Finished (id left the queue and the printer is not stopped), Failed (queue reports stopped, or the job stays with an error line for two consecutive polls).
  6. Stop polling on Finished, Failed, Cancel (a button that only stops polling — it does not cancel the CUPS job in this ticket), or after 30 minutes.

Do not parse the printer’s private job language. lpstat text is enough, using the existing CupsParsers style (pure functions over fixture strings).

Acceptance

  • A successful run() with no identifiable job id does not say “printed”.
  • A fixture where the id disappears and the printer is idle becomes Finished.
  • A fixture where the printer is stopped becomes Failed, even if the id is gone.
  • Polling does not spawn Argyll and does not hold a ProcessID that chartread needs.

Tests

Pure parser fixtures for queued, printing, idle-after-gone, and stopped. No live printer in CI.

Out of scope

Cancelling a CUPS job. Progress percentages.


F10 — Read a printed chart with a scanner (scanin)

Depends on: F3 (-s / -S writes the .cht). Blocks: nothing.

Problem

scanin is bundled and unused. A shop with a flatbed and no spectro can still build a printer profile: print the chart, scan it, convert the scan to a .ti3. It is a printer-measurement path, not a display path. It is also easy to do wrong (the scanner’s own colour has to be characterised). The app should expose the path with that constraint visible, not hide it.

Proposal

When a .cht sits next to the .ti2 (F3’s checkbox), Stage 3 offers Read from scan…:

  1. Pick a TIFF or JPEG of the printed chart, and a scanner .icc or .icm (required; the button stays disabled without it).
  2. Run scanin -v with the .cht, the scan, and the scanner profile, writing {basename}.ti3 through the same artefact path chartread uses. Confirm the argument order against scanin -? at implementation time and lock it in a golden test. Do not guess the order in this ticket.
  3. On success, Stage 3’s swatch grid loads that .ti3 the same way a finished chartread does, and Stage 4 unlocks under the existing probe.
  4. The panel states, in one sentence, that the scanner profile is the measurement device’s profile, not the printer profile being built.

Failure of scanin (alignment, missing .cht) is the tool’s stderr in the Stage 3 log, plus an error notice. It must not delete an existing chartread .ti3; write to a temp file and promote only on exit code 0.

Acceptance

  • No .cht → the scan button is absent.
  • Non-zero scanin leaves the previous .ti3 in place.
  • The scanner profile path is an argument, never installed as the printer profile.

Tests

Argv golden test after the usage check. Promotion test: failed run does not replace the canonical .ti3.

Out of scope

Building the scanner’s own profile inside this app. Camera capture. Display profiling of the scanner.


Depends on: none. Later than F4–F6; it does not unblock them.

Problem

collink is bundled and unused. A proofing workflow needs a device link from a reference printer profile (or a press profile) to this printer, so a RIP can simulate the reference. That is a printer-to-printer transform. It is not a display transform.

Proposal

A Device link sheet, opened from Stage 5 after a profile exists:

  • Source profile: file picker, required, .icc / .icm.
  • Destination: the profile just built, shown read-only, with a button to pick a different one.
  • Intent: the same tokens as F4’s intent picker. If F4 has not shipped, use a local four-value picker and do not block this ticket on F4.
  • Output: {destBasename}-link.icc in the working directory, via a save panel if the file exists.
  • Argv: take the order and required flags from collink -? during implementation, record them in the test, and keep the sheet to those controls. No black-generation curve on this sheet (that is F4’s colprof).

The sheet does not install the device link. It reveals the file. Installing a device link into ColorSync is a follow-up only if a user asks; this ticket stops at the file.

Acceptance

  • The wizard’s printer profile is not overwritten.
  • Cancel leaves no partial file (write via AtomicFileWriter to the final name only after collink exits 0, or write to the destination collink names and delete it on failure — pick one and test it).
  • Missing collink sidecar is an error notice.

Tests

Argv golden test. Failure test that the previous link file is unchanged.

Out of scope

A RIP. Soft-proof on screen. Abstract profile chains.


2. Defects

D1 — Chart-geometry instrument names are display devices

Depends on: none. Blocks: D2.

Problem

printtarg -i selects chart geometry, not the live USB device. The bundled binary’s usage text is:

-i 20 | 22 | 41 | 51 | SS | i1 | p3 | CM
i1 = i1Pro, 3p = i1Pro3+, CM = ColorMunki
20 = DTP20, 22 = DTP22, 41 = DTP41, 51 = DTP51,
SS = SpectroScan

The app labels the same raw codes as display colorimeters and as a different spectro. PrintInstrument.displayName in PrinttargConfig.swift:

Code App label Binary usage
p3 X-Rite i1Pro 3 / 3 Plus flag is p3; the gloss says 3p = i1Pro3+
SS Specbos / Spectraval (XY table) SpectroScan
20 Gretag i1Display 2 DTP20
22 X-Rite i1Display Pro / ColorMunki Display DTP22
41 Datacolor Spyder 4/5 DTP41
51 Spyder X DTP51

docs/01-overview.md and README.md still say p3 is SpyderPrint and 20/22/41/51 are the DTP readers. Three sources disagree. Choosing “i1Display Pro” emits -i 22, which lays out a DTP22 chart. A display colorimeter cannot read a printer chart. This app does not do display profiling; the picker should not offer display instruments as if it did.

The same table is copied into Settings (SettingsView.instruments) and into SpotReadViewModel.matches. code(for:) tests "22" (haystack.contains("display")) before "20" (contains("display 2")), so an “i1 Display 2” name is stored as 22.

p3 must not be relabelled by guesswork. The usage gloss says 3p while the accepted flag token is p3, and the product docs say SpyderPrint.

Proposal

  1. Before changing the p3 string, run the bundled printtarg on one tiny .ti1 with -i i1, -i p3, and -i 3p. Record patch size from the .ti2. If 3p is rejected, say so in the commit message. Label p3 from that geometry, not from the gloss and not from docs/01 alone. Update docs/01 and the README instrument list in the same change so they match the binary.
  2. Replace the 20/22/41/51/SS labels with the usage-text names (DTP20, DTP22, DTP41, DTP51, SpectroScan). These do not need the experiment.
  3. Delete the Settings copy of the table. Settings and Stage 2 both read PrintInstrument.
  4. Rewrite SpotReadViewModel.matches / code(for:) against spectro names: i1Pro (not i1Pro3), i1Pro3, ColorMunki, SpectroScan / i1iO, DTP20/22/41/51. Do not match “display” or “spyder”. A plugged-in display colorimeter matches nothing and is not offered as the default chart instrument. Spot Read may still list it in the live instlist picker (that picker uses the device’s own name); it must not be stored as a printtarg -i code.
  5. Keep the raw codes (i1, p3, CM, SS, 20, 22, 41, 51). Presets on disk stay valid.

Acceptance

  • Stage 2 and Settings show the same labels, and none of them contain “Display” or “Spyder”.
  • An instlist name “i1 Display 2” does not produce a settings code.
  • p3’s label matches the measured patch geometry, and the note of that measurement is in the PR.
  • Existing preset JSON with "instrument": "22" still applies and still emits -i 22.

Tests

Update PrinttargTests / preset fixtures that pin the old words. matches table: i1Pro, i1Pro3, ColorMunki, SpectroScan, each DTP, and a display name that must return nil. Argv tests stay on raw codes.

Out of scope

Adding instruments the binary does not accept (iS, Barbieri, …). Display profiling.


D2 — The default instrument setting does not seed Stage 3

Depends on: D1. Blocks: nothing.

Problem

Settings says: “Seeds Spot Read and Stage 3 when the instrument is plugged in.” SpotReadViewModel.seedDefault does read AppSettings.defaultInstrument. MeasurementWorkflowViewModel.loadSettings reads ΔE thresholds and enableI1Pro2Leds only. Stage 3 always starts on Auto.

AppSettings’s own comment says the field is “never applied to argv”. That comment is stale: Spot Read uses it to pick a device, and Stage 3 is documented as using it and does not.

Proposal

After D1’s matcher exists, call it from detectInstruments on Stage 3. Same rule as Spot Read: one matching device selects it; no match leaves Auto and sets a notice “Default instrument is not connected”. Do not pass the settings code to chartread -i or -c. Port selection stays InstrumentSelection.chartreadPort (port 1 omits -c).

Update the AppSettings comment so it describes device selection, not argv.

Acceptance

  • With the default set to ColorMunki and a matching instlist device, Stage 3’s picker is that device after Detect.
  • With no match, the picker is Auto and the notice is shown.
  • chartread argv is unchanged for Auto and for an explicit port.

Tests

View-model test with a fake instrument list. No hardware.

Out of scope

Changing Spot Read’s seeding. Auto-detect on stage appear (Detect stays a button).


D3 — Stage 4 can build a display-class profile

Depends on: none. Blocks: F4.

Problem

Stage4View’s algorithm picker includes Display XYZ+matrix, tagged X. The bundled colprof documents X as “display XYZ cLUT + matrix”. From printer measurements that writes a display-class profile. This application is a printing workflow. The control is a footgun, not a feature.

Proposal

Remove the X tag from the picker. If a preset or a saved session contains colprof_algorithm = "X", map it to l on apply and set an info notice: “Display algorithm X is not used; Lab cLUT was selected.” Do not emit -a X.

Leave l, x, and m. F4 renames m; this ticket only removes X.

Acceptance

  • The picker has no display row.
  • A preset with "X" produces argv -a l and a notice.
  • New presets cannot store X.

Tests

ColprofArgs / preset-apply test for the X → l mapping. UI test that the picker labels are Lab cLUT, XYZ cLUT, and Matrix.

Out of scope

The rest of F4. Deleting the m algorithm.


D4 — Colour bypass is one key, and HP is missing

Depends on: none. Do this inside the current spooler. It is not issue #16.

Problem

Unmanaged printing needs the driver’s colour controls off as well as ColorSync. CupsParsers.detectDriverColorBypass returns one pair, and only if it sees CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, or EpsonColorMode.

docs/14-iccery-cpu-targetprint.md records the keys real queues actually honour, including ones this function never writes: HP HPColorControl=Off together with ColorModel=RGB, Canon CNColorMatching=None beside CNIJIntent2=4, Epson EPSONColorControls=Off beside EPIJ_CMat / EPIJ_CCor. An HP queue today gets no bypass write. The job can still be colour-managed, and the chart is then useless.

TicketWriteResolver asks for that single pair and appends one TicketWrite.

Proposal

Change the detector to return [TicketWrite] (key, value, locked: false), in a fixed order tests can pin:

  • Canon, if CNIJIntent2 is present: CNIJIntent2=4. Also CNColorMatching=None when that key is present. If only CNIJIntent is present, CNIJIntent=4 (keep today’s precedence).
  • Epson: EPIJ_CCor=0 if that key exists, else EPIJ_CMat=3 if that key exists. Also EPSONColorControls=Off when that key exists. Also EpsonColorMode=Off when that key exists.
  • HP: when HPColorControl is present, write HPColorControl=Off and, if ColorModel is present, ColorModel=RGB.
  • Gutenprint: StpColorCorrection=Uncorrected when present.
  • Generic: ColorCorrection=Uncorrected when present and none of the vendor rules above fired.

“Key is present” means the queue’s lpoptions -l roster, the same optionKeys set as today.

Do not drop the CUPS keys that already ship. Add the companion keys beside them. Lock the order in TicketWriteResolverTests.

Confirm each new value against one real PPD or lpoptions -l dump attached to the PR (HP, and a Canon that exposes CNColorMatching). If a queue does not expose the key, write nothing for it — never send a key the roster does not list.

Acceptance

  • An option-key set of HPColorControl and ColorModel produces both writes, and no Canon/Epson write.
  • An Epson set that contains both EPIJ_CCor and EPSONColorControls produces both, and does not also produce EPIJ_CMat.
  • Existing Canon / Epson / Gutenprint golden tests still pass, plus the new companion key where the fixture includes it.
  • A queue with none of these keys gets no bypass write (unchanged).

Tests

Extend CupsParserTests / TicketWriteResolverTests. No printer in CI. One captured lpoptions -l fixture per new vendor, checked in under the existing print fixtures.

Out of scope

A separate TargetPrint app. Guessing keys that are not in the roster. Brother / Roland until a fixture exists; the function should make the next vendor a new row, not a special case in the spooler.


D5 — A clipped target prints, and the warning is replaced by “Sent”

Depends on: none. Blocks: F2, F9.

Problem

NativeTargetSpooler reports “exceeds the paper size — it will be clipped, not scaled” through diagnostics, which PrintSessionViewModel.attachDiagnostics writes into printNotice. spool then prints anyway. printAllPages / printPage then assign printNotice to “Sent N page(s)…”. The clip warning is visible only until run() returns. Manifest-drift warnings die the same way.

A profiling chart that lost a column of patches looks, in the UI, like a successful print.

Proposal

This ticket is the minimum fix. F2 is the full preflight.

  • Give the print panel two slots: printNotice (info/error of the action) and printWarning (clip, drift, oversize). Setting one does not clear the other.
  • On clip or drift, leave printWarning up until the next spool attempt.
  • The success text is “Accepted N page(s) by {queue}”, not “Sent” and not “Printed”.
  • Still print, in this ticket. Refusing the job is F2. Do not do both in one change.

Acceptance

  • A spool that emits a clip diagnostic and then succeeds shows both the warning and the accepted message.
  • A spool with no diagnostic clears printWarning from the previous attempt at the start of the new attempt, not at the end.

Tests

View-model test with a stub spooler that calls diagnostics then returns. Assert both strings are visible.

Out of scope

The block-unless-confirmed policy (F2). Job-finished polling (F9).


D6 — The properties dialog can put back a quality the tray does not allow

Depends on: none.

Problem

Changing media or tray in the Stage 2 pickers reclamps quality (selectedMediaType / selectedTray didSet call clampQualityToMedia, which uses availableQualities and therefore the tray token). The properties-dialog return path does not.

In openPrinterPreferences, the tray is applied first (which reclamps), and then:

printerCaps.allowsQuality(quality, forMediaType: selectedMediaType)

allowsQuality’s tray argument defaults to nil, so the check uses the queue-default map, not the tray map. A quality that is legal for the media on the default tray and illegal on the tray just chosen is written back into selectedQuality after the clamp. makeRequest later substitutes availableQualities.first at spool time, so the printed quality is not the one the dialog showed, and the notice does not say it was changed.

Proposal

Pass trayToken: selectedTrayToken into that allowsQuality call. If it returns false, do not set selectedQuality; run clampQualityToMedia() and set the notice to “Quality {token} is not valid for {media} on {tray}; using {replacement}.”

Use the same notice when makeRequest has to substitute, so a stale selection cannot change quality silently even if another caller skips the clamp.

Acceptance

  • A dialog result whose quality is absent from the selected tray’s map does not stick, and the notice names both tokens.
  • A quality that is legal for that pair still sticks, with no substitution notice.
  • The picker and the spool log show the same quality token.

Tests

Extend PrintSessionViewModelTests with a tray-specific constraint fixture (the file already has Epson EPIJUIConstraint cases). One case for dialog apply-back, one for the spool substitution notice.

Out of scope

Rebuilding the constraint parser. D4.


D7 — Changing the printer picker discards the captured ticket

Depends on: none.

Problem

PrintSessionViewModel.selectedPrinter.didSet deletes capturedTickets[oldValue] whenever the queue string changes. Issue #217 restores that ticket when the properties dialog opens again, but only if it is still in the dictionary. Picking another printer and picking the first one again drops the ticket. The dialog then opens on driver defaults, which is the bug #217 closed, reached through a different control.

The comment on the didSet says a ticket must not replay onto a different queue. Deleting the previous queue’s ticket is stronger than that. PMTicketBridge.restore already refuses a cross-queue replay.

Proposal

Stop deleting on picker change. Keep the dictionary keyed by queue. Spool and the panel already pass capturedTickets[selectedPrinter]. Add a test that a ticket stored for queue A is still returned after the selection moves to B and back to A, and that a spool for B does not receive A’s ticket.

Do not persist the dictionary (F1 is the disk format, and it is semantic tokens only).

Acceptance

  • Switch away and back: Printer Properties restores the ticket (#217’s behaviour).
  • A spool request for queue B never contains queue A’s printSettings bytes.

Tests

View-model test. No printer: the ticket value can be empty Data as long as the queue key is what is asserted.

Out of scope

Writing tickets to the recipe or the project file.


D8 — A failed option-key read silently drops media type and colour bypass

Depends on: B4.

Problem

makeRequest does:

let optionKeys = (try? await environment.cupsService.optionKeys(for: queue)) ?? []

On failure the set is empty. TicketWriteResolver then skips the media-type write (detectMediaTypeKey needs the roster) and skips colour bypass. Paper size, tray, and quality still go out, because those keys ride in on TargetPrintOverrides. The spool succeeds. The print is colour-managed, or on the wrong media, and the notice says it was accepted.

Proposal

After B4, the roster is the one the pickers were built from.

  • If that roster is empty because enumeration failed, do not spool. Notice: “Printer capabilities for {queue} could not be read. Nothing was printed.”
  • If the user selected a media type and detectMediaTypeKey returns nil against a non-empty roster, spool (the driver may use a key we do not recognise) but set printWarning: “Media type was not written; the driver key is not one ICCery recognises.”
  • Do not swallow the capabilities error into an empty set at the spool boundary.

Acceptance

  • A stub capabilities error prints zero pages and shows the notice.
  • A non-empty roster that contains EPIJ_Medi still writes EPIJ_Medi={selection}.

Tests

View-model test with a cups stub that throws, and one that returns a known key set.

Out of scope

Recognising every vendor media key. That stays detectMediaTypeKey.


3. UI refactors and tweaks

U1 — Stage 2 printer controls don’t fit the minimum window

Depends on: none.

Problem

The window minimum is 1100×700. Stage 2 puts tray, media, paper, quality, and orientation in one HStack (Stage2View, the block commented “Tray / media / paper / quality / orientation”). Each picker asks for up to 200 pt. With the sidebar, five pickers do not fit; they compress and the labels truncate. The print-all row is a second HStack under them, so the driver settings and the action are easy to miss as one task.

The media picker also sets two accessibility identifiers on the same control (mediaTypeGroup, then printerMediaTypeSelect). The second replaces the first. Paper and quality use a group id on the container and the control id on the picker. Media does not.

Proposal

Lay the five driver pickers out in a wrapping row or a two-row grid that keeps each picker at least 160 pt wide and lets the row grow downward inside the existing ScrollView. Do not raise the window minimum.

Put Print All, the single-job toggle, and Advance on their own row, right-aligned, with the preflight result from F2 beside Print All once F2 exists. This ticket does not wait for F2.

One identifier per control: printerTraySelect, printerMediaTypeSelect, printerPaperSizeSelect, printerQualitySelect, printerOrientationSelect. Group ids stay on the containers that already have them (paperSizeGroup, qualityGroup). Remove the dead mediaTypeGroup on the picker itself, or move it to a container the way paper does — pick container-vs-control and make media match paper. Update Milestone11PrintSettingsUITests in the same change.

Acceptance

  • At 1100×700 every picker label is fully visible without horizontal scrolling.
  • UI tests that hit printerMediaTypeSelect still hit it.
  • No picker is identified by two ids.

Tests

Existing M11 UI tests, plus a screenshot or frame assertion is not required in CI. Manually resize to the minimum once; say so in the PR.

Out of scope

A new visual theme. Moving printer settings to another stage.


U2 — Group the Stage 4 form before adding controls

Depends on: D3. Blocks: F4.

Problem

Stage4View.formSection is one vertical stack: algorithm, quality, FWA, two free-text illuminant fields, two viewing-condition fields, description, copyright, calibration. F4 adds black, ink, and a source profile. Dropping those into the same stack makes a form the user cannot scan, and it will hit the Swift 5.7 10-child limit again (the view already has a comment about that limit, from #111).

Proposal

Split the current fields into four subviews, each well under 10 children, with a headline:

  1. Profile type — algorithm, quality.
  2. Paper white — FWA, illuminant, observer.
  3. Viewing conditions — the two condition fields and the existing “none” caption.
  4. Identity and calibration — description, copyright, the apply-cal toggle.

No new fields in this ticket. F4 adds Black and ink and Gamut mapping as further subviews, not as more children of formSection.

Acceptance

  • Every current accessibility id (colprofAlgorithm, colprofQuality, colprofFwa, colprofIlluminant, colprofObserver, colprofInputViewCond, colprofOutputViewCond, colprofDescription, colprofCopyright, colprofApplyCalibration) still resolves.
  • The file compiles under Swift 5.7 without a new buildPartialBlock workaround beyond the subview split.

Tests

Existing Stage 4 UI tests. No behaviour change to assert beyond the ids.

Out of scope

F4’s new controls. Restyling Stage 1–3 to match, unless a later ticket asks.


U3 — Keep the process log from shoving the form

Depends on: none.

Problem

Stage 3 inserts ProcessLogView only when chartreadLog is non-empty (Stage3View.chartreadControlsSection). The first line of chartread output grows the panel and moves the buttons the user is about to hit. Other stages that share ProcessLogView do the same. docs/21-ui-reference.md asks for one collapsible log, not a log that appears by changing layout.

Proposal

Always reserve the log disclosure under the stage’s action row, collapsed when there are no lines, expanded when the first line arrives if the user has not collapsed it themselves. Collapsing sticks for that stage until the run ends. Empty disclosure title: “Process output”. Do this in ProcessLogView so Stage 1, 2, 3, 4, and calibration pick it up together. Do not give each stage its own log chrome.

The disclosure stays inside the stage ScrollView. It does not cover the buttons.

Acceptance

  • Before a run, the disclosure is visible and collapsed, and the buttons do not move when the first log line arrives (the disclosure body opens below them).
  • A user who collapses it during a run does not have it forced open by the next line.
  • Identifiers chartreadLogContainer / chartreadLog and the other stages’ existing log ids stay on the same views.

Tests

UI test: the container exists before Start Read. Unit test is not useful.

Out of scope

Changing what is logged. A global log window.


U4 — Show whether a print ticket is captured

Depends on: none. Survives D7; D7 is what makes the indicator truthful after a printer switch.

Problem

Stage 2 can be in two different states that look the same: driver defaults plus the pickers, or a captured PrintTicket with the pickers written on top (#201 / #217). PrintTicket.capturedAt exists and is never shown. After a cancel, a capture, or a printer change, the user cannot tell which state the next Print All will use.

Proposal

Under the printer row, one caption, identifier printTicketCaption:

  • No ticket for this queue: “No properties captured — Print uses the pickers on driver defaults.”
  • Ticket present: “Properties captured {relative time} for {queue} — pickers override the ticket.”

Use the ticket’s capturedAt. Do not show a byte count or any ticket contents. Updating the caption is the only UI change.

Acceptance

  • Cancel of the properties dialog leaves the “no properties” caption if there was no earlier ticket, and leaves the old timestamp if a ticket was already stored.
  • A successful capture updates the timestamp.
  • The caption is per selected queue, not global.

Tests

View-model already holds the dictionary; add an assertion on a small derived string function rather than a new UI test if the existing print UI tests are too heavy. If a UI test is added, it uses the stub panel, not a real dialog.

Out of scope

Editing the ticket. Showing individual PDE keys.


4. Backend tweaks

B1 — Move pure ticket policy into ICCeryCore

Depends on: none. Behaviour-preserving. Prefer it before a second of D4, D6, D7 if more than one is about to be scheduled. A single defect may edit the types where they sit.

Problem

TicketWriteResolver, TargetPrintOverrides, TicketWrite, and ResolvedTicketWrites are pure values. They do not touch AppKit. They live in the app target (Sources/ICCery/Print/TicketWriteResolver.swift) because that is where the spooler grew during #201. PrintTicket is also pure (Data plus a queue name and a date) and lives beside them. Core Printing calls correctly stay in PMTicketBridge, PrintPanelService, and NativeTargetSpooler.

The split means a core test cannot construct a write list without the app target, and the next vendor-key change (D4) has nowhere obvious to live next to CupsParsers, which is already in the package.

Proposal

Move the four resolver types and PrintTicket into ICCeryCore/Print/. Keep PMTicketBridge and the spooler in the app. The app’s PanelCaptureResult can stay in the app; it only pairs a core PrintPropertiesResult with a PrintTicket.

No argv, key order, or lock-bit change. TicketWriteResolverTests should compile against the package alone after the move.

Acceptance

  • rg TicketWriteResolver finds the type only under Packages/ICCeryCore.
  • The existing resolver tests pass unchanged.
  • The app still spools. No new public spool API.

Tests

The current TicketWriteResolverTests are the test. Run the print unit-test slice.

Out of scope

Rewriting PMTicketBridge. Combining PrintOptions with TargetPrintOverrides (they are the mirror and the write list; leave both).


B2 — Use the fork’s -u progress for targen and colprof

Depends on: none.

Problem

printtarg, chartread, and profcheck already pass the fork’s -u. TargenArgs documents “Never emits -u”. ColprofArgs does not pass it either. ColprofProgressClassifier guesses from English substrings (“fitting”, “clut”, “writing”), and its comment says the fork supports -u but v2.0 does not pass it. A localised or slightly different build of the sidecar drops the Stage 4 progress badge to unknown for the whole run.

Proposal

Pass -u from TargenArgs and ColprofArgs. Parse the JSON line the fork actually emits (record one real stdout sample in the test fixture; do not invent a schema). Keep the substring classifier as a fallback when a line is not JSON, so a sidecar without -u still moves the badge.

Stage 4’s existing colprofProgress label consumes the parsed events. Stage 1 shows the same kind of one-line status (targenProgress) only if the JSON has a fraction or a phase; if the sample has neither, log the line and skip the badge rather than inventing a percentage.

Acceptance

  • Golden argv for both tools contains -u once.
  • A fixture of real -u stdout maps to the existing ColprofProgress cases.
  • A fixture of today’s plaintext still maps through the classifier.
  • A line that is neither does not fail the run.

Tests

Argv tests updated. Parser tests with both fixtures. No live colprof in CI.

Out of scope

spotread -u (docs/15 says spotread does not use -u). A progress bar widget.


B3 — The contributor-facing print description still says lp

Depends on: none.

Problem

The shipping spooler is NSPrintOperation (#201). People implementing the next print change are still pointed at lp:

  • README.md version line says 2.0.3. project.yml MARKETING_VERSION is 2.0.4. The milestone list in that README stops at M11 and does not mention the native spool or paper source.
  • README.md “What it does” says Stage 2 is an “unmanaged lp spool”.
  • docs/10-print-system.md still describes the architecture as print/macos.rs plus lp, and says the current path “is in-process / lp”. docs/11-print-macos.md has already been updated to say lp is gone. The two chapters disagree.

docs/01–docs/09 are the v0.8.5 behavioural spec. This ticket does not rewrite them. It only fixes text that claims to describe the app on develop.

Proposal

  • README: set the version to 2.0.4, add M12 and M13 as shipped, and describe Stage 2 as unmanaged NSPrintOperation replaying a captured ticket, with lpstat / lpoptions used for enumeration only.
  • docs/10-print-system.md: add a one-paragraph banner at the top: enumeration is still CUPS (lpstat, lpoptions, PPD); spooling on the macOS app is the native ticket path in Sources/ICCery/Print/; the lp paragraphs below are the v0.8.5 spec. Do not delete the historical paragraphs.
  • Leave docs/14 in place as the record of why a second app was considered. Point at the out-of-scope note in this document from that banner, not from a new copy of the TargetPrint design.

Acceptance

  • rg -n "lp spool" README.md finds nothing.
  • README version matches project.yml.
  • docs/10’s first screen says native spool, and the historical body is still there.

Tests

None. A doc review is the check.

Out of scope

Rewriting docs/11’s SPI chapter. Regenerating nav.json (optional; do it only if the doc index is otherwise regenerated in the same change).


B4 — Spool from the capabilities roster, not a second lpoptions call

Depends on: none. Blocks: D8.

Problem

Loading capabilities for a queue already runs lpoptions -l and parses the roster (CupsService.capabilities). makeRequest runs optionKeys(for:) again at spool time and turns any error into [] (that swallow is D8). The two reads can also disagree if the user changes the driver between picker load and print, with no signal.

PrinterCapabilities holds trays, media, paper, qualities, and the detected tray and quality keys. It does not hold the set of option keys the resolver needs for media-type detection and colour bypass.

Proposal

Add optionKeys: Set<String> to PrinterCapabilities, filled in the same parse that builds the pickers. makeRequest uses printerCaps.optionKeys and does not call optionKeys(for:).

If a later refresh replaces printerCaps, the next spool uses the new set. There is still only one roster in memory.

Empty set stays empty when the printer genuinely has no options. D8 defines how spool treats empty-because-failed versus empty-because-none; this ticket only stops the second read. Until D8, preserve today’s spool behaviour given the same key set (including writing nothing when the set is empty).

Acceptance

  • One capabilities load is enough for a spool; a test double that fails on the second optionKeys call still spools when the capabilities object has the keys.
  • TicketWriteResolver output is unchanged for a fixed key set.

Tests

Capabilities parse fixture asserts optionKeys contains the listing keys. View-model spool test uses the stored set.

Out of scope

The refuse-to-print policy (D8). Caching across app launches.


5. What was reviewed and deliberately not proposed

  • Another pass on Epson quality enumeration, Canon media locale, paper size, or paper source. #180, #181, #183, #186, #214, #217, and #218 are closed on develop. D6 is the remaining hole found in that path.
  • Re-opening the lp versus native-spool decision. #201 shipped. B3 only stops the docs describing the old path as current.
  • Display instruments as something the app should drive. D1 removes them from chart geometry. It does not add a display workflow.
  • Gamut-viewer features. Compare, reference, and click-inspect shipped as #147. The viewer is for printer profiles already on disk. No new gamut work is proposed here.
  • Closing the M13 milestone record. Housekeeping, not a product change. The issues themselves are done.