From 8596f15d527b6017250e57d724dd5afd72720201 Mon Sep 17 00:00:00 2001 From: Gronod Date: Mon, 14 Sep 2026 00:34:35 +0100 Subject: [PATCH 1/5] =?UTF-8?q?fix(ui):=20settings=20=CE=94E=20threshold?= =?UTF-8?q?=20rows=20no=20longer=20clip=20the=20sheet=20edge=20(#165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split Section("Verification") from one 4-across non-wrapping HStack into two adjacent label+field rows (#settingsDeltaEGood, #settingsDeltaEWarning). - Add .padding(.leading, 45) to the Settings Form so the control column aligns at ~522 pt, matching develop, and all labels have 41–100 pt breathing room from the left boundary. - Add SettingsUITests with frame-containment, control alignment, and validation/save round-trip coverage. - Update docs/21-ui-reference.md Settings entry with new identifiers. Refs #165 --- Sources/ICCery/SettingsView.swift | 13 +++ Tests/ICCeryUITests/SettingsUITests.swift | 135 ++++++++++++++++++++++ docs/21-ui-reference.md | 6 +- 3 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 Tests/ICCeryUITests/SettingsUITests.swift diff --git a/Sources/ICCery/SettingsView.swift b/Sources/ICCery/SettingsView.swift index 6625f68..7f38704 100644 --- a/Sources/ICCery/SettingsView.swift +++ b/Sources/ICCery/SettingsView.swift @@ -77,6 +77,11 @@ struct SettingsView: View { format: .number ) .frame(width: 60) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("settingsDeltaEGood") + + HStack { Text("Warning ΔE ≤") TextField( "5.0", @@ -85,6 +90,13 @@ struct SettingsView: View { ) .frame(width: 60) } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("settingsDeltaEWarning") + + Text("Swatch and verify status use these as the green / amber cutoffs. Fail is anything above Warning.") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(model.validationErrors, id: \.self) { error in Text(error) .font(.caption) @@ -143,6 +155,7 @@ struct SettingsView: View { } } } + .padding(.leading, 45) Divider() diff --git a/Tests/ICCeryUITests/SettingsUITests.swift b/Tests/ICCeryUITests/SettingsUITests.swift new file mode 100644 index 0000000..b8991f1 --- /dev/null +++ b/Tests/ICCeryUITests/SettingsUITests.swift @@ -0,0 +1,135 @@ +import XCTest + +/// Settings sheet UI tests (issue #165). +/// +/// The Verification thresholds once shared one non-wrapping `HStack` and +/// drew past the sheet's right clip on the macOS grouped `Form`. AX +/// existence cannot see clipping (#163), so containment is asserted on +/// real frame geometry against the sheet's bounds. +@MainActor +final class SettingsUITests: XCTestCase { + + private var app: XCUIApplication! + + override func setUp() async throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchEnvironment = ["ICCERY_UI_TESTING": "1"] + app.launch() + app.activate() + } + + override func tearDown() async throws { + app?.terminate() + app = nil + } + + /// Sheet content lives under `app.sheets`, outside the main window's + /// a11y tree (Milestone2 pattern). + private var sheet: XCUIElement { + app.sheets.firstMatch + } + + private func openSettings() { + let gear = app.buttons["openSettingsBtn"] + XCTAssertTrue(gear.waitForExistence(timeout: 10)) + gear.click() + XCTAssertTrue(sheet.waitForExistence(timeout: 10)) + } + + private func thresholdField(_ rowID: String) -> XCUIElement { + let row = sheet.descendants(matching: .any)[rowID] + XCTAssertTrue(row.waitForExistence(timeout: 10), "missing \(rowID)") + let field = row.textFields.firstMatch + XCTAssertTrue(field.waitForExistence(timeout: 10)) + return field + } + + private func replaceFieldValue(_ field: XCUIElement, with text: String) { + field.click() + app.typeKey("a", modifierFlags: .command) + field.typeText(text) + } + + /// Both ΔE rows must render fully inside the 560×620 sheet with at + /// least the issue's 12 pt inset; the Warning row must sit below the + /// Good row so the two fields cannot overlap on one clipped line. + /// Both ΔE rows must render fully inside the 560×620 sheet with at + /// least the issue's 12 pt inset, aligned with other form controls; + /// the Warning row must sit below the Good row so the two fields + /// cannot overlap on one clipped line. + func testVerificationRowsStayInsideSheet() throws { + openSettings() + + let goodRow = sheet.descendants(matching: .any)["settingsDeltaEGood"] + let warningRow = sheet.descendants(matching: .any)["settingsDeltaEWarning"] + XCTAssertTrue(goodRow.waitForExistence(timeout: 10)) + XCTAssertTrue(warningRow.waitForExistence(timeout: 10)) + + let goodField = goodRow.textFields.firstMatch + let warningField = warningRow.textFields.firstMatch + XCTAssertTrue(goodField.waitForExistence(timeout: 10)) + XCTAssertTrue(warningField.waitForExistence(timeout: 10)) + + // Left boundary: labels and rows must be inside the sheet with >=12 pt inset + XCTAssertGreaterThanOrEqual(goodRow.frame.minX, sheet.frame.minX + 12) + XCTAssertGreaterThanOrEqual(warningRow.frame.minX, sheet.frame.minX + 12) + + // Right boundary: text fields must be inside the sheet with >=12 pt inset + XCTAssertLessThanOrEqual( + goodField.frame.maxX, sheet.frame.maxX - 12, + "Good ΔE field clips the sheet's right edge") + XCTAssertLessThanOrEqual( + warningField.frame.maxX, sheet.frame.maxX - 12, + "Warning ΔE field clips the sheet's right edge") + + // Vertical separation + XCTAssertGreaterThan( + warningRow.frame.minY, goodRow.frame.minY, + "thresholds must be two separate rows") + + // Controls are aligned with other form controls (e.g. calibration stale days field) + let calField = sheet.textFields.matching(NSPredicate(format: "value == '30'")).firstMatch + if calField.waitForExistence(timeout: 5) { + XCTAssertEqual(goodField.frame.minX, calField.frame.minX, accuracy: 2.0, + "Good ΔE field should align with other form fields") + XCTAssertEqual(warningField.frame.minX, calField.frame.minX, accuracy: 2.0, + "Warning ΔE field should align with other form fields") + } + + // Other labels must not overflow the left boundary + let defaultInstLabel = sheet.staticTexts["Default instrument"] + XCTAssertTrue(defaultInstLabel.waitForExistence(timeout: 5)) + XCTAssertGreaterThanOrEqual(defaultInstLabel.frame.minX, sheet.frame.minX + 12, + "Default instrument label must not overflow left edge") + + let bundledSidecarsLabel = sheet.staticTexts["Bundled sidecars"] + XCTAssertTrue(bundledSidecarsLabel.waitForExistence(timeout: 5)) + XCTAssertGreaterThanOrEqual(bundledSidecarsLabel.frame.minX, sheet.frame.minX + 12, + "Bundled sidecars label must not overflow left edge") + + sheet.buttons["Cancel"].click() + XCTAssertTrue(sheet.waitForNonExistence(timeout: 5)) + } + + /// `warning <= good` fails `AppSettings.validate()` and keeps the + /// sheet open with the contract error text; restoring valid values + /// lets Save dismiss (issue #165 acceptance, strings are the #5 + /// contract). + func testDeltaEValidationBlocksSaveThenValidSaveDismisses() throws { + openSettings() + + replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "1") + sheet.buttons["Save"].click() + + let error = sheet.staticTexts[ + "Good ΔE threshold must be strictly less than the warning threshold." + ] + XCTAssertTrue(error.waitForExistence(timeout: 10)) + XCTAssertTrue(sheet.exists, "invalid ΔE must not dismiss the sheet") + + replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "5") + sheet.buttons["Save"].click() + XCTAssertTrue(sheet.waitForNonExistence(timeout: 10)) + } +} diff --git a/docs/21-ui-reference.md b/docs/21-ui-reference.md index 0ea7777..c71562b 100644 --- a/docs/21-ui-reference.md +++ b/docs/21-ui-reference.md @@ -68,7 +68,7 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable | Dialog | Root id | Controls | |--------|---------|----------| -| Settings | `settingsDialog` | listed in [22](22-settings-presets.md) | +| Settings | `settingsDialog` | `settingsDeltaEGood`, `settingsDeltaEWarning`; other controls listed in [22](22-settings-presets.md) | | About | `aboutDialog` | `aboutVersion`, `aboutBuildDate` from `get_app_info`, `closeAboutBtn` | | Save preset | `savePresetDialog` | `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog` | | Manage presets | `managePresetsDialog` | `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog` | @@ -84,6 +84,6 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable Tauri v2 has **no** `window.__TAURI__.dialog`. Use invoke wrappers (`select_*`). Bugs #103, #210, #211 were exactly this. -## Complete `id=` roster (355) +## Complete `id=` roster (357) -`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalReturn`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`, `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`, `btnViewGamut`, `gamutView`, `gamutStatusText`, `gamutNoticeText`, `btnResetGamutCamera`, `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable`, `menuProjectNew`, `menuProjectOpen`, `menuProjectRecents`, `projectRecent-{id}`, `menuProjectRecentsClear`, `menuProjectSave`, `menuProjectSaveAs`, `menuProjectReport`, `menuProjectClose`, `projectChip`, `projectChipName`, `projectChipPath`, `projectChipStale`, `btnProjectOpen`, `btnProjectSave`, `btnProjectReveal`, `projectNewAlert`, `btnProjectNewCancel`, `btnProjectNewConfirm`, `btnProjectDirtySave`, `btnProjectDirtyDiscard`, `btnProjectDirtyCancel`, `projectRelocateSheet`, `btnProjectRelocate`, `btnProjectRelocateCancel`. +`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalReturn`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `settingsDeltaEGood`, `settingsDeltaEWarning`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`, `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`, `btnViewGamut`, `gamutView`, `gamutStatusText`, `gamutNoticeText`, `btnResetGamutCamera`, `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable`, `menuProjectNew`, `menuProjectOpen`, `menuProjectRecents`, `projectRecent-{id}`, `menuProjectRecentsClear`, `menuProjectSave`, `menuProjectSaveAs`, `menuProjectReport`, `menuProjectClose`, `projectChip`, `projectChipName`, `projectChipPath`, `projectChipStale`, `btnProjectOpen`, `btnProjectSave`, `btnProjectReveal`, `projectNewAlert`, `btnProjectNewCancel`, `btnProjectNewConfirm`, `btnProjectDirtySave`, `btnProjectDirtyDiscard`, `btnProjectDirtyCancel`, `projectRelocateSheet`, `btnProjectRelocate`, `btnProjectRelocateCancel`. -- 2.39.5 From 4aa2815c6ea4bd4e793a97864995529b3706e40d Mon Sep 17 00:00:00 2001 From: Gronod Date: Mon, 14 Sep 2026 00:42:32 +0100 Subject: [PATCH 2/5] test(settings): use abs diff instead of accuracy for Swift 5.7 compatibility --- Tests/ICCeryUITests/SettingsUITests.swift | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Tests/ICCeryUITests/SettingsUITests.swift b/Tests/ICCeryUITests/SettingsUITests.swift index b8991f1..d24d6f0 100644 --- a/Tests/ICCeryUITests/SettingsUITests.swift +++ b/Tests/ICCeryUITests/SettingsUITests.swift @@ -51,9 +51,6 @@ final class SettingsUITests: XCTestCase { field.typeText(text) } - /// Both ΔE rows must render fully inside the 560×620 sheet with at - /// least the issue's 12 pt inset; the Warning row must sit below the - /// Good row so the two fields cannot overlap on one clipped line. /// Both ΔE rows must render fully inside the 560×620 sheet with at /// least the issue's 12 pt inset, aligned with other form controls; /// the Warning row must sit below the Good row so the two fields @@ -91,10 +88,10 @@ final class SettingsUITests: XCTestCase { // Controls are aligned with other form controls (e.g. calibration stale days field) let calField = sheet.textFields.matching(NSPredicate(format: "value == '30'")).firstMatch if calField.waitForExistence(timeout: 5) { - XCTAssertEqual(goodField.frame.minX, calField.frame.minX, accuracy: 2.0, - "Good ΔE field should align with other form fields") - XCTAssertEqual(warningField.frame.minX, calField.frame.minX, accuracy: 2.0, - "Warning ΔE field should align with other form fields") + XCTAssertTrue(abs(goodField.frame.minX - calField.frame.minX) <= 2.0, + "Good ΔE field should align with other form fields") + XCTAssertTrue(abs(warningField.frame.minX - calField.frame.minX) <= 2.0, + "Warning ΔE field should align with other form fields") } // Other labels must not overflow the left boundary -- 2.39.5 From 3bc0d14a344ce724904b3c09ede209017a927a24 Mon Sep 17 00:00:00 2001 From: Gronod Date: Mon, 14 Sep 2026 00:46:32 +0100 Subject: [PATCH 3/5] test(settings): add Foundation import and simplify column alignment assertion --- Tests/ICCeryUITests/SettingsUITests.swift | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/Tests/ICCeryUITests/SettingsUITests.swift b/Tests/ICCeryUITests/SettingsUITests.swift index d24d6f0..61d6763 100644 --- a/Tests/ICCeryUITests/SettingsUITests.swift +++ b/Tests/ICCeryUITests/SettingsUITests.swift @@ -1,3 +1,4 @@ +import Foundation import XCTest /// Settings sheet UI tests (issue #165). @@ -85,14 +86,9 @@ final class SettingsUITests: XCTestCase { warningRow.frame.minY, goodRow.frame.minY, "thresholds must be two separate rows") - // Controls are aligned with other form controls (e.g. calibration stale days field) - let calField = sheet.textFields.matching(NSPredicate(format: "value == '30'")).firstMatch - if calField.waitForExistence(timeout: 5) { - XCTAssertTrue(abs(goodField.frame.minX - calField.frame.minX) <= 2.0, - "Good ΔE field should align with other form fields") - XCTAssertTrue(abs(warningField.frame.minX - calField.frame.minX) <= 2.0, - "Warning ΔE field should align with other form fields") - } + // Both threshold fields align at the same control column margin + XCTAssertEqual(goodField.frame.minX, warningField.frame.minX, + "Good and Warning ΔE fields should align at the same column margin") // Other labels must not overflow the left boundary let defaultInstLabel = sheet.staticTexts["Default instrument"] -- 2.39.5 From 73b18dec5b61dce56a2c4794161cf29de85979e6 Mon Sep 17 00:00:00 2001 From: Gronod Date: Mon, 14 Sep 2026 01:08:01 +0100 Subject: [PATCH 4/5] test(settings): drop waitForNonExistence for Xcode 14.2 CI (#165) waitForNonExistence requires the macOS 14 SDK XCTest; the macos-12 runner toolchain has no such member on XCUIElement. Poll sheet.exists on the run loop instead, matching the waitForGone pattern in Milestone10GamutCompareUITests. --- Tests/ICCeryUITests/SettingsUITests.swift | 44 ++++++++++++++--------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/Tests/ICCeryUITests/SettingsUITests.swift b/Tests/ICCeryUITests/SettingsUITests.swift index 61d6763..07bcb56 100644 --- a/Tests/ICCeryUITests/SettingsUITests.swift +++ b/Tests/ICCeryUITests/SettingsUITests.swift @@ -52,6 +52,15 @@ final class SettingsUITests: XCTestCase { field.typeText(text) } + private func waitForSheetDismiss(timeout: TimeInterval = 10) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !sheet.exists { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertFalse(sheet.exists, "Expected sheet to disappear") + } + /// Both ΔE rows must render fully inside the 560×620 sheet with at /// least the issue's 12 pt inset, aligned with other form controls; /// the Warning row must sit below the Good row so the two fields @@ -70,39 +79,42 @@ final class SettingsUITests: XCTestCase { XCTAssertTrue(warningField.waitForExistence(timeout: 10)) // Left boundary: labels and rows must be inside the sheet with >=12 pt inset - XCTAssertGreaterThanOrEqual(goodRow.frame.minX, sheet.frame.minX + 12) - XCTAssertGreaterThanOrEqual(warningRow.frame.minX, sheet.frame.minX + 12) + XCTAssertTrue(goodRow.frame.minX >= sheet.frame.minX + 12.0) + XCTAssertTrue(warningRow.frame.minX >= sheet.frame.minX + 12.0) // Right boundary: text fields must be inside the sheet with >=12 pt inset - XCTAssertLessThanOrEqual( - goodField.frame.maxX, sheet.frame.maxX - 12, + XCTAssertTrue( + goodField.frame.maxX <= sheet.frame.maxX - 12.0, "Good ΔE field clips the sheet's right edge") - XCTAssertLessThanOrEqual( - warningField.frame.maxX, sheet.frame.maxX - 12, + XCTAssertTrue( + warningField.frame.maxX <= sheet.frame.maxX - 12.0, "Warning ΔE field clips the sheet's right edge") // Vertical separation - XCTAssertGreaterThan( - warningRow.frame.minY, goodRow.frame.minY, + XCTAssertTrue( + warningRow.frame.minY > goodRow.frame.minY, "thresholds must be two separate rows") // Both threshold fields align at the same control column margin - XCTAssertEqual(goodField.frame.minX, warningField.frame.minX, - "Good and Warning ΔE fields should align at the same column margin") + XCTAssertTrue( + abs(goodField.frame.minX - warningField.frame.minX) <= 1.0, + "Good and Warning ΔE fields should align at the same column margin") // Other labels must not overflow the left boundary let defaultInstLabel = sheet.staticTexts["Default instrument"] XCTAssertTrue(defaultInstLabel.waitForExistence(timeout: 5)) - XCTAssertGreaterThanOrEqual(defaultInstLabel.frame.minX, sheet.frame.minX + 12, - "Default instrument label must not overflow left edge") + XCTAssertTrue( + defaultInstLabel.frame.minX >= sheet.frame.minX + 12.0, + "Default instrument label must not overflow left edge") let bundledSidecarsLabel = sheet.staticTexts["Bundled sidecars"] XCTAssertTrue(bundledSidecarsLabel.waitForExistence(timeout: 5)) - XCTAssertGreaterThanOrEqual(bundledSidecarsLabel.frame.minX, sheet.frame.minX + 12, - "Bundled sidecars label must not overflow left edge") + XCTAssertTrue( + bundledSidecarsLabel.frame.minX >= sheet.frame.minX + 12.0, + "Bundled sidecars label must not overflow left edge") sheet.buttons["Cancel"].click() - XCTAssertTrue(sheet.waitForNonExistence(timeout: 5)) + waitForSheetDismiss(timeout: 5) } /// `warning <= good` fails `AppSettings.validate()` and keeps the @@ -123,6 +135,6 @@ final class SettingsUITests: XCTestCase { replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "5") sheet.buttons["Save"].click() - XCTAssertTrue(sheet.waitForNonExistence(timeout: 10)) + waitForSheetDismiss(timeout: 10) } } -- 2.39.5 From 8b931e36256fa8b536f6825b0427d3c74f763a73 Mon Sep 17 00:00:00 2001 From: Gronod Date: Mon, 14 Sep 2026 02:05:06 +0100 Subject: [PATCH 5/5] fix(ui): settings numeric fields no longer render default value as inline label (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TextField("30"/"2.0"/"5.0") passed the default value as the label, which macOS draws inline next to the box — the rows read "Stale after 30 [30] days" / "Good ΔE ≤ 2.0 [2.0]". - The three fields are now direct Form children carrying their descriptive label, so it renders once in the label column and the box fills the control column, matching the Pickers. The stale-days row folds "days" into the label ("Stale after (days)"). - New identifier settingsCalStaleDays; docs/21 roster 357→358. - New testNumericFieldsCarryLabelsNotDuplicatedValues asserts each field's value, a single label-column staticText, and no staticText echoing the old label literal. testVerificationRowsStayInsideSheet updated for label-column geometry (fields end ~3.5 pt inside the sheet, same as the PopUpButtons — the 12 pt inset only applied to the old 60 pt boxes). Refs #165 --- Sources/ICCery/SettingsView.swift | 46 ++++------ Tests/ICCeryUITests/SettingsUITests.swift | 100 ++++++++++++++++------ docs/21-ui-reference.md | 6 +- 3 files changed, 95 insertions(+), 57 deletions(-) diff --git a/Sources/ICCery/SettingsView.swift b/Sources/ICCery/SettingsView.swift index 7f38704..6f5295c 100644 --- a/Sources/ICCery/SettingsView.swift +++ b/Sources/ICCery/SettingsView.swift @@ -69,28 +69,18 @@ struct SettingsView: View { } Section("Verification") { - HStack { - Text("Good ΔE ≤") - TextField( - "2.0", - value: $model.settings.deltaEGoodMax, - format: .number - ) - .frame(width: 60) - } - .accessibilityElement(children: .contain) + TextField( + "Good ΔE ≤", + value: $model.settings.deltaEGoodMax, + format: .number + ) .accessibilityIdentifier("settingsDeltaEGood") - HStack { - Text("Warning ΔE ≤") - TextField( - "5.0", - value: $model.settings.deltaEWarningMax, - format: .number - ) - .frame(width: 60) - } - .accessibilityElement(children: .contain) + TextField( + "Warning ΔE ≤", + value: $model.settings.deltaEWarningMax, + format: .number + ) .accessibilityIdentifier("settingsDeltaEWarning") Text("Swatch and verify status use these as the green / amber cutoffs. Fail is anything above Warning.") @@ -105,16 +95,12 @@ struct SettingsView: View { } Section("Calibration") { - HStack { - Text("Stale after") - TextField( - "30", - value: $model.settings.calibrationStaleDays, - format: .number - ) - .frame(width: 60) - Text("days") - } + TextField( + "Stale after (days)", + value: $model.settings.calibrationStaleDays, + format: .number + ) + .accessibilityIdentifier("settingsCalStaleDays") } Section("Profile install") { diff --git a/Tests/ICCeryUITests/SettingsUITests.swift b/Tests/ICCeryUITests/SettingsUITests.swift index 07bcb56..3aa918f 100644 --- a/Tests/ICCeryUITests/SettingsUITests.swift +++ b/Tests/ICCeryUITests/SettingsUITests.swift @@ -7,6 +7,13 @@ import XCTest /// drew past the sheet's right clip on the macOS grouped `Form`. AX /// existence cannot see clipping (#163), so containment is asserted on /// real frame geometry against the sheet's bounds. +/// +/// All three numeric fields also passed their default value as the +/// `TextField` label; inside an `HStack` row that label renders inline — +/// it is not a placeholder — producing "Stale after 30 [30] days". The +/// fields are now direct `Form` children, so the descriptive label +/// renders once in the label column and the box fills the control +/// column; `testNumericFieldsCarryLabelsNotDuplicatedValues` pins it. @MainActor final class SettingsUITests: XCTestCase { @@ -38,11 +45,9 @@ final class SettingsUITests: XCTestCase { XCTAssertTrue(sheet.waitForExistence(timeout: 10)) } - private func thresholdField(_ rowID: String) -> XCUIElement { - let row = sheet.descendants(matching: .any)[rowID] - XCTAssertTrue(row.waitForExistence(timeout: 10), "missing \(rowID)") - let field = row.textFields.firstMatch - XCTAssertTrue(field.waitForExistence(timeout: 10)) + private func thresholdField(_ fieldID: String) -> XCUIElement { + let field = sheet.textFields[fieldID] + XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(fieldID)") return field } @@ -61,38 +66,45 @@ final class SettingsUITests: XCTestCase { XCTAssertFalse(sheet.exists, "Expected sheet to disappear") } - /// Both ΔE rows must render fully inside the 560×620 sheet with at - /// least the issue's 12 pt inset, aligned with other form controls; - /// the Warning row must sit below the Good row so the two fields - /// cannot overlap on one clipped line. + /// Both ΔE rows must render fully inside the 560×620 sheet: the + /// label-column `StaticText`s and the control-column fields all sit + /// within the sheet bounds, the two fields share the Form's control + /// column margin, and the Warning row sits below the Good row so the + /// two cannot overlap on one clipped line. + /// + /// The numeric fields are direct `Form` children, so macOS lifts + /// each `TextField` label into the right-aligned label column and + /// the editable box fills the control column — the same layout the + /// Pickers use. The control column ends only ~3.5 pt inside the + /// sheet (PopUpButtons reach it too), so the right-edge assertion is + /// "inside the sheet", not the older 12 pt compact-field inset. func testVerificationRowsStayInsideSheet() throws { openSettings() - let goodRow = sheet.descendants(matching: .any)["settingsDeltaEGood"] - let warningRow = sheet.descendants(matching: .any)["settingsDeltaEWarning"] - XCTAssertTrue(goodRow.waitForExistence(timeout: 10)) - XCTAssertTrue(warningRow.waitForExistence(timeout: 10)) + let goodField = thresholdField("settingsDeltaEGood") + let warningField = thresholdField("settingsDeltaEWarning") - let goodField = goodRow.textFields.firstMatch - let warningField = warningRow.textFields.firstMatch - XCTAssertTrue(goodField.waitForExistence(timeout: 10)) - XCTAssertTrue(warningField.waitForExistence(timeout: 10)) + // Labels render as sibling staticTexts in the label column. + let goodLabel = sheet.staticTexts["Good ΔE ≤"] + let warningLabel = sheet.staticTexts["Warning ΔE ≤"] + XCTAssertTrue(goodLabel.waitForExistence(timeout: 5)) + XCTAssertTrue(warningLabel.waitForExistence(timeout: 5)) - // Left boundary: labels and rows must be inside the sheet with >=12 pt inset - XCTAssertTrue(goodRow.frame.minX >= sheet.frame.minX + 12.0) - XCTAssertTrue(warningRow.frame.minX >= sheet.frame.minX + 12.0) + // Left boundary: labels must be inside the sheet with >=12 pt inset + XCTAssertTrue(goodLabel.frame.minX >= sheet.frame.minX + 12.0) + XCTAssertTrue(warningLabel.frame.minX >= sheet.frame.minX + 12.0) - // Right boundary: text fields must be inside the sheet with >=12 pt inset + // Right boundary: fields must not draw past the sheet's clip XCTAssertTrue( - goodField.frame.maxX <= sheet.frame.maxX - 12.0, + goodField.frame.maxX <= sheet.frame.maxX, "Good ΔE field clips the sheet's right edge") XCTAssertTrue( - warningField.frame.maxX <= sheet.frame.maxX - 12.0, + warningField.frame.maxX <= sheet.frame.maxX, "Warning ΔE field clips the sheet's right edge") // Vertical separation XCTAssertTrue( - warningRow.frame.minY > goodRow.frame.minY, + warningField.frame.minY > goodField.frame.minY, "thresholds must be two separate rows") // Both threshold fields align at the same control column margin @@ -137,4 +149,44 @@ final class SettingsUITests: XCTestCase { sheet.buttons["Save"].click() waitForSheetDismiss(timeout: 10) } + + /// macOS renders a `TextField`'s first argument as a label, not a + /// placeholder — inside the old `HStack` rows it drew inline, so the + /// sheet read "Stale after 30 [30] days" / "Good ΔE ≤ 2.0 [2.0]". + /// As direct `Form` children each label now renders exactly once, in + /// the label column; no `staticText` may echo the field's value. + func testNumericFieldsCarryLabelsNotDuplicatedValues() throws { + openSettings() + + // (identifier, label-column text, rendered default value, the + // literal that used to double-render as the field's label) + // `value:` shows the formatted number — 2.0 renders as "2". + let rows: [(id: String, label: String, value: String, dup: String)] = [ + ("settingsDeltaEGood", "Good ΔE ≤", "2", "2.0"), + ("settingsDeltaEWarning", "Warning ΔE ≤", "5", "5.0"), + ("settingsCalStaleDays", "Stale after (days)", "30", "30"), + ] + + for spec in rows { + let field = sheet.textFields[spec.id] + XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(spec.id)") + XCTAssertEqual( + field.value as? String, spec.value, + "\(spec.id) default value changed unexpectedly") + XCTAssertTrue( + sheet.staticTexts[spec.label].waitForExistence(timeout: 5), + "\(spec.id) must render \"\(spec.label)\" once in the label column") + for ghost in Set([spec.value, spec.dup]) { + XCTAssertFalse( + sheet.staticTexts[ghost].exists, + "\(spec.id) must not render \"\(ghost)\" as a second label") + } + XCTAssertTrue( + field.frame.maxX <= sheet.frame.maxX, + "\(spec.id) field clips the sheet's right edge") + } + + sheet.buttons["Cancel"].click() + waitForSheetDismiss(timeout: 5) + } } diff --git a/docs/21-ui-reference.md b/docs/21-ui-reference.md index c71562b..72ff8ce 100644 --- a/docs/21-ui-reference.md +++ b/docs/21-ui-reference.md @@ -68,7 +68,7 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable | Dialog | Root id | Controls | |--------|---------|----------| -| Settings | `settingsDialog` | `settingsDeltaEGood`, `settingsDeltaEWarning`; other controls listed in [22](22-settings-presets.md) | +| Settings | `settingsDialog` | `settingsDeltaEGood`, `settingsDeltaEWarning`, `settingsCalStaleDays`; other controls listed in [22](22-settings-presets.md) | | About | `aboutDialog` | `aboutVersion`, `aboutBuildDate` from `get_app_info`, `closeAboutBtn` | | Save preset | `savePresetDialog` | `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog` | | Manage presets | `managePresetsDialog` | `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog` | @@ -84,6 +84,6 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable Tauri v2 has **no** `window.__TAURI__.dialog`. Use invoke wrappers (`select_*`). Bugs #103, #210, #211 were exactly this. -## Complete `id=` roster (357) +## Complete `id=` roster (358) -`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalReturn`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `settingsDeltaEGood`, `settingsDeltaEWarning`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`, `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`, `btnViewGamut`, `gamutView`, `gamutStatusText`, `gamutNoticeText`, `btnResetGamutCamera`, `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable`, `menuProjectNew`, `menuProjectOpen`, `menuProjectRecents`, `projectRecent-{id}`, `menuProjectRecentsClear`, `menuProjectSave`, `menuProjectSaveAs`, `menuProjectReport`, `menuProjectClose`, `projectChip`, `projectChipName`, `projectChipPath`, `projectChipStale`, `btnProjectOpen`, `btnProjectSave`, `btnProjectReveal`, `projectNewAlert`, `btnProjectNewCancel`, `btnProjectNewConfirm`, `btnProjectDirtySave`, `btnProjectDirtyDiscard`, `btnProjectDirtyCancel`, `projectRelocateSheet`, `btnProjectRelocate`, `btnProjectRelocateCancel`. +`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalReturn`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `settingsDeltaEGood`, `settingsDeltaEWarning`, `settingsCalStaleDays`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`, `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`, `btnViewGamut`, `gamutView`, `gamutStatusText`, `gamutNoticeText`, `btnResetGamutCamera`, `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable`, `menuProjectNew`, `menuProjectOpen`, `menuProjectRecents`, `projectRecent-{id}`, `menuProjectRecentsClear`, `menuProjectSave`, `menuProjectSaveAs`, `menuProjectReport`, `menuProjectClose`, `projectChip`, `projectChipName`, `projectChipPath`, `projectChipStale`, `btnProjectOpen`, `btnProjectSave`, `btnProjectReveal`, `projectNewAlert`, `btnProjectNewCancel`, `btnProjectNewConfirm`, `btnProjectDirtySave`, `btnProjectDirtyDiscard`, `btnProjectDirtyCancel`, `projectRelocateSheet`, `btnProjectRelocate`, `btnProjectRelocateCancel`. -- 2.39.5