Files
iccery-v2-mac/docs/24-issues-invariants.md
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 4c2863e52c Add bootstrap docs: AGENTS.md, BUILD-PLAN.md, LICENCE.md, vendored spec (docs/), brand assets
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 17:36:50 +01:00

43 KiB
Raw Permalink Blame History

24 — Issue tickets and rewrite invariants

Line numbers refer to ICCery v0.8.5 (/tmp/ICCery at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.

Source corpus: Gitea issues #1#225 (/tmp/iccery-research/issues/), issues-index.md, ROADMAP.md, git log on /tmp/ICCery (main @ e12ef41, tag v0.8.5), PR titles pages 12, plus sibling issue dumps argyll-issues.json and cpu-issues.json.

This is a rewrite-invariants document. Every closed bug gets a root cause → fix → invariant. Features are traced so a rewrite does not re-invent the same CLI/IPC contracts.

Open tickets #223 and #224 are implemented on main (see § Open issues). Won't-fix #90 / #96 are documented in § Won't-fix.


1. Feature genealogy (issue → ship)

The product is a printer-profiling workstation: vanilla JS + Tauri v2 wrapping ArgyllCMS sidecars over stdin/stdout JSON (-u) with a disk-artefact wizard.

Era Version Issues What landed
Scaffold v0.1.0 #1, #2 Tauri v2 shell, AGPLv3 isolation, ProcessManager with process:stdout/stderr/exit events
Stage 12 v0.1.3 #3, #4 targen patch sets; printtarg layout + instrument geometry
Stage 3 v0.1.6 #5, #6 chartread -u state machine + live swatch grid + ΔE₀₀
Stage 45 v0.1.9 #7, #8 colprof cLUT; profcheck peak/avg/RMS
Print + UI v0.1.100.1.13 #21, #23, #25#28, #34, #54, #61 Logo; raw GDI/CUPS print; DEVMODE prefs; collapsible logs; 1280×800 window
Gamut + ship v0.1.140.2.0 #9, #10, #57, #58, #60, #69 iccgamut + Three.js; settings/packaging; artefact-gated wizard
Hotfix v0.2.1 #84 (from #63 regression) Stdin decoupled from Child::wait()
M8 v0.3.0 #86, #87, #88, #89 instlist; multi-pass average; presets; 3D hull
Hotfixes v0.3.10.3.6 #103, #108#116, #119, #127, #134, #137 Browse dialogs, GLIBC, averaging snapshots, comm-port vs index, density vs dir, XSS/DPI, fetch-argyll, ARGYLL_NOT_INTERACTIVE, Accept/Override
macOS print v0.4.0 #91, #92 Universal dmg path + CUPS raw spooler
Production v0.5.00.5.5 #139#141, #147/#149, #151, #158#165 Logging, Stage 3 resume from .ti2, advanced targen, kill_all on exit, deterministic -R 1, universal sidecars
Enterprise colour v0.6.00.7.4 #94, #171, #172, #175#179, #184, #185, #188, #189 CGATS import, overlay tooltips, profile picker, d/u chartread keys, OBA/FWA, button system, swatch split, .gam dual-table parse, ΔE thresholds, ColorSync/vendor bypass, DMG background
Hardware + analytics v0.8.10.8.2 #204, #93, #95 i1Pro2 -Y l; XY tables; drift history
Reliability v0.8.30.8.4 #210#215, #225 Native file pickers, Node-safe gamut tests, atomic history, frontend CI, Monterey WKWebView survival
Cal + install v0.8.5 #224, #223 (tickets still open) Stage 0 printcal/applycal; OS profile install

ArgyllCMS fork genealogy (why ICCery's CLI looks the way it does):

Argyll issue Flag / contract ICCery depends on
argyll #1 chartread -uROW_COLORS_JSON: per row
argyll #2 targen -u JSON progress
argyll #3 printtarg -u page manifest JSON
argyll #4 colprof -u JSON progress
argyll #5 profcheck -u JSON ΔE report
argyll #6 instlist JSON {event:"instruments", devices:[{port,name,type}]}
argyll #19 printtarg -d "<label>" custom chart legend (ICCery #119)
argyll #24 Windows anonymous-pipe PeekNamedPipe so chartread does not deadlock on ReadFile
argyll #32 ad-hoc codesign -s - of Mach-O sidecars (ICCery #165)
argyll #37 i1Pro2 LED — ICCery shipped as chartread -Y l (#204), not the originally proposed -L

2. Closed bugs — root cause → fix → rewrite invariant

Grouped by subsystem. Issue numbers are always cited.

2.1 Process manager / IPC

#21 Process logs spill horizontally

  • Root cause: #colprofLog / #profcheckLog had no CSS; even stages with white-space: pre-wrap could not wrap Argyll's space-less progress dots.
  • Fix: Class selector .log-container pre { white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; max-height: 200px; } (PR #22). Later #54 made boxes collapsible.
  • Invariant: All subprocess consoles share one CSS class. Never style per-stage IDs. Argyll stdout is not English prose; it is long runs of . and must wrap anywhere.

#46 Visible cmd.exe on every Argyll spawn (Windows)

  • Root cause: Sidecars are IMAGE_SUBSYSTEM_WINDOWS_CUI. tokio::process::Command without creation flags allocates a console.
  • Fix: command.creation_flags(0x08000000) (CREATE_NO_WINDOW) in ProcessManager::spawn (#[cfg(windows)]).
  • Invariant: Every Windows spawn of an Argyll CUI binary must set CREATE_NO_WINDOW. Do not "fix" this by rebuilding Argyll as WINDOWS_GUI — that would also kill stdin/stdout.

#63 Zombie children (enhancement that caused #84)

  • Root cause: 100 ms try_wait polling never removed finished Child handles.
  • Fix (PR #77): child.wait().await and reap from the map.
  • Invariant: Reap is required. But never wait() while still holding the Mutex<Child> that send_stdin needs — see #84.

#84 P0: Child::wait() deadlocks send_stdin and closes stdin (v0.2.1)

  • Root cause: PR #77 awaited c.wait() under Arc<Mutex<Child>>. Tokio's Child::wait drops stdin to avoid parent/child deadlock. Interactive chartread therefore (1) could not acquire the mutex for Calibrate/Retry/Skip/Cancel, and (2) had its pipe closed immediately after spawn.
  • Fix (PR #83, v0.2.1): take() stdin/stdout/stderr at spawn; store ChildStdin in a separate stdins: HashMap<String, Arc<Mutex<ChildStdin>>>; tokio::select! between child.wait() and a oneshot kill_rx.
  • Invariant: Interactive subprocess lifetime has three independent handles: wait task, stdin mutex, kill oneshot. send_stdin must never take the Child lock. Never call wait() before take()-ing stdin.

#85 resolve_binary ignores .exe

  • Root cause: Path joins used "targen"; on disk the file is targen.exe. exists() returned false for custom dirs and bundled resources.
  • Fix (PR #97): On Windows, try {name} and {name}.exe for both settings dir and resource resolution. Unit-test both.
  • Invariant: Windows binary lookup is always a two-candidate search. Do not assume Unix names in tauri.conf.json resources.

#116 Duplicate spawn ids steal the live child's maps

  • Root cause: stdins.insert(id) / killers.insert(id) with no occupancy check. Averaging reuses chartread_${basename}. Old wait-task remove()s the new child's entries → "Process not found" on Calibrate.
  • Fix (PR #123): Hard-error if id is occupied; disable Start / Measure Another Sheet until process:exit. Tokio test: spawn, reject duplicate, stdin still works, kill, reuse.
  • Invariant: Process IDs are exclusive leases. Prefer a loud error over silently replacing the occupant. After exit, the id is reusable.

#134 Calibrate button does nothing; instlist shows nothing

  • Root cause: (1) Without ARGYLL_NOT_INTERACTIVE=1, Argyll on Windows uses _getch() / ReadConsoleInput(), ignoring piped stdin — especially combined with #46's hidden console. (2) Forked instlist emits JSON; frontend only had a text regex.
  • Fix (PR #135, v0.3.5): command.env("ARGYLL_NOT_INTERACTIVE", "1") on every spawn; parse event === "instruments" JSON with text fallback.
  • Invariant: All Argyll children get ARGYLL_NOT_INTERACTIVE=1. Frontend instrument lists parse JSON first. Related upstream: argyll #24 (PeekNamedPipe before ReadFile on anonymous pipes) — the env var is necessary but not sufficient on Windows.

#147 / #149 Orphan chartread after window close

  • Root cause: Only Stage 3 Cancel called kill_process. lib.rs used bare .run(...) with no RunEvent::Exit / CloseRequested. Spectrophotometer stays USB-locked.
  • Fix: ProcessManager::kill_all (drop stdins, signal killers, start_kill); hook app/window exit.
  • Invariant: Host exit always drains the process table. Interactive instruments are the scary case, but colprof/printtarg must die too.

#158 Settings log level is decorative

  • Root cause: tauri-plugin-log filter hardcoded Debug vs Info from cfg!(debug_assertions). AppSettings.log_level was persisted and ignored.
  • Fix: Map stored string → LevelFilter at startup and on Save.
  • Invariant: A settings control that does not change observable behaviour is a bug. Log level is runtime state, not a compile-time constant.

2.2 Print (Win / Mac / Linux)

#33 Windows CI: unresolved GDI imports

  • Root cause: windows crate 0.58 does not export SetICMMode / ICM_OFF under Win32::Graphics::Gdi as used in print/windows.rs. GitHub windows-2025 runner compiled the print backend and died with error[E0432].
  • Fix: Declare the GDI printing functions/structs used (CreateDCW, ICM off, StretchDIBits, …) directly rather than depending on incomplete crate bindings (a9802ad, c9e16ab). Later also: runner label, native rustup, shell: powershell.
  • Invariant: Colour-management bypass (SetICMMode(ICM_OFF)) is a link-time contract. Pin/vend the FFI; do not assume windows crate surface area. CI on the real Windows target is the only proof.

#36 DEVMODE preferences not applied

  • Root cause: OEM dialog (DocumentPropertiesW) writes public DEVMODEW plus dmDriverExtra private bytes. The print path was not forwarding the full buffer (and mishandled the anonymous union for dmDefaultSource / dmOrientation) into CreateDCW/StartDocW. Epson "Print Preview" etc. therefore vanished.
  • Fix: Retain the entire DEVMODE allocation including dmDriverExtra; fix union field access (19449e3).
  • Invariant: Windows driver settings are a blob, not a parsed struct. Never memcpy only dmSize bytes. Never reconstruct DEVMODE from a handful of public fields.

#48 CUPS/PPD "Uncorrected Passthrough" shown on Windows

  • Root cause: #cupsOptionsGroup rendered unconditionally.
  • Fix: Hide on navigator.userAgent Windows (v0.1.7).
  • Invariant: Print UI is per-backend. Windows = DEVMODE; Unix = PPD/CUPS. Do not show controls the active spooler cannot honour.

#67 Duplicate print IPC (cleanup, not a user bug)

  • Fix: Collapse get_windows_printers/get_cups_printers/print_target_* into get_printers / print_target.
  • Invariant: One IPC surface, platform switch inside Rust.

#188 macOS: Preferences opens System Settings; no way to disable driver CM (Epson XP-55, Canon Pro 9500 II)

  • Root cause: "Preferences" deep-linked to the OS Printers & Scanners pane, not the selected queue's driver panel. ColorSync plus vendor filters (CNIJIntent2, Epson ColorCorrection) re-managed the raw target. Follow-on: PMSessionSetColorMatchingMode 3-arg SPI crashed.
  • Fix (v0.7.20.7.3): Native NSPrintPanel bound to the current PMPrinter; PPD/lpoptions media-type extraction; vendor uncorrected-color keys; AP_ColorMatchingMode=AP_ApplicationColorMatching; remove the crashing 3-arg SPI and use the validated 2-arg / lock sequence (8b01c98). Windows media_types added in the same stack so CI still built.
  • Invariant: Raw print means three layers must all be off: OS ColorSync, driver colour, and ICM/CUPS filters. Opening the generic Printers pane is not a preferences dialog. Never call private Core Printing SPIs whose arity is guessed.

Sister-product traps (ICCery-CPU #1, #2, #5) that also apply to any CUPS rewrite:

  • PPD ChoiceName/TranslationString — UI must show the title, send the code (MediaType=13 not MediaType=Epson Premium Glossy).
  • Tray keywords are vendor-specific (InputSlot vs Epson EPIJ_FdSo vs Canon CNIJMediaSupply). Empty arrays are not nil — coalescing ?? ["Auto"] does not fire.
  • Canon Super Fine is three coordinated keys (CNIJPrintQuality=0, CNIJPrintMode2=5, CNIJPQualitySlider=5), not cupsPrintQuality=High.

2.3 Stage 1 — targen

#44 Patch count ignored → always 836

  • Root cause: Frontend sent patch_count; Rust build_targen_args only read total_patches. -f omitted → Argyll's built-in default 836 RGB patches.
  • Fix (v0.1.5, PR #45): Honour patch_count (fallback total_patches); unit-test argv.
  • Invariant: targen without -f is not "use the UI value". Default 836 is an Argyll formula, not an ICCery default. Serde field names must match across JS and Rust and be tested.

#59 Empty working directory

  • Root cause: Basename typed without Browse → cwd: "" → process launch dir (read-only / undefined). Same hole in later stages.
  • Fix (v0.1.12): Init currentWorkingDir to document_dir/home_dir; disable Generate until both basename and cwd exist; backend also refuses empty cwd.
  • Invariant: Empty cwd is illegal. Frontend guard and backend fallback. Never spawn Argyll in the install/resource directory.

#103 Stage 1 Browse does nothing; Generate stays disabled

  • Root cause: Frontend called window.__TAURI__.dialog (Tauri v1 API). With withGlobalTauri: true on Tauri v2, window.__TAURI__.dialog is undefined. Same class of bug as #210/#211.
  • Fix (v0.3.1, PR #104): Backend command select_target_file using app.dialog().
  • Invariant: Never use window.__TAURI__.dialog. All file pickers are Rust commands with explicit filters. See also #172, #210, #211.

#162 Advanced options misaligned

  • Root cause: Mixed control heights (number vs range vs checkbox-without-label) plus a full-width -c row breaking the 2-col grid; flex-end hacks.
  • Fix (v0.5.5 / v0.6.1): Semantic sub-groups, normalized row heights, flexbox auto-margins.
  • Invariant: Advanced targen controls are a 2-column grid of equal-height rows. Checkboxes get a label column. Do not use justify-content: flex-end to fake alignment.

#172 Preconditioning browse filters .ti1/.ti2/.ti3 instead of .icc/.icm/.mpp

  • Root cause: Reused select_target_file (target artefacts) for targen -c.
  • Fix: Dedicated select_profile_file.
  • Invariant: Each browse button has its own backend command and extension filter. Sharing "the dialog command" is how you open a Save-.ti1 dialog for an ICC.

#211 CGATS Import opens a Save .ti1 dialog and jumps to Stage 4 with empty basename

  • Root cause: handleImport invoked select_target_file (save_file, filter ti1). Empty cwd/basename were forwarded into import_measurement_dataset; wizardState.setTarget never ran.
  • Fix (v0.8.3): select_dataset_file (open, filters ti3/txt/cgats/csv); sync wizard target dir+basename from the imported artefact.
  • Invariant: Import = open dialog. After import, basename/cwd are initialized from the dataset before any stage jump. Do not reuse the target-save picker.

2.4 Stage 2 — printtarg

#50 Redundant Stage 2 badges (cleanup)

  • Fix: Remove duplicate status text. Not a functional invariant beyond "one source of layout truth".

#58 / #70 TIFF previews fail in WebView; image crate Windows-only

  • Root cause: Browsers/WKWebView/WebView2 do not decode TIFF. img.src = base64(tiff) is a blank. image was under [target.'cfg(windows)'.dependencies] (GDI print), so Linux/macOS could not even compile a converter.
  • Fix: Move image (png+tiff features) to common deps; read_tiff_preview_png decodes, caps ~1200 px, returns PNG base64. Keep read_file_base64 for text artefacts (.gam).
  • Invariant: Never feed TIFF to <img>. Preview pipeline is always TIFF→PNG in Rust. The image crate is a common dependency, not Windows-only.

#68 printtarg JSON parsed by hunting for a brace

  • Root cause: String-index search for {\n "event": "manifest" instead of a structured object.
  • Fix: Parse a single JSON event (Argyll #3 -u manifest).
  • Invariant: -u tools emit one JSON object per event. Accumulate stdout, JSON.parse whole objects; do not indexOf pretty-printed fragments.

#163 Regenerating Stage 2 shuffles patches (printed sheet ≠ .ti2)

  • Root cause: printtarg randomizes layout unless -R <seed> or -r. build_printtarg_args passed neither.
  • Fix (v0.5.5): Default -R 1; optional custom seed and raster-order -r.
  • Invariant: Printed charts are not reproducible without an explicit seed. Default is deterministic. Re-running Stage 2 on the same .ti1 must produce the same .ti2/.tif or chartread will score the wrong patches.

#119 (feature, not bug) printtarg -d is a label, not a directory

  • See idiosyncrasies. Do not confuse with iccgamut -d (#112).

2.5 Stage 3 — chartread / averaging / XY

#109 Multi-pass averaging overwrites the same .ti3

  • Root cause: #87 UI shipped, but every chartread wrote {basename}.ti3. Pass 2 clobbered pass 1. average was invoked as foo.ti3 foo.ti3 foo.ti3.
  • Fix: After each successful exit, snapshot {basename}.ti3{basename}_pass{N}.ti3. Canonical {basename}.ti3 is written only at Finish (copy of pass1, or average -v pass1 pass2 … basename.ti3). Do not change the chartread inoutfile (still must match .ti2 basename).
  • Invariant: Measurement identity ≠ averaging identity. chartread always uses the chart basename. Pass files are snapshots. Stage 4 never sees a pass file unless Finish has run.

#110 Stage 4 unlocks on first pass, before Average

  • Root cause: process:exit of pass 1 called wizardState.setStage3Result. verify_stage_artefacts saw {basename}.ti3 and enabled Stage 4 while "Measure Another Sheet" was still offered.
  • Fix: Same PR as #109. setStage3Result only from Finish and Average / Accept-single-pass. Prefer the canonical-file rule over a new stage3_accepted flag.
  • Invariant: Wizard gates on disk artefacts with defined meaning. {basename}.ti3 means "accepted measurement", not "a pass happened".

#111 instlist ordinal passed as chartread -c

  • Root cause: Dropdown value = inst.index (1-based instrument list). build_chartread_args emitted -c {index}. Argyll -c is a communication port list (chartread -??), a different enumeration. Works by coincidence for a single USB i1Pro on port 1.
  • Fix: Do not assume ordinal equality. Use instlist as presence/health; pass -c only when the value is a documented comm-port. Tighten regex to require instrument tokens.
  • Invariant: instlist index ≠ -c port. After JSON instlist (argyll #6 / ICCery #134), the field to pass is device.port, never the array index, and only if it is a real comm port.

#137 (feature) Accept/Override + multi-sheet prompts

  • Chartread emits multi-key warnings. UI must send the exact key (n/y/…) the prompt asks for, not just Enter.

#175 Stage 3 cannot complete — never sends d

  • Root cause: After the last strip, chartread waits for d (Done) before writing .ti3. ICCery never sent it. Follow-on: snapshot_ti3 IPC used passIndex vs the deserializer's expected key.
  • Fix (v0.6.70.6.8): Explicit Done & Save .ti3d\n; Undo Stripu\n; completion state machine; invoke("snapshot_ti3", { passIndex }) camelCase to match Tauri v2 serde rename.
  • Invariant: chartread is not finished when the last row JSON arrives. Done is a keystroke. Tauri v2 IPC argument names are camelCase on the JS side. Always add a completion control rather than inferring EOF.

#93 XY tables (SpectroScan / i1iO) — feature, but its invariants are bug-shaped: classify multi-line prompts; fiducial alignment is a 4-step checklist; cancel must park the head.

#204 i1Pro2 LEDs — pass -Y l (not -L). Silent no-op on instruments without LEDs.


2.6 Stage 4 — colprof

#56 Stage 4 is broken (IPC schema / process-id mismatch)

  • Root cause: colprof.js sent { basename, cwd }. Rust expected { ti3_path, icc_path }. Process IDs disagreed (colprof_${basename} vs colprof_{icc_path}), so UI listeners never fired even on a successful spawn.
  • Fix (PR #64, v0.1.11): Align struct, argv builder, and process id with the frontend.
  • Invariant: Spawn id is a protocol. Frontend listen-filters and backend spawn ids are the same string. Serde structs are tested against the JS payload, not against a parallel imagined schema.

#69 Profile extension .icm (Windows) vs .icc (Unix)

  • Root cause: Hardcoded ${basename}.icc. Argyll colprof writes .icm on Windows. profcheck / iccgamut then "cannot find profile".
  • Fix: Platform-aware resolver, or existence-check both extensions. Used by Stage 5 and gamut (#57).
  • Invariant: Never hardcode .icc. Resolve {icc, icm} on disk. This also applies to install (#223) and preconditioning (#172).

#176 OBA/FWA + viewing conditions — feature. colprof -c / -d here are illuminant / viewing condition, not directories and not printtarg -d labels.

#210 Custom .sp Browse: window.__TAURI__.dialog TypeError

  • Root cause: Identical to #103. No select_spectrum_file command.
  • Fix (v0.8.3): Native select_spectrum_file (filter .sp).
  • Invariant: Same as #103. Third time this class of bug shipped (#103, #210, #211). A rewrite should have a single pick_file { filters } command and never touch window.__TAURI__.dialog.

2.7 Stage 5 — gamut / profcheck / history

#57 3D gamut is a dead panel

  • Root cause: extract_gamut / loadGamutMesh existed but nothing called them after colprof. initGamutViewer never stored meshes. Blocked by #56 and #69.
  • Fix (v0.1.14): Run iccgamut after successful profile; load printer .gam + bundled sRGB.gam; dispose previous geometries; ResizeObserver on the hidden Stage 5 canvas.
  • Invariant: Gamut is a post-colprof artefact, not a demo mesh. Viewer must not allocate WebGL until the stage is shown (#225).

#112 iccgamut -d 50.0 is surface density, not a directory

  • Root cause: extract_gamut passed -d 50.0. In Argyll, iccgamut -d s is surface point density (useful ~110). Density 50 yields thousands of vertices and freezes the O(n²) hull filter. Cwd is already set on the spawn.
  • Fix (PR #121): -d 10; extract build_iccgamut_args; unit-test argv. Do not pass -w (VRML) unless required.
  • Invariant: -d is not "directory" on iccgamut. Cwd is a ProcessManager concern. Density default is 10. Argv builders exist so this cannot silently regress.

#179 Mesh empty; profcheck cards show 0.00

  • Root cause: .gam has two CGATS tables (vertices VERTEX_NO LAB_L LAB_A LAB_B, then faces VERTEX_0 VERTEX_1 VERTEX_2). Parser kept only the first BEGIN_DATA and produced {L,a,b}. QuickHull indexed p.x/p.y/p.z → all undefined → empty hull. Profcheck regex expected one wording of errors(CIEDE2000): max. =; real output variants yielded 0.
  • Fix (v0.7.0): Dual-table parse; map x=a*, y=L*, z=b*; use file faces when present; broader max/avg/rms regexes + user-visible parse warnings.
  • Invariant: .gam is two tables. Lab is not XYZ-style {x,y,z} until remapped. Profcheck text is not a stable API — prefer profcheck -u JSON (argyll #5) and keep regex as fallback only.

#117 Inlined QuickHull — modularize under src/js/vendor/quickhull.js (and later prefer file faces over recomputing hulls).

#185 Gamut rework — CIELAB axes + CSS2D labels, EdgesGeometry sRGB reference, per-vertex labToSrgb(), layer toggles. Invariant: reference overlay is edges, not a competing solid that hides the profile.

#212 gamut_viewer.test.js : window is not defined

  • Root cause: Module top-level const { invoke } = window.__TAURI__.core. Node test import evaluates that before any polyfill. Unlike profcheck.test.js / chartread.test.js, no globalThis.window mock and no CLI auto-run.
  • Fix: Guard typeof window !== 'undefined'; polyfill in the test; node src/js/gamut_viewer.test.js must exit non-zero on failure. Later #215 runs this in CI.
  • Invariant: Frontend modules that import in Node tests cannot touch window at load time. Tests are first-class; if AGENTS.md documents node src/js/….test.js, CI must run it (#215).

#213 verification_history.json non-atomic write

  • Root cause: fs::write in place. Crash ⇒ truncated JSON. load_history_file treats parse failure as empty vec, so the next save wipes the archive. Contradicted AGENTS.md's own atomic-write rule.
  • Fix (v0.8.4): Write .tmp, flush/sync, rename.
  • Invariant: History files are append-only user data. Write temp+rename. Parse failure must not be indistinguishable from "no history".

#95 Drift analytics — 1,000-record cap, SVG dual-series, consecutive-breach card, RFC-4180 CSV. Relies on #213.


2.8 macOS window / WebKit

#61 Default window 800×600

  • Fix: 1280×800 default, min 1100×700.
  • Invariant: The wizard + TIFF cards + WebGL viewer are not a mobile layout. Set min bounds in tauri.conf.json.

#148 Align minimumSystemVersion with Argyll

  • Argyll macOS builds and Tauri 2 / WebGL do not support 10.15 in practice.

#165 Unsigned Argyll sidecars SIGKILL on Apple Silicon

  • Root cause: arm64 requires at least ad-hoc signature. fetch-argyll.mjs staged Jam-built unsigned Mach-Os into Resources. Kernel kills with Killed: 9, no useful error.
  • Fix: Upstream argyll #32 (v3.5.0-ICCery.1.5) codesign -f -s - in makepackagebin.sh + CI codesign -dvv, including lipo fat binaries.
  • Invariant: Every shipped Mach-O sidecar is signed (ad-hoc at minimum). Fetch pipeline must verify signatures, not assume "it ran on Intel".

#164 Universal binary (Intel + arm64)

  • Need universal Argyll archive (argyll #27), runtime fallback if a slice is missing, CI --no-run when cross-compiling tests for the other arch.

#225 Monterey white-flash loop then silent exit (v0.8.4)

  • Root cause: Main window visible: true with no dark backing. initGamutViewer() created THREE.WebGLRenderer({ antialias: true, alpha: true }) and started rAF at DOMContentLoaded while Stage 5 was hidden. On macOS 12 (esp. Intel GPU) the Web Content/GPU helper died; WKWebView respawned its white default store; after a few cycles the host tore the window down. No Crash Reporter on ICCery.app. minimumSystemVersion still claimed 10.15.
  • Fix: Defer WebGL until the gamut viewer is shown; survive context-lost; hide window until first paint; dark WKWebView backgroundColor; log unexpected window destroy / Web Content death; raise minimumSystemVersion to 12.0.
  • Invariant: Do not create a WebGL context at startup. Do not show a window before first paint on macOS. Helper-process death ≠ app crash — log it. Support matrix must match WKWebView/WebGL reality.

2.9 Packaging / CI

#38 Unused / duplicated crates — audit deps; don't let Windows-only crates leak (inverse of #70).

#40 Linux CI too slow

  • Fix (PR #41): Allow strip (NO_STRIP off), rust-cache workspaces: "src-tauri -> target", apt archive cache, --bundles deb,appimage (skip rpm), upload-artifact v4.

#42 Windows MSI/NSIS branding.

#52 Strip foreign-platform Argyll binaries from each OS bundle.

  • Invariant: A Windows installer must not ship linux-x86_64/ and macOS/ trees. Fetch-at-build (#127) makes this a packaging filter, not a git-lfs problem.

#80 Mermaid diagram does not render

  • Root cause: Node label Raw Print Subsystem (GDI / CUPS) — parentheses parsed as shape syntax.
  • Fix: Quote / drop parens.
  • Invariant: Mermaid node text with () must be quoted.

#91 Universal .dmg + codesign pipeline (still evolving through #189).

#108 .deb requires GLIBC_2.39 (won't run on Ubuntu 22.04)

  • Root cause: Linux CI runner newer than the support target. Binary linked against glibc 2.39; 22.04 ships 2.35.
  • Fix (v0.3.2): Build on Ubuntu 22.04 LTS.
  • Invariant: The CI image is the minimum glibc. If you support 22.04, you build on 22.04 (or older), not ubuntu-latest.

#127 Fetch Argyll at build time + Windows USB driver offer

  • Removed vendored binaries. NSIS hook for ArgyllCMS_install_USB.exe. Follow-on Windows NSIS bugs in the same train: LogicLib admin check, domain-user install path must be a local fixed disk, shell-folder drive mapping, skip WebView2 bootstrapper msiexec invalid-drive.
  • Invariant: Sidecars are release artefacts of gronod/argyllcms, not git blobs. Windows USB instruments need the libusb0 INF. Packaging scripts must tr -d '\r' manifest lists (argyll #21 — CRLF in binfiles dropped drivers/docs from the zip).

#189 DMG has no background image

  • AppleScript Finder decoration does not run headlessly. Multiple CI attempts (#196, #208, #219) then dmgbuild (#222, commit 6976ced).
  • Invariant: Do not use Finder AppleScript to style a DMG on a headless runner. Use dmgbuild.

#215 Frontend tests not in CI

  • Added npm test (profcheck, chartread, gamut_viewer) to macOS/Linux/Windows workflows. Prevents #212-class breakage.

#214 Docs drift — AGENTS/README/licenses vs code. Keep ROADMAP version = Cargo version (#115, #152).


2.10 Security (innerHTML presets)

#114 Preset manager XSS via innerHTML

  • Root cause: renderManagePresetsList interpolated p.name / p.description into innerHTML. Imported JSON is attacker-controlled. Tauri WebView is a privileged surface (<img src=x onerror=…> runs with backend IPC).
  • Fix (PR #122, with #113): createElement + textContent. Optional Rust reject of < / control chars. No HTML sanitizer.
  • Invariant: Untrusted strings (preset import, CGATS descriptors, chart labels, log excerpts) never go through innerHTML. textContent only.

2.11 Settings / presets / wizard chrome

#113 applyPreset never applies TIFF DPI

  • Root cause: Schema had dpi; Fast RGB Draft is 150. applyPreset skipped #tiffDpi; collectCurrentSettingsAsPreset hardcoded dpi: 300.
  • Fix: Round-trip #tiffDpi (72600); show DPI in manage-presets subtitle.
  • Invariant: Every ProfilingPreset field that exists on disk has a DOM binding in both directions. Adding a Stage 1/2/4 control without a preset field (or vice versa) is a bug. (v0.7.0 also broke the build by adding fields without updating the struct — c917f05.)

#60 Wizard is not a wizard

  • Root cause: Stepper allowed clicking Stage 5 with no files. Modules fell back to "test_target".
  • Fix (v0.1.15 / v0.2.0): Gate .ti1.ti2+.tif.ti3.icc/.icm. Remove every || "test_target". Completed stages remain clickable backwards.
  • Invariant: No placeholder basenames. Navigation = disk. #151 re-validates artefacts on stage entry and app start (deleted files must re-lock).

#171 Tooltips reflow the layout

  • Root cause: Tooltip content inserted in-flow.
  • Fix (v0.6.2): Absolute overlay on hover/focus; in-flow hints only when the global "show tooltip hints" toggle is on.
  • Invariant: Hover must not change document flow.

#184 Configurable ΔE₀₀ thresholds — settings persistence applied to the Stage 3 traffic lights. Same "settings must actually apply" rule as #158.


2.12 UI leftovers that still encode invariants

#178 Swatch grid: honour is_pad (padding patches, id == "0"); render white patches; diagonal split intended vs measured; printtarg row/column order is canonical after bi-directional reverse. Device channels are 0100%; XYZ in JSON is 0100 and must be /100 before icmXYZ2Lab.

#177 Buttons: .btn-sm/.btn-md/.btn-lg/.btn-icon-sq only. No inline sizes. #223 install button must use this hierarchy.

#140 Resume: opening a .ti2 jumps to Stage 3; .ti1 to Stage 2. Parse header (instrument, patch count, colorants, pages). Do not regenerate #163-style random layouts over a chart that was already printed.


3. Open issues — implemented on main (v0.8.5)

Gitea still shows these as open. Git on main (merge e12ef41, tag v0.8.5) already contains the work. ROADMAP "Printer Calibration Release (v0.8.5)" matches.

Ticket State in tracker Evidence on main What shipped
#223 System-wide ICC/ICM install open 980b44b feat(stage5): install generated ICC/ICM profiles into the OS (#223) ; PR #228 → development; PR #229 → main Stage 5 "Install Profile to System": copy into OS colour store (user or system), overwrite/rename/cancel, elevation guidance, note when printcal curves were applied
#224 printcal / applycal open 24318bc feat(cal): printer linearization via printcal / applycal (#224) ; PR #227 → development; PR #229 → main Optional Stage 0: CAL_ artefacts, Apply Calibration toggle → printtarg -K + applycal, channel-response plots, stale-cal warnings, project/library persistence

Rewrite must treat Stage 0 calibration and Stage 5 install as shipped contracts, not backlog. Close the tickets when convenient.


4. Won't-fix

#90 Display wizard (dispwin / dispread) — Reviewed/Won't Fix

Asked for a second wizard mode: emissive monitor profiling (white point, gamma, cd/m², colorimeter loop, dispwin -I install).

Why not:

  • ICCery's architecture, print engine, artefact chain (.ti1.tif.ti3.icc), and raw-print colour-bypass work are reflective printer problems. Display calibration is a different product (full-screen test patches, VCGT, per-monitor ColorSync/ICC install).
  • dispwin/dispread already are interactive GUIs; wrapping them duplicates Argyll's own display tools and DisplayCAL without sharing the print pipeline.
  • Hardware, prompts, and OS install APIs do not reuse Stage 3's strip-reader state machine.
  • Priority/Low, closed the same day it was filed during M8 triage. Printer-only scope is the product.

Do not quietly add a "Print vs Display" toggle in a rewrite. If display profiling happens, it is a separate app.

#96 i18n (en/de/fr/ja) — Reviewed/Won't Fix

ROADMAP: Closed — Won't Fix (English UI retained as standard color-management terminology).

Why not:

  • The domain language is English: ΔE₀₀, cLUT, CGATS, Lab, FWA, OFPS, targen -f. Translating chrome without translating Argyll's English prompts (Place the instrument on its reflective white reference…) produces a mixed UI. Translating prompt matchers (#137, #93, #175) is how Stage 3 silently deadlocks.
  • Argyll itself is English-only; ARGYLL_NOT_INTERACTIVE keystroke protocol is letter keys (d, u, n), not localized buttons.
  • Cost of four complete dictionaries + locale-aware regex across every prompt classifier exceeded the value for a specialist tool.

Keep the UI English. Do not introduce t() until prompt classification is table-driven and Argyll grows locale support — which it will not.


5. Idiosyncrasies (do not rediscover)

These are the sharp edges issues burned in. A rewrite that "simplifies" any of them will re-open the ticket.

5.1 Argyll CLI is a minefield of one-letter flags

Tool Flag Means Ticket
targen -f N patch count; omit ⇒ 836 RGB #44
targen -c file preconditioning profile (.icc/.icm/.mpp) #172
targen -u JSON progress argyll #2
printtarg -d "str" custom label on the chart #119 / argyll #19
printtarg -D 8-bit TIFF dither (different letter) argyll #19
printtarg -R N PRNG seed; omit ⇒ non-deterministic layout #163
printtarg -r raster order, no randomize #163
printtarg -K apply calibration (Stage 0) #224
printtarg -u JSON page manifest #68 / argyll #3
chartread -u ROW_COLORS_JSON: stream #5 / argyll #1
chartread -c N comm port, not instlist index #111
chartread -d display read mode (not a directory, not a label) argyll #37 audit
chartread -Y l i1Pro2 LED (not -L) #204
chartread stdin d Done, write .ti3 #175
chartread stdin u undo last strip #175
iccgamut -d s surface density (use 10, not 50, not a path) #112
colprof -d viewing condition #176
colprof -c illuminant / FWA #176
colprof -u JSON progress argyll #4
profcheck -u JSON ΔE report argyll #5
instlist stdout JSON {event:"instruments", devices:[{port,name,type}]} #134 / argyll #6

Same letter, four meanings of -d: printtarg label, iccgamut density, chartread display mode, colprof viewing condition. Never name a helper dash_d without the tool.

5.2 Process / Windows / stdin

  • Argyll binaries are console subsystem → CREATE_NO_WINDOW (0x08000000) (#46).
  • ARGYLL_NOT_INTERACTIVE=1 is mandatory or Windows ignores pipes (#134).
  • Even then, Argyll's SetNamedPipeHandleState(PIPE_NOWAIT) fails on anonymous pipes; upstream PeekNamedPipe (argyll #24) is required or the instrument trigger never fires.
  • Child::wait() drops stdin (#84). Take streams first; store stdin separately; kill via oneshot.
  • Spawn ids are exclusive (#116).
  • App exit must kill_all (#147, #149) or the spectro stays locked.
  • Windows resolve: targen and targen.exe (#85).
  • tokio::process + GDI print FFI: don't trust windows crate completeness (#33).

5.3 Files, extensions, artefacts

  • colprof writes .icm on Windows, .icc on Unix (#69). Always probe both.
  • Wizard chain: .ti1.ti2 + .tif.ti3.icc/.icm (+ .gam) (#60, #151).
  • {basename}.ti3 is the accepted measurement; passes live in {basename}_passN.ti3 (#109, #110).
  • Calibration artefacts use CAL_ prefix and .cal (#224).
  • TIFF is not a web image (#58). Convert in Rust.
  • .gam = vertex table plus face table (#179).
  • History JSON: atomic rename or you delete the user's drift archive (#213).
  • Empty cwd is forbidden (#59).

5.4 Frontend / Tauri v2

  • window.__TAURI__.dialog does not exist. Three tickets (#103, #210, #211). File dialogs are Rust commands with dedicated filters.
  • Tauri v2 invoke args are camelCase (passIndex, not pass_index) (#175).
  • JS payload field names must match Rust (patch_count vs total_patches#44; basename/cwd vs ti3_path/icc_path#56).
  • Spawn id strings are part of the UI protocol (colprof_${basename}).
  • Do not innerHTML untrusted preset/CGATS text (#114).
  • Do not read window at module top-level if Node tests import the file (#212).
  • Do not create WebGL on DOMContentLoaded (#225).
  • Tooltips are overlays (#171).

5.5 Colour / print pipeline

  • Raw print = unmanaged raster. Bypass OS + driver + ICM (#25#28, #36, #188).
  • Windows: full DEVMODE blob including dmDriverExtra (#36); SetICMMode(ICM_OFF) (#33).
  • macOS: AP_ApplicationColorMatching + vendor keys (CNIJIntent2, Epson ColorCorrection); never crash-call 3-arg PMSessionSetColorMatchingMode (#188).
  • PPD: send choice codes, display titles; vendor keywords differ (InputSlot / EPIJ_FdSo / CNIJMediaSupply; MediaType / CNIJMediaType) — CPU #1/#2.
  • Hide CUPS widgets on Windows (#48).
  • Default printtarg seed -R 1 (#163).
  • Device JSON is 0100%; XYZ 0100 must be divided by 100 before Lab (ROW_COLORS_JSON contract, argyll #1).
  • Padding patches: is_pad / id == "0" (#178).

5.6 Packaging

  • Linux: build on the oldest glibc you support (#108 = Ubuntu 22.04).
  • Strip foreign Argyll trees (#52).
  • Fetch sidecars from gronod/argyllcms releases (#127); verify macOS ad-hoc signatures (#165).
  • Manifest copy loops: strip CR (\r) or Windows zips silently omit USB drivers (argyll #21).
  • Headless DMG: dmgbuild, not Finder AppleScript (#189).
  • minimumSystemVersion is 12.0 after Monterey WebKit reality (#225), not 10.15.

5.7 Defaults people will hardcode wrong

  • Patch count default if -f missing: 836, not 0, not 1000 (#44).
  • TIFF DPI built-in Standard = 300; Fast RGB Draft preset = 150 (#113).
  • iccgamut -d = 10 (#112).
  • Window 1280×800, min 1100×700 (#61).
  • Log level is a setting, not cfg!(debug_assertions) (#158).
  • LED flag is -Y l (#204).

6. Suggested rewrite test locks (from the tickets)

If the rewrite has tests, these are the ones the history says you will regret omitting:

  1. build_targen_args includes -f from patch_count (#44).
  2. build_printtarg_args includes -R 1 (or explicit seed) (#163).
  3. build_iccgamut_args is -v -d 10 {profile} — never a path, never 50 (#112).
  4. build_chartread_args does not pass instlist ordinal as -c (#111).
  5. Windows resolve_binary("targen") finds targen.exe (#85).
  6. ProcessManager: duplicate id errors; stdin works during wait; kill_all on drop (#84, #116, #149).
  7. Averaging: two snapshots, average argv unique files, Stage 4 locked until Finish (#109, #110).
  8. Preset round-trip includes DPI; imported <img onerror> is text (#113, #114).
  9. Profile resolver accepts .icc and .icm (#69).
  10. .gam dual-table parse; profcheck JSON then regex fallback (#179).
  11. History write is temp+rename (#213).
  12. Node can import gamut/profcheck/chartread modules (#212, #215).

7. PR title index (page 2, remaining)

Page 2 of pulls?state=all&limit=50&page=2 (older half of the genealogy):

#144 Stage 1 advanced targen (#141) · #143 resume .ti2 (#140) · #142 logging (#139) · #138 Accept/Override (#137) · #136/#135 v0.3.5 ARGYLL_NOT_INTERACTIVE (#134) · #133 v0.3.4 · #132 printtarg -d (#119) · #131 v0.4.0 · #130 macOS CUPS (#92) · #129/#128 fetch-argyll (#127) · #126 v0.3.3 · #125 QuickHull module (#117) · #124 docs v0.3.2 (#115) · #123 duplicate spawn (#116) · #122 preset DPI+XSS (#113,#114) · #121 iccgamut density (#112) · #120 comm port (#111) · #118 averaging unique ti3 (#109,#110) · #107 release asset naming · #105/#104 Stage 1 browse (#103) · #102 v0.3.0 · #101 3D hull (#89) · #100 presets (#88) · #99 averaging (#87) · #98 instlist (#86) · #97 .exe (#85) · #83 process deadlock v0.2.1 (#84) · #81 mermaid (#80) · #79 print IPC consolidate (#67,#68) · #77 wait/reap (#63) · #75 wizard gating (#60) · #74 gamut+extension (#57,#69) · #73 TIFF PNG + window (#58,#61) · #72 default cwd (#59) · #64 colprof IPC (#56) · #55 collapsible logs (#54) · #53 strip foreign binaries (#52) · #51 Stage 2 cleanup (#50) · #49 hide CUPS on Windows (#48) · #47 CREATE_NO_WINDOW (#46) · #45 targen -f (#44) · #43 installer art (#42) · #41 Linux CI (#40)

Recent page-1 merges of note: #229 v0.8.5 (#223+#224) · #228 install · #227 printcal · #226 Monterey (#225) · #222 dmgbuild (#189).