Project file: index over basename + cwd + recipe + last ΔE #149

Closed
opened 2026-09-12 18:34:20 +01:00 by gronod · 2 comments
Owner

Summary

Resume-from-.ti1/.ti2 and wizard_state.json already exist (WizardState / WizardStateStore). What is missing is a user-visible project they can reopen a month later: printer, paper, preset/recipe, calibration, artefact paths, last profcheck, notes. The project file is an index. Artefact gating stays on disk (#60, #151). Do not make the project a second source of truth.

Spec refs

  • docs/06-wizard-and-artefacts.md
  • WizardState, WizardGating, ArtefactProbe
  • VerificationHistoryStore / VerificationRecord
  • MediaRecipe (M10 sibling) — optional field, not required to land first
  • FileDialogService one-picker-per-purpose
  • JSONFileStore atomic writes (#213)
  • No placeholder basenames (#60). Empty cwd illegal (#59).

Scope

In:

  • ICCeryProject Codable + .icceryproj JSON on disk (user-chosen folder)
  • Recent-projects list in app data
  • Open / Save / Save As / New from current session
  • Opening a project sets basename + cwd + optional recipe/preset then re-runs verifyStageArtefacts; stepper follows the disk, not the file
  • Export a one-page UTF-8 report (Markdown next to the project) with last ΔE and artefact list

Out:

  • Copying .ti1/.ti2/.ti3/.icc into the project file
  • Replacing wizard_state.json
  • Cloud sync
  • Migrating v0.8.5 settings (still a separate optional ticket)

Implementation notes

Model

public struct ICCeryProject: Codable, Equatable, Sendable {
  var schemaVersion: Int            // 1
  var name: String
  var notes: String
  var basename: String
  var cwd: String                   // absolute path
  var profileBasename: String?
  var printerID: String?
  var printerDisplayName: String?
  var mediaRecipeID: String?        // optional; ignore if library missing
  var presetID: String?
  var calibrationURL: String?
  var lastVerification: VerificationSnapshot?
  var updated: Date
}
public struct VerificationSnapshot: Codable, Equatable, Sendable {
  var date: Date
  var avgDE00: Double
  var maxDE00: Double
  var status: String                // VerificationStatus rawValue
  var profileFilename: String
}

Snake_case keys. schemaVersion != 1 → hard error on open (do not partial-decode v2 fields). Paths are stored absolute; on open, if cwd is missing prompt selectDirectory and rewrite the file — do not silently use Documents.

Security: basename still rejects /, \, ... cwd must pass PathSecurity / resolveSafeCwd. Refuse to save if basename empty or cwd empty (#59/#60).

Persistence

  • Project file: user location via new pickers
    • selectProjectFile open .icceryproj
    • selectProjectSavePath save .icceryproj
    • Do not reuse selectPresetFile / selectDatasetFile.
  • Recents: AppPaths.appDataDir/recent_projects.json cap 20, store bookmark Data and path; drop entries whose file is gone.
  • Writes atomic via JSONFileStore.

wizard_state.json continues to persist the live session. Opening a project writes WizardState { basename, cwd, printerName, profileBasename, sessionMode = .profile } then calls the existing gating probe. If disk says Stage 3 but the project JSON says Stage 5, the stepper shows Stage 3. Show an info banner “Project notes say profile done; artefacts on disk stop at .ti3”.

Apply order

  1. Set cwd / basename on WizardViewModel
  2. If mediaRecipeID and library contains it → MediaLibraryViewModel.apply; else if presetID → existing preset apply
  3. Re-validate artefacts on window focus (#151)
  4. Do not auto-run targen / colprof / chartread

Save from current session writes whatever is live, including last VerificationHistoryStore record for this profile stem if any.

Report

Save Report… writes {basename}-report.md in cwd (atomic) with: name, printer, paper/recipe, artefact checklist (exists/missing), last avg/max ΔE₀₀, notes. No HTML. Filenames only.

UI

There is no File menu on develop. ICCeryApp is a single Window("ICCery", id: "main") with no .commands. Add the menu there. Do not invent a sixth wizard stage, and do not park Open/Save as large sidebar buttons next to Calibrate / Gamut / Spot Read — those slots are already spoken for by #146 / #148. Project chrome is the macOS File menu plus a compact status chip at the bottom of the 270 pt sidebar (above Spacer is wrong; put it in the spacer’s bottom, under the stepper).

AppEnvironment already documents that XCUITest cannot drive NSOpenPanel / NSSavePanel. Every panel in this ticket goes through new FileDialogService methods so UI tests can inject fixtures. Do not call NSOpenPanel from a View.

Window title

Window("ICCery", id: "main") stays the scene id (CI activates that window). Bind the represented title on the NSWindow from ProjectSession:

  • no project bound → ICCery
  • bound, clean → ICCery — {project.name}
  • bound, unsaved session fields newer than project.updated → ICCery — {project.name} •

Use Text / String only. Never put the raw path in the title (it overflows the traffic-light bar). Path belongs in the sidebar chip .help.

File menu (ICCeryApp .commands)

Insert a CommandGroup(replacing: .newItem) so we do not stack a second New under SwiftUI’s empty defaults.

Item Shortcut Id Enabled Action
New Project ⌘N menuProjectNew always confirm then resetLiveSession()
Open Project… ⌘O menuProjectOpen not while chartread/spotread/colprof child live selectProjectFile
Open Recent ▸ — menuProjectRecents recents non-empty submenu
Save Project ⌘S menuProjectSave project bound and basename + cwd legal write current session to projectURL
Save Project As… ⇧⌘S menuProjectSaveAs basename + cwd legal selectProjectSavePath then bind
Save Report… — menuProjectReport cwd set write {basename}-report.md in cwd
Close Project — menuProjectClose project bound unbind projectURL, keep live basename/cwd/artefacts

Disable Open / Save / Save As while isChartreadRunning or a spotread child is live. Help copy on the disabled item is not available in CommandGroup; show a NoticeBanner if the user hits the keyboard shortcut anyway: “Finish the instrument session before opening a project.”

Do not add these items to the dock menu or to Stage 1.

Menu identifiers must be set with .accessibilityIdentifier on Buttons inside the CommandGroup. Sidebar twins below use different ids so waitFor("btnProjectOpen") does not hit two hits.

Sidebar chip (bottom of SidebarView)

A 1–2 line footer, id projectChip, Theme.panel, 8 pt padding.

  • Unbound: Text("No project") id projectChipName + button Open… btnProjectOpen
  • Bound: Text(project.name) + caption Text(cwd.lastPathComponent) id projectChipPath. Clicking the chip does not open Finder (accidental). Secondary click / Show in Finder button btnProjectReveal opens the project file, not cwd
  • Stale-disk caption Text("Disk behind project notes") id projectChipStale when the info banner in Apply order fired (JSON claims Stage 5, artefacts stop earlier)
  • Save button btnProjectSave in the chip — same enable rules as ⌘S. Hidden when unbound (Save As lives in the menu only)

helpOverlay on the chip: “A project remembers printer, preset and folder. The stepper still follows files on disk.” Overlay must not change sidebar height.

Do not add New / Recents / Report to the chip. Menu only. The 270 pt column cannot take four more large buttons after Media + Calibrate + Gamut + Spot Read.

New Project

⌘N / menuProjectNew presents an .alert id projectNewAlert, not a sheet:

  • Copy: Text("Start a new project? The working folder and targets on disk are not deleted.")
  • Cancel btnProjectNewCancel — no-op
  • Start btnProjectNewConfirm

Confirm:

  1. Unbind projectURL
  2. Clear WizardState.basename to "" (empty is the legal “no target yet” state; do not write a placeholder — #60)
  3. Leave cwd as-is (user already picked a folder this session) unless cwd is nil
  4. Clear selectedRecipeID / do not auto-apply a recipe
  5. sessionMode = .profile
  6. Re-run artefact probe — stepper should lock 2–5 if no files match the now-empty basename
  7. Title back to ICCery

Do not delete .ti1/.ti2/.ti3/.icc/.cal. Do not wipe media_library.json.

If a child process is live, the alert does not appear; banner instead.

Open Project

selectProjectFile — NSOpenPanel, .icceryproj only, allowsOtherFileTypes = false, title “Open ICCery Project”. Cancel is a no-op.

Success path is the Apply order already in this ticket. Additional UI:

  • schemaVersion != 1 → NoticeBanner error “This project file is not schema 1.” Do not touch live state
  • basename illegal (/, ..) → same, do not apply
  • cwd path missing → present projectRelocateSheet (not a nested open panel from the first panel):
    • copy Text("The folder for {name} is missing. Choose a new working folder.")
    • Choose Folder… btnProjectRelocate → existing selectDirectory
    • Cancel btnProjectRelocateCancel — abort open, live session unchanged
    • On choose: rewrite cwd in the project file atomically, then continue Apply order
  • After apply, if JSON lastVerification exists but disk has no .icc, show info banner “Project notes say profile done; artefacts on disk stop at {last existing ext}.” Stepper follows disk. Do not jump to Stage 5
  • Push onto recents (bookmark + path). Missing files already in recents are dropped when the submenu builds, not at launch with a modal

Opening the same projectURL again is idempotent (re-probe, no second banner unless disk changed).

Save / Save As

Save (⌘S / btnProjectSave) writes the live session into the bound URL. Disabled when:

  • no projectURL
  • basename empty
  • cwd empty / unsafe
  • live basename is CAL_* — Save must first resolve CalibrationIdentity.originalBasename; if that resolve fails, banner “Finish or exit calibration before saving a project.” and refuse. Never persist CAL_ as basename

Save As always shows selectProjectSavePath (.icceryproj, suggested name {basename}.icceryproj in cwd). After write, bind that URL and push recents.

Both writes are atomic (JSONFileStore). Failure → error banner, bound URL unchanged.

There is no auto-save on stage change. wizard_state.json remains the crash-resume file.

Recents submenu

menuProjectRecents lists up to 20, newest first. Each item label is Text(name) — not the full path. Id projectRecent-{bookmarkHash} (stable-ish; path can change after relocate).

  • Click → same Open path without the open panel
  • Missing file → drop from the list, banner “Project file is gone.”, do not present relocate (we do not know it was a relocate vs deleted)
  • Clear Menu menuProjectRecentsClear at the bottom — wipes recent_projects.json, does not delete .icceryproj files

Recents file lives in AppPaths.appDataDir, never next to the project.

Report

Save Report… does not ask for a location. It writes {cwd}/{basename}-report.md atomically. If the file exists, overwrite (it is generated). Banner “Wrote {basename}-report.md”. No HTML preview sheet. No WKWebView.

If basename or cwd is empty, the menu item is disabled.

Settings / other sheets

No Project tab in Settings. No project fields on Stage 1 — Stage 1 already has basename + folder pickers; a project drives those fields, it does not duplicate them.

After Open, Stage 1’s basename field and folder label must show the applied values. After New, basename field is empty.

Dirty state

Compare live {basename, cwd, printerID, presetID, mediaRecipeID, calibrationURL} to the last saved ICCeryProject. If different, title gets • and btnProjectSave is enabled. Opening or New with dirty state:

  • New / Open / Close → the New-style alert also says “Save the current project first?” with Save / Don’t Save / Cancel (btnProjectDirtySave / btnProjectDirtyDiscard / btnProjectDirtyCancel)
  • Do not invent a third alert flavour

UI tests (Milestone10ProjectUITests)

Inject FileDialogService fixtures (AppEnvironment test seam). Never present a real panel on CI.

  • testNewProjectClearsBasenameDoesNotDeleteFixtureTi3 — seed cwd with a .ti3; New; basename field empty; file still on disk; Stage 4 still gated on the new empty basename
  • testOpenProjectDiskWinsOverJsonStage — fixture .icceryproj claims a profile; fixture folder has only .ti2; stepper Stage 5 locked; projectChipStale exists
  • testSaveDisabledWithoutBasename
  • testCalBasenameRefused — live CAL_ basename, Save shows banner, file not written with CAL_
  • testRecentsCap is a unit test on the store, not UI
  • Menu items may be absent from the AX tree on the runner; drive btnProjectOpen / btnProjectSave / menuProjectNew via the chip + alert where possible
  • testHelpOverlaysDoNotChangeSidebarHeight still passes with the chip visible

Identifiers (complete)

menuProjectNew
menuProjectOpen
menuProjectRecents
menuProjectSave
menuProjectSaveAs
menuProjectReport
menuProjectClose
menuProjectRecentsClear
projectRecent-{id}
projectNewAlert
btnProjectNewCancel
btnProjectNewConfirm
projectRelocateSheet
btnProjectRelocate
btnProjectRelocateCancel
projectChip
projectChipName
projectChipPath
projectChipStale
btnProjectOpen
btnProjectSave
btnProjectReveal
btnProjectDirtySave
btnProjectDirtyDiscard
btnProjectDirtyCancel

Rewrite invariants

Project never unlocks Stage 4 without a canonical .ti3 on disk (#109/#110). Import-dataset projects may lack .ti1/.ti2; gating already allows Stage 4 from .ti3 — do not re-print. CAL_ session: saving a project mid-calibration stores CalibrationIdentity.originalBasename as basename and calibration URL separately; never persist live CAL_ as the project basename.

Dependencies

Blocks-on: #4 gating, #6 dialogs, #26 history (snapshot is optional if history empty).
Soft-depends: Media library (mediaRecipeID ignored until that issue merges — decodeIfPresent).
Unblocks: none.

Test

  • CI/mock:
    • schemaVersion 2 throws
    • empty basename / empty cwd refuse save
    • open with missing .icc leaves Stage 4 locked if .ti3 missing, Stage 5 locked if .icc missing — disk wins over JSON
    • recents cap 20
    • CAL_ live session saves original basename
  • UI (mock, Milestone10ProjectUITests, injected dialogs): New clears basename without deleting fixtures; Open disk-wins banner; Save disabled without basename; CAL_ save refused.
  • Hardware: quit and reopen a real project; artefacts and stepper match disk

Acceptance criteria

  • .icceryproj is JSON schemaVersion 1
  • Disk artefacts own the stepper
  • Dedicated selectProjectFile / selectProjectSavePath pickers (not preset/dataset)
  • Recents live in app data, not the project file
  • No second copy of .ti3 inside the project
  • File menu on the existing Window("ICCery") scene; sidebar only has a bottom chip
  • New Project does not delete artefacts; empty basename is not a placeholder
  • Missing cwd opens relocate sheet; missing project file in Recents is dropped
  • CAL_ cannot be saved as the project basename
  • Branch: feat/<this>-project-file → milestone/m10-studio → develop
## Summary Resume-from-`.ti1`/`.ti2` and `wizard_state.json` already exist (`WizardState` / `WizardStateStore`). What is missing is a user-visible *project* they can reopen a month later: printer, paper, preset/recipe, calibration, artefact paths, last profcheck, notes. The project file is an **index**. Artefact gating stays on disk (#60, #151). Do not make the project a second source of truth. ## Spec refs - `docs/06-wizard-and-artefacts.md` - `WizardState`, `WizardGating`, `ArtefactProbe` - `VerificationHistoryStore` / `VerificationRecord` - `MediaRecipe` (M10 sibling) — optional field, not required to land first - `FileDialogService` one-picker-per-purpose - `JSONFileStore` atomic writes (#213) - No placeholder basenames (#60). Empty cwd illegal (#59). ## Scope **In:** - `ICCeryProject` Codable + `.icceryproj` JSON on disk (user-chosen folder) - Recent-projects list in app data - Open / Save / Save As / New from current session - Opening a project sets basename + cwd + optional recipe/preset then *re-runs* `verifyStageArtefacts`; stepper follows the disk, not the file - Export a one-page UTF-8 report (Markdown next to the project) with last ΔE and artefact list **Out:** - Copying `.ti1`/`.ti2`/`.ti3`/`.icc` into the project file - Replacing `wizard_state.json` - Cloud sync - Migrating v0.8.5 settings (still a separate optional ticket) ## Implementation notes ### Model ``` public struct ICCeryProject: Codable, Equatable, Sendable { var schemaVersion: Int // 1 var name: String var notes: String var basename: String var cwd: String // absolute path var profileBasename: String? var printerID: String? var printerDisplayName: String? var mediaRecipeID: String? // optional; ignore if library missing var presetID: String? var calibrationURL: String? var lastVerification: VerificationSnapshot? var updated: Date } public struct VerificationSnapshot: Codable, Equatable, Sendable { var date: Date var avgDE00: Double var maxDE00: Double var status: String // VerificationStatus rawValue var profileFilename: String } ``` Snake_case keys. `schemaVersion != 1` → hard error on open (do not partial-decode v2 fields). Paths are stored absolute; on open, if cwd is missing prompt `selectDirectory` and rewrite the file — do not silently use Documents. Security: basename still rejects `/`, `\`, `..`. cwd must pass `PathSecurity` / `resolveSafeCwd`. Refuse to save if basename empty or cwd empty (#59/#60). ### Persistence - Project file: user location via new pickers - `selectProjectFile` open `.icceryproj` - `selectProjectSavePath` save `.icceryproj` - Do not reuse `selectPresetFile` / `selectDatasetFile`. - Recents: `AppPaths.appDataDir/recent_projects.json` cap 20, store bookmark `Data` *and* path; drop entries whose file is gone. - Writes atomic via `JSONFileStore`. `wizard_state.json` continues to persist the live session. Opening a project *writes* `WizardState { basename, cwd, printerName, profileBasename, sessionMode = .profile }` then calls the existing gating probe. If disk says Stage 3 but the project JSON says Stage 5, the stepper shows Stage 3. Show an info banner “Project notes say profile done; artefacts on disk stop at .ti3”. ### Apply order 1. Set cwd / basename on `WizardViewModel` 2. If `mediaRecipeID` and library contains it → `MediaLibraryViewModel.apply`; else if `presetID` → existing preset apply 3. Re-validate artefacts on window focus (#151) 4. Do not auto-run `targen` / `colprof` / `chartread` Save from current session writes whatever is live, including last `VerificationHistoryStore` record for this profile stem if any. ### Report `Save Report…` writes `{basename}-report.md` in cwd (atomic) with: name, printer, paper/recipe, artefact checklist (exists/missing), last avg/max ΔE₀₀, notes. No HTML. Filenames only. ### UI There is no File menu on `develop`. `ICCeryApp` is a single `Window("ICCery", id: "main")` with no `.commands`. Add the menu there. Do **not** invent a sixth wizard stage, and do **not** park Open/Save as large sidebar buttons next to Calibrate / Gamut / Spot Read — those slots are already spoken for by #146 / #148. Project chrome is the macOS File menu plus a compact status chip at the **bottom** of the 270 pt sidebar (above `Spacer` is wrong; put it *in* the spacer’s bottom, under the stepper). `AppEnvironment` already documents that XCUITest cannot drive `NSOpenPanel` / `NSSavePanel`. Every panel in this ticket goes through new `FileDialogService` methods so UI tests can inject fixtures. Do not call `NSOpenPanel` from a `View`. #### Window title `Window("ICCery", id: "main")` stays the scene id (CI activates that window). Bind the represented title on the `NSWindow` from `ProjectSession`: - no project bound → `ICCery` - bound, clean → `ICCery — {project.name}` - bound, unsaved session fields newer than `project.updated` → `ICCery — {project.name} •` Use `Text` / `String` only. Never put the raw path in the title (it overflows the traffic-light bar). Path belongs in the sidebar chip `.help`. #### File menu (`ICCeryApp` `.commands`) Insert a `CommandGroup(replacing: .newItem)` so we do not stack a second New under SwiftUI’s empty defaults. | Item | Shortcut | Id | Enabled | Action | |---|---|---|---|---| | New Project | ⌘N | `menuProjectNew` | always | confirm then `resetLiveSession()` | | Open Project… | ⌘O | `menuProjectOpen` | not while chartread/spotread/colprof child live | `selectProjectFile` | | Open Recent ▸ | — | `menuProjectRecents` | recents non-empty | submenu | | Save Project | ⌘S | `menuProjectSave` | project bound **and** basename + cwd legal | write current session to `projectURL` | | Save Project As… | ⇧⌘S | `menuProjectSaveAs` | basename + cwd legal | `selectProjectSavePath` then bind | | Save Report… | — | `menuProjectReport` | cwd set | write `{basename}-report.md` in cwd | | Close Project | — | `menuProjectClose` | project bound | unbind `projectURL`, keep live basename/cwd/artefacts | Disable Open / Save / Save As while `isChartreadRunning` or a `spotread` child is live. Help copy on the disabled item is not available in `CommandGroup`; show a `NoticeBanner` if the user hits the keyboard shortcut anyway: “Finish the instrument session before opening a project.” Do **not** add these items to the dock menu or to Stage 1. Menu identifiers must be set with `.accessibilityIdentifier` on `Button`s inside the `CommandGroup`. Sidebar twins below use different ids so `waitFor("btnProjectOpen")` does not hit two hits. #### Sidebar chip (bottom of `SidebarView`) A 1–2 line footer, id `projectChip`, `Theme.panel`, 8 pt padding. - Unbound: `Text("No project")` id `projectChipName` + button `Open…` `btnProjectOpen` - Bound: `Text(project.name)` + caption `Text(cwd.lastPathComponent)` id `projectChipPath`. Clicking the chip does not open Finder (accidental). Secondary click / `Show in Finder` button `btnProjectReveal` opens the project file, not cwd - Stale-disk caption `Text("Disk behind project notes")` id `projectChipStale` when the info banner in Apply order fired (JSON claims Stage 5, artefacts stop earlier) - Save button `btnProjectSave` in the chip — same enable rules as ⌘S. Hidden when unbound (Save As lives in the menu only) `helpOverlay` on the chip: “A project remembers printer, preset and folder. The stepper still follows files on disk.” Overlay must not change sidebar height. Do not add New / Recents / Report to the chip. Menu only. The 270 pt column cannot take four more large buttons after Media + Calibrate + Gamut + Spot Read. #### New Project ⌘N / `menuProjectNew` presents an `.alert` id `projectNewAlert`, not a sheet: - Copy: `Text("Start a new project? The working folder and targets on disk are not deleted.")` - Cancel `btnProjectNewCancel` — no-op - Start `btnProjectNewConfirm` Confirm: 1. Unbind `projectURL` 2. Clear `WizardState.basename` to `""` (empty is the legal “no target yet” state; do not write a placeholder — #60) 3. Leave `cwd` as-is (user already picked a folder this session) unless cwd is nil 4. Clear `selectedRecipeID` / do not auto-apply a recipe 5. `sessionMode = .profile` 6. Re-run artefact probe — stepper should lock 2–5 if no files match the now-empty basename 7. Title back to `ICCery` Do **not** delete `.ti1`/`.ti2`/`.ti3`/`.icc`/`.cal`. Do **not** wipe `media_library.json`. If a child process is live, the alert does not appear; banner instead. #### Open Project `selectProjectFile` — `NSOpenPanel`, `.icceryproj` only, `allowsOtherFileTypes = false`, title “Open ICCery Project”. Cancel is a no-op. Success path is the Apply order already in this ticket. Additional UI: - `schemaVersion != 1` → `NoticeBanner` error “This project file is not schema 1.” Do not touch live state - basename illegal (`/`, `..`) → same, do not apply - cwd path missing → present `projectRelocateSheet` (not a nested open panel from the first panel): - copy `Text("The folder for {name} is missing. Choose a new working folder.")` - `Choose Folder…` `btnProjectRelocate` → existing `selectDirectory` - Cancel `btnProjectRelocateCancel` — abort open, live session unchanged - On choose: rewrite `cwd` in the project file atomically, then continue Apply order - After apply, if JSON `lastVerification` exists but disk has no `.icc`, show info banner “Project notes say profile done; artefacts on disk stop at {last existing ext}.” Stepper follows disk. Do not jump to Stage 5 - Push onto recents (bookmark + path). Missing files already in recents are dropped when the submenu builds, not at launch with a modal Opening the same `projectURL` again is idempotent (re-probe, no second banner unless disk changed). #### Save / Save As Save (`⌘S` / `btnProjectSave`) writes the live session into the bound URL. Disabled when: - no `projectURL` - basename empty - cwd empty / unsafe - live basename is `CAL_*` — Save must first resolve `CalibrationIdentity.originalBasename`; if that resolve fails, banner “Finish or exit calibration before saving a project.” and refuse. Never persist `CAL_` as `basename` Save As always shows `selectProjectSavePath` (`.icceryproj`, suggested name `{basename}.icceryproj` in cwd). After write, bind that URL and push recents. Both writes are atomic (`JSONFileStore`). Failure → error banner, bound URL unchanged. There is no auto-save on stage change. `wizard_state.json` remains the crash-resume file. #### Recents submenu `menuProjectRecents` lists up to 20, newest first. Each item label is `Text(name)` — not the full path. Id `projectRecent-{bookmarkHash}` (stable-ish; path can change after relocate). - Click → same Open path without the open panel - Missing file → drop from the list, banner “Project file is gone.”, do not present relocate (we do not know it was a relocate vs deleted) - `Clear Menu` `menuProjectRecentsClear` at the bottom — wipes `recent_projects.json`, does not delete `.icceryproj` files Recents file lives in `AppPaths.appDataDir`, never next to the project. #### Report `Save Report…` does **not** ask for a location. It writes `{cwd}/{basename}-report.md` atomically. If the file exists, overwrite (it is generated). Banner “Wrote {basename}-report.md”. No HTML preview sheet. No `WKWebView`. If basename or cwd is empty, the menu item is disabled. #### Settings / other sheets No Project tab in Settings. No project fields on Stage 1 — Stage 1 already has basename + folder pickers; a project *drives* those fields, it does not duplicate them. After Open, Stage 1’s basename field and folder label must show the applied values. After New, basename field is empty. #### Dirty state Compare live `{basename, cwd, printerID, presetID, mediaRecipeID, calibrationURL}` to the last saved `ICCeryProject`. If different, title gets `•` and `btnProjectSave` is enabled. Opening or New with dirty state: - New / Open / Close → the New-style alert also says “Save the current project first?” with Save / Don’t Save / Cancel (`btnProjectDirtySave` / `btnProjectDirtyDiscard` / `btnProjectDirtyCancel`) - Do not invent a third alert flavour #### UI tests (`Milestone10ProjectUITests`) Inject `FileDialogService` fixtures (`AppEnvironment` test seam). Never present a real panel on CI. - `testNewProjectClearsBasenameDoesNotDeleteFixtureTi3` — seed cwd with a `.ti3`; New; basename field empty; file still on disk; Stage 4 still gated on the *new* empty basename - `testOpenProjectDiskWinsOverJsonStage` — fixture `.icceryproj` claims a profile; fixture folder has only `.ti2`; stepper Stage 5 locked; `projectChipStale` exists - `testSaveDisabledWithoutBasename` - `testCalBasenameRefused` — live `CAL_` basename, Save shows banner, file not written with `CAL_` - `testRecentsCap` is a unit test on the store, not UI - Menu items may be absent from the AX tree on the runner; drive `btnProjectOpen` / `btnProjectSave` / `menuProjectNew` via the chip + alert where possible - `testHelpOverlaysDoNotChangeSidebarHeight` still passes with the chip visible #### Identifiers (complete) ``` menuProjectNew menuProjectOpen menuProjectRecents menuProjectSave menuProjectSaveAs menuProjectReport menuProjectClose menuProjectRecentsClear projectRecent-{id} projectNewAlert btnProjectNewCancel btnProjectNewConfirm projectRelocateSheet btnProjectRelocate btnProjectRelocateCancel projectChip projectChipName projectChipPath projectChipStale btnProjectOpen btnProjectSave btnProjectReveal btnProjectDirtySave btnProjectDirtyDiscard btnProjectDirtyCancel ``` ### Rewrite invariants Project never unlocks Stage 4 without a canonical `.ti3` on disk (#109/#110). Import-dataset projects may lack `.ti1`/`.ti2`; gating already allows Stage 4 from `.ti3` — do not re-print. `CAL_` session: saving a project mid-calibration stores `CalibrationIdentity.originalBasename` as basename and calibration URL separately; never persist live `CAL_` as the project basename. ## Dependencies Blocks-on: #4 gating, #6 dialogs, #26 history (snapshot is optional if history empty). Soft-depends: Media library (`mediaRecipeID` ignored until that issue merges — `decodeIfPresent`). Unblocks: none. ## Test - CI/mock: - `schemaVersion` 2 throws - empty basename / empty cwd refuse save - open with missing `.icc` leaves Stage 4 locked if `.ti3` missing, Stage 5 locked if `.icc` missing — disk wins over JSON - recents cap 20 - `CAL_` live session saves original basename - UI (mock, `Milestone10ProjectUITests`, injected dialogs): New clears basename without deleting fixtures; Open disk-wins banner; Save disabled without basename; `CAL_` save refused. - Hardware: quit and reopen a real project; artefacts and stepper match disk ## Acceptance criteria - [ ] `.icceryproj` is JSON `schemaVersion` 1 - [ ] Disk artefacts own the stepper - [ ] Dedicated `selectProjectFile` / `selectProjectSavePath` pickers (not preset/dataset) - [ ] Recents live in app data, not the project file - [ ] No second copy of `.ti3` inside the project - [ ] File menu on the existing `Window("ICCery")` scene; sidebar only has a bottom chip - [ ] New Project does not delete artefacts; empty basename is not a placeholder - [ ] Missing cwd opens relocate sheet; missing project file in Recents is dropped - [ ] `CAL_` cannot be saved as the project basename - [ ] Branch: `feat/<this>-project-file` → `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:20 +01:00
gronod self-assigned this 2026-09-12 18:34:20 +01:00
Author
Owner

M10 implementation order (see milestone): 4 of 4. Blocked by #146 (mediaRecipeID). Do not start until #146 apply/capture is on milestone/m10-studio.

M10 implementation order (see milestone): **4 of 4**. Blocked by #146 (`mediaRecipeID`). Do not start until #146 apply/capture is on `milestone/m10-studio`.
gronod added a new dependency 2026-09-12 18:48:02 +01:00
Author
Owner

Implementation landed in PR #154 at 1d78957d7a on milestone/m10-studio (merged as c39afe0dfd — the current milestone tip).

Acceptance evidence:

  • .icceryproj is JSON schema_version: 1 — ICCeryProject Codable with strict version check; schema_version != 1 is a hard decode error.
  • Disk artefacts own the stepper — the project stores basename + cwd only; artefact gating still probes the working directory on open (no cached stage flags).
  • Dedicated selectProjectFile / selectProjectSavePath pickers in FileDialogService (.icceryproj only; suggested name on save).
  • Recents live in app data (recent_projects.json via RecentProjectsStore, cap 20, dedupe by path), never inside the project file.
  • No second copy of .ti3 inside the project — it indexes paths, stores no measurement data.
  • File menu is ProjectCommands on the existing WindowGroup("ICCery") scene (menuProjectNew/Open/Save/SaveAs/Report/Close, menuProjectRecents); sidebar has only the bottom ProjectChip (projectChip, btnProjectOpen, btnProjectSave, btnProjectReveal) — no four large buttons.
  • New Project does not delete artefacts; empty basename is not a placeholder.
  • Missing cwd opens the relocate sheet (projectRelocateSheet, btnProjectRelocate); a missing project file in Recents is dropped on load.
  • CAL_ cannot be saved as the project basename — ICCeryProject validation refuses it, matching the wizard invariant.
  • Dirty tracking (• window title) + dirty-gate alert + {basename}-report.md via ProjectReport.

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

  • targeted gate (PR #154 body): passed
  • full host-arch ICCeryCoreTests: 439 passed, 0 failed
  • full ICCeryUITests: 49 passed, 0 failed (real Automation attach, no skip; Milestone10ProjectUITests green)
  • 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 (quit and reopen a real project end-to-end) remains a milestone gate (Phase 8), not this issue's merge gate.
Risk register rows proven at this SHA: R18 (disk-wins artefact gating), R19 (WindowGroup commands + chip, no sidebar buttons).
Closing manually only after code and tests are present on the milestone branch.

Implementation landed in PR #154 at 1d78957d7a0140eb669383019fdbf99f04c610ff on milestone/m10-studio (merged as c39afe0dfd007863660bafff15c8da3ca11d3b5d — the current milestone tip). Acceptance evidence: - [x] `.icceryproj` is JSON `schema_version: 1` — `ICCeryProject` Codable with strict version check; `schema_version != 1` is a hard decode error. - [x] Disk artefacts own the stepper — the project stores basename + cwd only; artefact gating still probes the working directory on open (no cached stage flags). - [x] Dedicated `selectProjectFile` / `selectProjectSavePath` pickers in `FileDialogService` (`.icceryproj` only; suggested name on save). - [x] Recents live in app data (`recent_projects.json` via `RecentProjectsStore`, cap 20, dedupe by path), never inside the project file. - [x] No second copy of `.ti3` inside the project — it indexes paths, stores no measurement data. - [x] File menu is `ProjectCommands` on the existing `WindowGroup("ICCery")` scene (`menuProjectNew/Open/Save/SaveAs/Report/Close`, `menuProjectRecents`); sidebar has only the bottom `ProjectChip` (`projectChip`, `btnProjectOpen`, `btnProjectSave`, `btnProjectReveal`) — no four large buttons. - [x] New Project does not delete artefacts; empty basename is not a placeholder. - [x] Missing cwd opens the relocate sheet (`projectRelocateSheet`, `btnProjectRelocate`); a missing project file in Recents is dropped on load. - [x] `CAL_` cannot be saved as the project basename — `ICCeryProject` validation refuses it, matching the wizard invariant. - [x] Dirty tracking (`•` window title) + dirty-gate alert + `{basename}-report.md` via `ProjectReport`. Verification at milestone/m10-studio c39afe0dfd007863660bafff15c8da3ca11d3b5d (x86_64): - targeted gate (PR #154 body): passed - full host-arch ICCeryCoreTests: 439 passed, 0 failed - full ICCeryUITests: 49 passed, 0 failed (real Automation attach, no skip; `Milestone10ProjectUITests` green) - 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 (quit and reopen a real project end-to-end) remains a milestone gate (Phase 8), not this issue's merge gate. Risk register rows proven at this SHA: R18 (disk-wins artefact gating), R19 (WindowGroup commands + chip, no sidebar buttons). Closing manually only after code and tests are present on the milestone branch.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: gronod/iccery-v2-mac#149