diff --git a/Packages/GameShellKit/Sources/GameShellUI/Completion/CompletionAccentGlow.swift b/Packages/GameShellKit/Sources/GameShellUI/Completion/CompletionAccentGlow.swift new file mode 100644 index 00000000..9b616461 --- /dev/null +++ b/Packages/GameShellKit/Sources/GameShellUI/Completion/CompletionAccentGlow.swift @@ -0,0 +1,72 @@ +// CompletionAccentGlow — the accent glow painted BEHIND the completion glass +// (#1023 §3.5, `color.md:68`: glass has no inherent color, it picks up what +// sits behind it). Fixed for #1065. +// +// One view owns BOTH glows that `CompletionOverlayScaffold` used to fold into +// a single `if case .rise` branch: M1's accent seep (the success ritual) and +// M10's accompanying glow while the panel rises (any `liveSolve`, so an MS +// loss keeps its `status.error` wash). #1065: that gate keyed the glow on M10 +// being in its non-Reduce-Motion `.rise` form, so under Reduce Motion the +// glow was dropped outright while `motionPlan.accentSeep`'s `.crossfade` was +// computed and never rendered. design.md §6 M1 + hard constraint 2 ("Reduce +// Motion 不是關掉,是換 fade"): the seep must still appear, as a 0.25 s +// opacity crossfade — no sweep, no positional motion. +// +// `form(for:)` and `animation(for:)` are the pure resolutions the view body +// renders through, and what `CompletionAccentGlowTests` pins — there is no +// second, unrendered copy of the Reduce Motion decision any more. + +import SwiftUI + +struct CompletionAccentGlow: View { + let motionPlan: CompletionMotionPlan + let tint: Color + let revealed: Bool + + var body: some View { + let form = Self.form(for: motionPlan) + if case .none = form { + EmptyView() + } else { + // Subtle by design — this paints BEHIND the glass so the glass + // itself picks up the tint (§3.5); it must not overpower the + // CTA text sitting on top of the glass. Both forms settle at the + // same tint; only the way opacity gets there differs. + RadialGradient( + colors: [tint.opacity(revealed ? 0.16 : 0), .clear], + center: .bottom, + startRadius: 0, + endRadius: 260 + ) + .ignoresSafeArea() + .animation(Self.animation(for: form), value: revealed) + } + } + + /// Which glow plays, from the plan alone: + /// - the ritual's M1 form (`.seep` / `.crossfade`) whenever it plays; + /// - otherwise M10's glow (`.seep(glowDuration)`) while the panel rises + /// — the MS-loss error wash, unchanged from before #1065; + /// - otherwise `.none` (`review`, and a loss under Reduce Motion — M10's + /// Reduce-Motion row says nothing about its glow; left as-is here). + nonisolated static func form(for plan: CompletionMotionPlan) -> CompletionMotionPlan.AccentSeepForm { + switch (plan.accentSeep, plan.panelRise) { + case (.seep, _), (.crossfade, _): + plan.accentSeep + case (.none, .rise(_, let glowDuration)): + .seep(duration: glowDuration) + case (.none, .fadeInPlace), (.none, .none): + .none + } + } + + /// `.seep` keeps the pre-#1065 0.6 s ease-out; `.crossfade` is a plain + /// symmetric opacity fade so nothing reads as the seep's outward push. + nonisolated static func animation(for form: CompletionMotionPlan.AccentSeepForm) -> Animation? { + switch form { + case .seep(let duration): .easeOut(duration: duration) + case .crossfade(let duration): .easeInOut(duration: duration) + case .none: nil + } + } +} diff --git a/Packages/GameShellKit/Sources/GameShellUI/Completion/CompletionOverlayScaffold.swift b/Packages/GameShellKit/Sources/GameShellUI/Completion/CompletionOverlayScaffold.swift index b454bae1..7e0ab37d 100644 --- a/Packages/GameShellKit/Sources/GameShellUI/Completion/CompletionOverlayScaffold.swift +++ b/Packages/GameShellKit/Sources/GameShellUI/Completion/CompletionOverlayScaffold.swift @@ -33,6 +33,9 @@ public import SwiftUI public struct CompletionOverlayScaffold: View { @Environment(\.theme) private var theme @Environment(\.accessibilityReduceMotion) private var reduceMotion + // #1065: `accessibilityReduceMotion` is read-only in `EnvironmentValues`, + // so the rendered Reduce-Motion path needs its own offline seam. + @Environment(\.completionReduceMotionOverride) private var reduceMotionOverride // #1023: mirrors `completionHeroSkipsReveal` — the panel-rise state below // is ALSO onAppear-driven, so it needs the same offline-renderer escape // hatch (snapshot tests / ASC screenshot emitter never fire `.onAppear`). @@ -72,7 +75,18 @@ public struct CompletionOverlayScaffold: View { } private var motionPlan: CompletionMotionPlan { - CompletionMotionPlan.plan(variant: variant, outcomeKind: outcomeKind, reduceMotion: reduceMotion) + CompletionMotionPlan.plan( + variant: variant, + outcomeKind: outcomeKind, + reduceMotion: Self.effectiveReduceMotion(override: reduceMotionOverride, system: reduceMotion) + ) + } + + /// The seam falls through to the live `accessibilityReduceMotion` unless + /// a test set it — pinned by `CompletionAccentGlowTests` so production + /// can't silently ignore the user's setting. + nonisolated static func effectiveReduceMotion(override: Bool?, system: Bool) -> Bool { + override ?? system } private var isPanelRevealed: Bool { @@ -166,24 +180,13 @@ public struct CompletionOverlayScaffold: View { } /// M1 (accent seep) + M10's accompanying glow, painted BEHIND the glass - /// so the glass naturally picks up the tint (§3.5, `color.md:68`). Only - /// renders when the panel actually rises (`liveSolve`) — `review` shows - /// no glow, matching its "no ritual" contract. - @ViewBuilder + /// so the glass naturally picks up the tint (§3.5, `color.md:68`). + /// `CompletionAccentGlow` resolves WHICH glow plays from the plan (M1's + /// `.seep`/`.crossfade` when the ritual plays, M10's glow for a live + /// loss, nothing for `review`) — #1065: no `panelRise` gate here any + /// more, that is what dropped the seep under Reduce Motion. private var accentGlow: some View { - if case .rise(_, let glowDuration) = motionPlan.panelRise { - // Subtle by design — this paints BEHIND the glass so the glass - // itself picks up the tint (§3.5); it must not overpower the - // CTA text sitting on top of the glass. - RadialGradient( - colors: [glowTint.opacity(isPanelRevealed ? 0.16 : 0), .clear], - center: .bottom, - startRadius: 0, - endRadius: 260 - ) - .ignoresSafeArea() - .animation(.easeOut(duration: glowDuration), value: isPanelRevealed) - } + CompletionAccentGlow(motionPlan: motionPlan, tint: glowTint, revealed: isPanelRevealed) } private var glowTint: Color { @@ -299,6 +302,25 @@ public struct CompletionOverlayScaffold: View { } } +// MARK: - Reduce Motion offline seam (#1065) + +private struct CompletionReduceMotionOverrideKey: EnvironmentKey { + static let defaultValue: Bool? = nil +} + +extension EnvironmentValues { + /// Test/offline-renderer-only override for `accessibilityReduceMotion`, + /// which SwiftUI exposes read-only (`.environment(\.accessibilityReduceMotion, _)` + /// does not compile). Lets `CompletionAccentGlowTests` render the + /// scaffold's real Reduce-Motion path — the one #1065 found dead — in + /// an offscreen `NSHostingView`. `nil` (default) reads the live setting. + /// Internal on purpose: not part of the shell's public surface. + var completionReduceMotionOverride: Bool? { + get { self[CompletionReduceMotionOverrideKey.self] } + set { self[CompletionReduceMotionOverrideKey.self] = newValue } + } +} + private struct OptionalAccessibilityIdentifier: ViewModifier { let identifier: String? diff --git a/Packages/GameShellKit/Tests/GameShellUITests/CompletionAccentGlowTests.swift b/Packages/GameShellKit/Tests/GameShellUITests/CompletionAccentGlowTests.swift new file mode 100644 index 00000000..f4ca3e64 --- /dev/null +++ b/Packages/GameShellKit/Tests/GameShellUITests/CompletionAccentGlowTests.swift @@ -0,0 +1,165 @@ +import SwiftUI +import Testing +@testable import GameShellUI +#if canImport(AppKit) +import AppKit +#endif + +// MARK: - CompletionAccentGlow (#1065) +// +// #1065 (B-5 finding): `CompletionMotionPlan.accentSeep` computed the Reduce +// Motion `.crossfade` form but nothing rendered it — the painted glow was +// gated on M10's `.rise` form, so under Reduce Motion the seep was dropped, +// not cross-faded, while the plan-level unit test stayed green. These tests +// therefore pin the RENDERED path, not the plan value: +// +// 1. `form(for:)` / `animation(for:)` — the exact resolutions the view body +// renders through (a mutation that renders `.crossfade` with the seep's +// animation goes red here); +// 2. an offscreen AppKit render of the real `CompletionOverlayScaffold` +// under the Reduce-Motion seam, probing the bottom band the glow is +// anchored to (restoring the `if case .rise` gate goes red here). +// +// Honest limit of (2): an offscreen `NSHostingView` capture is one settled +// frame, so it proves the glow EXISTS under Reduce Motion at the same settled +// tint as the sweep path; the crossfade-vs-seep timing itself is pinned by +// (1) on the function the body calls, not observed frame-by-frame. + +@Suite("GameShellUI — CompletionAccentGlow (#1065)") +struct CompletionAccentGlowTests { + + private static func plan( + variant: CompletionVariant = .liveSolve, + outcomeKind: CompletionOutcome.Kind = .success, + reduceMotion: Bool + ) -> CompletionMotionPlan { + CompletionMotionPlan.plan(variant: variant, outcomeKind: outcomeKind, reduceMotion: reduceMotion) + } + + // MARK: - Which glow plays (resolved from the plan alone) + + @Test func liveSolveSuccessRendersTheSeep() { + #expect(CompletionAccentGlow.form(for: Self.plan(reduceMotion: false)) == .seep(duration: 0.6)) + } + + @Test func reduceMotionRendersTheCrossfadeNotNothing() { + #expect(CompletionAccentGlow.form(for: Self.plan(reduceMotion: true)) == .crossfade(duration: 0.25)) + } + + @Test func liveSolveFailureKeepsM10GlowWithoutRitual() { + // MS loss: no M1 ritual (`accentSeep == .none`), but the panel still + // rises with its 0.6 s glow — unchanged from before #1065. + let plan = Self.plan(outcomeKind: .failure, reduceMotion: false) + #expect(plan.accentSeep == .none) + #expect(CompletionAccentGlow.form(for: plan) == .seep(duration: 0.6)) + } + + @Test func liveSolveFailureUnderReduceMotionStaysUnlit() { + // Pre-#1065 behavior kept on purpose (M10's Reduce-Motion row is + // silent on its glow) — pinned so a change here is a decision, not drift. + #expect(CompletionAccentGlow.form(for: Self.plan(outcomeKind: .failure, reduceMotion: true)) == .none) + } + + @Test func reviewRendersNoGlow() { + #expect(CompletionAccentGlow.form(for: Self.plan(variant: .review, reduceMotion: false)) == .none) + #expect(CompletionAccentGlow.form(for: Self.plan(variant: .review, reduceMotion: true)) == .none) + } + + // MARK: - How opacity gets there + + @Test func crossfadeAnimatesOpacityOnlyNotTheSweep() { + let crossfade = CompletionAccentGlow.animation(for: .crossfade(duration: 0.25)) + let sweep = CompletionAccentGlow.animation(for: .seep(duration: 0.6)) + #expect(crossfade == .easeInOut(duration: 0.25)) + #expect(crossfade != sweep) + } + + @Test func seepKeepsTheOriginalEaseOut() { + #expect(CompletionAccentGlow.animation(for: .seep(duration: 0.6)) == .easeOut(duration: 0.6)) + #expect(CompletionAccentGlow.animation(for: .none) == nil) + } + + // MARK: - The Reduce-Motion seam falls through to the real setting + + @Test func reduceMotionSeamDefaultsToTheSystemSetting() { + // No override (the production case) → `accessibilityReduceMotion` decides. + #expect(EnvironmentValues().completionReduceMotionOverride == nil) + #expect(CompletionOverlayScaffold.effectiveReduceMotion(override: nil, system: true) == true) + #expect(CompletionOverlayScaffold.effectiveReduceMotion(override: nil, system: false) == false) + #expect(CompletionOverlayScaffold.effectiveReduceMotion(override: true, system: false) == true) + } + + // MARK: - Rendered path: the real scaffold under Reduce Motion + + #if canImport(AppKit) + /// The settled completion screen's bottom band (design.md §3.5 anchors + /// the radial glow at `.bottom`) — same band #1028/#1065 measured on the + /// simulator (y 640–870 pt of a 402×874 pt iPhone 17 Pro frame). + @MainActor + @Test func reduceMotionStillPaintsTheGlowInTheScaffold() throws { + let reduceMotionOn = try Self.bottomBandMeanLuminance(reduceMotion: true) + let reduceMotionOff = try Self.bottomBandMeanLuminance(reduceMotion: false) + let noGlow = try Self.bottomBandMeanLuminance(variant: .review, reduceMotion: true) + + // Sanity for the probe itself: the sweep path must be measurable. + #expect(noGlow - reduceMotionOff > 1.0, "sweep path left no tint in the band — probe is blind") + // #1065: Reduce Motion must still tint the band (crossfade, not off) … + #expect(noGlow - reduceMotionOn > 1.0, "Reduce Motion dropped the accent glow instead of cross-fading it") + // … and settle at the same glow as the sweep path. + #expect(abs(reduceMotionOn - reduceMotionOff) < 0.5) + } + + /// A live Minesweeper loss keeps M10's `status.error` wash exactly as + /// before #1065: painted with Reduce Motion off, absent with it on + /// (M10's Reduce-Motion row is silent on the glow — kept, not decided here). + @MainActor + @Test func lossWashIsUnchangedByResolvingFromThePlan() throws { + let lossOff = try Self.bottomBandMeanLuminance(outcomeKind: .failure, reduceMotion: false) + let lossOn = try Self.bottomBandMeanLuminance(outcomeKind: .failure, reduceMotion: true) + let noGlow = try Self.bottomBandMeanLuminance(variant: .review, outcomeKind: .failure, reduceMotion: false) + + #expect(noGlow - lossOff > 1.0, "loss wash no longer paints with Reduce Motion off") + #expect(abs(lossOn - noGlow) < 0.5, "loss wash started painting under Reduce Motion — that is a design decision, not #1065") + } + + @MainActor + private static func bottomBandMeanLuminance( + variant: CompletionVariant = .liveSolve, + outcomeKind: CompletionOutcome.Kind = .success, + reduceMotion: Bool + ) throws -> Double { + let scaffold = CompletionOverlayScaffold( + variant: variant, + outcomeKind: outcomeKind, + context: .practice(onPlayAgain: nil), + onClose: {}, + card: { Color.clear.frame(height: 120) } + ) + // Offline renderer: `.onAppear` never fires, so settle the M10 latch + // through the existing seam; the Reduce-Motion seam is #1065's. + .environment(\.completionHeroSkipsReveal, true) + .environment(\.completionReduceMotionOverride, reduceMotion) + .background(Color.white) + + let host = NSHostingView(rootView: scaffold) + host.frame = CGRect(x: 0, y: 0, width: 402, height: 874) + host.layoutSubtreeIfNeeded() + let rep = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: rep) + + // Bitmap rows run top-down; scale the point band to the backing pixels. + let scale = Double(rep.pixelsHigh) / 874 + let rows = Int(640 * scale)..