Spot-read console: one patch Lab/XYZ from the live instrument #148

Open
opened 2026-09-12 18:34:19 +01:00 by gronod · 1 comment
Owner

Summary

AppSettings.defaultInstrument is stored and never applied to argv (AppSettings.swift, docs/04 §0.1). Stage 3 owns chartread. Add a console that runs bundled spotread so the user can trust the instrument before a long chart, and so defaultInstrument finally has a consumer that is not printtarg -i.

Spec refs

  • docs/15-stage3-chartread.md (classifier, stdin table, -c port rules)
  • docs/04-argyll-binaries.md / docs/05-argyll-fork.md instlist JSON
  • InstrumentDevice / InstrumentSelection (port 1 / Auto omit -c, #111)
  • ProcessManager exclusive ids (#116), stdin independent of wait (#84)
  • XY cancel: q\n, ~500 ms, then kill (#147)
  • enable_i1pro2_leds-Y l only (letter L, #204)
  • ARGYLL_NOT_INTERACTIVE=1 on every child
  • Never search $PATH (BinaryResolver)

Scope

In:

  • SpotReadArgs / SpotReadConfig / SpotReadSample in ICCeryCore
  • ArgyllRunner.runSpotreadAsyncStream of samples + prompts
  • SpotReadViewModel + sheet from sidebar / Stage 3
  • History of last N samples, copy Lab as text, optional CSV via existing selectCsvSavePath
  • defaultInstrument seeds the picker when instlist contains that type; still never written into printtarg -i or targen

Out:

  • Replacing Stage 3 chartread
  • Sending mock keys s / u
  • Display measurement (dispwin / dispread, #90)
  • Linking Argyll instrument libraries
  • Silent $PATH or chartread fallback when spotread is missing

Implementation notes

Argv

Prefer bundled spotread if BinaryResolver.resolve("spotread") exists (Gronod 3.5.0 fork ships it next to instlist). Goldens:

spotread -v -e [-c port] [-Y l]
  • Always -v. -e = paper / reflective (no display -d).
  • -c omitted for Auto and port 1 (#111). Never pass array index.
  • -Y l iff settings.enableI1Pro2Leds (default false).
  • Do not pass spotread -u unless fork docs for this binary explicitly say JSON-on-stdout. v2.0 -u policy is printtarg + chartread + profcheck only (AGENTS.md / docs/25).

If spotread is absent, do not search $PATH. Show “spotread sidecar missing — run fetch-argyll” and stop. Do not silently reuse process id chartread_{basename}.

Process id: spotread (single lease). Duplicate Detect/Read while live → exclusive-id error (#116), same as instlist.

I/O

Streaming ProcessManager path (not runCaptured): prompts come on stdout. Reuse ChartreadClassifier matchers where the C strings are identical (Calibrate / Place / Trigger). New matchers go in SpotReadClassifier after reading real spotread prompt strings from the fork — do not guess.

stdin:

  • calibrate → same bytes Stage 3 already sends for calibrate
  • trigger → " \n" or "\n" (match ChartreadInput)
  • quit → "q\n" then ~500 ms then kill if XY; handheld may kill after q\n

Parse a sample from a line containing Lab / XYZ. Store:

public struct SpotReadSample: Codable, Sendable, Equatable, Identifiable {
  var id: UUID
  var timestamp: Date
  var lab: LabColor          // D50
  var xyz: XYZColor?         // 0–100 scale already used by the fork
  var instrumentName: String
  var port: Int?
  var rawLine: String        // diagnostics only; never render as HTML
}

ΔE₀₀ between last two samples using existing ColorDifference. Traffic lights use settings deltaEGoodMax / deltaEWarningMax (swatch bands, not Stage 5 profcheck bands).

defaultInstrument

On sheet open:

  1. detectInstruments() (existing)
  2. if settings.defaultInstrument != nil, select the first device whose type or printtarg -i code matches (i1, CM, p3, SS, 20/22/41/51)
  3. if none match, selection = .auto and a one-line note “Saved default instrument not present”

Applying the picker does not write printtarg instrument. A checkbox “Also set as default instrument” updates AppSettings only.

UI

This is a sheet, not a sixth wizard stage and not a Stage 3 tab. Stage 3 keeps chartread. Clone the look of Stage3View.instrumentSection (Theme.panel, 16 pt padding) but do not reuse Stage 3 identifiers. Milestone4 / measurement UI tests query btnDetectInstruments, chartreadInstrumentSelect, btnCalibrate, btnTrigger, btnCancel, chartreadPrompt. A second control with those ids will make waitFor flaky.

Entry

Add one sidebar button under View Gamut, above the stepper divider:

Button { workflow.showingSpotRead = true }
  Label("Spot Read", systemImage: "eyedropper")
  • Id: btnSpotRead
  • .controlSize(.large), same full-width Label style as btnCalibratePrinter / btnViewGamut
  • Enabled when WizardState.cwd is non-nil (#59). Does not require .ti2 / Stage 3 unlocked. Disabled + help “Set a working folder in Stage 1 first.” when cwd is empty
  • Disabled while MeasurementWorkflowViewModel.isChartreadRunning is true. Help: “Stop the Stage 3 chart read first.” Opening the sheet must not kill a live chartread_{basename} child
  • Do not add a second btnSpotRead on Stage 3. One entry point. Tests use waitFor("btnSpotRead")
  • helpOverlay on the button, wired to $showingAllHelp. Overlay must not change sidebar height
  • #146 may already have inserted a Media block above Calibrate Printer. This button goes after View Gamut only — do not reshuffle Media / Calibrate / Gamut

Present from RootView, sibling of the preset / gamut / media sheets:

.sheet(isPresented: $workflow.showingSpotRead) {
    SpotReadView(model: workflow.spotRead)
}

Do not present from SidebarView (270 pt column clips). On dismiss (onDisappear): send q\n, wait ~500 ms, kill process id spotread if still live. Clearing the sheet flag is not enough.

Sheet size: 560×640, Theme.background, title Text("Spot Read"). Close button btnCloseSpotRead bottom trailing (not a competing Cancel that could be confused with Stage 3 btnCancel).

Missing sidecar

If BinaryResolver.resolve("spotread") is nil, the sheet still opens but the body is only:

  • Text("spotread sidecar missing — run fetch-argyll") id spotSidecarMissing
  • Close

No Detect, no picker, no Start. Do not fall back to chartread.

Layout (top → bottom)

  1. Instrument card (clone of Stage 3 instrument section)
  2. Prompt / notice line
  3. Transport: Start · Calibrate · Read · Stop
  4. Last sample: L* a* b* + swatch + ΔE vs previous
  5. History table (last 50)
  6. Footer: Copy Lab · Export CSV · Also set as default · Close

Instrument card

Same structure as Stage3View.instrumentSection, own ids:

Control Id Behaviour
Detect btnSpotDetectInstruments calls existing detectInstruments() / instlist. Disabled while a spotread child is live or while instlist is already leased (#116)
Detection error spotDetectError caption, red; hide when nil
Picker spotInstrumentSelect Text("Auto (first available port)") tag "" + Text(device.displayName) per device. .pickerStyle(.menu)
Default-missing note spotDefaultMissing visible only when settings.defaultInstrument is set and no detected device matches
Set as default spotSetDefault Toggle("Also set as default instrument"). On apply, writes AppSettings.defaultInstrument only. Never writes printtarg -i
XY caption spotXYHint if the selected device isXY: Text("XY tables use Stage 3. Spot Read is a handheld / reflective probe.") Spot Read still runs -e reflective; do not show Stage 3’s xyTablePanel / Place-Align-Scan-Remove chips

On sheet appear:

  1. Run detect once (same as opening Stage 3)
  2. Seed selection from settings.defaultInstrument against type / printtarg -i codes (i1, CM, p3, SS, 20/22/41/51)
  3. No match → .auto + spotDefaultMissing copy Text("Saved default instrument not present")

Update the Settings caption under Default instrument (today: “Display-only — Stage 2's instrument select is used for actual runs.”) to Text("Seeds Spot Read and Stage 3 when the instrument is plugged in. printtarg -i is still chosen on Stage 2."). That is a one-line Settings change in this ticket, not a new Settings tab.

LED toggle is not duplicated here. enable_i1pro2_leds stays in Settings and flows into argv only.

Prompt line

Id spotPrompt. Idle copy: Text("Press Start to open the instrument.")

Then reuse classifier states as text only (do not reuse chartreadPrompt):

  • calibrating → “Place the instrument on the calibration tile, then Calibrate.”
  • awaiting trigger → “Place on the patch, then Read.”
  • running / waiting for sample → “Waiting for a reading…”
  • stopped → last notice or idle

Errors go to spotLastError (caption, Theme error colour) and a WizardViewModel notice if the sheet was just opened onto a missing sidecar. Never render rawLine / log HTML.

A compact ProcessLogView is allowed, ids spotLogContainer / spotLog — optional, collapsed by default. Do not copy chartreadLog ids.

Transport

One HStack, 12 pt spacing, same pattern as chartreadControlsSection but own buttons:

Visible when Label Id Action
no child Start btnSpotStart spawn spotread with current selection
child live, calibrate prompt Calibrate btnSpotCalibrate stdin = Stage 3 calibrate bytes
child live, trigger prompt Read btnSpotTrigger stdin = Stage 3 trigger (" \n" / "\n")
child live Stop btnSpotStop q\n + ~500 ms + kill id spotread

Rules:

  • Start disabled when sidecar missing, detect in flight, or Stage 3 isChartreadRunning
  • Start while id spotread is already live → exclusive-id error in spotLastError, do not spawn a second child
  • There is no Done & Save / Measure Another Sheet / Finish & Average. Those are chartread
  • There is no s / u button
  • Do not name any button btnCancel or btnCalibrate

While a child is live, the instrument picker and Detect are disabled (changing port under a running spotread is undefined).

Last sample

Card id spotLastSample. Empty state: Text("No readings yet.") id spotLastEmpty.

After a sample:

Field Id
L* one decimal spotLabL
a* one decimal spotLabA
b* one decimal spotLabB
XYZ (if parsed) caption spotXYZ
32×32 swatch spotSwatch
ΔE₀₀ vs previous spotDeltaE
instrument + port caption spotLastInstrument

Swatch colour from the same Lab→sRGB display convert SwatchPatchView already uses. ΔE uses ColorDifference and settings deltaEGoodMax / deltaEWarningMax. Render as Text plus a 8×8 traffic-light circle (good / warning / fail). First sample hides spotDeltaE (no previous).

L* outside 0…100 still displays; add caption Text("Implausible L*") id spotLabImplausible — do not clamp.

History

Table or List id spotHistoryTable, max 50 rows, newest first. Row id spotHistoryRow-{uuid}.

Columns: time (HH:mm:ss), L*, a*, b*, ΔE vs previous (blank on first), instrument short name.

  • Click row → copies that sample into the last-sample card (does not re-trigger the instrument)
  • Empty: Text("No history.") id spotHistoryEmpty
  • Do not persist history across launches. Sheet dismiss clears the in-memory list. CSV is the only export
  • Ban showing rawLine in the table

Footer buttons:

  • Copy Lab btnSpotCopyLab — puts L* a* b* of the displayed last sample on the pasteboard as plain text (50.0 1.2 -3.4). Disabled when empty
  • Export CSV… btnSpotExportCsvFileDialogService.selectCsvSavePath (same helper as verification history). RFC-4180 header timestamp,L,a,b,dE00,instrument,port. Disabled when empty. Cancel of the panel is a no-op
  • Do not add a dedicated CSV picker; do not reuse selectDatasetFile

Concurrency the UI has to surface

  • Batch incoming samples; the Lab labels must not flicker once per stdout byte
  • showingSpotRead = false mid-read runs the same Stop path as btnSpotStop
  • App terminate / killAll already covers id spotread; the sheet should just disappear
  • Never hop chartread_{basename} into this UI

UI tests (Milestone10SpotReadUITests)

Mock sidecar / no hardware:

  • testSpotReadButtonDisabledWithoutCwd
  • testSpotReadButtonDisabledDuringChartread (start a mock chartread fixture, assert btnSpotRead disabled)
  • testSheetHasOwnInstrumentIdsspotInstrumentSelect exists; chartreadInstrumentSelect does not appear inside spotReadView
  • testMissingSidecarShowsMessage — fixture without spotread binary shows spotSidecarMissing, hides btnSpotStart
  • testHistoryCopyDisabledWhenEmpty
  • Query by identifier only. Do not click Detect against a real USB device on CI

Hardware gate stays in the Test section below (one calibrate + one read).

Identifiers (complete)

btnSpotRead
spotReadView
btnCloseSpotRead
spotSidecarMissing
btnSpotDetectInstruments
spotDetectError
spotInstrumentSelect
spotDefaultMissing
spotSetDefault
spotXYHint
spotPrompt
spotLastError
spotLogContainer
spotLog
btnSpotStart
btnSpotCalibrate
btnSpotTrigger
btnSpotStop
spotLastSample
spotLastEmpty
spotLabL
spotLabA
spotLabB
spotXYZ
spotSwatch
spotDeltaE
spotLastInstrument
spotLabImplausible
spotHistoryTable
spotHistoryEmpty
spotHistoryRow-{uuid}
btnSpotCopyLab
btnSpotExportCsv

Concurrency

View model @MainActor. Do not hop to main per stdout line. Batch classifier + parse off the main actor; publish samples in groups. killAll on terminate still covers id spotread (#147/#149).

Rewrite invariants

Never PATH-resolve. Never -Y l unless the LED setting is on. Never reuse process id chartread_{basename} for this sheet. No s/u keys.

Dependencies

Blocks-on: #2 ProcessManager, #3 resolver, #5 settings, #18 instlist, #19 classifier strings as reference.
Unblocks: media-library “trust instrument before capture” UX (soft).

Test

  • CI/mock:
    • SpotReadArgs goldens (auto omits -c, port 2 includes -c 2, LED flag)
    • mock sidecar under UI fixtures emitting one Lab line
    • duplicate id rejected
    • ΔE between two fixtures uses ColorDifference
    • defaultInstrument matching / not-present note
  • UI (mock, Milestone10SpotReadUITests): button gating, own instrument ids, missing-sidecar copy, empty history disables Copy/Export.
  • Hardware: Detect real i1 or ColorMunki, one calibrate + one read, Lab plausible (L* in 0–100)

Acceptance criteria

  • Works with bundled spotread only
  • defaultInstrument seeds picker and does not touch printtarg -i
  • Stop / quit / sheet dismiss does not leave a child after ~500 ms
  • History export is RFC-4180 via existing selectCsvSavePath
  • Stage 3 chartread session remains exclusive of this sheet
  • Sidebar button is after View Gamut; enabled only with cwd and no live chartread
  • No Stage 3 identifiers reused (btnCalibrate, btnTrigger, btnCancel, chartreadInstrumentSelect, btnDetectInstruments)
  • Settings default-instrument caption updated to mention Spot Read
  • Branch: feat/<this>-spot-readmilestone/m10-studiodevelop
## Summary `AppSettings.defaultInstrument` is stored and **never applied to argv** (`AppSettings.swift`, docs/04 §0.1). Stage 3 owns `chartread`. Add a console that runs bundled `spotread` so the user can trust the instrument before a long chart, and so `defaultInstrument` finally has a consumer that is *not* `printtarg -i`. ## Spec refs - `docs/15-stage3-chartread.md` (classifier, stdin table, `-c` port rules) - `docs/04-argyll-binaries.md` / `docs/05-argyll-fork.md` `instlist` JSON - `InstrumentDevice` / `InstrumentSelection` (port 1 / Auto omit `-c`, #111) - ProcessManager exclusive ids (#116), stdin independent of wait (#84) - XY cancel: `q\n`, ~500 ms, then kill (#147) - `enable_i1pro2_leds` → `-Y l` only (letter L, #204) - `ARGYLL_NOT_INTERACTIVE=1` on every child - Never search `$PATH` (`BinaryResolver`) ## Scope **In:** - `SpotReadArgs` / `SpotReadConfig` / `SpotReadSample` in `ICCeryCore` - `ArgyllRunner.runSpotread` → `AsyncStream` of samples + prompts - `SpotReadViewModel` + sheet from sidebar / Stage 3 - History of last N samples, copy Lab as text, optional CSV via existing `selectCsvSavePath` - `defaultInstrument` seeds the picker when `instlist` contains that type; still never written into `printtarg -i` or `targen` **Out:** - Replacing Stage 3 `chartread` - Sending mock keys `s` / `u` - Display measurement (`dispwin` / `dispread`, #90) - Linking Argyll instrument libraries - Silent `$PATH` or `chartread` fallback when `spotread` is missing ## Implementation notes ### Argv Prefer bundled `spotread` if `BinaryResolver.resolve("spotread")` exists (Gronod 3.5.0 fork ships it next to `instlist`). Goldens: ``` spotread -v -e [-c port] [-Y l] ``` - Always `-v`. `-e` = paper / reflective (no display `-d`). - `-c` omitted for Auto and port 1 (#111). Never pass array index. - `-Y l` iff `settings.enableI1Pro2Leds` (default false). - Do **not** pass `spotread -u` unless fork docs for this binary explicitly say JSON-on-stdout. v2.0 `-u` policy is `printtarg` + `chartread` + `profcheck` only (`AGENTS.md` / `docs/25`). If `spotread` is absent, do **not** search `$PATH`. Show “spotread sidecar missing — run fetch-argyll” and stop. Do not silently reuse process id `chartread_{basename}`. Process id: `spotread` (single lease). Duplicate Detect/Read while live → exclusive-id error (#116), same as `instlist`. ### I/O Streaming `ProcessManager` path (not `runCaptured`): prompts come on stdout. Reuse `ChartreadClassifier` matchers where the C strings are identical (Calibrate / Place / Trigger). New matchers go in `SpotReadClassifier` *after* reading real `spotread` prompt strings from the fork — do not guess. stdin: - calibrate → same bytes Stage 3 already sends for calibrate - trigger → `" \n"` or `"\n"` (match `ChartreadInput`) - quit → `"q\n"` then ~500 ms then kill if XY; handheld may kill after `q\n` Parse a sample from a line containing Lab / XYZ. Store: ``` public struct SpotReadSample: Codable, Sendable, Equatable, Identifiable { var id: UUID var timestamp: Date var lab: LabColor // D50 var xyz: XYZColor? // 0–100 scale already used by the fork var instrumentName: String var port: Int? var rawLine: String // diagnostics only; never render as HTML } ``` ΔE₀₀ between last two samples using existing `ColorDifference`. Traffic lights use settings `deltaEGoodMax` / `deltaEWarningMax` (swatch bands, not Stage 5 profcheck bands). ### defaultInstrument On sheet open: 1. `detectInstruments()` (existing) 2. if `settings.defaultInstrument != nil`, select the first device whose type or `printtarg -i` code matches (`i1`, `CM`, `p3`, `SS`, `20`/`22`/`41`/`51`) 3. if none match, selection = `.auto` and a one-line note “Saved default instrument not present” Applying the picker does **not** write `printtarg` instrument. A checkbox “Also set as default instrument” updates `AppSettings` only. ### UI This is a **sheet**, not a sixth wizard stage and not a Stage 3 tab. Stage 3 keeps `chartread`. Clone the look of `Stage3View.instrumentSection` (`Theme.panel`, 16 pt padding) but **do not reuse Stage 3 identifiers**. `Milestone4` / measurement UI tests query `btnDetectInstruments`, `chartreadInstrumentSelect`, `btnCalibrate`, `btnTrigger`, `btnCancel`, `chartreadPrompt`. A second control with those ids will make `waitFor` flaky. #### Entry Add one sidebar button **under View Gamut**, above the stepper divider: ``` Button { workflow.showingSpotRead = true } Label("Spot Read", systemImage: "eyedropper") ``` - Id: `btnSpotRead` - `.controlSize(.large)`, same full-width `Label` style as `btnCalibratePrinter` / `btnViewGamut` - Enabled when `WizardState.cwd` is non-nil (#59). Does **not** require `.ti2` / Stage 3 unlocked. Disabled + help “Set a working folder in Stage 1 first.” when cwd is empty - Disabled while `MeasurementWorkflowViewModel.isChartreadRunning` is true. Help: “Stop the Stage 3 chart read first.” Opening the sheet must not `kill` a live `chartread_{basename}` child - Do **not** add a second `btnSpotRead` on Stage 3. One entry point. Tests use `waitFor("btnSpotRead")` - `helpOverlay` on the button, wired to `$showingAllHelp`. Overlay must not change sidebar height - #146 may already have inserted a Media block above Calibrate Printer. This button goes after View Gamut only — do not reshuffle Media / Calibrate / Gamut Present from `RootView`, sibling of the preset / gamut / media sheets: ``` .sheet(isPresented: $workflow.showingSpotRead) { SpotReadView(model: workflow.spotRead) } ``` Do not present from `SidebarView` (270 pt column clips). On dismiss (`onDisappear`): send `q\n`, wait ~500 ms, kill process id `spotread` if still live. Clearing the sheet flag is not enough. Sheet size: **560×640**, `Theme.background`, title `Text("Spot Read")`. Close button `btnCloseSpotRead` bottom trailing (not a competing Cancel that could be confused with Stage 3 `btnCancel`). #### Missing sidecar If `BinaryResolver.resolve("spotread")` is nil, the sheet still opens but the body is only: - `Text("spotread sidecar missing — run fetch-argyll")` id `spotSidecarMissing` - Close No Detect, no picker, no Start. Do not fall back to `chartread`. #### Layout (top → bottom) 1. Instrument card (clone of Stage 3 instrument section) 2. Prompt / notice line 3. Transport: Start · Calibrate · Read · Stop 4. Last sample: L* a* b* + swatch + ΔE vs previous 5. History table (last 50) 6. Footer: Copy Lab · Export CSV · Also set as default · Close #### Instrument card Same structure as `Stage3View.instrumentSection`, own ids: | Control | Id | Behaviour | |---|---|---| | Detect | `btnSpotDetectInstruments` | calls existing `detectInstruments()` / `instlist`. Disabled while a `spotread` child is live **or** while `instlist` is already leased (#116) | | Detection error | `spotDetectError` | caption, red; hide when nil | | Picker | `spotInstrumentSelect` | `Text("Auto (first available port)")` tag `""` + `Text(device.displayName)` per device. `.pickerStyle(.menu)` | | Default-missing note | `spotDefaultMissing` | visible only when `settings.defaultInstrument` is set and no detected device matches | | Set as default | `spotSetDefault` | `Toggle("Also set as default instrument")`. On apply, writes `AppSettings.defaultInstrument` only. Never writes `printtarg -i` | | XY caption | `spotXYHint` | if the selected device `isXY`: `Text("XY tables use Stage 3. Spot Read is a handheld / reflective probe.")` Spot Read still runs `-e` reflective; do not show Stage 3’s `xyTablePanel` / Place-Align-Scan-Remove chips | On sheet appear: 1. Run detect once (same as opening Stage 3) 2. Seed selection from `settings.defaultInstrument` against type / `printtarg -i` codes (`i1`, `CM`, `p3`, `SS`, `20`/`22`/`41`/`51`) 3. No match → `.auto` + `spotDefaultMissing` copy `Text("Saved default instrument not present")` Update the Settings caption under Default instrument (today: “Display-only — Stage 2's instrument select is used for actual runs.”) to `Text("Seeds Spot Read and Stage 3 when the instrument is plugged in. printtarg -i is still chosen on Stage 2.")`. That is a one-line Settings change in this ticket, not a new Settings tab. LED toggle is **not** duplicated here. `enable_i1pro2_leds` stays in Settings and flows into argv only. #### Prompt line Id `spotPrompt`. Idle copy: `Text("Press Start to open the instrument.")` Then reuse classifier states as **text only** (do not reuse `chartreadPrompt`): - calibrating → “Place the instrument on the calibration tile, then Calibrate.” - awaiting trigger → “Place on the patch, then Read.” - running / waiting for sample → “Waiting for a reading…” - stopped → last notice or idle Errors go to `spotLastError` (caption, `Theme` error colour) **and** a `WizardViewModel` notice if the sheet was just opened onto a missing sidecar. Never render `rawLine` / log HTML. A compact `ProcessLogView` is allowed, ids `spotLogContainer` / `spotLog` — optional, collapsed by default. Do not copy `chartreadLog` ids. #### Transport One `HStack`, 12 pt spacing, same pattern as `chartreadControlsSection` but own buttons: | Visible when | Label | Id | Action | |---|---|---|---| | no child | Start | `btnSpotStart` | spawn `spotread` with current selection | | child live, calibrate prompt | Calibrate | `btnSpotCalibrate` | stdin = Stage 3 calibrate bytes | | child live, trigger prompt | Read | `btnSpotTrigger` | stdin = Stage 3 trigger (`" \n"` / `"\n"`) | | child live | Stop | `btnSpotStop` | `q\n` + ~500 ms + kill id `spotread` | Rules: - Start disabled when sidecar missing, detect in flight, or Stage 3 `isChartreadRunning` - Start while id `spotread` is already live → exclusive-id error in `spotLastError`, do not spawn a second child - There is **no** Done & Save / Measure Another Sheet / Finish & Average. Those are chartread - There is **no** `s` / `u` button - Do not name any button `btnCancel` or `btnCalibrate` While a child is live, the instrument picker and Detect are disabled (changing port under a running `spotread` is undefined). #### Last sample Card id `spotLastSample`. Empty state: `Text("No readings yet.")` id `spotLastEmpty`. After a sample: | Field | Id | |---|---| | L* one decimal | `spotLabL` | | a* one decimal | `spotLabA` | | b* one decimal | `spotLabB` | | XYZ (if parsed) caption | `spotXYZ` | | 32×32 swatch | `spotSwatch` | | ΔE₀₀ vs previous | `spotDeltaE` | | instrument + port caption | `spotLastInstrument` | Swatch colour from the same Lab→sRGB display convert `SwatchPatchView` already uses. ΔE uses `ColorDifference` and settings `deltaEGoodMax` / `deltaEWarningMax`. Render as `Text` plus a 8×8 traffic-light circle (good / warning / fail). First sample hides `spotDeltaE` (no previous). L* outside 0…100 still displays; add caption `Text("Implausible L*")` id `spotLabImplausible` — do not clamp. #### History `Table` or `List` id `spotHistoryTable`, max 50 rows, newest first. Row id `spotHistoryRow-{uuid}`. Columns: time (HH:mm:ss), L*, a*, b*, ΔE vs previous (blank on first), instrument short name. - Click row → copies that sample into the last-sample card (does not re-trigger the instrument) - Empty: `Text("No history.")` id `spotHistoryEmpty` - Do not persist history across launches. Sheet dismiss clears the in-memory list. CSV is the only export - Ban showing `rawLine` in the table Footer buttons: - `Copy Lab` `btnSpotCopyLab` — puts `L* a* b*` of the displayed last sample on the pasteboard as plain text (`50.0 1.2 -3.4`). Disabled when empty - `Export CSV…` `btnSpotExportCsv` — `FileDialogService.selectCsvSavePath` (same helper as verification history). RFC-4180 header `timestamp,L,a,b,dE00,instrument,port`. Disabled when empty. Cancel of the panel is a no-op - Do not add a dedicated CSV picker; do not reuse `selectDatasetFile` #### Concurrency the UI has to surface - Batch incoming samples; the Lab labels must not flicker once per stdout byte - `showingSpotRead = false` mid-read runs the same Stop path as `btnSpotStop` - App terminate / `killAll` already covers id `spotread`; the sheet should just disappear - Never hop `chartread_{basename}` into this UI #### UI tests (`Milestone10SpotReadUITests`) Mock sidecar / no hardware: - `testSpotReadButtonDisabledWithoutCwd` - `testSpotReadButtonDisabledDuringChartread` (start a mock chartread fixture, assert `btnSpotRead` disabled) - `testSheetHasOwnInstrumentIds` — `spotInstrumentSelect` exists; `chartreadInstrumentSelect` does **not** appear inside `spotReadView` - `testMissingSidecarShowsMessage` — fixture without `spotread` binary shows `spotSidecarMissing`, hides `btnSpotStart` - `testHistoryCopyDisabledWhenEmpty` - Query by identifier only. Do not click Detect against a real USB device on CI Hardware gate stays in the Test section below (one calibrate + one read). #### Identifiers (complete) ``` btnSpotRead spotReadView btnCloseSpotRead spotSidecarMissing btnSpotDetectInstruments spotDetectError spotInstrumentSelect spotDefaultMissing spotSetDefault spotXYHint spotPrompt spotLastError spotLogContainer spotLog btnSpotStart btnSpotCalibrate btnSpotTrigger btnSpotStop spotLastSample spotLastEmpty spotLabL spotLabA spotLabB spotXYZ spotSwatch spotDeltaE spotLastInstrument spotLabImplausible spotHistoryTable spotHistoryEmpty spotHistoryRow-{uuid} btnSpotCopyLab btnSpotExportCsv ``` ### Concurrency View model `@MainActor`. Do not hop to main per stdout line. Batch classifier + parse off the main actor; publish samples in groups. `killAll` on terminate still covers id `spotread` (#147/#149). ### Rewrite invariants Never PATH-resolve. Never `-Y l` unless the LED setting is on. Never reuse process id `chartread_{basename}` for this sheet. No `s`/`u` keys. ## Dependencies Blocks-on: #2 ProcessManager, #3 resolver, #5 settings, #18 instlist, #19 classifier strings as reference. Unblocks: media-library “trust instrument before capture” UX (soft). ## Test - CI/mock: - `SpotReadArgs` goldens (auto omits `-c`, port 2 includes `-c 2`, LED flag) - mock sidecar under UI fixtures emitting one Lab line - duplicate id rejected - ΔE between two fixtures uses `ColorDifference` - `defaultInstrument` matching / not-present note - UI (mock, `Milestone10SpotReadUITests`): button gating, own instrument ids, missing-sidecar copy, empty history disables Copy/Export. - Hardware: Detect real i1 or ColorMunki, one calibrate + one read, Lab plausible (L* in 0–100) ## Acceptance criteria - [ ] Works with bundled `spotread` only - [ ] `defaultInstrument` seeds picker and does not touch `printtarg -i` - [ ] Stop / quit / sheet dismiss does not leave a child after ~500 ms - [ ] History export is RFC-4180 via existing `selectCsvSavePath` - [ ] Stage 3 `chartread` session remains exclusive of this sheet - [ ] Sidebar button is after View Gamut; enabled only with cwd and no live chartread - [ ] No Stage 3 identifiers reused (`btnCalibrate`, `btnTrigger`, `btnCancel`, `chartreadInstrumentSelect`, `btnDetectInstruments`) - [ ] Settings default-instrument caption updated to mention Spot Read - [ ] Branch: `feat/<this>-spot-read` → `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:19 +01:00
gronod self-assigned this 2026-09-12 18:34:19 +01:00
Author
Owner

M10 implementation order (see milestone): 2 of 4. Code-independent of #146; merge after #146 so capture-from-session can use a live Lab check. Do not share a PR with Stage 3 chartread work.

M10 implementation order (see milestone): **2 of 4**. Code-independent of #146; merge after #146 so capture-from-session can use a live Lab check. Do not share a PR with Stage 3 chartread work.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: gronod/iccery-v2-mac#148