Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/UI/HistoryInsightsOverview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -214,10 +214,10 @@ struct HistoryInsightsOverview: View {

private var cardBackground: some View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
.fill(SettingsSurface.card)
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 0.5)
.stroke(SettingsSurface.cardStroke.opacity(0.7), lineWidth: 0.5)
}
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/UI/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,9 @@ struct OnboardingView: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { refreshPermissions() }
}
}
.background(Color(nsColor: .controlBackgroundColor))
.background(SettingsSurface.card)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(nsColor: .separatorColor), lineWidth: 0.5))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(SettingsSurface.cardStroke, lineWidth: 0.5))

HStack {
Spacer()
Expand Down
4 changes: 2 additions & 2 deletions Sources/UI/PermissionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ struct PermissionsView: View {
action: requestScreenCapture
)
}
.background(Color(nsColor: .controlBackgroundColor))
.background(SettingsSurface.card)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(nsColor: .separatorColor), lineWidth: 0.5)
.stroke(SettingsSurface.cardStroke, lineWidth: 0.5)
)

HStack {
Expand Down
31 changes: 31 additions & 0 deletions Sources/UI/SettingsSurface.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import AppKit
import SwiftUI

/// Shared semantic surfaces for the settings and onboarding windows.
///
/// macOS System Settings paints its content pane with `windowBackgroundColor`
/// and separates grouped boxes with a slightly offset fill. Utter previously did
/// the opposite — a gray page with white cards — which reads as an off-system
/// gray. These seams keep every surface on the system palette, so light, dark,
/// and increased-contrast appearances adapt without fixed colors.
enum SettingsSurface {
/// The AppKit semantic colors the two seams are defined by. Kept as the
/// single source of truth so the surfaces cannot drift from their roles.
static var pageSemanticColor: NSColor { .windowBackgroundColor }
static var cardSemanticColor: NSColor { .underPageBackgroundColor }

/// Window and page background, matching the System Settings content pane.
static var page: Color {
Color(nsColor: pageSemanticColor)
}

/// Grouped box and card background, subtly separated from the page.
static var card: Color {
Color(nsColor: cardSemanticColor)
}

/// Hairline used to outline grouped boxes.
static var cardStroke: Color {
Color(nsColor: .separatorColor)
}
}
2 changes: 1 addition & 1 deletion Sources/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ struct SettingsView: View {
.tabItem { Label(L("tab.about"), systemImage: "info.circle") }
}
.frame(width: SettingsWindowLayout.width, height: SettingsWindowLayout.height)
.background(Color(nsColor: .underPageBackgroundColor))
.background(SettingsSurface.page)
.id(settings.uiLanguage)
}
}
6 changes: 3 additions & 3 deletions Sources/UI/SettingsVoiceIllustration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ struct SettingsCardBackground: View {

var body: some View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
.fill(SettingsSurface.card)
.overlay {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(Color(nsColor: .separatorColor), lineWidth: 0.5)
.stroke(SettingsSurface.cardStroke, lineWidth: 0.5)
}
}
}

extension View {
func settingsPageSurface() -> some View {
frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .underPageBackgroundColor))
.background(SettingsSurface.page)
}
}
131 changes: 131 additions & 0 deletions Tests/OpenTypeTests/SettingsSurfaceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import AppKit
import SwiftUI
import XCTest
@testable import OpenType

/// Verifies the settings surfaces through the SwiftUI `Color` values the app
/// actually renders, so swapping `page` and `card` (or recoloring either) fails.
///
/// Rasterizing happens in an `NSHostingView`, the same view hierarchy the
/// settings window uses, with an explicit `appearance` set. That makes light and
/// dark deterministic and independent of the process appearance, unlike
/// `ImageRenderer`, which follows the ambient appearance and ignored
/// `performAsCurrentDrawingAppearance`.
@MainActor
final class SettingsSurfaceTests: XCTestCase {
func testPageSurfaceRendersAsWindowBackgroundColor() {
assertSurface(SettingsSurface.page, matches: Color(nsColor: .windowBackgroundColor), label: "page")
}

func testCardSurfaceRendersAsUnderPageBackgroundColor() {
assertSurface(SettingsSurface.card, matches: Color(nsColor: .underPageBackgroundColor), label: "card")
}

func testCardDoesNotRenderAsWindowBackgroundColor() throws {
for appearance in Self.appearances {
let card = try render(SettingsSurface.card, in: appearance)
let page = try render(Color(nsColor: .windowBackgroundColor), in: appearance)
XCTAssertFalse(
isApproximatelyEqual(card, page),
"card must not render as windowBackgroundColor in \(appearance.name.rawValue)"
)
}
}

func testPageAndCardSeparateVisibly() throws {
for appearance in Self.appearances {
let page = try render(SettingsSurface.page, in: appearance)
let card = try render(SettingsSurface.card, in: appearance)
let delta = abs(page.redComponent - card.redComponent)
+ abs(page.greenComponent - card.greenComponent)
+ abs(page.blueComponent - card.blueComponent)
XCTAssertGreaterThan(
delta,
0.005,
"boxes must separate from the page in \(appearance.name.rawValue)"
)
}
}

func testAppearancesProduceDistinctRenderings() throws {
let light = try render(SettingsSurface.page, in: NSAppearance(named: .aqua)!)
let dark = try render(SettingsSurface.page, in: NSAppearance(named: .darkAqua)!)
XCTAssertGreaterThan(
abs(light.redComponent - dark.redComponent),
0.1,
"light and dark must render the page differently"
)
}

/// Pins the two seams to their semantic roles so a future edit is caught
/// even when the colors are re-derived.
func testSurfaceIdentitiesAreTheTwoSemanticRoles() {
XCTAssertEqual(
SettingsSurface.pageSemanticColor,
NSColor.windowBackgroundColor,
"page must be the window background role"
)
XCTAssertEqual(
SettingsSurface.cardSemanticColor,
NSColor.underPageBackgroundColor,
"card must be the under-page background role"
)
XCTAssertNotEqual(
SettingsSurface.pageSemanticColor,
SettingsSurface.cardSemanticColor,
"the two roles must not collapse onto one color"
)
}

// MARK: - Helpers

private static var appearances: [NSAppearance] {
[NSAppearance(named: .aqua)!, NSAppearance(named: .darkAqua)!]
}

private func assertSurface(
_ color: Color,
matches reference: @autoclosure () -> Color,
label: String,
file: StaticString = #filePath,
line: UInt = #line
) {
for appearance in Self.appearances {
guard let actual = try? render(color, in: appearance),
let expected = try? render(reference(), in: appearance) else {
XCTFail("\(label): could not render in \(appearance.name.rawValue)", file: file, line: line)
continue
}
XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.02, "\(label) red", file: file, line: line)
XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.02, "\(label) green", file: file, line: line)
XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.02, "\(label) blue", file: file, line: line)
}
}

private func render(_ color: Color, in appearance: NSAppearance) throws -> NSColor {
let size: CGFloat = 10
let hosting = NSHostingView(rootView: color.frame(width: size, height: size))
hosting.appearance = appearance
hosting.frame = NSRect(x: 0, y: 0, width: size, height: size)
guard let rep = hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds) else {
throw RenderError.noBitmap
}
hosting.cacheDisplay(in: hosting.bounds, to: rep)
guard let sampled = rep.colorAt(x: Int(size / 2), y: Int(size / 2)),
let srgb = sampled.usingColorSpace(.sRGB) else {
throw RenderError.noPixel
}
return srgb
}

private func isApproximatelyEqual(_ lhs: NSColor, _ rhs: NSColor) -> Bool {
abs(lhs.redComponent - rhs.redComponent) < 0.02
&& abs(lhs.greenComponent - rhs.greenComponent) < 0.02
&& abs(lhs.blueComponent - rhs.blueComponent) < 0.02
}

private enum RenderError: Error {
case noBitmap
case noPixel
}
}
59 changes: 59 additions & 0 deletions docs/sdlc/changes/2026-09-21-settings-surface-parity/intent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Intent: Settings surface parity with System Settings

**Status:** pending approval
**Approved-by:** —
**Approved-date:** —
**Upstream:** —

## Problem

The user reports that the app's gray background "does not look like the system's
mainstream palette" and asks for the colors used by System Settings.

Measured from the attached screenshots (sampled with an sRGB reader):

- System Settings (light): content pane `#FFFFFF`, grouped boxes `#F7F7F7`.
- Utter (light): page `#F6F6F6`, cards `#FFFFFF`.

Utter uses the two semantic grays the other way round: the page is gray and the
boxes are white, while System Settings is a white pane with slightly offset
boxes. On this machine `windowBackgroundColor` is `#FFFFFF` light / `#1E1E1E`
dark and `underPageBackgroundColor` is `#F6F6F6` light / `#282828` dark, so the
current page choice (`underPageBackgroundColor`) is exactly the off-system gray.

## Outcome

Every settings and onboarding surface uses the system semantic palette in the
same roles as System Settings: page/window background from
`windowBackgroundColor`, grouped boxes from `underPageBackgroundColor`. Light,
dark, and increased-contrast appearances adapt without fixed colors.

## Scope

Affected: settings shell and page surfaces, grouped cards/boxes in history
insights, model management, permissions, style, integrations, about, and the
onboarding permission list.

Non-goals: layout, spacing, typography, the recording overlay colors, the
waveform, or the app icon. No appearance override or custom material layer.

## Constraints

- AppKit semantic colors only; no fixed RGB values.
- Follow light and dark appearances automatically.
- List/editor surfaces that use `textBackgroundColor` keep a white editing
surface.
- One central seam for the two surfaces so future changes stay consistent.

## Acceptance criteria

- Page/window surfaces render `windowBackgroundColor`; grouped boxes render
`underPageBackgroundColor`, in both appearances.
- The box fill is visibly distinct from the page so the grouping still reads.
- Rendered swatches match the semantic colors within color-management tolerance
(`SettingsSurfaceTests`).
- `swift test`, `scripts/ci-basic-checks.sh`, and `scripts/sdlc-checks.sh` pass.

## Open questions

None.
33 changes: 33 additions & 0 deletions docs/sdlc/changes/2026-09-21-settings-surface-parity/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Plan: Settings surface parity with System Settings

**Status:** pending approval
**Approved-by:** —
**Approved-date:** —
**Upstream:** `spec.md`

## Work items

- [x] Add `SettingsSurface` with `page`, `card`, and `cardStroke` seams.
- [x] Point `SettingsView` and `settingsPageSurface()` at `SettingsSurface.page`.
- [x] Point `SettingsCardBackground` and the history overview cards at
`SettingsSurface.card`.
- [x] Point the onboarding and permissions grouped boxes at the same seams.
- [x] Add `SettingsSurfaceTests` rasterizing the production seams against the
semantic references inside explicit light/dark appearances.
- [x] Prove the tests fail on a page/card swap and on a wrong card color.
- [x] Capture real-window light and dark rendering from the running app.

## Verification plan

- [x] `bash scripts/ci-basic-checks.sh`
- [x] `bash scripts/sdlc-checks.sh`
- [x] `swift test` (full suite)
- [x] Real-window light and dark rendering of the Activity tab captured and
inspected against System Settings.
- [ ] Reviewer confirms the remaining tabs and the onboarding window visually
(they share the same seams; only the Activity tab was captured here).

## Human gates

- Intent, spec, and verification approval before merge.
- Reviewer confirms the remaining tabs and onboarding in light and dark.
61 changes: 61 additions & 0 deletions docs/sdlc/changes/2026-09-21-settings-surface-parity/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Spec: Settings surface parity with System Settings

**Status:** pending approval
**Approved-by:** —
**Approved-date:** —
**Upstream:** `intent.md`

## Context

`SettingsView` and `settingsPageSurface()` painted
`Color(nsColor: .underPageBackgroundColor)` from the approved 2026-08-31
`settings-semantic-background` change. Grouped cards and boxes painted
`Color(nsColor: .controlBackgroundColor)`. On macOS 26/27 those resolve to:

| Semantic color | Light | Dark |
|---|---|---|
| `windowBackgroundColor` | `#FFFFFF` | `#1E1E1E` |
| `underPageBackgroundColor` | `#F6F6F6` | `#282828` |
| `controlBackgroundColor` | `#FFFFFF` | `#1E1E1E` |

System Settings samples (light, from the user's screenshot): content `#FFFFFF`,
box `#F7F7F7`. So the correct mapping is page = `windowBackgroundColor`, box =
`underPageBackgroundColor`.

## Design

Add `SettingsSurface` (a small enum namespace) with three seams:
`page` (`windowBackgroundColor`), `card` (`underPageBackgroundColor`), and
`cardStroke` (`separatorColor`). Replace the ad-hoc colors:

- `SettingsView` root and `settingsPageSurface()` use `SettingsSurface.page`.
- `SettingsCardBackground` uses `SettingsSurface.card` / `cardStroke`.
- `HistoryInsightsOverview.cardBackground` uses the same.
- The grouped boxes in `OnboardingView` and `PermissionsView` use
`SettingsSurface.card` / `cardStroke` instead of `controlBackgroundColor`.

Editing surfaces (`DictionaryStyleView`) keep `textBackgroundColor`, so text
fields stay white.

## Safety and failure modes

Visual-only change. No behavior, data, privacy, or permission impact. Fixed
colors are avoided, so contrast in increased-contrast and dark appearances comes
from AppKit. Risk is limited to contrast regressions, which the distinctness test
and review of light/dark appearances cover.

## Test strategy

`SettingsSurfaceTests` rasterizes the production `SettingsSurface.page` / `card`
and the semantic reference `Color(nsColor:)` in an `NSHostingView` with an explicit
`NSAppearance` (`.aqua` / `.darkAqua`), ensuring both sides share the exact same
rendering pipeline and that dynamic colors resolve deterministically regardless of
ambient process appearance (addressing `ImageRenderer`'s limitation of ignoring
`performAsCurrentDrawingAppearance`). It asserts pixel equality in sRGB (within
color-management tolerance) plus a page/card distinctness assertion so boxes stay
visible. Full `swift test`, and the two check scripts.

## Rollout and rollback

Ships with the next Utter release. Rollback is reverting the commit; the change
touches only color seams.
Loading
Loading