Media library: printer + paper + ink + .cal bound to a preset #146
Notifications
Due Date
No due date set.
Blocks
#149 Project file: index over basename + cwd + recipe + last ΔE
gronod/iccery-v2-mac
Reference: gronod/iccery-v2-mac#146
Reference in New Issue
Block a user
Summary
Presets today are targen / printtarg / colprof field bags (
ProfilingPresetinPackages/ICCeryCore/Sources/ICCeryCore/Settings/ProfilingPreset.swift). Users think in “Epson P900 + Canson Rag Photographique + PK + 800 patches + this.cal”. Add a first-classMediaRecipelibrary that references a preset rather than forking a second form schema.Spec refs
docs/22-settings-presets.mddocs/07-stage0-calibration.mddocs/11-print-macos.md(queue identity is CUPS printer-id, not display name)CalibrationIdentity/CalibrationStore(issues #29, #83)AppSettings.calibrationStaleDaysprinttarg -KaCAL_basename (CalibrationIdentity.isCalibration)Scope
In:
MediaRecipeCodable model +MediaLibraryStoreonJSONFileStorePresetMapping— do not duplicate form controls-Kwhen the bound cal basename isCAL_Out:
media_typeis fine)Implementation notes
Model (
ICCeryCore)Snake_case
CodingKeys. Unknown keys ignored.validated()rejects empty name, emptyprinterID,colour_spacenotrgb/cmyk, emptypresetID, path traversal incalibrationURL(.., NUL). Names viaTextonly.Store:
~/Library/Application Support/com.gronod.iccery2/media_library.jsonviaJSONFileStore<[MediaRecipe]>(corrupt: keep file + return[]+ banner, same policy asVerificationHistoryStore— do notreplaceWithDefaultand wipe). Cap 200 recipes. Atomic.tmp+replaceItemAt(#213).Do not put recipes inside
settings.json. Settings already ownscustom_presets. Library is a sibling file.Binding to live session
MediaLibraryViewModel(@MainActor,@Observableondevelop):apply(recipe):PresetCatalog+SettingsStore.customPresets. Missing preset → error banner, do not partial-apply.PresetMappingontoTargetWorkflowViewModel/ProfileWorkflowViewModel/PrintSessionViewModel(the #82 maps).WizardState.printerNametorecipe.printerDisplayNameand PrintSession selected queue torecipe.printerIDif that queue still exists; else banner “Printer X is not installed” and leave queue as-is.applyCalibration&&calibrationURLfile exists: load throughCalibrationStoreactor; set apply-cal toggle ON. If file missing: toggle OFF + error, do not invent a.cal.CalibrationIdentity.isCalibration(cal file basename or live wizard basename): forceapplyCalibrationfalse and never passprinttarg -K.captureFromSession()builds a recipe from current VM fields + selected queue + current.calpath. Name prompt required.Staleness
Reuse
CalibrationStore.staleDays(settings.calibrationStaleDays, default 30). A recipe is stale when:.calparse date older thanstaleDays, ORrecipe.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:
Picker(presetSelect) + Save / ManagePicker+ Capture / ManagebtnCalibratePrinter)btnViewGamut)Keep the Media picker on one line. Label
Media(not “Media library”) so it fits the 270 pt column the same wayPresetdoes. Menu style.pickerStyle(.menu), same padding aspresetSelect(horizontal 12, vertical 8).Sidebar controls
Picker("Media", selection:)tags:"none"→Text("No media recipe")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.presetSelect→workflow.applyPreset. Choosing"none"clearsselectedRecipeIDand does not undo the live Stage 1/2/4 fields (applying"none"is not “reset to factory”).HStackas presets:Capture→ opens the save-from-session sheet (btnMediaLibraryCapture)Manage→ opens the manage sheet (btnMediaLibraryManage)mediaRecipeStale, visible only when the selected recipe is stale (cal older thancalibrationStaleDays, orprinterID≠ current PrintSession queue). Copy:Text("Calibration stale")orText("Printer not installed")— two distinct strings, not a combined opaque badge. Use.font(.caption)+Themewarning colour. Help overlay: “Re-run Stage 0 or pick a different recipe.”helpOverlayon the picker and both buttons, wired to the existing$showingAllHelpbinding. Overlays must not change sidebar height (#171 / AboutHelp UI tests).Do not collide with preset a11y (M7/M9 lessons)
.accessibilityIdentifier("sidebar")or any identifier on the sidebarVStackthat contains both pickers. #130: an identifier on the container clobbers child identifiers on older SDKs.mediaSelect, notpresetSelect. #137:presetSelectalready matches both a label and a pop-up; do not reuse it.Capture sheet (
SaveMediaRecipeDialog)Clone
SavePresetDialog(#savePresetDialog): 380 pt wide,Theme.background, titleText("Save Media Recipe").Fields, all
Text/TextField/ existing pickers — no free-form HTML:saveMediaNamesaveMediaNotessaveMediaPapersaveMediaInkText)saveMediaPrinterPrintSessionViewModelselected queue display name + idText)saveMediaPresetselectedPresetIDname, or “No preset”Text)saveMediaColourSpaceText)saveMediaCal.calpath or “None”TogglesaveMediaApplyCalCAL_*or missingButtons: Cancel
btnCloseSaveMediaDialog, SavebtnConfirmSaveMedia. Save callscaptureFromSession()thenshowingSaveMedia = 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
RootViewas a sibling of the preset sheets: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.ListidmediaLibraryList. One row per recipe, row idmediaRow-{id}as a containment element (accessibilityElement(children: .contain)), same pattern aspresetRow-{id}.Each row, left-to-right:
VStackofText(name)+ captionText("\(printerDisplayName) · \(paperName) · \(inkSet)")Text(resolvedPresetName)orText("Missing preset")in warning colour ifpresetIDis gone)Text("Cal 12d")/Text("No cal")/Text("Stale")— computed, not storedApplybtnMediaLibraryApply-{id}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 setsselectedRecipeIDto"none"without resetting Stage 1 fields.Footer:
Apply selectedbtnMediaLibraryApply— disabled when list selection is emptyCapture current…btnMediaLibraryCaptureFromManage— dismisses manage and opens the capture sheetbtnCloseManageMediaDialogClicking 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 aNoticeonWizardViewModel(kind: error/warning). Success publisheskind: 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.jsonfor 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:
presetSelectjumps torecipe.presetIDif that preset existsmediaSelectshows the recipe namerecipe.printerIDwhen the queue is installed; otherwise the pop-up is unchanged and the mainNoticeBannershows “Printer {display} is not installed”recipe.applyCalibrationafter theCAL_/ missing-file guardsCalibrationStorewhen a.calwas appliedEmpty and overflow states
No media recipe; Manage list showsText("No media recipes yet. Capture the current printer, paper and preset.")idmediaLibraryEmpty. Capture stays enabled if a printer queue is selected; disabled with help “Select a printer in Stage 2 first” otherwise.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 nextobjectupdate without reopening Manage.UI tests (
Tests/ICCeryUITests)Add
Milestone10MediaLibraryUITests(mock queues / no real printer):testMediaPickerDoesNotReusePresetSelect—presetSelectandmediaSelectboth exist; applying media does not renamepresetSelecttestCaptureRequiresNamePaperInk— Save disabled until those three fields are non-emptytestManageApplyMissingPrinterShowsBanner— fixture recipe with a fakeprinterID; Apply leaves Stage 2 queue unchanged;NoticeBannerappearstestHelpOverlaysDoNotChangeSidebarHeightstill passes with the new block visible and help toggledIdentifiers (complete)
Rewrite invariants
#114 user strings via
Text. #213 atomic write. #59 empty cwd still illegal (apply does not set cwd). #83 onlyCalibrationIdentityprefixesCAL_. Never search$PATHfor.cal.Dependencies
Blocks-on: #11 presets, #12 printer enum, #29/#83 calibration, #5 settings, #82 preset mapping.
Unblocks: Project document (this milestone).
Test
MediaRecipe.validatedgoldensJSONFileStoreround-trip + corrupt file preservedapply()with missing preset / missing.cal/CAL_basename refuses-KcaptureFromSessioncopiesprinterID+presetIDMilestone10MediaLibraryUITests): picker ids distinct frompresetSelect; capture validation; missing-printer banner; help overlays do not change sidebar height..calis older than settings days.Acceptance criteria
settings.jsonPresetMapping; no second Stage 1 formCAL_/ missing.calcannot enableprinttarg -KTextmediaSelectandpresetSelectare distinct identifiers; no identifier on the sidebar containerRootViewsheets in the preset-dialog stylefeat/<this>-media-library→milestone/m10-studio→developM10 implementation order (see milestone): 1 of 4. No M10 predecessor. Land on
milestone/m10-studiofirst. Unblocks #149.Implementation landed in PR #151 at
f896f60d1aon milestone/m10-studio (merged as1931da8448).Acceptance evidence:
media_library.json, sibling ofsettings.json—MediaLibraryStoredefaults toAppPaths.appDataDir.appendingPathComponent("media_library.json")(Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaLibraryStore.swift), never a settings field.workflow.applyPreset(preset)/ PresetMapping path (#82) —MediaLibraryViewModel.applycalls it directly; no second Stage 1 form exists.CAL_/ missing.calcannot enableprinttarg -K— apply checksCalibrationIdentity.isCalibrationon both the cal-file basename and the live wizard basename and forcesapplyCalibrationoff with a warning banner.wizard.showNotice"not installed"), not a crash.Textonly (SidebarView media picker rows).Theme.Metrics.sidebarWidth = 270).mediaSelectandpresetSelectare distinct identifiers; no identifier on the sidebar container.RootViewsheets (SaveMediaRecipeDialog/ManageMediaRecipesDialog), preset-dialog style.AboutHelpUITests.testHelpOverlaysDoNotChangeSidebarHeightgreen (9.084 s).Verification at milestone/m10-studio
c39afe0dfd(x86_64):xcodebuild test -scheme ICCery -destination 'platform=macOS' -derivedDataPath build/DerivedData-m10-final ARCHS=x86_64 CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'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.
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), jobbuild-and-test(34939), step "Build for testing (host arch)". Runnermacos-12, Xcode 14.2 (Swift 5.7, SDK 13.1).Root cause:
SwiftUI.ViewBuilder.buildBlocktops 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'sVStacknow has 14 children (media picker + stale badge + capture/manage row pushed it over; spot-read button and project chip added more).SaveMediaRecipeDialog.body'sVStackhas 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 intomilestone/m10-studio): split the over-capacity VStacks intoGrouped sections or extracted subviews. Do not reopen the other M10 issues for this.Refs #156