Skip to content

Fix and polish the Home edit-layout experience - #2

Merged
adborbas merged 12 commits into
mainfrom
adborbas/fix-edit-home-layout-bugs
Jul 2, 2026
Merged

adborbas merged 12 commits into
mainfrom
adborbas/fix-edit-home-layout-bugs

Conversation

@adborbas

@adborbas adborbas commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the broken tile wiggle and polishes the whole edit-layout experience: uniform out-of-phase wiggle, undo/redo history instead of Cancel, animated drag reorder, always-visible resize badges, and several robustness/accessibility fixes found by on-device verification and code review.

Wiggle

  • Fix wiggle not animating at all — withAnimation(.repeatForever) from an async task continuation applies the final value statically; driven declaratively instead, then rewritten to compute rotation as a pure function of wall-clock time in TimelineView(.animation) so it survives view identity changes and can never stick mid-tilt. Capped at 60Hz.
  • Uniform motion, distinct phase — all tiles share amplitude (2°) and frequency (0.28s cycle); each starts at a per-seed point in the cycle.

Edit-mode UX

  • Undo/redo history replaces Cancel — Cancel was inconsistent (two of three exit paths committed anyway) and sat confusingly next to the undo arrow. Leading undo/redo pair + Done, matching Apple Home / jiggle-mode precedent. Undo and redo are one operation walking opposite stacks (apply(_:) returns the action's inverse); new edits clear redo.
  • Animated drag reorder — displaced tiles glided in theory but teleported in practice: 60Hz unanimated drag-offset updates stomp withAnimation transactions (confirmed frame-by-frame on video). Value-scoped .animation(_:value:) on each tile's frame fixes it; the dragged tile opts out to track the finger, then settles on drop. Undo/redo ride the same mechanism.
  • Resize badge on every tile — previously only on the tapped-to-select tile, hard to discover. The now-purposeless selection mechanism is removed entirely.
  • 44pt tap target + VoiceOver label on the resize badge (new Mortar.TapTarget.minimum token).
  • Leaving the tab mid-edit commits the draft instead of stranding it.
  • Context menu detached (not emptied) while editing so its long-press recognizer doesn't compete with the reorder drag.

Testing

  • 4 new Swift Testing cases for undo/redo semantics; CuratedHomeViewModelTests 17/17 green.
  • Every change verified on-device (iPhone 17 sim, demo mode) via accessibility-driven simulator automation; animation fixes proven with frame-by-frame video extraction (pre-fix: single-frame 25% pixel jump; post-fix: spring curve spread over ~5 frames).

Known follow-ups (deliberately out of scope)

  • Tile/tab-bar accessibility traits (edit mode is effectively invisible to VoiceOver beyond the resize badge)
  • Auto-scroll while dragging beyond one screenful
  • Moving draft-commit ownership from the view's onDisappear into the view model / session teardown

🤖 Generated with Claude Code

adborbas and others added 12 commits June 3, 2026 13:23
The wiggle never visually oscillated because withAnimation(.repeatForever)
was invoked from inside the .task continuation (resumed after Task.sleep),
outside SwiftUI's update transaction. There SwiftUI commits the final
rotation statically but never registers the repeating interpolation, so
each tile snapped to a fixed ~2.5° tilt instead of wiggling.

Drive the repeat declaratively via .animation(_:value:) so repeatForever is
registered in the view graph and oscillates regardless of which context
mutated the state. The animation switches to a gentle ease-out when the
wiggle stops so tiles settle upright instead of snapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Give every tile the same amplitude (2°) and frequency (0.14s) so they move
identically, and derive a per-seed phase offset (a fraction of one cycle,
applied via .delay) so they wiggle in lockstep yet desynchronized rather
than all tilting the same way at once. Replaces the old per-tile amplitude,
duration, and start-stagger variation.

Gate `rotation` on `started` instead of `isWiggling` so that on exit the
return to zero lands in the same transaction as `animating` flipping false.
The ease-out then replaces the running repeatForever; gating on the external
isWiggling zeroed the rotation a render too early, leaving the repeat with
no delta to cancel against, so it never stopped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Edit mode used to survive tab switches: isEditing and the draft stayed
resident in the view model, and if the session tore down in between the
draft died uncommitted. Commit on disappear, matching the existing
tap-outside-to-exit behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The context menu stayed attached with conditionally-empty content in edit
mode, leaving its long-press recognizer competing with the reorder drag.
Attach the modifier only outside edit mode instead.

This flips tile view identity when edit mode toggles, which resets wiggle
state: entry is unaffected (the task re-fires on appear) and exit trades
the 0.15s settle ease for an instant 2-degree snap, which is imperceptible
at that amplitude.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The resize button was a bare 28pt Image(systemName:) — under the HIG
minimum tap target and invisible to VoiceOver. Wrap it in a Label
(iconOnly) named "Make Larger"/"Make Smaller" by direction, and expand
the hit area to 44pt around the unchanged visible circle.

Also correct the Done button's localization comment, which described
dismissing a screen rather than committing layout edits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Detaching the context menu in edit mode recreates tile views when edit
mode toggles, and the freshly inserted WiggleModifier appeared with
isWiggling already true: the toggle-a-state-then-repeatForever approach
never engaged and tiles froze statically mid-tilt.

Compute rotation as a pure function of wall-clock time inside
TimelineView(.animation) instead. No stored animation state means no
way to get stuck, the per-seed phase becomes a true mid-cycle offset
with no startup delay, and start/stop transitions inherit the caller's
withAnimation. The timeline pauses outside edit mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
minimumInterval: nil let TimelineView tick at display refresh rate —
on ProMotion that is 120 main-thread content-closure evaluations per
second per tile while editing, competing with the reorder drag. A 0.28s
sine cycle gains nothing above 60Hz.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The resize button hardcoded 44; design-system guardrails belong in
Mortar tokens so future controls reference one definition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cancel was already inconsistent: tapping outside the grid and leaving
the tab both commit, so discard only worked through one of three exit
doors. It also sat next to the undo arrow, reading as back/cancel
navigation. Follow the platform precedent (Apple Home, home screen
jiggle mode): a leading undo/redo pair navigating the edit history,
and Done to persist.

Undo and redo are one operation walking opposite stacks: apply(_:)
performs an EditAction on the draft and returns its inverse. New edits
clear the redo stack (standard history semantics).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hover shuffle teleported: withAnimation around engine.hover() gets
stomped when the 60Hz unanimated drag-offset updates land in the same
render pass, so displaced tiles jumped whole slots in a single frame
(confirmed by frame-by-frame video capture).

Scope the animation to the values instead: .animation(_:value:) on each
tile's origin and size, active only in edit mode. The dragged tile opts
out so it tracks the finger exactly, and on drop the modifier re-arms
and animates the settle into its slot. The ghost placeholder glides to
its new drop slot for the same reason. Undo/redo layout changes flow
through the same value-scoped animation, making them robust regardless
of transaction plumbing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The badge only appeared after tapping a tile to select it, which made
resizing hard to discover. Show it on all tiles while editing, matching
iOS jiggle-mode badges.

Selection existed solely to gate badge visibility, so remove the whole
mechanism: selectedTileID leaves the view model, the modifier loses its
binding, and the edit-mode tap gesture becomes a deliberate no-op so
tile taps don't bubble to the grid's tap-outside-to-exit handler.
CuratedHomeView no longer needs @bindable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@adborbas
adborbas merged commit 0eda942 into main Jul 2, 2026
@adborbas
adborbas deleted the adborbas/fix-edit-home-layout-bugs branch July 8, 2026 11:10
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