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
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public import SwiftUI
public struct CompletionOverlayScaffold<Card: View>: 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`).
Expand Down Expand Up @@ -72,7 +75,18 @@ public struct CompletionOverlayScaffold<Card: View>: 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 {
Expand Down Expand Up @@ -166,24 +180,13 @@ public struct CompletionOverlayScaffold<Card: View>: 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 {
Expand Down Expand Up @@ -299,6 +302,25 @@ public struct CompletionOverlayScaffold<Card: View>: 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?

Expand Down
Original file line number Diff line number Diff line change
@@ -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<EmptyView>.effectiveReduceMotion(override: nil, system: true) == true)
#expect(CompletionOverlayScaffold<EmptyView>.effectiveReduceMotion(override: nil, system: false) == false)
#expect(CompletionOverlayScaffold<EmptyView>.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)..<Int(870 * scale)
var total = 0.0
var count = 0
for row in rows.clamped(to: 0..<rep.pixelsHigh) {
for col in stride(from: 0, to: rep.pixelsWide, by: 4) {
guard let color = rep.colorAt(x: col, y: row) else { continue }
total += Double(color.redComponent + color.greenComponent + color.blueComponent) / 3 * 255
count += 1
}
}
return count == 0 ? 0 : total / Double(count)
}
#endif
}
Loading