diff --git a/Sources/UI/HistoryInsightsOverview.swift b/Sources/UI/HistoryInsightsOverview.swift index 3ac542b6..44e0cd2f 100644 --- a/Sources/UI/HistoryInsightsOverview.swift +++ b/Sources/UI/HistoryInsightsOverview.swift @@ -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) } } diff --git a/Sources/UI/OnboardingView.swift b/Sources/UI/OnboardingView.swift index 1c2b236c..fd6ba6fc 100644 --- a/Sources/UI/OnboardingView.swift +++ b/Sources/UI/OnboardingView.swift @@ -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() diff --git a/Sources/UI/PermissionsView.swift b/Sources/UI/PermissionsView.swift index 928f7b19..ad0c0d5e 100644 --- a/Sources/UI/PermissionsView.swift +++ b/Sources/UI/PermissionsView.swift @@ -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 { diff --git a/Sources/UI/SettingsSurface.swift b/Sources/UI/SettingsSurface.swift new file mode 100644 index 00000000..7e073293 --- /dev/null +++ b/Sources/UI/SettingsSurface.swift @@ -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) + } +} diff --git a/Sources/UI/SettingsView.swift b/Sources/UI/SettingsView.swift index 187108fe..f2452931 100644 --- a/Sources/UI/SettingsView.swift +++ b/Sources/UI/SettingsView.swift @@ -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) } } diff --git a/Sources/UI/SettingsVoiceIllustration.swift b/Sources/UI/SettingsVoiceIllustration.swift index bd1afba9..f5eb61b1 100644 --- a/Sources/UI/SettingsVoiceIllustration.swift +++ b/Sources/UI/SettingsVoiceIllustration.swift @@ -10,10 +10,10 @@ 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) } } } @@ -21,6 +21,6 @@ struct SettingsCardBackground: View { extension View { func settingsPageSurface() -> some View { frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .underPageBackgroundColor)) + .background(SettingsSurface.page) } } diff --git a/Tests/OpenTypeTests/SettingsSurfaceTests.swift b/Tests/OpenTypeTests/SettingsSurfaceTests.swift new file mode 100644 index 00000000..418b3b8a --- /dev/null +++ b/Tests/OpenTypeTests/SettingsSurfaceTests.swift @@ -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 + } +} diff --git a/docs/sdlc/changes/2026-09-21-settings-surface-parity/intent.md b/docs/sdlc/changes/2026-09-21-settings-surface-parity/intent.md new file mode 100644 index 00000000..f256d93b --- /dev/null +++ b/docs/sdlc/changes/2026-09-21-settings-surface-parity/intent.md @@ -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. diff --git a/docs/sdlc/changes/2026-09-21-settings-surface-parity/plan.md b/docs/sdlc/changes/2026-09-21-settings-surface-parity/plan.md new file mode 100644 index 00000000..b7b3b510 --- /dev/null +++ b/docs/sdlc/changes/2026-09-21-settings-surface-parity/plan.md @@ -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. diff --git a/docs/sdlc/changes/2026-09-21-settings-surface-parity/spec.md b/docs/sdlc/changes/2026-09-21-settings-surface-parity/spec.md new file mode 100644 index 00000000..24588b3d --- /dev/null +++ b/docs/sdlc/changes/2026-09-21-settings-surface-parity/spec.md @@ -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. diff --git a/docs/sdlc/changes/2026-09-21-settings-surface-parity/verification.md b/docs/sdlc/changes/2026-09-21-settings-surface-parity/verification.md new file mode 100644 index 00000000..4deefd85 --- /dev/null +++ b/docs/sdlc/changes/2026-09-21-settings-surface-parity/verification.md @@ -0,0 +1,107 @@ +# Verification: Settings surface parity with System Settings + +**Status:** pending approval +**Approved-by:** — +**Approved-date:** — +**Upstream:** `plan.md` + +## Evidence + +| Check | Result | Evidence | +|---|---|---| +| `bash scripts/ci-basic-checks.sh` | Pass | "Basic CI checks passed." | +| `bash scripts/sdlc-checks.sh` | Pass | "SDLC checks passed." | +| `swift build` | Pass | `Build of product 'OpenType' complete!` | +| `swift test` (full suite) | Pass | 639 tests, 10 skipped, 0 failures | +| `swift test --filter SettingsSurfaceTests` | Pass | 6 tests, 0 failures | +| Mutation: swap page/card roles | Fails as required | Both the identity and the light/dark render assertions fail | +| Mutation: recolor card to a wrong color | Fails as required | `testCardSurfaceRendersAsUnderPageBackgroundColor` and the role test fail | +| [Baseline] User screenshot sampling | Pass | Reference System Settings: content `#FFFFFF` / box `#F7F7F7`; Pre-fix Utter: page `#F6F6F6` / card `#FFFFFF` (roles inverted) | +| [Direct probe] AppKit semantic colors (headless / ambient) | Pass | Light: window `#FFFFFF`, underPage `#F6F6F6`; Dark: window `#1E1E1E`, underPage `#282828` | +| [Real-window capture] Running app in light appearance | Pass | `dist/Utter.app` (dev-signed) Activity tab: page `#FFFFFF`, grouped boxes `#F7F7F7` (matches System Settings content `#FFFFFF` / box `#F7F7F7`) | +| [Real-window capture] Running app in dark appearance | Pass | Same window with dark appearance: page `#252525`, grouped boxes `#303030` (adapts to window compositing / contrast hierarchy) | + +The real-window captures were produced by building and launching the app, forcing +light/dark appearance, and caching the live window's `contentView` into a PNG +(`cacheDisplay(in:to:)`). They are not mockups: they are the actual SwiftUI tab +rendering. + +### Test revision in `09bb96f0` / `c30047df` and reproducible mutation steps + +Review found the first revision could not detect a regression: it asserted against +duplicated `NSColor` getters (`pageSRGB` / `cardSRGB`) that were never used by the +render path, so swapping `page` and `card` still passed. The explicit light/dark +case was also ineffective, because a dynamic `NSColor` re-resolves against the +ambient appearance once it leaves `performAsCurrentDrawingAppearance`. + +The tests now rasterize the **production** `SettingsSurface.page` / `card` and the +semantic reference `Color(nsColor:)` in an `NSHostingView` with an explicit +`appearance`, so both sides go through the same pipeline and light/dark are +deterministic regardless of the process appearance. (Probed: `ImageRenderer` +ignores `performAsCurrentDrawingAppearance` and always renders ambient; an +`NSHostingView` whose `appearance` is set renders `#FFFFFF` for aqua and +`#1E1E1E` for darkAqua.) + +#### Reproducible mutation steps + +1. **Mutation 1 (Role inversion)**: In `Sources/UI/SettingsSurface.swift`, swap `pageSemanticColor` and `cardSemanticColor`: + ```swift + static var pageSemanticColor: NSColor { .underPageBackgroundColor } + static var cardSemanticColor: NSColor { .windowBackgroundColor } + ``` + Command: + ```bash + swift test --filter SettingsSurfaceTests + ``` + Observed result: 4 tests fail across both `.aqua` and `.darkAqua`: + - `testPageSurfaceRendersAsWindowBackgroundColor`: failed (rendered `#F6F6F6`, expected `#FFFFFF`) + - `testCardSurfaceRendersAsUnderPageBackgroundColor`: failed (rendered `#FFFFFF`, expected `#F6F6F6`) + - `testCardDoesNotRenderAsWindowBackgroundColor`: failed (rendered `#FFFFFF`, card must not match window background) + - `testSurfaceIdentitiesAreTheTwoSemanticRoles`: failed (identity check) + +2. **Mutation 2 (Arbitrary color corruption)**: In `Sources/UI/SettingsSurface.swift`, alter `cardSemanticColor` to an incorrect color: + ```swift + static var cardSemanticColor: NSColor { .systemRed } + ``` + Command: + ```bash + swift test --filter SettingsSurfaceTests + ``` + Observed result: 2 tests fail: + - `testCardSurfaceRendersAsUnderPageBackgroundColor`: failed (rendered `#FF3B30`, expected `#F6F6F6`) + - `testSurfaceIdentitiesAreTheTwoSemanticRoles`: failed (identity check) + +3. **Restore baseline**: Revert `Sources/UI/SettingsSurface.swift` back to production implementation: + ```swift + static var pageSemanticColor: NSColor { .windowBackgroundColor } + static var cardSemanticColor: NSColor { .underPageBackgroundColor } + ``` + Command: + ```bash + swift test --filter SettingsSurfaceTests + ``` + Observed result: 6 tests pass, 0 failures. + +## Acceptance criteria + +- Page surfaces use `windowBackgroundColor`; boxes use + `underPageBackgroundColor` — pass (`SettingsSurfaceTests` render assertions, + the mutation counterexamples, and the real-window captures). +- Box fill distinct from the page — pass (`testPageAndCardSeparateVisibly`, and + visible in both captures). +- All check scripts and `swift test` pass — pass. + +## Scope of verification and remaining human acceptance items + +### Verified evidence (completed) +- **Production seams & test suite**: `SettingsSurface` routes through `pageSemanticColor` / `cardSemanticColor`. `SettingsSurfaceTests` (6 tests) rasterizes views in `NSHostingView` under `.aqua` and `.darkAqua`, passes deterministically, and catches role-swap and color-corruption regressions. Full suite: 639 executed, 10 skipped, 0 failures. +- **Real-window live captures**: `dist/Utter.app` running window Activity tab captured in standard light (`#FFFFFF` page / `#F7F7F7` cards) and dark (`#252525` page / `#303030` cards), matching the user reference screenshot palette. +- **CI / SDLC gates**: `bash scripts/ci-basic-checks.sh` and `bash scripts/sdlc-checks.sh` pass. + +### Remaining unverified scope (reserved for human acceptance / CTO) +- **Remaining tabs and onboarding real-device matrix**: Activity tab is verified with real-window captures. The other 5 settings tabs (General, Models, Style, Integrations, About) and the onboarding window share the identical `SettingsSurface` seams and `SettingsPageLayout`, but visual inspection on physical hardware across standard light, standard dark, and increased contrast remains to be completed by a reviewer with interactive desktop control (scripted tab switching is unavailable in headless CLI as SwiftUI tab bars do not expose `NSButton` subviews). +- **Formal SDLC approvals**: `intent.md`, `spec.md`, and `verification.md` remain in `pending approval` status and must be formally signed off by an authorized human; automated CI passes cannot substitute for human approval. + +## Decision + +Ready for review. Human approval is recorded separately.