Settings sheet clips Good / Warning ΔE threshold fields past the modal edge #165

Closed
opened 2026-09-13 22:37:10 +01:00 by gronod · 1 comment
Owner

Summary

On the Settings sheet, the Verification thresholds (Good ΔE / Warning ΔE — the green and amber bands used as the traffic-light cutoffs) draw past the right edge of the modal. The Warning field is the one that leaves the sheet; depending on macOS 12 Form metrics the Good field can clip as well.

This is a layout bug in SettingsView, not a settings-store or validation bug. Values still persist if you can reach Save.

Where

  • File: Sources/ICCery/SettingsView.swift
  • Sheet host: RootView .sheet { SettingsView() } (no inner frame on the presenter)
  • Branch observed against: milestone/m10-studio
  • Floor: macOS 12 / SwiftUI Form grouped style (M9 retarget)

Reproduction

  1. Launch ICCery on macOS 12 (also check 14; Form insets differ).
  2. Open Settings from the sidebar gear.
  3. Look at the Verification section: Good ΔE ≤ [ 2.0 ] Warning ΔE ≤ [ 5.0 ].
  4. The second label + field sit on or beyond the sheet's right clip. The footer Save/Cancel row still fits because it is outside the Form.

Expected: both fields fully visible, with ≥12 pt inset from the sheet edge, matching the Calibration row (Stale after [ 30 ] days).

Root cause

Three layout facts stack.

1. One non-wrapping HStack holds four controls

Section("Verification") {
    HStack {
        Text("Good ΔE ≤")
        TextField("2.0", value: $model.settings.deltaEGoodMax, format: .number)
            .frame(width: 60)
        Text("Warning ΔE ≤")
        TextField("5.0", value: $model.settings.deltaEWarningMax, format: .number)
            .frame(width: 60)
    }
}

HStack does not wrap. The texts have no lineLimit, minimumScaleFactor, layoutPriority, or minWidth: 0. The fields are a fixed 60 pt each. Intrinsic width of the row is:

width("Good ΔE ≤") + 60 + width("Warning ΔE ≤") + 60 + HStack spacing

The Δ and ≤ glyphs are wider than ASCII. On the app's default body font that row wants ~360–400 pt before Form chrome.

2. macOS Form steals horizontal space the HStack does not know about

The sheet is hard-sized:

.frame(width: 560, height: 620)

A macOS grouped Form then applies:

  • sheet / form outer inset (~16–20 pt each side)
  • section content inset
  • on Monterey, Form often treats the first Text in a row as the trailing-label column and puts the rest of the HStack in the remaining control column

Available width for the remaining three children is therefore far less than 560. Four-across content is laid out at its ideal size and drawn past the clip of the NSHostingView / sheet.

The Calibration section only has three children (Stale after + 60 pt field + days) and still fits. Verification does not.

3. Fixed sheet height + M10 copy makes the Form tighter

M10 added a long caption under Default instrument (Seeds Spot Read and Stage 3…). That grows the Argyll section. The Form sits in a VStack with a non-flexible footer (Divider + 12 pt padded button row). If the Form fails to become a scroll view inside that fixed 620 pt height (a known SwiftUI-on-12 pattern when Form is not given frame(maxHeight: .infinity) explicitly), the Verification block is also the first section pushed toward the bottom-right clip.

Horizontal overflow is the primary defect; vertical clip is a related risk on 12.

This is not caused by:

  • SettingsViewModel / AppSettings.validate() (good < warning still works)
  • Theme colours
  • The sheet presenter in RootView (no competing frame)
  • Help overlays (Settings is a separate sheet)

Suggested fix

Do not keep two labelled numeric fields on one HStack inside a Form row.

Preferred (Form-native, ViewBuilder-safe):

Section("Verification") {
    HStack {
        Text("Good ΔE ≤")
        Spacer()
        TextField("2.0", value: $model.settings.deltaEGoodMax, format: .number)
            .frame(width: 60)
            .multilineTextAlignment(.trailing)
    }
    .accessibilityElement(children: .contain)
    .accessibilityIdentifier("settingsDeltaEGood")

    HStack {
        Text("Warning ΔE ≤")
        Spacer()
        TextField("5.0", value: $model.settings.deltaEWarningMax, format: .number)
            .frame(width: 60)
            .multilineTextAlignment(.trailing)
    }
    .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).foregroundStyle(.red)
    }
}

Also:

  • Give the Form .frame(maxHeight: .infinity) so it scrolls inside the 620 pt sheet after M10 captions.
  • Do not widen the sheet as the first fix; 560 pt is enough for one field per row.
  • Keep the footer outside the Form.
  • No new identifiers on sibling Settings controls (preset / media / project isolation still applies). If UI tests later query these fields, use the two new identifiers above, not a shared deltaE id.

Acceptance

  • Both fields and both labels are fully visible at 560×620 on macOS 12 and 14, with the sheet's default Form inset.
  • Changing either value, failing validation (warning <= good), and a successful Save still work.
  • Calibration / install / logging rows are unchanged.
  • A 560-pt-wide preview or UI test screenshot of the Verification section shows no clip.
  • ViewBuilder child count in SettingsView.body stays ≤10 per stack (split if the Form grows).

Test notes

No existing UI test targets these two fields (Settings is opened from the gear; current UI tests do not assert the Verification row). Add a focused layout assertion if a Settings UI test is introduced; until then, manual check on the Monterey runner-class machine is enough to close.

## Summary On the Settings sheet, the Verification thresholds (Good ΔE / Warning ΔE — the green and amber bands used as the traffic-light cutoffs) draw past the right edge of the modal. The Warning field is the one that leaves the sheet; depending on macOS 12 Form metrics the Good field can clip as well. This is a layout bug in `SettingsView`, not a settings-store or validation bug. Values still persist if you can reach Save. ## Where - File: `Sources/ICCery/SettingsView.swift` - Sheet host: `RootView` `.sheet { SettingsView() }` (no inner frame on the presenter) - Branch observed against: `milestone/m10-studio` - Floor: macOS 12 / SwiftUI Form grouped style (M9 retarget) ## Reproduction 1. Launch ICCery on macOS 12 (also check 14; Form insets differ). 2. Open Settings from the sidebar gear. 3. Look at the **Verification** section: `Good ΔE ≤ [ 2.0 ] Warning ΔE ≤ [ 5.0 ]`. 4. The second label + field sit on or beyond the sheet's right clip. The footer Save/Cancel row still fits because it is outside the `Form`. Expected: both fields fully visible, with ≥12 pt inset from the sheet edge, matching the Calibration row (`Stale after [ 30 ] days`). ## Root cause Three layout facts stack. ### 1. One non-wrapping HStack holds four controls ```swift Section("Verification") { HStack { Text("Good ΔE ≤") TextField("2.0", value: $model.settings.deltaEGoodMax, format: .number) .frame(width: 60) Text("Warning ΔE ≤") TextField("5.0", value: $model.settings.deltaEWarningMax, format: .number) .frame(width: 60) } } ``` `HStack` does not wrap. The texts have no `lineLimit`, `minimumScaleFactor`, `layoutPriority`, or `minWidth: 0`. The fields are a fixed 60 pt each. Intrinsic width of the row is: `width("Good ΔE ≤") + 60 + width("Warning ΔE ≤") + 60 + HStack spacing` The Δ and ≤ glyphs are wider than ASCII. On the app's default body font that row wants ~360–400 pt before Form chrome. ### 2. macOS Form steals horizontal space the HStack does not know about The sheet is hard-sized: ```swift .frame(width: 560, height: 620) ``` A macOS grouped `Form` then applies: - sheet / form outer inset (~16–20 pt each side) - section content inset - on Monterey, Form often treats the first `Text` in a row as the trailing-label column and puts the rest of the `HStack` in the remaining control column Available width for the *remaining* three children is therefore far less than 560. Four-across content is laid out at its ideal size and drawn past the clip of the `NSHostingView` / sheet. The Calibration section only has three children (`Stale after` + 60 pt field + `days`) and still fits. Verification does not. ### 3. Fixed sheet height + M10 copy makes the Form tighter M10 added a long caption under Default instrument (`Seeds Spot Read and Stage 3…`). That grows the Argyll section. The `Form` sits in a `VStack` with a non-flexible footer (`Divider` + 12 pt padded button row). If the Form fails to become a scroll view inside that fixed 620 pt height (a known SwiftUI-on-12 pattern when Form is not given `frame(maxHeight: .infinity)` explicitly), the Verification block is also the first section pushed toward the bottom-right clip. Horizontal overflow is the primary defect; vertical clip is a related risk on 12. This is **not** caused by: - `SettingsViewModel` / `AppSettings.validate()` (good < warning still works) - Theme colours - The sheet presenter in `RootView` (no competing frame) - Help overlays (Settings is a separate sheet) ## Suggested fix Do not keep two labelled numeric fields on one `HStack` inside a Form row. Preferred (Form-native, ViewBuilder-safe): ```swift Section("Verification") { HStack { Text("Good ΔE ≤") Spacer() TextField("2.0", value: $model.settings.deltaEGoodMax, format: .number) .frame(width: 60) .multilineTextAlignment(.trailing) } .accessibilityElement(children: .contain) .accessibilityIdentifier("settingsDeltaEGood") HStack { Text("Warning ΔE ≤") Spacer() TextField("5.0", value: $model.settings.deltaEWarningMax, format: .number) .frame(width: 60) .multilineTextAlignment(.trailing) } .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).foregroundStyle(.red) } } ``` Also: - Give the `Form` `.frame(maxHeight: .infinity)` so it scrolls inside the 620 pt sheet after M10 captions. - Do not widen the sheet as the first fix; 560 pt is enough for one field per row. - Keep the footer outside the Form. - No new identifiers on sibling Settings controls (preset / media / project isolation still applies). If UI tests later query these fields, use the two new identifiers above, not a shared `deltaE` id. ## Acceptance - Both fields and both labels are fully visible at 560×620 on macOS 12 and 14, with the sheet's default Form inset. - Changing either value, failing validation (`warning <= good`), and a successful Save still work. - Calibration / install / logging rows are unchanged. - A 560-pt-wide preview or UI test screenshot of the Verification section shows no clip. - ViewBuilder child count in `SettingsView.body` stays ≤10 per stack (split if the Form grows). ## Test notes No existing UI test targets these two fields (Settings is opened from the gear; current UI tests do not assert the Verification row). Add a focused layout assertion if a Settings UI test is introduced; until then, manual check on the Monterey runner-class machine is enough to close.
gronod added this to the M10 — Studio workflow (media library, gamut compare, spot-read, projects) milestone 2026-09-13 22:37:10 +01:00
gronod added the Kind/Bug
Priority
Medium
3
Project/ICCery-v2Bug/UI
labels 2026-09-13 22:37:10 +01:00
Author
Owner

Second defect found on the same sheet (fixed on this branch)

All three numeric fields — Good ΔE, Warning ΔE, and the Calibration row — passed their default value as the TextField's first argument. On macOS that argument is a label rendered inline, not a placeholder, so the rows drew the value twice:

  • Stale after 30 [30] days
  • Good ΔE ≤ 2.0 [2.0]
  • Warning ΔE ≤ 5.0 [5.0]

The earlier Text("Stale after") in each HStack was being lifted into the Form's right-aligned label column, and the TextField's own label ("30") rendered inline inside the control column next to the real box.

Fix

The three fields are now direct Form children carrying their descriptive label — TextField("Good ΔE ≤", …), TextField("Warning ΔE ≤", …), TextField("Stale after (days)", …) — so macOS renders the label once in the label column (ending x≈513.7, same as the Pickers) and the editable box fills the control column (x=522), matching Default instrument, Install location, and Log level.

The .frame(width: 60) boxes were dropped: the modifier wraps the composite label+box and would truncate the label. Fields now flex to the control-column edge (~3.5 pt inside the sheet, same as the PopUpButtons).

settingsCalStaleDays was added as the stale-days field identifier (roster → 358).

Test changes

  • New testNumericFieldsCarryLabelsNotDuplicatedValues: asserts each field's value, that the descriptive label renders exactly once as a label-column staticText, and that no staticText echoes the old label literal ("2.0"/"5.0"/"30").
  • testVerificationRowsStayInsideSheet updated for the label-column layout: right-edge assertion is now "inside the sheet" (the control column ends ~3.5 pt in — the 12 pt inset only applied to the old 60 pt boxes); alignment and left-inset checks unchanged.
## Second defect found on the same sheet (fixed on this branch) All three numeric fields — Good ΔE, Warning ΔE, and the Calibration row — passed their *default value* as the `TextField`'s first argument. On macOS that argument is a **label rendered inline**, not a placeholder, so the rows drew the value twice: - `Stale after 30 [30] days` - `Good ΔE ≤ 2.0 [2.0]` - `Warning ΔE ≤ 5.0 [5.0]` The earlier `Text("Stale after")` in each `HStack` was being lifted into the Form's right-aligned label column, and the `TextField`'s own label ("30") rendered inline inside the control column next to the real box. ## Fix The three fields are now **direct `Form` children** carrying their descriptive label — `TextField("Good ΔE ≤", …)`, `TextField("Warning ΔE ≤", …)`, `TextField("Stale after (days)", …)` — so macOS renders the label once in the label column (ending x≈513.7, same as the Pickers) and the editable box fills the control column (x=522), matching `Default instrument`, `Install location`, and `Log level`. The `.frame(width: 60)` boxes were dropped: the modifier wraps the composite label+box and would truncate the label. Fields now flex to the control-column edge (~3.5 pt inside the sheet, same as the PopUpButtons). `settingsCalStaleDays` was added as the stale-days field identifier (roster → 358). ## Test changes - New `testNumericFieldsCarryLabelsNotDuplicatedValues`: asserts each field's value, that the descriptive label renders exactly once as a label-column `staticText`, and that no `staticText` echoes the old label literal (`"2.0"`/`"5.0"`/`"30"`). - `testVerificationRowsStayInsideSheet` updated for the label-column layout: right-edge assertion is now "inside the sheet" (the control column ends ~3.5 pt in — the 12 pt inset only applied to the old 60 pt boxes); alignment and left-inset checks unchanged.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: gronod/iccery-v2-mac#165