Skip to content

Use semantic control colours, snappy animations, and guard the shimmer - #558

Merged
bradleymackey merged 1 commit into
mainfrom
native-standardization-progress-motion
Sep 14, 2026
Merged

bradleymackey merged 1 commit into
mainfrom
native-standardization-progress-motion

Conversation

@bradleymackey

Copy link
Copy Markdown
Member

Reduce Motion

Shimmer runs .linear(duration: 1.5).repeatForever(autoreverses: false)
— an indefinitely repeating animation — and it is applied to every card in
the feed simultaneously while editing. There was no
accessibilityReduceMotion check anywhere in the codebase.

.shimmering(active:) now routes through a wrapper that drops the effect
entirely when Reduce Motion is on. The shimmer itself is unchanged.

The obvious alternative was to replace Shimmer with
.redacted(reason: .placeholder), which is reduce-motion aware for free.
That is not equivalent here: .redacted replaces content with grey
placeholder capsules, whereas these call sites shimmer a card that is
still meant to show its own text ("is editing", "Tap to View"). Swapping
them would change what the card says, not just how it animates.

Semantic colours for control tracks

systemGray2/systemGray6 are background-ramp colours and were being
used as the fill behind a progress indicator. Both timer bars now use
Color(.quaternarySystemFill), which is the ramp intended for controls.

CodeTimerHorizontalBarView's default color was a literal .blue, and
VaultCardModifier's .prominent background was Color.blue. Both are
now .accentColor, so they follow the app's accent instead of pinning
themselves to blue.

Animations

All 22 .animation(.easeOut, …) sites become .snappy. .easeOut reads
flat and mechanical next to the system's own transitions; .snappy is
the current idiom. The feed's .easeOut(duration: 0.1) went with them —
100ms reads as a jump rather than a transition.

Deprecations

The 16 remaining .foregroundColor(_:) call sites become
.foregroundStyle(_:), which is its replacement and the only one of the
two that accepts hierarchical and material styles.

Not done, deliberately

The in-app timer bar keeps its hand-built implementation. Converting
it to ProgressView(value:total:) + .progressViewStyle(.linear) was
planned, on the grounds that the widget already renders the same countdown
with ProgressView(timerInterval:).

Two reasons not to:

  • .progressViewStyle(.linear) draws a ~4pt hairline. These bars are a
    deliberately chunky 12pt filled indicator, so the swap would be a
    visual regression on the app's most-viewed screen, not a
    standardization.
  • ProgressView(timerInterval:) specifically cannot be used in-app. The
    bar is driven by the injected injector.clock (an EpochClockMock
    under test) so that snapshots are deterministic; timerInterval:
    renders against wall-clock Date and would make every OTP snapshot
    time-dependent. The widget can use it because a widget timeline is
    already Date-based.

The accessibility value this would have bought — a free
.accessibilityValue on the bar — belongs with the accessibility pass,
which is out of scope for this series.

The two animation-suppression workarounds (PlaceholderView's
.transaction { $0.animation = nil } and BackupKeyChangeView's
.animation(.none, value:)) are also left alone. Removing them requires
re-scoping a broad .animation(_:value:) higher in the tree, which risks
a behaviour change for no visual gain.

Verification

Local, iPhone 18 Pro Max / iOS 27.0:

  • xcodebuild build-for-testing — TEST BUILD SUCCEEDED
  • Full suite, -parallel-testing-enabled NO — 2834 passed, 0 failures
    across 24 bundles
  • 3 snapshots re-recorded (HorizontalTimerProgressBarView, from the
    track colour) and visually reviewed
  • make format + make lint — clean

⚠️ Automatic CI is still disabled (#548), so this is local verification only.

🤖 Generated with Claude Code

@bradleymackey
bradleymackey force-pushed the native-standardization-dynamic-type branch from ac0af5d to 2b07fb4 Compare September 14, 2026 16:50
Base automatically changed from native-standardization-dynamic-type to main September 14, 2026 16:50
## Reduce Motion

`Shimmer` runs `.linear(duration: 1.5).repeatForever(autoreverses: false)`
— an indefinitely repeating animation — and it is applied to every card in
the feed simultaneously while editing. There was no
`accessibilityReduceMotion` check anywhere in the codebase.

`.shimmering(active:)` now routes through a wrapper that drops the effect
entirely when Reduce Motion is on. The shimmer itself is unchanged.

The obvious alternative was to replace `Shimmer` with
`.redacted(reason: .placeholder)`, which is reduce-motion aware for free.
That is not equivalent here: `.redacted` replaces content with grey
placeholder capsules, whereas these call sites shimmer a card that is
still meant to show its own text ("is editing", "Tap to View"). Swapping
them would change what the card says, not just how it animates.

## Semantic colours for control tracks

`systemGray2`/`systemGray6` are *background-ramp* colours and were being
used as the fill behind a progress indicator. Both timer bars now use
`Color(.quaternarySystemFill)`, which is the ramp intended for controls.

`CodeTimerHorizontalBarView`'s default `color` was a literal `.blue`, and
`VaultCardModifier`'s `.prominent` background was `Color.blue`. Both are
now `.accentColor`, so they follow the app's accent instead of pinning
themselves to blue.

## Animations

All 22 `.animation(.easeOut, …)` sites become `.snappy`. `.easeOut` reads
flat and mechanical next to the system's own transitions; `.snappy` is
the current idiom. The feed's `.easeOut(duration: 0.1)` went with them —
100ms reads as a jump rather than a transition.

## Deprecations

The 16 remaining `.foregroundColor(_:)` call sites become
`.foregroundStyle(_:)`, which is its replacement and the only one of the
two that accepts hierarchical and material styles.

## Not done, deliberately

**The in-app timer bar keeps its hand-built implementation.** Converting
it to `ProgressView(value:total:)` + `.progressViewStyle(.linear)` was
planned, on the grounds that the widget already renders the same countdown
with `ProgressView(timerInterval:)`.

Two reasons not to:

- `.progressViewStyle(.linear)` draws a ~4pt hairline. These bars are a
  deliberately chunky 12pt filled indicator, so the swap would be a
  visual regression on the app's most-viewed screen, not a
  standardization.
- `ProgressView(timerInterval:)` specifically cannot be used in-app. The
  bar is driven by the injected `injector.clock` (an `EpochClockMock`
  under test) so that snapshots are deterministic; `timerInterval:`
  renders against wall-clock `Date` and would make every OTP snapshot
  time-dependent. The widget can use it because a widget timeline is
  already `Date`-based.

The accessibility value this would have bought — a free
`.accessibilityValue` on the bar — belongs with the accessibility pass,
which is out of scope for this series.

The two animation-suppression workarounds (`PlaceholderView`'s
`.transaction { $0.animation = nil }` and `BackupKeyChangeView`'s
`.animation(.none, value:)`) are also left alone. Removing them requires
re-scoping a broad `.animation(_:value:)` higher in the tree, which risks
a behaviour change for no visual gain.

## Verification

Local, iPhone 18 Pro Max / iOS 27.0:

- `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED`
- Full suite, `-parallel-testing-enabled NO` — 2834 passed, 0 failures
  across 24 bundles
- 3 snapshots re-recorded (`HorizontalTimerProgressBarView`, from the
  track colour) and visually reviewed
- `make format` + `make lint` — clean

⚠️ Automatic CI is still disabled (#548), so this is local verification only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bradleymackey
bradleymackey force-pushed the native-standardization-progress-motion branch from 4b93bfa to 8d7b546 Compare September 14, 2026 16:51
@bradleymackey
bradleymackey merged commit 62cff23 into main Sep 14, 2026
@bradleymackey
bradleymackey deleted the native-standardization-progress-motion branch September 14, 2026 16:51
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