Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5c57649e1 | ||
|
|
eb3ae08cf9 | ||
|
|
3d8e70be62 | ||
|
|
f43c275152 | ||
|
|
48d2816c73 | ||
|
|
b494b4fffd | ||
|
|
c39afe0dfd |
@@ -8,7 +8,7 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Product | ICCery v2 for macOS |
|
| Product | ICCery v2 for macOS |
|
||||||
| Bundle | `com.gronod.iccery2` |
|
| Bundle | `com.gronod.iccery2` |
|
||||||
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
|
||||||
| Default branch | `develop` |
|
| Default branch | `develop` |
|
||||||
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
|
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
|
||||||
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
||||||
|
|||||||
@@ -144,6 +144,10 @@ struct GamutView: View {
|
|||||||
.toggleStyle(.checkbox)
|
.toggleStyle(.checkbox)
|
||||||
.disabled(layer == nil || viewModel.viewerUnavailable)
|
.disabled(layer == nil || viewModel.viewerUnavailable)
|
||||||
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
|
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
|
||||||
|
// macOS 12 puts the identifier on the Toggle's container, an
|
||||||
|
// element that never reports isEnabled — combine so the a11y
|
||||||
|
// leaf is the checkbox itself.
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
.accessibilityIdentifier("gamutLayer-\(id)")
|
.accessibilityIdentifier("gamutLayer-\(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,11 @@ struct SaveMediaRecipeDialog: View {
|
|||||||
|| media.captureColourSpaceMismatch
|
|| media.captureColourSpaceMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
// Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
// children (#146); Group blocks are layout-transparent, so field
|
||||||
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
|
// order and every docs/21 id are unchanged.
|
||||||
|
private var fields: some View {
|
||||||
|
Group {
|
||||||
TextField("Name", text: $media.saveMediaName)
|
TextField("Name", text: $media.saveMediaName)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.accessibilityIdentifier("saveMediaName")
|
.accessibilityIdentifier("saveMediaName")
|
||||||
@@ -61,7 +63,11 @@ struct SaveMediaRecipeDialog: View {
|
|||||||
TextField("Ink set", text: $media.saveMediaInk)
|
TextField("Ink set", text: $media.saveMediaInk)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.accessibilityIdentifier("saveMediaInk")
|
.accessibilityIdentifier("saveMediaInk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var readOnlyRows: some View {
|
||||||
|
Group {
|
||||||
captureRow("Printer", value: printerCaption,
|
captureRow("Printer", value: printerCaption,
|
||||||
identifier: "saveMediaPrinter")
|
identifier: "saveMediaPrinter")
|
||||||
captureRow("Preset",
|
captureRow("Preset",
|
||||||
@@ -74,6 +80,14 @@ struct SaveMediaRecipeDialog: View {
|
|||||||
value: workflow.profile.calibrationFile.isEmpty
|
value: workflow.profile.calibrationFile.isEmpty
|
||||||
? "None" : workflow.profile.calibrationFile,
|
? "None" : workflow.profile.calibrationFile,
|
||||||
identifier: "saveMediaCal")
|
identifier: "saveMediaCal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
|
||||||
|
fields
|
||||||
|
readOnlyRows
|
||||||
Toggle("Apply calibration to profile",
|
Toggle("Apply calibration to profile",
|
||||||
isOn: $media.saveMediaApplyCal)
|
isOn: $media.saveMediaApplyCal)
|
||||||
.disabled(!media.calApplyable)
|
.disabled(!media.calApplyable)
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ struct SidebarView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header
|
||||||
|
presetBlock
|
||||||
|
mediaBlock
|
||||||
|
studioButtons
|
||||||
|
stepperAndProject
|
||||||
|
}
|
||||||
|
.frame(width: Theme.Metrics.sidebarWidth)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
|
||||||
|
// children (#146); these Group blocks are layout-transparent, so
|
||||||
|
// visual order, ids and the 270 pt column are unchanged.
|
||||||
|
private var header: some View {
|
||||||
|
Group {
|
||||||
HStack {
|
HStack {
|
||||||
Image("ICCery-logo")
|
Image("ICCery-logo")
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -65,7 +80,11 @@ struct SidebarView: View {
|
|||||||
.padding(12)
|
.padding(12)
|
||||||
|
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var presetBlock: some View {
|
||||||
|
Group {
|
||||||
// Preset select (`#presetSelect`) — issue #11. Selection
|
// Preset select (`#presetSelect`) — issue #11. Selection
|
||||||
// applies the preset immediately; names render via Text only.
|
// applies the preset immediately; names render via Text only.
|
||||||
Picker("Preset", selection: Binding(
|
Picker("Preset", selection: Binding(
|
||||||
@@ -97,7 +116,11 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mediaBlock: some View {
|
||||||
|
Group {
|
||||||
// Media library (`#mediaSelect`) — issue #146. Selection
|
// Media library (`#mediaSelect`) — issue #146. Selection
|
||||||
// applies the recipe immediately, like presets; names render
|
// applies the recipe immediately, like presets; names render
|
||||||
// via Text only (#114). Never reuses `presetSelect` (#137).
|
// via Text only (#114). Never reuses `presetSelect` (#137).
|
||||||
@@ -147,7 +170,11 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var studioButtons: some View {
|
||||||
|
Group {
|
||||||
// Calibrate Printer (`#btnCalibratePrinter`).
|
// Calibrate Printer (`#btnCalibratePrinter`).
|
||||||
Button(action: { model.enterCalibration() }) {
|
Button(action: { model.enterCalibration() }) {
|
||||||
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
||||||
@@ -187,7 +214,11 @@ struct SidebarView: View {
|
|||||||
showing: $showingAllHelp)
|
showing: $showingAllHelp)
|
||||||
.accessibilityIdentifier("btnSpotRead")
|
.accessibilityIdentifier("btnSpotRead")
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stepperAndProject: some View {
|
||||||
|
Group {
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
@@ -214,8 +245,6 @@ struct SidebarView: View {
|
|||||||
ProjectChip(project: project, showingAllHelp: $showingAllHelp)
|
ProjectChip(project: project, showingAllHelp: $showingAllHelp)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
}
|
}
|
||||||
.frame(width: Theme.Metrics.sidebarWidth)
|
|
||||||
.background(Theme.panel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,22 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exists **and** `isEnabled`. Layer toggles render as disabled
|
||||||
|
/// placeholders until the async layer load lands — on the macOS 12
|
||||||
|
/// runner `waitFor` alone wins the race against `parse`.
|
||||||
|
private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists && el.isEnabled { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(
|
||||||
|
el.exists && el.isEnabled, "Expected enabled element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
private func launchApp() {
|
private func launchApp() {
|
||||||
app.launch()
|
app.launch()
|
||||||
if !app.wait(for: .runningForeground, timeout: 10) {
|
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||||
@@ -91,9 +107,8 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
func testLayerTogglesExistWithSRGB() throws {
|
func testLayerTogglesExistWithSRGB() throws {
|
||||||
openGamutSheet()
|
openGamutSheet()
|
||||||
|
|
||||||
let srgb = waitFor("gamutLayer-sRGB")
|
let srgb = waitUntilEnabled("gamutLayer-sRGB")
|
||||||
XCTAssertTrue(srgb.exists)
|
XCTAssertTrue(srgb.exists)
|
||||||
XCTAssertTrue(srgb.isEnabled)
|
|
||||||
// NSButton checkbox value is 1 when checked.
|
// NSButton checkbox value is 1 when checked.
|
||||||
XCTAssertEqual(srgb.value as? Int, 1, "sRGB layer should be on")
|
XCTAssertEqual(srgb.value as? Int, 1, "sRGB layer should be on")
|
||||||
|
|
||||||
@@ -124,7 +139,8 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
waitFor("btnGamutAddCompare").click()
|
waitFor("btnGamutAddCompare").click()
|
||||||
waitFor("btnGamutOpenGam").click()
|
waitFor("btnGamutOpenGam").click()
|
||||||
|
|
||||||
let compare = waitFor("gamutLayer-compare")
|
// The pre-load placeholder also exists — wait for enabled.
|
||||||
|
let compare = waitUntilEnabled("gamutLayer-compare")
|
||||||
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load")
|
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load")
|
||||||
|
|
||||||
let status = waitFor("gamutStatusText")
|
let status = waitFor("gamutStatusText")
|
||||||
@@ -146,7 +162,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
waitFor("btnGamutAddCompare").click()
|
waitFor("btnGamutAddCompare").click()
|
||||||
waitFor("btnGamutOpenProfile").click()
|
waitFor("btnGamutOpenProfile").click()
|
||||||
|
|
||||||
let compare = waitFor("gamutLayer-compare")
|
let compare = waitUntilEnabled("gamutLayer-compare")
|
||||||
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut")
|
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import XCTest
|
|||||||
/// Milestone 10 UI tests — issue #149 project file. Panels are never
|
/// Milestone 10 UI tests — issue #149 project file. Panels are never
|
||||||
/// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE`
|
/// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE`
|
||||||
/// inject fixture paths through `UITestHooks`. Menu commands are driven
|
/// inject fixture paths through `UITestHooks`. Menu commands are driven
|
||||||
/// by their keyboard shortcuts (⌘N) or the sidebar chip so the tests do
|
/// through the File menu when it is in the AX tree, else by their
|
||||||
/// not depend on menu AX exposure (R19). All queries by identifier.
|
/// keyboard shortcuts (⌘N) — the tests do not depend on menu AX
|
||||||
|
/// exposure (R19). All queries by identifier.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class Milestone10ProjectUITests: XCTestCase {
|
final class Milestone10ProjectUITests: XCTestCase {
|
||||||
|
|
||||||
@@ -77,6 +78,39 @@ final class Milestone10ProjectUITests: XCTestCase {
|
|||||||
return (el.value as? String) ?? el.label
|
return (el.value as? String) ?? el.label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Alert/sheet button by visible title, falling back to the a11y
|
||||||
|
/// id; nil when neither matches. macOS 12 SwiftUI alerts often
|
||||||
|
/// drop `accessibilityIdentifier` on their buttons, so the title
|
||||||
|
/// is the reliable handle there.
|
||||||
|
private func alertButton(title: String, id: String) -> XCUIElement? {
|
||||||
|
let inDialog = app.dialogs.firstMatch.buttons[title].firstMatch
|
||||||
|
if inDialog.exists { return inDialog }
|
||||||
|
let inSheet = app.sheets.firstMatch.buttons[title].firstMatch
|
||||||
|
if inSheet.exists { return inSheet }
|
||||||
|
let byId = element(id)
|
||||||
|
return byId.exists ? byId : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires File ▸ New Project via the menu when it is in the AX
|
||||||
|
/// tree, else ⌘N. On macOS 12 `typeKey` may not reach the
|
||||||
|
/// `CommandGroup`, and menu item ids are unreliable — the menu
|
||||||
|
/// item is matched by its "New Project" label first.
|
||||||
|
private func triggerNewProject() {
|
||||||
|
let fileMenu = app.menuBarItems["File"]
|
||||||
|
if fileMenu.waitForExistence(timeout: 5) {
|
||||||
|
fileMenu.click()
|
||||||
|
let byTitle = app.menuItems["New Project"].firstMatch
|
||||||
|
let byId = app.menuItems["menuProjectNew"].firstMatch
|
||||||
|
let item = byTitle.exists ? byTitle : byId
|
||||||
|
if item.waitForExistence(timeout: 5) {
|
||||||
|
item.click()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||||
|
}
|
||||||
|
app.typeKey("n", modifierFlags: .command)
|
||||||
|
}
|
||||||
|
|
||||||
/// Writes a `.icceryproj` fixture under `testRoot` and points the
|
/// Writes a `.icceryproj` fixture under `testRoot` and points the
|
||||||
/// open-picker hook at it.
|
/// open-picker hook at it.
|
||||||
private func stageProjectFixture(
|
private func stageProjectFixture(
|
||||||
@@ -133,23 +167,26 @@ final class Milestone10ProjectUITests: XCTestCase {
|
|||||||
XCTAssertTrue(basenameField.waitForExistence(timeout: 10))
|
XCTAssertTrue(basenameField.waitForExistence(timeout: 10))
|
||||||
XCTAssertEqual(basenameField.value as? String, "ui149job")
|
XCTAssertEqual(basenameField.value as? String, "ui149job")
|
||||||
|
|
||||||
// ⌘N fires the File-menu New command even when the menu is not
|
// File ▸ New Project when the menu is in the AX tree, else
|
||||||
// in the AX tree. Mock CUPS may have enumerated a queue that the
|
// ⌘N. Mock CUPS may have enumerated a queue that the fixture
|
||||||
// fixture does not record, making the session dirty — in that
|
// does not record, making the session dirty — in that case
|
||||||
// case the dirty alert gates New first.
|
// the dirty alert gates New first. macOS 12 alerts often lack
|
||||||
app.typeKey("n", modifierFlags: .command)
|
// button identifiers, so confirm by title with id fallback.
|
||||||
|
triggerNewProject()
|
||||||
let deadline = Date().addingTimeInterval(10)
|
let deadline = Date().addingTimeInterval(10)
|
||||||
var confirmed = false
|
var confirmed = false
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
// Dirty sessions show the dirty alert first; discarding it
|
// Dirty sessions show the dirty alert first; discarding it
|
||||||
// runs the New reset directly (no second confirm).
|
// runs the New reset directly (no second confirm).
|
||||||
if element("btnProjectDirtyDiscard").exists {
|
if let discard = alertButton(
|
||||||
element("btnProjectDirtyDiscard").click()
|
title: "Don't Save", id: "btnProjectDirtyDiscard") {
|
||||||
|
discard.click()
|
||||||
confirmed = true
|
confirmed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if element("btnProjectNewConfirm").exists {
|
if let start = alertButton(
|
||||||
element("btnProjectNewConfirm").click()
|
title: "Start", id: "btnProjectNewConfirm") {
|
||||||
|
start.click()
|
||||||
confirmed = true
|
confirmed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,31 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exists **and** `isEnabled` — guards clicks against buttons that
|
||||||
|
/// appear a beat before their `.disabled` condition clears.
|
||||||
|
private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists && el.isEnabled { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(
|
||||||
|
el.exists && el.isEnabled, "Expected enabled element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-asserting existence poll for the retry-or-fail pattern.
|
||||||
|
private func existsAfter(_ id: String, timeout: TimeInterval) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if element(id).exists { return true }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
return element(id).exists
|
||||||
|
}
|
||||||
|
|
||||||
/// Assert an element stays absent after a short dwell — unlike
|
/// Assert an element stays absent after a short dwell — unlike
|
||||||
/// `waitForExistence`, which always burns its full timeout on the
|
/// `waitForExistence`, which always burns its full timeout on the
|
||||||
/// negative path.
|
/// negative path.
|
||||||
@@ -181,8 +206,15 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
XCTAssertTrue(element("tiffDpi").exists)
|
XCTAssertTrue(element("tiffDpi").exists)
|
||||||
XCTAssertTrue(element("targetLabelPreview").exists)
|
XCTAssertTrue(element("targetLabelPreview").exists)
|
||||||
|
|
||||||
app.buttons["btnCreateLayout"].click()
|
// On the slow macOS 12 runner a synthesized click can land
|
||||||
XCTAssertTrue(waitFor("galleryPage-0", timeout: 20).exists)
|
// while the button is still rebuilding — retry once if the
|
||||||
|
// gallery never materialises, then allow a generous window
|
||||||
|
// for the fixture printtarg + PNG render.
|
||||||
|
waitUntilEnabled("btnCreateLayout").click()
|
||||||
|
if !existsAfter("galleryPage-0", timeout: 15) {
|
||||||
|
waitUntilEnabled("btnCreateLayout").click()
|
||||||
|
}
|
||||||
|
XCTAssertTrue(waitFor("galleryPage-0", timeout: 30).exists)
|
||||||
XCTAssertTrue(FileManager.default.fileExists(
|
XCTAssertTrue(FileManager.default.fileExists(
|
||||||
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Persisted at `{app_data}/settings.json` via `load_settings` / `save_settings`. I
|
|||||||
| Field | Default | Notes |
|
| Field | Default | Notes |
|
||||||
|-------|---------|-------|
|
|-------|---------|-------|
|
||||||
| `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. |
|
| `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. |
|
||||||
| `default_instrument` | `null` | **Stored but not applied to argv.** Stage 2 `#instrumentSelect` is the source of truth. Do not start honouring this without an explicit product decision. |
|
| `default_instrument` | `null` | Stored; seeds the Spot Read instrument picker when that instrument is present (#148). **Never** written into `printtarg -i` or `targen` argv. Stage 2 `#instrumentSelect` remains the printtarg instrument. |
|
||||||
| `log_level` | `null` | `error` / `warn` / `info` / `debug` / `trace`. `null` → Debug in debug builds, Info in release. Applied at startup **and** on save (#158). |
|
| `log_level` | `null` | `error` / `warn` / `info` / `debug` / `trace`. `null` → Debug in debug builds, Info in release. Applied at startup **and** on save (#158). |
|
||||||
| `delta_e_good_max` | `2.0` | Stage 3 swatch traffic-light "Good". Must be ≥ 0. |
|
| `delta_e_good_max` | `2.0` | Stage 3 swatch traffic-light "Good". Must be ≥ 0. |
|
||||||
| `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. |
|
| `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. |
|
||||||
|
|||||||
Reference in New Issue
Block a user