Skip to content

Render the reduced-motion frame at raw engine time - #12

Open
andresain123 wants to merge 1 commit into
Jakubantalik:mainfrom
andresain123:fix/swiftui-reduced-motion-instant
Open

Render the reduced-motion frame at raw engine time#12
andresain123 wants to merge 1 commit into
Jakubantalik:mainfrom
andresain123:fix/swiftui-reduced-motion-instant

Conversation

@andresain123

@andresain123 andresain123 commented Sep 8, 2026

Copy link
Copy Markdown

ThinkingOrbsKit renders the reduced-motion / paused static frame at a
different instant than the web build, for every state and size.

swiftui-reduced-motion-bug swiftui-reduced-motion-connecting

The two branches disagree

ports/ios/ThinkingOrbsKit/Sources/ThinkingOrbsKit/ThinkingOrb.swift:64-72

if let frozenTime {
    // Raw engine time, NOT scaled by speed: the golden vectors and
    // the web parity harness both evaluate the engine at this t
    // directly, so applying the preset speed here would compare
    // two different instants and report a false mismatch.
    canvas(preset: preset, t: frozenTime)              // <- correct
} else if reduceMotion || paused {
    // one static, deterministic frame — same instant as the web
    canvas(preset: preset, t: OrbSpec.reducedMotionT * effSpeed)   // <- scaled
}

Same rule, two answers. The comment on the second states the intent — "same
instant as the web" — that the code then breaks.

The web reference does not scale it (src/ThinkingOrb.tsx:90-99):

// reduced motion → one static, deterministic frame
if (reduced) {
  frame(0.6);
  return;
}
// ... the live loop, nine lines later:
frame((performance.now() / 1000) * effSpeed);

0.6 is already engine time. spec.paint.clock defines engine time as
elapsedSeconds * presetSpeed * userSpeed, which is why the live loop
multiplies on the way in and the constant must not be multiplied again.

Measured

paused reaches the identical line as reduceMotion, so the test needs no
faked accessibility environment. Both paths render through the real SwiftUI
Canvas via ImageRenderer and the rasters are compared, so the guard fails
on painted output rather than on a recomputed number — headless, no simulator.

Before, all 18 (state × size) pairs differ:

  working@64    speed 1.885  static t=1.131  differing 19.57%  maxD 255
  working@20    speed 3.900  static t=2.340  differing 22.69%  maxD 255
  searching@64  speed 2.015  static t=1.209  differing 20.76%  maxD 200
  searching@20  speed 2.665  static t=1.599  differing 32.50%  maxD 210
  solving@64    speed 1.820  static t=1.092  differing 14.41%  maxD 255
  solving@20    speed 1.950  static t=1.170  differing 26.31%  maxD 255
  listening@64  speed 4.388  static t=2.633  differing 15.47%  maxD 255
  listening@20  speed 3.998  static t=2.399  differing 30.19%  maxD 255
  connecting@64 speed 3.315  static t=1.989  differing 29.93%  maxD 255
  connecting@20 speed 6.630  static t=3.978  differing 12.38%  maxD 255
  weaving@64    speed 1.625  static t=0.975  differing 16.64%  maxD 250
  weaving@20    speed 2.750  static t=1.650  differing 24.62%  maxD 252
  composing@64  speed 2.340  static t=1.404  differing 23.33%  maxD 255
  composing@20  speed 3.120  static t=1.872  differing 30.75%  maxD 255
  breathing@64  speed 3.240  static t=1.944  differing 10.41%  maxD 255
  breathing@20  speed 3.780  static t=2.268  differing 15.69%  maxD 255
  shaping@64    speed 2.405  static t=1.443  differing  4.27%  maxD 255
  shaping@20    speed 2.080  static t=1.248  differing 15.25%  maxD 161

No preset has speed 1.0 — they run 1.625 to 6.63 — so nothing is exempt.
connecting@20 renders t = 3.978 where the web renders 0.6.

After removing * effSpeed, all 18 are byte-identical (0.00%, maxD 0), and
the existing suite is unchanged:

Executed 5 tests, with 2 tests skipped and 0 failures
golden: 72 cases, 70115 values within 0.0001 (spec 1.0.0)

Also confirmed on a device: ThinkingOrbsDemo on an iPhone 17 Pro simulator
with Reduce Motion enabled in Settings, one simulator and one setting, only
ThinkingOrbsKit rebuilt between two captures. The frozen frame moves by
5-25% of pixels per orb. The screen was verified genuinely static first — 0 of
3162132 pixels changed across 14 seconds.

Why the existing suite missed it

0.6 is one of the four timestamps in spec/orbs-golden.json, picked so this
frame is verifiable — but OrbGoldenTests evaluates orbFrame directly and
OrbSnapshotTests goes through .orbFrozenTime. Neither ever renders the
reduceMotion || paused branch, so it had no coverage at all.
ReducedMotionParityTests closes that by comparing the two paths against each
other, so it stays honest if reducedMotionT is ever retuned.

Severity is parity, not breakage — t = 1.989 is still a valid pose. But it is
the pose reduced-motion users see, it disagrees with every other platform, and
paused inherits it.

OrbSpec.reducedMotionT is already engine time — spec.paint.clock defines
engine time as elapsedSeconds * presetSpeed * userSpeed, which is why the
live TimelineView multiplies by effSpeed on the way in. The static branch
multiplied it a second time, so every reduced-motion and paused frame
rendered a different instant than the web build, which paints a bare
frame(0.6).

No preset has speed 1.0 — they run 1.625 to 6.63 — so all 18 (mode × size)
pairs were affected; web@20 rendered t = 3.978 where the web renders 0.6.
The frozen-time branch directly above already stated this rule and got it
right.

ReducedMotionParityTests renders both paths through the real SwiftUI Canvas
and compares rasters, so the guard fails on painted output rather than on a
recomputed number. Before the fix all 18 pairs differed by 4.3–32.5% of
pixels; after, they are byte-identical. The branch previously had no
coverage at all: the golden tests call orbFrame directly and the snapshot
tests go through .orbFrozenTime, so neither ever rendered it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant