Media library: printer + paper + ink + .cal bound to a preset #146

Open
opened 2026-09-12 18:34:18 +01:00 by gronod · 3 comments
Owner

Summary

Presets today are targen / printtarg / colprof field bags (ProfilingPreset in Packages/ICCeryCore/Sources/ICCeryCore/Settings/ProfilingPreset.swift). Users think in “Epson P900 + Canson Rag Photographique + PK + 800 patches + this .cal”. Add a first-class MediaRecipe library that references a preset rather than forking a second form schema.

Spec refs

  • docs/22-settings-presets.md
  • docs/07-stage0-calibration.md
  • docs/11-print-macos.md (queue identity is CUPS printer-id, not display name)
  • CalibrationIdentity / CalibrationStore (issues #29, #83)
  • AppSettings.calibrationStaleDays
  • Invariant: never printtarg -K a CAL_ basename (CalibrationIdentity.isCalibration)

Scope

In:

  • MediaRecipe Codable model + MediaLibraryStore on JSONFileStore
  • Sidebar / Settings library UI: list, create from current session, apply, delete custom
  • Apply writes into existing Stage 1/2/4 view-model fields via PresetMapping — do not duplicate form controls
  • Stale-cal warning per recipe (days + printer mismatch)
  • Refuse apply-calibration / -K when the bound cal basename is CAL_

Out:

  • Quartz / TargetPrint (#16)
  • Inventing a second preset schema
  • Auto-detecting paper from the driver PDE (read-only display of last captured media_type is fine)

Implementation notes

Model (ICCeryCore)

public struct MediaRecipe: Codable, Equatable, Sendable, Identifiable {
  var id: String                 // uuid, never user-typed
  var name: String               // SwiftUI Text only (#114)
  var notes: String
  var printerID: String          // CUPS queue id from Printer.id / lpstat -e
  var printerDisplayName: String
  var paperName: String          // human, e.g. "Canson Rag Photographique"
  var driverMediaType: String?   // last captured cups media_type if any
  var inkSet: String             // free text: "PK", "MK", "Photo Black", …
  var colourSpace: String        // "rgb" | "cmyk" — must match bound preset
  var presetID: String           // ProfilingPreset.id (built-in or custom)
  var calibrationURL: String?    // bookmark-safe absolute path; empty = none
  var applyCalibration: Bool
  var created: Date
  var updated: Date
}

Snake_case CodingKeys. Unknown keys ignored. validated() rejects empty name, empty printerID, colour_space not rgb/cmyk, empty presetID, path traversal in calibrationURL (.., NUL). Names via Text only.

Store: ~/Library/Application Support/com.gronod.iccery2/media_library.json via JSONFileStore<[MediaRecipe]> (corrupt: keep file + return [] + banner, same policy as VerificationHistoryStore — do not replaceWithDefault and wipe). Cap 200 recipes. Atomic .tmp + replaceItemAt (#213).

Do not put recipes inside settings.json. Settings already owns custom_presets. Library is a sibling file.

Binding to live session

MediaLibraryViewModel (@MainActor, @Observable on develop):

  1. apply(recipe):
    • Resolve preset from PresetCatalog + SettingsStore.customPresets. Missing preset → error banner, do not partial-apply.
    • Call existing PresetMapping onto TargetWorkflowViewModel / ProfileWorkflowViewModel / PrintSessionViewModel (the #82 maps).
    • Set WizardState.printerName to recipe.printerDisplayName and PrintSession selected queue to recipe.printerID if that queue still exists; else banner “Printer X is not installed” and leave queue as-is.
    • If applyCalibration && calibrationURL file exists: load through CalibrationStore actor; set apply-cal toggle ON. If file missing: toggle OFF + error, do not invent a .cal.
    • If CalibrationIdentity.isCalibration (cal file basename or live wizard basename): force applyCalibration false and never pass printtarg -K.
  2. captureFromSession() builds a recipe from current VM fields + selected queue + current .cal path. Name prompt required.

Staleness

Reuse CalibrationStore.staleDays (settings.calibrationStaleDays, default 30). A recipe is stale when:

  • bound .cal parse date older than staleDays, OR
  • stored printer / recipe.printerID ≠ current selected queue.

Show the existing dashed-vs-solid curve chip plus a library badge mediaRecipeStale. Do not auto-delete.

UI

Follow the existing preset chrome. Do not invent a second settings page or a second Stage 1 form. Tokens, sidebar width, and sheet style stay as they are on develop.

Placement (Sources/ICCery/SidebarView.swift)

Insert a Media block between the preset Save/Manage row and Calibrate Printer. Do not put it above the logo/settings row and do not push the 1–5 stepper off the 270 pt sidebar (Theme.Metrics.sidebarWidth).

Current order that must remain:

  1. Logo + settings / about / help
  2. Preset Picker (presetSelect) + Save / Manage
  3. NEW: Media library Picker + Capture / Manage
  4. Calibrate Printer (btnCalibratePrinter)
  5. View Gamut (btnViewGamut)
  6. Stepper 1–5

Keep the Media picker on one line. Label Media (not “Media library”) so it fits the 270 pt column the same way Preset does. Menu style .pickerStyle(.menu), same padding as presetSelect (horizontal 12, vertical 8).

Sidebar controls

  • Picker("Media", selection:) tags:
    • "none"Text("No media recipe")
    • one tag per MediaRecipe.idText(recipe.name) only (#114). Do not concatenate printer/paper into the menu label; that overflows the column. Detail lives in the manage sheet.
  • Selection applies immediately, same contract as presetSelectworkflow.applyPreset. Choosing "none" clears selectedRecipeID and does not undo the live Stage 1/2/4 fields (applying "none" is not “reset to factory”).
  • Button row under the picker, same HStack as presets:
    • Capture → opens the save-from-session sheet (btnMediaLibraryCapture)
    • Manage → opens the manage sheet (btnMediaLibraryManage)
  • Stale badge: a small caption under the picker, identifier mediaRecipeStale, visible only when the selected recipe is stale (cal older than calibrationStaleDays, or printerID ≠ current PrintSession queue). Copy: Text("Calibration stale") or Text("Printer not installed") — two distinct strings, not a combined opaque badge. Use .font(.caption) + Theme warning colour. Help overlay: “Re-run Stage 0 or pick a different recipe.”
  • helpOverlay on the picker and both buttons, wired to the existing $showingAllHelp binding. Overlays must not change sidebar height (#171 / AboutHelp UI tests).

Do not collide with preset a11y (M7/M9 lessons)

  • Never put .accessibilityIdentifier("sidebar") or any identifier on the sidebar VStack that contains both pickers. #130: an identifier on the container clobbers child identifiers on older SDKs.
  • Media picker id is mediaSelect, not presetSelect. #137: presetSelect already matches both a label and a pop-up; do not reuse it.
  • Query UI tests with identifier, not the word “Media” as a label (that string will also appear in help overlays).

Capture sheet (SaveMediaRecipeDialog)

Clone SavePresetDialog (#savePresetDialog): 380 pt wide, Theme.background, title Text("Save Media Recipe").

Fields, all Text / TextField / existing pickers — no free-form HTML:

Control Id Source when opened
Name saveMediaName empty; required; trim; disable Save when empty
Notes saveMediaNotes empty, optional
Paper saveMediaPaper last recipe paper or empty; required
Ink set saveMediaInk last recipe ink or empty; required
Printer (read-only Text) saveMediaPrinter PrintSessionViewModel selected queue display name + id
Preset (read-only Text) saveMediaPreset current selectedPresetID name, or “No preset”
Colour space (read-only Text) saveMediaColourSpace live Stage 1 colour space; must match bound preset
Calibration path (read-only Text) saveMediaCal current .cal path or “None”
Apply calibration Toggle saveMediaApplyCal live apply-cal flag; forced off when path is CAL_* or missing

Buttons: Cancel btnCloseSaveMediaDialog, Save btnConfirmSaveMedia. Save calls captureFromSession() then showingSaveMedia = false. Missing printer queue or empty paper/ink → inline caption error, do not close, do not write the JSON file.

If the live colour space ≠ bound preset colour space, show Text("Colour space does not match the selected preset.") and disable Save.

Present from RootView as a sibling of the preset sheets:

.sheet(isPresented: $workflow.showingSaveMedia) { SaveMediaRecipeDialog(...) }
.sheet(isPresented: $workflow.showingManageMedia) { ManageMediaDialog(...) }

Do not present from inside SidebarView (sheets from a 270 pt column clip).

Manage sheet (ManageMediaDialog)

Clone ManagePresetsDialog (#managePresetsDialog) but wider: 640 pt, min list height 260, Theme.background.

List id mediaLibraryList. One row per recipe, row id mediaRow-{id} as a containment element (accessibilityElement(children: .contain)), same pattern as presetRow-{id}.

Each row, left-to-right:

  1. VStack of Text(name) + caption Text("\(printerDisplayName) · \(paperName) · \(inkSet)")
  2. Preset name caption (Text(resolvedPresetName) or Text("Missing preset") in warning colour if presetID is gone)
  3. Cal age caption: Text("Cal 12d") / Text("No cal") / Text("Stale") — computed, not stored
  4. Apply btnMediaLibraryApply-{id}
  5. Delete (destructive) btnMediaLibraryDelete-{id}

There are no built-in recipes. Every row is deletable. Confirm delete with an .alert (“Delete “{name}”? This does not delete the .cal or the preset.”). Deleting the selected recipe sets selectedRecipeID to "none" without resetting Stage 1 fields.

Footer:

  • Apply selected btnMediaLibraryApply — disabled when list selection is empty
  • Capture current… btnMediaLibraryCaptureFromManage — dismisses manage and opens the capture sheet
  • Close btnCloseManageMediaDialog

Clicking a row selects it. Double-click or Return = Apply and dismiss. Apply failure (missing preset, missing .cal, printer gone) keeps the sheet open and publishes a Notice on WizardViewModel (kind: error / warning). Success publishes kind: info “Applied {name}” and dismisses.

Do not add Import/Export JSON in this ticket. That is a follow-up; recipes live only in media_library.json for v1 of the feature.

What Apply must look like in the already-shipped views

After a successful apply, the user stays on the current wizard stage. Visible side effects only:

  • Sidebar presetSelect jumps to recipe.presetID if that preset exists
  • Sidebar mediaSelect shows the recipe name
  • Stage 2 printer pop-up selects recipe.printerID when the queue is installed; otherwise the pop-up is unchanged and the main NoticeBanner shows “Printer {display} is not installed”
  • Stage 2 / Stage 4 apply-cal toggle matches recipe.applyCalibration after the CAL_ / missing-file guards
  • Calibration chip / curve (existing Stage 0 chrome) reloads from CalibrationStore when a .cal was applied
  • No new Stage 1 controls appear. Paper and ink are not extra targen flags; they are library metadata only, shown in the manage caption and capture sheet

Empty and overflow states

  • Zero recipes: picker has only No media recipe; Manage list shows Text("No media recipes yet. Capture the current printer, paper and preset.") id mediaLibraryEmpty. Capture stays enabled if a printer queue is selected; disabled with help “Select a printer in Stage 2 first” otherwise.
  • Cap 200: Capture Save fails with notice “Media library is full (200). Delete a recipe first.” Do not silently drop the oldest.
  • Corrupt media_library.json: picker falls back to "none", Manage shows the empty copy plus a persistent warning notice. Never overwrite the corrupt file from the UI (store policy already says keep + return []).

Settings

Do not add a Media tab to SettingsView. Settings already owns ΔE thresholds, stale days, default instrument, and custom presets. Stale-day changes in Settings must re-evaluate the sidebar badge on the next object update without reopening Manage.

UI tests (Tests/ICCeryUITests)

Add Milestone10MediaLibraryUITests (mock queues / no real printer):

  • testMediaPickerDoesNotReusePresetSelectpresetSelect and mediaSelect both exist; applying media does not rename presetSelect
  • testCaptureRequiresNamePaperInk — Save disabled until those three fields are non-empty
  • testManageApplyMissingPrinterShowsBanner — fixture recipe with a fake printerID; Apply leaves Stage 2 queue unchanged; NoticeBanner appears
  • testHelpOverlaysDoNotChangeSidebarHeight still passes with the new block visible and help toggled
  • Query every new control by the identifiers listed above, never by loose label text

Identifiers (complete)

mediaSelect
btnMediaLibraryCapture
btnMediaLibraryManage
mediaRecipeStale
saveMediaRecipeDialog
saveMediaName
saveMediaNotes
saveMediaPaper
saveMediaInk
saveMediaPrinter
saveMediaPreset
saveMediaColourSpace
saveMediaCal
saveMediaApplyCal
btnCloseSaveMediaDialog
btnConfirmSaveMedia
manageMediaDialog
mediaLibraryList
mediaLibraryEmpty
mediaRow-{id}
btnMediaLibraryApply-{id}
btnMediaLibraryDelete-{id}
btnMediaLibraryApply
btnMediaLibraryCaptureFromManage
btnCloseManageMediaDialog

Rewrite invariants

#114 user strings via Text. #213 atomic write. #59 empty cwd still illegal (apply does not set cwd). #83 only CalibrationIdentity prefixes CAL_. Never search $PATH for .cal.

Dependencies

Blocks-on: #11 presets, #12 printer enum, #29/#83 calibration, #5 settings, #82 preset mapping.
Unblocks: Project document (this milestone).

Test

  • CI/mock:
    • MediaRecipe.validated goldens
    • JSONFileStore round-trip + corrupt file preserved
    • apply() with missing preset / missing .cal / CAL_ basename refuses -K
    • captureFromSession copies printerID + presetID
  • UI (mock, Milestone10MediaLibraryUITests): picker ids distinct from presetSelect; capture validation; missing-printer banner; help overlays do not change sidebar height.
  • Hardware: apply a recipe onto a real installed Epson or Canon queue; stale badge when .cal is older than settings days.

Acceptance criteria

  • Library file is not settings.json
  • Apply uses PresetMapping; no second Stage 1 form
  • CAL_ / missing .cal cannot enable printtarg -K
  • Printer mismatch is a banner, not a crash
  • Names render through Text
  • Media block sits between preset Save/Manage and Calibrate Printer; sidebar stays 270 pt
  • mediaSelect and presetSelect are distinct identifiers; no identifier on the sidebar container
  • Capture and Manage are RootView sheets in the preset-dialog style
  • Help overlays do not change sidebar height
  • Branch: feat/<this>-media-librarymilestone/m10-studiodevelop
## Summary Presets today are targen / printtarg / colprof field bags (`ProfilingPreset` in `Packages/ICCeryCore/Sources/ICCeryCore/Settings/ProfilingPreset.swift`). Users think in “Epson P900 + Canson Rag Photographique + PK + 800 patches + this `.cal`”. Add a first-class `MediaRecipe` library that *references* a preset rather than forking a second form schema. ## Spec refs - `docs/22-settings-presets.md` - `docs/07-stage0-calibration.md` - `docs/11-print-macos.md` (queue identity is CUPS printer-id, not display name) - `CalibrationIdentity` / `CalibrationStore` (issues #29, #83) - `AppSettings.calibrationStaleDays` - Invariant: never `printtarg -K` a `CAL_` basename (`CalibrationIdentity.isCalibration`) ## Scope **In:** - `MediaRecipe` Codable model + `MediaLibraryStore` on `JSONFileStore` - Sidebar / Settings library UI: list, create from current session, apply, delete custom - Apply writes into existing Stage 1/2/4 view-model fields via `PresetMapping` — do not duplicate form controls - Stale-cal warning per recipe (days + printer mismatch) - Refuse apply-calibration / `-K` when the bound cal basename is `CAL_` **Out:** - Quartz / TargetPrint (#16) - Inventing a second preset schema - Auto-detecting paper from the driver PDE (read-only display of last captured `media_type` is fine) ## Implementation notes ### Model (`ICCeryCore`) ``` public struct MediaRecipe: Codable, Equatable, Sendable, Identifiable { var id: String // uuid, never user-typed var name: String // SwiftUI Text only (#114) var notes: String var printerID: String // CUPS queue id from Printer.id / lpstat -e var printerDisplayName: String var paperName: String // human, e.g. "Canson Rag Photographique" var driverMediaType: String? // last captured cups media_type if any var inkSet: String // free text: "PK", "MK", "Photo Black", … var colourSpace: String // "rgb" | "cmyk" — must match bound preset var presetID: String // ProfilingPreset.id (built-in or custom) var calibrationURL: String? // bookmark-safe absolute path; empty = none var applyCalibration: Bool var created: Date var updated: Date } ``` Snake_case `CodingKeys`. Unknown keys ignored. `validated()` rejects empty name, empty `printerID`, `colour_space` not `rgb`/`cmyk`, empty `presetID`, path traversal in `calibrationURL` (`..`, NUL). Names via `Text` only. Store: `~/Library/Application Support/com.gronod.iccery2/media_library.json` via `JSONFileStore<[MediaRecipe]>` (corrupt: keep file + return `[]` + banner, same policy as `VerificationHistoryStore` — do not `replaceWithDefault` and wipe). Cap 200 recipes. Atomic `.tmp` + `replaceItemAt` (#213). Do **not** put recipes inside `settings.json`. Settings already owns `custom_presets`. Library is a sibling file. ### Binding to live session `MediaLibraryViewModel` (`@MainActor`, `@Observable` on `develop`): 1. `apply(recipe)`: - Resolve preset from `PresetCatalog` + `SettingsStore.customPresets`. Missing preset → error banner, do not partial-apply. - Call existing `PresetMapping` onto `TargetWorkflowViewModel` / `ProfileWorkflowViewModel` / `PrintSessionViewModel` (the #82 maps). - Set `WizardState.printerName` to `recipe.printerDisplayName` **and** PrintSession selected queue to `recipe.printerID` if that queue still exists; else banner “Printer X is not installed” and leave queue as-is. - If `applyCalibration` && `calibrationURL` file exists: load through `CalibrationStore` actor; set apply-cal toggle ON. If file missing: toggle OFF + error, do not invent a `.cal`. - If `CalibrationIdentity.isCalibration` (cal file basename or live wizard basename): force `applyCalibration` false and never pass `printtarg -K`. 2. `captureFromSession()` builds a recipe from current VM fields + selected queue + current `.cal` path. Name prompt required. ### Staleness Reuse `CalibrationStore.staleDays` (`settings.calibrationStaleDays`, default 30). A recipe is stale when: - bound `.cal` parse date older than `staleDays`, OR - stored printer / `recipe.printerID` ≠ current selected queue. Show the existing dashed-vs-solid curve chip plus a library badge `mediaRecipeStale`. Do not auto-delete. ### UI Follow the existing preset chrome. Do not invent a second settings page or a second Stage 1 form. Tokens, sidebar width, and sheet style stay as they are on `develop`. #### Placement (`Sources/ICCery/SidebarView.swift`) Insert a **Media** block *between* the preset Save/Manage row and **Calibrate Printer**. Do not put it above the logo/settings row and do not push the 1–5 stepper off the 270 pt sidebar (`Theme.Metrics.sidebarWidth`). Current order that must remain: 1. Logo + settings / about / help 2. Preset `Picker` (`presetSelect`) + Save / Manage 3. **NEW: Media library `Picker` + Capture / Manage** 4. Calibrate Printer (`btnCalibratePrinter`) 5. View Gamut (`btnViewGamut`) 6. Stepper 1–5 Keep the Media picker on one line. Label `Media` (not “Media library”) so it fits the 270 pt column the same way `Preset` does. Menu style `.pickerStyle(.menu)`, same padding as `presetSelect` (horizontal 12, vertical 8). #### Sidebar controls - `Picker("Media", selection:)` tags: - `"none"` → `Text("No media recipe")` - one tag per `MediaRecipe.id` → `Text(recipe.name)` only (#114). Do not concatenate printer/paper into the menu label; that overflows the column. Detail lives in the manage sheet. - Selection **applies immediately**, same contract as `presetSelect` → `workflow.applyPreset`. Choosing `"none"` clears `selectedRecipeID` and does **not** undo the live Stage 1/2/4 fields (applying `"none"` is not “reset to factory”). - Button row under the picker, same `HStack` as presets: - `Capture` → opens the save-from-session sheet (`btnMediaLibraryCapture`) - `Manage` → opens the manage sheet (`btnMediaLibraryManage`) - Stale badge: a small caption under the picker, identifier `mediaRecipeStale`, visible only when the *selected* recipe is stale (cal older than `calibrationStaleDays`, or `printerID` ≠ current PrintSession queue). Copy: `Text("Calibration stale")` or `Text("Printer not installed")` — two distinct strings, not a combined opaque badge. Use `.font(.caption)` + `Theme` warning colour. Help overlay: “Re-run Stage 0 or pick a different recipe.” - `helpOverlay` on the picker and both buttons, wired to the existing `$showingAllHelp` binding. Overlays must not change sidebar height (#171 / AboutHelp UI tests). #### Do not collide with preset a11y (M7/M9 lessons) - **Never** put `.accessibilityIdentifier("sidebar")` or any identifier on the sidebar `VStack` that contains both pickers. #130: an identifier on the container clobbers child identifiers on older SDKs. - Media picker id is `mediaSelect`, **not** `presetSelect`. #137: `presetSelect` already matches both a label and a pop-up; do not reuse it. - Query UI tests with identifier, not the word “Media” as a label (that string will also appear in help overlays). #### Capture sheet (`SaveMediaRecipeDialog`) Clone `SavePresetDialog` (`#savePresetDialog`): 380 pt wide, `Theme.background`, title `Text("Save Media Recipe")`. Fields, all `Text` / `TextField` / existing pickers — no free-form HTML: | Control | Id | Source when opened | |---|---|---| | Name | `saveMediaName` | empty; required; trim; disable Save when empty | | Notes | `saveMediaNotes` | empty, optional | | Paper | `saveMediaPaper` | last recipe paper or empty; required | | Ink set | `saveMediaInk` | last recipe ink or empty; required | | Printer (read-only `Text`) | `saveMediaPrinter` | `PrintSessionViewModel` selected queue display name + id | | Preset (read-only `Text`) | `saveMediaPreset` | current `selectedPresetID` name, or “No preset” | | Colour space (read-only `Text`) | `saveMediaColourSpace` | live Stage 1 colour space; must match bound preset | | Calibration path (read-only `Text`) | `saveMediaCal` | current `.cal` path or “None” | | Apply calibration `Toggle` | `saveMediaApplyCal` | live apply-cal flag; forced off when path is `CAL_*` or missing | Buttons: Cancel `btnCloseSaveMediaDialog`, Save `btnConfirmSaveMedia`. Save calls `captureFromSession()` then `showingSaveMedia = false`. Missing printer queue or empty paper/ink → inline caption error, do not close, do not write the JSON file. If the live colour space ≠ bound preset colour space, show `Text("Colour space does not match the selected preset.")` and disable Save. Present from `RootView` as a sibling of the preset sheets: ``` .sheet(isPresented: $workflow.showingSaveMedia) { SaveMediaRecipeDialog(...) } .sheet(isPresented: $workflow.showingManageMedia) { ManageMediaDialog(...) } ``` Do not present from inside `SidebarView` (sheets from a 270 pt column clip). #### Manage sheet (`ManageMediaDialog`) Clone `ManagePresetsDialog` (`#managePresetsDialog`) but wider: **640 pt**, min list height 260, `Theme.background`. `List` id `mediaLibraryList`. One row per recipe, row id `mediaRow-{id}` as a **containment** element (`accessibilityElement(children: .contain)`), same pattern as `presetRow-{id}`. Each row, left-to-right: 1. `VStack` of `Text(name)` + caption `Text("\(printerDisplayName) · \(paperName) · \(inkSet)")` 2. Preset name caption (`Text(resolvedPresetName)` or `Text("Missing preset")` in warning colour if `presetID` is gone) 3. Cal age caption: `Text("Cal 12d")` / `Text("No cal")` / `Text("Stale")` — computed, not stored 4. `Apply` `btnMediaLibraryApply-{id}` 5. `Delete` (destructive) `btnMediaLibraryDelete-{id}` There are no built-in recipes. Every row is deletable. Confirm delete with an `.alert` (“Delete “{name}”? This does not delete the .cal or the preset.”). Deleting the selected recipe sets `selectedRecipeID` to `"none"` without resetting Stage 1 fields. Footer: - `Apply selected` `btnMediaLibraryApply` — disabled when list selection is empty - `Capture current…` `btnMediaLibraryCaptureFromManage` — dismisses manage and opens the capture sheet - Close `btnCloseManageMediaDialog` Clicking a row selects it. Double-click or Return = Apply and dismiss. Apply failure (missing preset, missing `.cal`, printer gone) keeps the sheet open and publishes a `Notice` on `WizardViewModel` (`kind: error` / `warning`). Success publishes `kind: info` “Applied {name}” and dismisses. Do **not** add Import/Export JSON in this ticket. That is a follow-up; recipes live only in `media_library.json` for v1 of the feature. #### What Apply must look like in the already-shipped views After a successful apply, the user stays on the current wizard stage. Visible side effects only: - Sidebar `presetSelect` jumps to `recipe.presetID` if that preset exists - Sidebar `mediaSelect` shows the recipe name - Stage 2 printer pop-up selects `recipe.printerID` when the queue is installed; otherwise the pop-up is unchanged and the main `NoticeBanner` shows “Printer {display} is not installed” - Stage 2 / Stage 4 apply-cal toggle matches `recipe.applyCalibration` after the `CAL_` / missing-file guards - Calibration chip / curve (existing Stage 0 chrome) reloads from `CalibrationStore` when a `.cal` was applied - No new Stage 1 controls appear. Paper and ink are *not* extra targen flags; they are library metadata only, shown in the manage caption and capture sheet #### Empty and overflow states - Zero recipes: picker has only `No media recipe`; Manage list shows `Text("No media recipes yet. Capture the current printer, paper and preset.")` id `mediaLibraryEmpty`. Capture stays enabled if a printer queue is selected; disabled with help “Select a printer in Stage 2 first” otherwise. - Cap 200: Capture Save fails with notice “Media library is full (200). Delete a recipe first.” Do not silently drop the oldest. - Corrupt `media_library.json`: picker falls back to `"none"`, Manage shows the empty copy plus a persistent warning notice. Never overwrite the corrupt file from the UI (store policy already says keep + return `[]`). #### Settings Do **not** add a Media tab to `SettingsView`. Settings already owns ΔE thresholds, stale days, default instrument, and custom presets. Stale-day changes in Settings must re-evaluate the sidebar badge on the next `object` update without reopening Manage. #### UI tests (`Tests/ICCeryUITests`) Add `Milestone10MediaLibraryUITests` (mock queues / no real printer): - `testMediaPickerDoesNotReusePresetSelect` — `presetSelect` and `mediaSelect` both exist; applying media does not rename `presetSelect` - `testCaptureRequiresNamePaperInk` — Save disabled until those three fields are non-empty - `testManageApplyMissingPrinterShowsBanner` — fixture recipe with a fake `printerID`; Apply leaves Stage 2 queue unchanged; `NoticeBanner` appears - `testHelpOverlaysDoNotChangeSidebarHeight` still passes with the new block visible and help toggled - Query every new control by the identifiers listed above, never by loose label text #### Identifiers (complete) ``` mediaSelect btnMediaLibraryCapture btnMediaLibraryManage mediaRecipeStale saveMediaRecipeDialog saveMediaName saveMediaNotes saveMediaPaper saveMediaInk saveMediaPrinter saveMediaPreset saveMediaColourSpace saveMediaCal saveMediaApplyCal btnCloseSaveMediaDialog btnConfirmSaveMedia manageMediaDialog mediaLibraryList mediaLibraryEmpty mediaRow-{id} btnMediaLibraryApply-{id} btnMediaLibraryDelete-{id} btnMediaLibraryApply btnMediaLibraryCaptureFromManage btnCloseManageMediaDialog ``` ### Rewrite invariants #114 user strings via `Text`. #213 atomic write. #59 empty cwd still illegal (apply does not set cwd). #83 only `CalibrationIdentity` prefixes `CAL_`. Never search `$PATH` for `.cal`. ## Dependencies Blocks-on: #11 presets, #12 printer enum, #29/#83 calibration, #5 settings, #82 preset mapping. Unblocks: Project document (this milestone). ## Test - CI/mock: - `MediaRecipe.validated` goldens - `JSONFileStore` round-trip + corrupt file preserved - `apply()` with missing preset / missing `.cal` / `CAL_` basename refuses `-K` - `captureFromSession` copies `printerID` + `presetID` - UI (mock, `Milestone10MediaLibraryUITests`): picker ids distinct from `presetSelect`; capture validation; missing-printer banner; help overlays do not change sidebar height. - Hardware: apply a recipe onto a real installed Epson or Canon queue; stale badge when `.cal` is older than settings days. ## Acceptance criteria - [ ] Library file is not `settings.json` - [ ] Apply uses `PresetMapping`; no second Stage 1 form - [ ] `CAL_` / missing `.cal` cannot enable `printtarg -K` - [ ] Printer mismatch is a banner, not a crash - [ ] Names render through `Text` - [ ] Media block sits between preset Save/Manage and Calibrate Printer; sidebar stays 270 pt - [ ] `mediaSelect` and `presetSelect` are distinct identifiers; no identifier on the sidebar container - [ ] Capture and Manage are `RootView` sheets in the preset-dialog style - [ ] Help overlays do not change sidebar height - [ ] Branch: `feat/<this>-media-library` → `milestone/m10-studio` → `develop`
gronod added this to the M10 — Studio workflow (media library, gamut compare, spot-read, projects) milestone 2026-09-12 18:34:18 +01:00
gronod self-assigned this 2026-09-12 18:34:18 +01:00
Author
Owner

M10 implementation order (see milestone): 1 of 4. No M10 predecessor. Land on milestone/m10-studio first. Unblocks #149.

M10 implementation order (see milestone): **1 of 4**. No M10 predecessor. Land on `milestone/m10-studio` first. Unblocks #149.
gronod added a new dependency 2026-09-12 18:48:02 +01:00
Author
Owner

Implementation landed in PR #151 at f896f60d1a on milestone/m10-studio (merged as 1931da8448).

Acceptance evidence:

  • Library file is media_library.json, sibling of settings.jsonMediaLibraryStore defaults to AppPaths.appDataDir.appendingPathComponent("media_library.json") (Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaLibraryStore.swift), never a settings field.
  • Apply uses the existing workflow.applyPreset(preset) / PresetMapping path (#82) — MediaLibraryViewModel.apply calls it directly; no second Stage 1 form exists.
  • CAL_ / missing .cal cannot enable printtarg -K — apply checks CalibrationIdentity.isCalibration on both the cal-file basename and the live wizard basename and forces applyCalibration off with a warning banner.
  • Printer mismatch is a banner (wizard.showNotice "not installed"), not a crash.
  • Recipe names render via SwiftUI Text only (SidebarView media picker rows).
  • Media block sits between preset Save/Manage and Calibrate Printer; sidebar stays 270 pt (Theme.Metrics.sidebarWidth = 270).
  • mediaSelect and presetSelect are distinct identifiers; no identifier on the sidebar container.
  • Capture and Manage are RootView sheets (SaveMediaRecipeDialog / ManageMediaRecipesDialog), preset-dialog style.
  • AboutHelpUITests.testHelpOverlaysDoNotChangeSidebarHeight green (9.084 s).

Verification at milestone/m10-studio c39afe0dfd (x86_64):

  • targeted gate (PR #151 body): passed
  • full host-arch ICCeryCoreTests: 439 passed, 0 failed
  • full ICCeryUITests: 49 passed, 0 failed (real Automation attach, no skip)
  • command: xcodebuild test -scheme ICCery -destination 'platform=macOS' -derivedDataPath build/DerivedData-m10-final ARCHS=x86_64 CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'
  • xcresult: build/DerivedData-m10-final/Logs/Test/Test-ICCery-2026.09.13_11-45-03-+0100.xcresult

Hardware item (bind a real printer + paper + .cal) remains a milestone gate (Phase 8), not this issue's merge gate.
Risk register rows proven at this SHA: R10 (sidebar/a11y), R11 (CAL_ discipline).
Closing manually only after code and tests are present on the milestone branch.

Implementation landed in PR #151 at f896f60d1a0fa11d985d4fb3c40f98752a26b617 on milestone/m10-studio (merged as 1931da8448b3b939e31ce892f965d72a47beba0c). Acceptance evidence: - [x] Library file is `media_library.json`, sibling of `settings.json` — `MediaLibraryStore` defaults to `AppPaths.appDataDir.appendingPathComponent("media_library.json")` (Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaLibraryStore.swift), never a settings field. - [x] Apply uses the existing `workflow.applyPreset(preset)` / PresetMapping path (#82) — `MediaLibraryViewModel.apply` calls it directly; no second Stage 1 form exists. - [x] `CAL_` / missing `.cal` cannot enable `printtarg -K` — apply checks `CalibrationIdentity.isCalibration` on both the cal-file basename and the live wizard basename and forces `applyCalibration` off with a warning banner. - [x] Printer mismatch is a banner (`wizard.showNotice` "not installed"), not a crash. - [x] Recipe names render via SwiftUI `Text` only (SidebarView media picker rows). - [x] Media block sits between preset Save/Manage and Calibrate Printer; sidebar stays 270 pt (`Theme.Metrics.sidebarWidth = 270`). - [x] `mediaSelect` and `presetSelect` are distinct identifiers; no identifier on the sidebar container. - [x] Capture and Manage are `RootView` sheets (`SaveMediaRecipeDialog` / `ManageMediaRecipesDialog`), preset-dialog style. - [x] `AboutHelpUITests.testHelpOverlaysDoNotChangeSidebarHeight` green (9.084 s). Verification at milestone/m10-studio c39afe0dfd007863660bafff15c8da3ca11d3b5d (x86_64): - targeted gate (PR #151 body): passed - full host-arch ICCeryCoreTests: 439 passed, 0 failed - full ICCeryUITests: 49 passed, 0 failed (real Automation attach, no skip) - command: `xcodebuild test -scheme ICCery -destination 'platform=macOS' -derivedDataPath build/DerivedData-m10-final ARCHS=x86_64 CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'` - xcresult: build/DerivedData-m10-final/Logs/Test/Test-ICCery-2026.09.13_11-45-03-+0100.xcresult Hardware item (bind a real printer + paper + .cal) remains a milestone gate (Phase 8), not this issue's merge gate. Risk register rows proven at this SHA: R10 (sidebar/a11y), R11 (CAL_ discipline). Closing manually only after code and tests are present on the milestone branch.
Author
Owner

Reopening — CI on the M10 develop PR proved a deterministic compile failure on the declared toolchain floor.

Evidence: Gitea Actions run 33666 (pull_request, head 48d2816), job build-and-test (34939), step "Build for testing (host arch)". Runner macos-12, Xcode 14.2 (Swift 5.7, SDK 13.1).

Sources/ICCery/SidebarView.swift:40:13: error: extra arguments at positions #11, #12, #13, #14 in call
Sources/ICCery/MediaLibraryDialogs.swift:51:13: error: extra arguments at positions #11, #12, #13 in call

Root cause: SwiftUI.ViewBuilder.buildBlock tops out at 10 children on Swift 5.7 — buildPartialBlock (SE-0348, unbounded) only exists from Swift 5.9. Two M10 ViewBuilders exceed the cap:

  • SidebarView.body's VStack now has 14 children (media picker + stale badge + capture/manage row pushed it over; spot-read button and project chip added more).
  • SaveMediaRecipeDialog.body's VStack has 13 children.

Local gates missed it because the dev host runs Xcode 15.x, where the limit is lifted. Unit/UI jobs were skipped (build never produced a bundle), so there is no CI test signal — the failure is 100% deterministic.

Suggested fix direction (later session, fix/ branch into milestone/m10-studio): split the over-capacity VStacks into Grouped sections or extracted subviews. Do not reopen the other M10 issues for this.

Refs #156

Reopening — CI on the M10 develop PR proved a deterministic compile failure on the declared toolchain floor. **Evidence**: Gitea Actions run 33666 (pull_request, head `48d2816`), job `build-and-test` (34939), step "Build for testing (host arch)". Runner `macos-12`, Xcode 14.2 (Swift 5.7, SDK 13.1). ``` Sources/ICCery/SidebarView.swift:40:13: error: extra arguments at positions #11, #12, #13, #14 in call Sources/ICCery/MediaLibraryDialogs.swift:51:13: error: extra arguments at positions #11, #12, #13 in call ``` Root cause: `SwiftUI.ViewBuilder.buildBlock` tops out at 10 children on Swift 5.7 — `buildPartialBlock` (SE-0348, unbounded) only exists from Swift 5.9. Two M10 ViewBuilders exceed the cap: - `SidebarView.body`'s `VStack` now has 14 children (media picker + stale badge + capture/manage row pushed it over; spot-read button and project chip added more). - `SaveMediaRecipeDialog.body`'s `VStack` has 13 children. Local gates missed it because the dev host runs Xcode 15.x, where the limit is lifted. Unit/UI jobs were skipped (build never produced a bundle), so there is no CI test signal — the failure is 100% deterministic. Suggested fix direction (later session, `fix/` branch into `milestone/m10-studio`): split the over-capacity VStacks into `Group`ed sections or extracted subviews. Do not reopen the other M10 issues for this. Refs #156
gronod reopened this issue 2026-09-13 12:52:21 +01:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: gronod/iccery-v2-mac#146