From 3239eda81852e099716080a6bb068d8cfb5c90e3 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Sun, 11 Oct 2026 00:31:47 -0400 Subject: [PATCH] Sheet text and errors use the Palette, 4.5:1 or better in every appearance (#252) Assisted-by: Claude Code:claude-opus-5-5 Claude-Session: https://claude.ai/code/session_01S6amVoNeB4GahnMMe79dZ6 --- App/AppearanceStyle.swift | 15 ++++++++++++++ App/ChannelNamesView.swift | 6 +++--- App/DeleteShowSheet.swift | 10 +++++----- App/FindMixersRow.swift | 4 ++-- App/MarkerListView.swift | 10 +++++----- App/NewShowSheet.swift | 2 +- App/PaywallSheet.swift | 2 +- App/ProSection.swift | 4 ++-- App/SettingsView.swift | 20 +++++++++---------- App/ShowDetailView.swift | 10 +++++----- App/ShowListView.swift | 4 ++-- App/TrialSheet.swift | 4 ++-- .../Sources/Recording/Appearance.swift | 17 ++++++++++++++++ .../RecordingTests/AppearanceTests.swift | 20 +++++++++++++++++++ 14 files changed, 90 insertions(+), 38 deletions(-) diff --git a/App/AppearanceStyle.swift b/App/AppearanceStyle.swift index 43c3a1ff..c55afc5a 100644 --- a/App/AppearanceStyle.swift +++ b/App/AppearanceStyle.swift @@ -71,6 +71,21 @@ struct SecondaryText: ViewModifier { } } +/// Error and warning text inside sheets and Settings: 4.5:1 or better on the sheet in every appearance (#252), where +/// the system's red and orange are not. +struct InlineText: ViewModifier { + let tone: Palette.InlineTone + @Environment(\.appearanceMode) private var mode + @Environment(\.colorScheme) private var scheme + + func body(content: Content) -> some View { + content.foregroundStyle(Color(Palette.inlineText(tone, mode: mode, systemDark: scheme == .dark))) + } +} + extension View { func secondaryText() -> some View { modifier(SecondaryText()) } + func errorText() -> some View { modifier(InlineText(tone: .error)) } + func warningText() -> some View { modifier(InlineText(tone: .warning)) } + func inlineText(_ tone: Palette.InlineTone) -> some View { modifier(InlineText(tone: tone)) } } diff --git a/App/ChannelNamesView.swift b/App/ChannelNamesView.swift index b75ec702..3c1623a9 100644 --- a/App/ChannelNamesView.swift +++ b/App/ChannelNamesView.swift @@ -19,7 +19,7 @@ struct ChannelNamesView: View { HStack { Text("\(row.number)") .monospacedDigit() - .foregroundStyle(.secondary) + .secondaryText() .frame(minWidth: 28, alignment: .trailing) TextField(row.hint, text: binding(for: row)) .focused($focused, equals: row.number) @@ -29,7 +29,7 @@ struct ChannelNamesView: View { .accessibilityLabel("Name for USB Channel \(row.number)") if !(drafts[row.number] ?? "").isEmpty { Button { drafts[row.number] = nil; commit(row.number) } label: { - Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) + Image(systemName: "xmark.circle.fill").secondaryText() } .buttonStyle(.borderless) .frame(minWidth: 44, minHeight: 44) @@ -41,7 +41,7 @@ struct ChannelNamesView: View { } Section { Button("Copy from last Show") { copyFromLastShow() } - if let copyMessage { Text(copyMessage).font(.footnote).foregroundStyle(.secondary) } + if let copyMessage { Text(copyMessage).font(.footnote).secondaryText() } } footer: { Text("The files take the names when the Take ends. A name stays for the rest of the Show.") } diff --git a/App/DeleteShowSheet.swift b/App/DeleteShowSheet.swift index 76a73295..0b537ef3 100644 --- a/App/DeleteShowSheet.swift +++ b/App/DeleteShowSheet.swift @@ -41,18 +41,18 @@ struct DeleteShowSheet: View { NavigationStack { Form { if let blocked { - Section { Label(blocked, systemImage: "lock.fill").foregroundStyle(.secondary) } + Section { Label(blocked, systemImage: "lock.fill").secondaryText() } } Section { if show.deviceCopy != nil { Toggle("Device copy", isOn: $fromDevice).onChange(of: fromDevice) { check = nil } } else { - LabeledContent("Device copy", value: "Not there").foregroundStyle(.secondary) + LabeledContent("Device copy", value: "Not there").secondaryText() } if show.driveCopy != nil { Toggle("Drive copy", isOn: $fromDrive).onChange(of: fromDrive) { check = nil } } else { - LabeledContent("Drive copy", value: "Not there").foregroundStyle(.secondary) + LabeledContent("Drive copy", value: "Not there").secondaryText() } } header: { Text("Delete from") @@ -65,7 +65,7 @@ struct DeleteShowSheet: View { if checking { ProgressView() } switch check { case .passed: Label("The Drive copy matches. The Device copy can be deleted.", systemImage: "checkmark.seal.fill").foregroundStyle(.green) - case .failed(let reasons): ForEach(reasons, id: \.self) { Text($0).foregroundStyle(.red) } + case .failed(let reasons): ForEach(reasons, id: \.self) { Text($0).errorText() } case nil: EmptyView() } } header: { @@ -97,7 +97,7 @@ struct DeleteShowSheet: View { } message: { Text(footer) } - if let message { Text(message).foregroundStyle(.red) } + if let message { Text(message).errorText() } } } .navigationTitle(channel != nil ? "Delete File" : take == nil ? "Delete Show" : "Delete Take") diff --git a/App/FindMixersRow.swift b/App/FindMixersRow.swift index 6631f16a..f9fb6955 100644 --- a/App/FindMixersRow.swift +++ b/App/FindMixersRow.swift @@ -16,12 +16,12 @@ struct FindMixersRow: View { .disabled(searching || disabled) if noNetwork { Text("Not on a Wi-Fi or Ethernet network.") - .font(.footnote).foregroundStyle(.secondary) + .font(.footnote).secondaryText() } else if let found, found.isEmpty, !searching { // On iOS the first search is also the first local-network send, so it asks for Local Network access and // the replies are lost while it asks. Text("No mixers answered. Check this device is on the mixer's network and Local Network access is on. If it just asked about Local Network, tap Find mixers again.") - .font(.footnote).foregroundStyle(.secondary) + .font(.footnote).secondaryText() } else if let found { ForEach(found, id: \.host) { mixer in Button { diff --git a/App/MarkerListView.swift b/App/MarkerListView.swift index 3c0473ff..481b8847 100644 --- a/App/MarkerListView.swift +++ b/App/MarkerListView.swift @@ -13,7 +13,7 @@ struct MarkerListView: View { List { if model.recorder.markerEntries.isEmpty { Text("No Markers yet. Press the flag during a Take to place one.") - .foregroundStyle(.secondary) + .secondaryText() } ForEach(model.recorder.markerEntries) { entry in row(entry) @@ -21,11 +21,11 @@ struct MarkerListView: View { if model.recorder.takePreRollSeconds > 0, !model.recorder.markerEntries.isEmpty { Text("Times are from when you pressed Record. The Stems, the report and the Reaper project count from the start of the Pre-roll, \(Int(model.recorder.takePreRollSeconds.rounded())) s earlier.") .font(.footnote) - .foregroundStyle(.secondary) + .secondaryText() } if let message { Label(message, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + .warningText() } } .alert("Rename Marker", isPresented: Binding(get: { renaming != nil }, set: { if !$0 { renaming = nil } })) { @@ -44,14 +44,14 @@ struct MarkerListView: View { let label = HStack { Text(ElapsedTime.format(seconds: Int(entry.secondsSincePress))) .monospacedDigit() - .foregroundStyle(.secondary) + .secondaryText() .frame(minWidth: 56, alignment: .leading) if entry.isDropout { Label("Dropout: audio was lost here", systemImage: "waveform.badge.exclamationmark") } else { Text(entry.name) Spacer() - Image(systemName: "pencil").foregroundStyle(.secondary) + Image(systemName: "pencil").secondaryText() } } .frame(minHeight: 44) diff --git a/App/NewShowSheet.swift b/App/NewShowSheet.swift index 892a58bf..207f3626 100644 --- a/App/NewShowSheet.swift +++ b/App/NewShowSheet.swift @@ -43,7 +43,7 @@ struct NewShowSheet: View { Text(sheet.startShowNote) } if let message { - Section { Text(message).foregroundStyle(.red) } + Section { Text(message).errorText() } } } .navigationTitle(sheet.title) diff --git a/App/PaywallSheet.swift b/App/PaywallSheet.swift index f89705d0..f363fdb9 100644 --- a/App/PaywallSheet.swift +++ b/App/PaywallSheet.swift @@ -32,7 +32,7 @@ struct PaywallSheet: View { Button("Restore Purchases") { Task { await store.restore() } } .disabled(store.busy) if let note = store.note { - Text(note).font(.footnote).foregroundStyle(.secondary) + Text(note).font(.footnote).secondaryText() } } footer: { Text(paywall.footnote) diff --git a/App/ProSection.swift b/App/ProSection.swift index 86afdb60..0a805c97 100644 --- a/App/ProSection.swift +++ b/App/ProSection.swift @@ -37,7 +37,7 @@ struct ProSection: View { Button("Restore Purchases") { Task { await store.restore() } } .disabled(store.busy) if let note = store.note { - Text(note).font(.footnote).foregroundStyle(.secondary) + Text(note).font(.footnote).secondaryText() } if tier == .free { freePicker @@ -79,7 +79,7 @@ struct ProSection: View { .pickerStyle(.menu) } Text("Free records 2 USB Channels. Every channel is still metered.") - .font(.footnote).foregroundStyle(.secondary) + .font(.footnote).secondaryText() } } } diff --git a/App/SettingsView.swift b/App/SettingsView.swift index b38e2b85..f81bdf23 100644 --- a/App/SettingsView.swift +++ b/App/SettingsView.swift @@ -29,7 +29,7 @@ struct SettingsView: View { if model.recorder.isRecording { Section { Label("Input, Pre-roll, Drive and Mixer Link can't be changed during a Take.", systemImage: "lock.fill") - .foregroundStyle(.secondary) + .secondaryText() } } Section { @@ -53,7 +53,7 @@ struct SettingsView: View { } .onChange(of: model.sampleRateChoice) { model.sampleRateChanged() } .disabled(locked(.input)) - Text(model.usbChannelSummary).foregroundStyle(.secondary).monospacedDigit() + Text(model.usbChannelSummary).secondaryText().monospacedDigit() Button("Channel names…") { showingChannelNames = true } .disabled(model.meterModel.channelCount == 0) Picker("Pre-roll", selection: $model.preRollSeconds) { @@ -63,7 +63,7 @@ struct SettingsView: View { } .onChange(of: model.preRollSeconds) { model.preRollChanged() } .disabled(locked(.preRoll)) - Text(model.preRollNote).font(.footnote).foregroundStyle(.secondary) + Text(model.preRollNote).font(.footnote).secondaryText() // A dropdown menu. The choice is stored the moment it is picked (in the binding's setter), so it does // not depend on the screen redrawing when the appearance changes under the open menu. Picker("Appearance", selection: Binding( @@ -75,7 +75,7 @@ struct SettingsView: View { } } .pickerStyle(.menu) - Text(model.appearance.footnote).font(.footnote).foregroundStyle(.secondary) + Text(model.appearance.footnote).font(.footnote).secondaryText() #if os(iOS) Toggle("Record button on the left in landscape", isOn: $model.transportLeading) .onChange(of: model.transportLeading) { model.saveTransportSide() } @@ -132,12 +132,12 @@ struct SettingsView: View { switch model.drive.status { case .notChosen: Label("No Drive folder. Takes go to this device only.", systemImage: "externaldrive.badge.questionmark") - .foregroundStyle(.secondary) + .secondaryText() case .available(let drive): Label("Drive: \(drive.name) · \(drive.availableBytes.formatted(.byteCount(style: .file))) free", systemImage: "externaldrive.fill.badge.checkmark") case .unavailable(let problem): Label(problem.message, systemImage: "externaldrive.badge.exclamationmark") - .foregroundStyle(.orange) + .warningText() } } @@ -152,7 +152,7 @@ struct SettingsView: View { } else { nil } Text("Time left · This device: \(device.description)" + (drive.map { " · Drive: \($0.description)" } ?? "")) .monospacedDigit() - .foregroundStyle(.secondary) + .secondaryText() } } @@ -160,15 +160,15 @@ struct SettingsView: View { switch model.mixerLink.status { case .idle: Label("Not connected to a mixer. Stems use USB Channel names.", systemImage: "link") - .foregroundStyle(.secondary) + .secondaryText() case .connecting(let host): Label("Connecting to \(host)…", systemImage: "antenna.radiowaves.left.and.right") - .foregroundStyle(.secondary) + .secondaryText() case .up(let identity, let path): Label("Mixer Link up · \(identity.networkName) (\(identity.model)) · \(path == .wifi ? "Wi-Fi" : "USB")", systemImage: "checkmark.circle.fill") case .down(let problem): Label(problem.message, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + .warningText() } } } diff --git a/App/ShowDetailView.swift b/App/ShowDetailView.swift index 490b6f4b..9a43c5d7 100644 --- a/App/ShowDetailView.swift +++ b/App/ShowDetailView.swift @@ -19,19 +19,19 @@ struct ShowDetailView: View { if let takes { List { if takes.isEmpty { - Text("No Takes yet").foregroundStyle(.secondary) + Text("No Takes yet").secondaryText() } ForEach(takes) { take in Section { if take.markers.isEmpty { - Text("No Markers").foregroundStyle(.secondary) + Text("No Markers").secondaryText() } ForEach(Array(take.markers.enumerated()), id: \.offset) { _, marker in row(marker, take: take.number) } if !take.channels.isEmpty { DisclosureGroup("Channel files (\(take.channels.count))") { ForEach(take.channels, id: \.number) { channel in HStack { - Text("\(channel.number)").monospacedDigit().foregroundStyle(.secondary) + Text("\(channel.number)").monospacedDigit().secondaryText() Text(channel.name) Spacer() Button("Delete…", role: .destructive) { deletingFile = (take.number, channel.number) } @@ -52,7 +52,7 @@ struct ShowDetailView: View { } } if let message { - Section { Text(message).foregroundStyle(.red) } + Section { Text(message).errorText() } } } } else { @@ -105,7 +105,7 @@ struct ShowDetailView: View { let content = HStack { Text(marker.name) Spacer() - Text(Self.clock(marker.seconds)).monospacedDigit().foregroundStyle(.secondary) + Text(Self.clock(marker.seconds)).monospacedDigit().secondaryText() } if marker.operatorIndex != nil { Button { newName = marker.name; renaming = (take, marker) } label: { content } diff --git a/App/ShowListView.swift b/App/ShowListView.swift index 1ebfa64c..b3ca5e90 100644 --- a/App/ShowListView.swift +++ b/App/ShowListView.swift @@ -68,9 +68,9 @@ struct ShowListView: View { HStack(spacing: 8) { VStack(alignment: .leading, spacing: 4) { Text(show.name).font(.headline) - Text(detail(show)).font(.subheadline).foregroundStyle(.secondary) + Text(detail(show)).font(.subheadline).secondaryText() if let note = show.copyNote { - Text(note.text).font(.caption).foregroundStyle(note.severity == .problem ? Color.red : .orange) + Text(note.text).font(.caption).inlineText(note.severity == .problem ? .error : .warning) } } .accessibilityElement(children: .combine) diff --git a/App/TrialSheet.swift b/App/TrialSheet.swift index 1cc94b9f..184f1275 100644 --- a/App/TrialSheet.swift +++ b/App/TrialSheet.swift @@ -23,12 +23,12 @@ struct TrialSheet: View { } .disabled(store.trialProduct == nil || store.busy) if let note = store.note { - Text(note).font(.footnote).foregroundStyle(.secondary) + Text(note).font(.footnote).secondaryText() } } } else { Text(store.loadingProducts ? "Loading…" : "The Trial isn't available right now.") - .foregroundStyle(.secondary) + .secondaryText() } } .formStyle(.grouped) diff --git a/Packages/ShowRecorderKit/Sources/Recording/Appearance.swift b/Packages/ShowRecorderKit/Sources/Recording/Appearance.swift index 141c02f9..48ffabac 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/Appearance.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/Appearance.swift @@ -73,6 +73,23 @@ public enum Palette { mode == .sunlight ? RGB(0.24, 0.24, 0.26) : RGB(0.74, 0.75, 0.78) } + /// Problem text inside sheets and Settings: an error ("couldn't…") or a warning. + public enum InlineTone: CaseIterable, Sendable { + case error, warning + } + + /// Error and warning text on a sheet's background, 4.5:1 or better (#252). The system's red and orange are + /// under that on white, so System gets its own pair for light and dark too. + public static func inlineText(_ tone: InlineTone, mode: AppearanceMode, systemDark: Bool) -> RGB { + let dark = mode == .dark || (mode == .system && systemDark) + switch (tone, dark) { + case (.error, false): return RGB(0.70, 0.0, 0.0) + case (.warning, false): return RGB(0.62, 0.30, 0.0) + case (.error, true): return RGB(1.0, 0.45, 0.42) + case (.warning, true): return RGB(1.0, 0.70, 0.25) + } + } + /// The colors the level meters draw with. public enum MeterColor: CaseIterable, Sendable { case green, yellow, red, low, orange diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/AppearanceTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/AppearanceTests.swift index 30c44c7e..10454e66 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/AppearanceTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/AppearanceTests.swift @@ -54,4 +54,24 @@ struct AppearanceTests { if weak && mode != .system { #expect(Palette.meterOutline(mode) != nil, "\(mode)") } } } + + @Test("Error and warning text inside sheets is at least 4.5:1 on the sheet's background in every appearance, System's light and dark included (#252)") + func inlineProblemText() { + let backgrounds: [(AppearanceMode, Bool, [RGB])] = [ + (.sunlight, false, [Palette.background(.sunlight)]), + (.dark, true, [Palette.background(.dark)]), + // System: a grouped Form's rows and the gray behind them, light and dark. + (.system, false, [RGB(1, 1, 1), RGB(0.95, 0.95, 0.97)]), + (.system, true, [RGB(0, 0, 0), RGB(0.11, 0.11, 0.12)]), + ] + for (mode, systemDark, behind) in backgrounds { + for tone in Palette.InlineTone.allCases { + let text = Palette.inlineText(tone, mode: mode, systemDark: systemDark) + for background in behind { + let ratio = text.contrast(with: background) + #expect(ratio >= 4.5, "\(tone) in \(mode) (dark \(systemDark)): \(ratio)") + } + } + } + } }