From 425e74f18f77936ea93264de1196cc3fc610cf88 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Wed, 3 Jun 2026 13:23:00 +0200 Subject: [PATCH 01/12] Fix tile wiggle not animating in edit mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Mortar/Modifiers/WiggleModifier.swift | 71 ++++++++++--------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift b/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift index 4bc8fd1..322f288 100644 --- a/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift +++ b/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift @@ -8,9 +8,24 @@ public struct WiggleModifier: ViewModifier { @State private var started = false @Environment(\.accessibilityReduceMotion) private var reduceMotion - private var angle: Double { - let base = 1.5 + Double(seed % 3) * 0.5 // 1.5-2.5 range - return animating ? base : -base + /// Peak rotation for this tile, varied per seed (1.5°-2.5°). + private var angleBase: Double { + 1.5 + Double(seed % 3) * 0.5 + } + + /// Current rotation. Zero until the staggered start fires, then oscillates + /// between -angleBase and +angleBase as `animating` toggles. + private var rotation: Double { + guard isWiggling, started else { return 0 } + return animating ? angleBase : -angleBase + } + + /// Repeating wiggle while running; a gentle ease-out once `started` clears + /// so the tile settles back to upright instead of snapping. + private var rotationAnimation: Animation { + started + ? .easeInOut(duration: wiggleDuration).repeatForever(autoreverses: true) + : .easeOut(duration: Mortar.Motion.fast) } /// Staggered delay so tiles don't all start wiggling at once. @@ -35,41 +50,31 @@ public struct WiggleModifier: ViewModifier { } } else { content - .rotationEffect(.degrees(isWiggling && started ? angle : 0)) - .onChange(of: isWiggling) { _, newValue in - if newValue { - startWiggleWithDelay() - } else { - stopWiggle() - } - } - .onAppear { + .rotationEffect(.degrees(rotation)) + // Declarative animation: registers `repeatForever` in the view + // graph so it keeps oscillating. (Imperative `withAnimation` + // from an async `.task` continuation only applies the final + // value statically and never runs the repeat.) + .animation(rotationAnimation, value: animating) + .task(id: isWiggling) { if isWiggling { - startWiggleWithDelay() + try? await Task.sleep(for: .seconds(staggerDelay)) + guard !Task.isCancelled else { return } + // Snap to the -angleBase resting tilt (unanimated: only + // `animating` is tracked), let SwiftUI render it, then + // toggle `animating` so the repeat sweeps symmetrically + // from -angleBase to +angleBase. + started = true + try? await Task.sleep(for: .milliseconds(1)) + guard !Task.isCancelled else { return } + animating = true + } else { + started = false + animating = false } } } } - - private func startWiggleWithDelay() { - DispatchQueue.main.asyncAfter(deadline: .now() + staggerDelay) { - guard isWiggling else { return } - started = true - withAnimation( - .easeInOut(duration: wiggleDuration) - .repeatForever(autoreverses: true) - ) { - animating = true - } - } - } - - private func stopWiggle() { - withAnimation(.easeOut(duration: Mortar.Motion.fast)) { - animating = false - started = false - } - } } extension View { From 3a3975ca88052fb92bcc209ac2730347a90c999f Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Wed, 3 Jun 2026 13:35:11 +0200 Subject: [PATCH 02/12] Make tile wiggle uniform and out of phase; stop cleanly on exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Mortar/Modifiers/WiggleModifier.swift | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift b/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift index 322f288..4673dce 100644 --- a/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift +++ b/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift @@ -8,34 +8,46 @@ public struct WiggleModifier: ViewModifier { @State private var started = false @Environment(\.accessibilityReduceMotion) private var reduceMotion - /// Peak rotation for this tile, varied per seed (1.5°-2.5°). - private var angleBase: Double { - 1.5 + Double(seed % 3) * 0.5 - } + /// Peak rotation, identical for every tile so they all sweep the same + /// distance. + private static let angleBase = 2.0 + + /// Duration of a single sweep, identical for every tile so they all wiggle + /// at the same frequency. + private static let wiggleDuration = 0.14 - /// Current rotation. Zero until the staggered start fires, then oscillates - /// between -angleBase and +angleBase as `animating` toggles. + /// Current rotation. Zero until the start fires, then oscillates between + /// -angleBase and +angleBase as `animating` toggles. + /// + /// Gated on `started` (not `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` would zero the rotation a render too early, leaving + /// the repeat with no delta to cancel against. private var rotation: Double { - guard isWiggling, started else { return 0 } - return animating ? angleBase : -angleBase + guard started else { return 0 } + return animating ? Self.angleBase : -Self.angleBase } /// Repeating wiggle while running; a gentle ease-out once `started` clears /// so the tile settles back to upright instead of snapping. + /// + /// Tiles share frequency and amplitude but start at a per-seed point in the + /// cycle (`phaseOffset`), so they move in lockstep yet out of phase rather + /// than all tilting the same way at the same instant. private var rotationAnimation: Animation { started - ? .easeInOut(duration: wiggleDuration).repeatForever(autoreverses: true) + ? .easeInOut(duration: Self.wiggleDuration) + .repeatForever(autoreverses: true) + .delay(phaseOffset) : .easeOut(duration: Mortar.Motion.fast) } - /// Staggered delay so tiles don't all start wiggling at once. - private var staggerDelay: Double { - let hash = abs(seed) - return Double(hash % 250) / 1000.0 - } - - private var wiggleDuration: Double { - 0.12 + Double(seed % 3) * 0.02 + /// Per-seed offset spread across one full oscillation cycle (a sweep there + /// and back), giving each tile a distinct, stable phase. + private var phaseOffset: Double { + let cycle = Self.wiggleDuration * 2 + return Double(seed.magnitude % 100) / 100.0 * cycle } public func body(content: Content) -> some View { @@ -58,8 +70,6 @@ public struct WiggleModifier: ViewModifier { .animation(rotationAnimation, value: animating) .task(id: isWiggling) { if isWiggling { - try? await Task.sleep(for: .seconds(staggerDelay)) - guard !Task.isCancelled else { return } // Snap to the -angleBase resting tilt (unanimated: only // `animating` is tracked), let SwiftUI render it, then // toggle `animating` so the repeat sweeps symmetrically From 6360eb686b4c9804bb527d59e140b84ddc84f4f8 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 2 Jul 2026 21:14:27 +0200 Subject: [PATCH 03/12] Commit layout edits when leaving the Home tab mid-edit 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) --- Hemera/UI/Screens/Home/CuratedHomeView.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Hemera/UI/Screens/Home/CuratedHomeView.swift b/Hemera/UI/Screens/Home/CuratedHomeView.swift index 410b390..467517e 100644 --- a/Hemera/UI/Screens/Home/CuratedHomeView.swift +++ b/Hemera/UI/Screens/Home/CuratedHomeView.swift @@ -122,6 +122,13 @@ struct CuratedHomeView: View { .task { viewModel.startFirstLoadTimer(tileCount: homeTiles.count) } + .onDisappear { + // Leaving the tab mid-edit would otherwise strand the draft; + // commit, matching the tap-outside-to-exit behavior. + if viewModel.isEditing { + viewModel.exitEditMode(commit: true) + } + } } } From 9ee61fb368da8671e109dc26a7fecdae88830eeb Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 2 Jul 2026 21:15:18 +0200 Subject: [PATCH 04/12] Detach tile context menu while editing 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) --- Hemera/UI/Screens/Home/CuratedHomeView.swift | 45 +++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/Hemera/UI/Screens/Home/CuratedHomeView.swift b/Hemera/UI/Screens/Home/CuratedHomeView.swift index 467517e..188c7d7 100644 --- a/Hemera/UI/Screens/Home/CuratedHomeView.swift +++ b/Hemera/UI/Screens/Home/CuratedHomeView.swift @@ -141,7 +141,7 @@ private extension CuratedHomeView { if let entityId = entityIdByTileId[tile.id], let vm = viewModel.viewModelFactory.makeViewModel(forEntityId: entityId) { let tileIndex = displayedTiles.firstIndex(where: { $0.id == tile.id }) ?? 0 - vm.makeCardView() + let card = vm.makeCardView() .matchedTransitionSource(id: vm.id, in: overlayTransition) .tileEntrance(index: tileIndex, isActive: viewModel.isFirstLoad) .environment(\.isMediumTile, tile.size == .medium) @@ -161,30 +161,35 @@ private extension CuratedHomeView { } } ) - .contextMenu { - if !viewModel.isEditing { - Button { - withAnimation { - viewModel.enterEditMode(homeTiles: homeTiles) - } - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } label: { - Label(Localization.editLayout, systemImage: "square.and.pencil") - } - if !viewModel.isDemoMode { - Button { - viewModel.openEntityInHA(entityId: vm.id, deviceId: vm.deviceId) - } label: { - Label(Localization.openInHomeAssistant, systemImage: "globe") - } + + // Detached (not emptied) while editing so its long-press + // recognizer doesn't compete with the reorder drag. + if viewModel.isEditing { + card + } else { + card.contextMenu { + Button { + withAnimation { + viewModel.enterEditMode(homeTiles: homeTiles) } - Button(role: .destructive) { - viewModel.removeFromHome(entityId: vm.id) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } label: { + Label(Localization.editLayout, systemImage: "square.and.pencil") + } + if !viewModel.isDemoMode { + Button { + viewModel.openEntityInHA(entityId: vm.id, deviceId: vm.deviceId) } label: { - Label(Localization.removeFromHome, systemImage: "minus.circle") + Label(Localization.openInHomeAssistant, systemImage: "globe") } } + Button(role: .destructive) { + viewModel.removeFromHome(entityId: vm.id) + } label: { + Label(Localization.removeFromHome, systemImage: "minus.circle") + } } + } } else { EntityCard { Text(tile.title) From a9b4ab0b6e61ab6fd8315f68c07bec7dcb5c516b Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 2 Jul 2026 21:16:10 +0200 Subject: [PATCH 05/12] Give the resize button a 44pt tap target and accessibility label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../UI/Components/EditableTileModifier.swift | 39 ++++++++++++++----- Hemera/UI/Screens/Home/CuratedHomeView.swift | 2 +- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/Hemera/UI/Components/EditableTileModifier.swift b/Hemera/UI/Components/EditableTileModifier.swift index 2182818..12d275d 100644 --- a/Hemera/UI/Components/EditableTileModifier.swift +++ b/Hemera/UI/Components/EditableTileModifier.swift @@ -55,22 +55,41 @@ struct EditableTileModifier: ViewModifier { Button { onResize(nextSize) } label: { - Image(systemName: resizeIcon) - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.white) - .frame(width: Mortar.IconSize.s, height: Mortar.IconSize.s) - .background( - Circle() - .fill(.thinMaterial) - .environment(\.colorScheme, .dark) - ) + Label( + tile.size == .small ? Localization.makeLarger : Localization.makeSmaller, + systemImage: resizeIcon + ) + .labelStyle(.iconOnly) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + .frame(width: Mortar.IconSize.s, height: Mortar.IconSize.s) + .background( + Circle() + .fill(.thinMaterial) + .environment(\.colorScheme, .dark) + ) + // Minimum 44pt tap target (HIG) around the smaller visible circle. + .frame(width: 44, height: 44) + .contentShape(Rectangle()) } .buttonStyle(.plain) - .padding(6) .transition(.scale.combined(with: .opacity)) } } +private extension EditableTileModifier { + enum Localization { + static let makeLarger = String( + localized: "Make Larger", + comment: "Accessibility label for the tile resize button when tapping it enlarges the tile" + ) + static let makeSmaller = String( + localized: "Make Smaller", + comment: "Accessibility label for the tile resize button when tapping it shrinks the tile" + ) + } +} + extension View { func editableTile( _ tile: Tile, diff --git a/Hemera/UI/Screens/Home/CuratedHomeView.swift b/Hemera/UI/Screens/Home/CuratedHomeView.swift index 188c7d7..9be2340 100644 --- a/Hemera/UI/Screens/Home/CuratedHomeView.swift +++ b/Hemera/UI/Screens/Home/CuratedHomeView.swift @@ -202,7 +202,7 @@ private extension CuratedHomeView { enum Localization { static let home = String(localized: "Home", comment: "Navigation title for the Home screen showing pinned entities") static let cancel = String(localized: "Cancel", comment: "Button to cancel layout editing and discard changes") - static let done = String(localized: "Done", comment: "Button to dismiss the current screen") + static let done = String(localized: "Done", comment: "Button to commit layout edits and leave edit mode on the Home screen") static let undo = String(localized: "Undo", comment: "Toolbar button to undo the last layout edit") static let editLayout = String(localized: "Edit Layout", comment: "Context menu action to enter tile layout editing mode") static let removeFromHome = String(localized: "Remove from Home", comment: "Context menu action to unpin an entity from the Home screen") From d187736aaf04ab2100a86667bc002184b0a1c59d Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 2 Jul 2026 21:23:54 +0200 Subject: [PATCH 06/12] Drive tile wiggle from TimelineView so it survives identity changes 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) --- .../Mortar/Modifiers/WiggleModifier.swift | 81 +++++-------------- 1 file changed, 22 insertions(+), 59 deletions(-) diff --git a/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift b/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift index 4673dce..09697e1 100644 --- a/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift +++ b/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift @@ -4,50 +4,20 @@ public struct WiggleModifier: ViewModifier { let isWiggling: Bool let seed: Int - @State private var animating = false - @State private var started = false @Environment(\.accessibilityReduceMotion) private var reduceMotion /// Peak rotation, identical for every tile so they all sweep the same /// distance. private static let angleBase = 2.0 - /// Duration of a single sweep, identical for every tile so they all wiggle - /// at the same frequency. - private static let wiggleDuration = 0.14 + /// One full oscillation (a sweep there and back), identical for every + /// tile so they all wiggle at the same frequency. + private static let cycleDuration = 0.28 - /// Current rotation. Zero until the start fires, then oscillates between - /// -angleBase and +angleBase as `animating` toggles. - /// - /// Gated on `started` (not `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` would zero the rotation a render too early, leaving - /// the repeat with no delta to cancel against. - private var rotation: Double { - guard started else { return 0 } - return animating ? Self.angleBase : -Self.angleBase - } - - /// Repeating wiggle while running; a gentle ease-out once `started` clears - /// so the tile settles back to upright instead of snapping. - /// - /// Tiles share frequency and amplitude but start at a per-seed point in the - /// cycle (`phaseOffset`), so they move in lockstep yet out of phase rather - /// than all tilting the same way at the same instant. - private var rotationAnimation: Animation { - started - ? .easeInOut(duration: Self.wiggleDuration) - .repeatForever(autoreverses: true) - .delay(phaseOffset) - : .easeOut(duration: Mortar.Motion.fast) - } - - /// Per-seed offset spread across one full oscillation cycle (a sweep there - /// and back), giving each tile a distinct, stable phase. - private var phaseOffset: Double { - let cycle = Self.wiggleDuration * 2 - return Double(seed.magnitude % 100) / 100.0 * cycle + /// Per-seed offset into the oscillation cycle, giving each tile a + /// distinct, stable phase. + private var phase: Double { + Double(seed.magnitude % 100) / 100.0 * 2 * .pi } public func body(content: Content) -> some View { @@ -61,30 +31,23 @@ public struct WiggleModifier: ViewModifier { } } } else { - content - .rotationEffect(.degrees(rotation)) - // Declarative animation: registers `repeatForever` in the view - // graph so it keeps oscillating. (Imperative `withAnimation` - // from an async `.task` continuation only applies the final - // value statically and never runs the repeat.) - .animation(rotationAnimation, value: animating) - .task(id: isWiggling) { - if isWiggling { - // Snap to the -angleBase resting tilt (unanimated: only - // `animating` is tracked), let SwiftUI render it, then - // toggle `animating` so the repeat sweeps symmetrically - // from -angleBase to +angleBase. - started = true - try? await Task.sleep(for: .milliseconds(1)) - guard !Task.isCancelled else { return } - animating = true - } else { - started = false - animating = false - } - } + // Rotation is a pure function of wall-clock time, so the wiggle + // needs no stored animation state: it survives view identity + // changes and can never end up statically stuck mid-tilt (the + // failure mode of toggling a `repeatForever` from a task). + // Start/stop transitions inherit the caller's `withAnimation`. + TimelineView(.animation(minimumInterval: nil, paused: !isWiggling)) { context in + content + .rotationEffect(.degrees(angle(at: context.date))) + } } } + + private func angle(at date: Date) -> Double { + guard isWiggling else { return 0 } + let t = date.timeIntervalSinceReferenceDate + return sin((t / Self.cycleDuration) * 2 * .pi + phase) * Self.angleBase + } } extension View { From e84ad523b0cda44722932fe2aed8e5fe40628b0b Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 2 Jul 2026 21:35:36 +0200 Subject: [PATCH 07/12] Cap wiggle timeline at 60Hz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift b/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift index 09697e1..aa2ff7c 100644 --- a/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift +++ b/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift @@ -36,7 +36,9 @@ public struct WiggleModifier: ViewModifier { // changes and can never end up statically stuck mid-tilt (the // failure mode of toggling a `repeatForever` from a task). // Start/stop transitions inherit the caller's `withAnimation`. - TimelineView(.animation(minimumInterval: nil, paused: !isWiggling)) { context in + // 60Hz cap: each tick re-evaluates on the main thread per tile, + // and a 0.28s sine cycle gains nothing from ProMotion's 120Hz. + TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: !isWiggling)) { context in content .rotationEffect(.degrees(angle(at: context.date))) } From 3f3452a2da55db7cb21ae3d7f2b2b87c434eef96 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 2 Jul 2026 21:35:36 +0200 Subject: [PATCH 08/12] Add Mortar.TapTarget.minimum token for the 44pt HIG tap target 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) --- Hemera/UI/Components/EditableTileModifier.swift | 4 ++-- Packages/Mortar/Sources/Mortar/Tokens/TapTarget.swift | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 Packages/Mortar/Sources/Mortar/Tokens/TapTarget.swift diff --git a/Hemera/UI/Components/EditableTileModifier.swift b/Hemera/UI/Components/EditableTileModifier.swift index 12d275d..f2ef1f3 100644 --- a/Hemera/UI/Components/EditableTileModifier.swift +++ b/Hemera/UI/Components/EditableTileModifier.swift @@ -68,8 +68,8 @@ struct EditableTileModifier: ViewModifier { .fill(.thinMaterial) .environment(\.colorScheme, .dark) ) - // Minimum 44pt tap target (HIG) around the smaller visible circle. - .frame(width: 44, height: 44) + // Minimum tap target (HIG) around the smaller visible circle. + .frame(width: Mortar.TapTarget.minimum, height: Mortar.TapTarget.minimum) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/Packages/Mortar/Sources/Mortar/Tokens/TapTarget.swift b/Packages/Mortar/Sources/Mortar/Tokens/TapTarget.swift new file mode 100644 index 0000000..66bf36d --- /dev/null +++ b/Packages/Mortar/Sources/Mortar/Tokens/TapTarget.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Mortar { + public enum TapTarget { + /// Minimum tappable area for any control (Apple HIG). + public static let minimum: CGFloat = 44 + } +} From a66c542b84ae8d705dd45591811a31578cd48efa Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 2 Jul 2026 21:52:07 +0200 Subject: [PATCH 09/12] Replace Cancel with undo/redo history in edit mode 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) --- Hemera/UI/Screens/Home/CuratedHomeView.swift | 22 ++++--- .../Screens/Home/CuratedHomeViewModel.swift | 32 ++++++++-- .../Home/CuratedHomeViewModelTests.swift | 61 +++++++++++++++++++ 3 files changed, 102 insertions(+), 13 deletions(-) diff --git a/Hemera/UI/Screens/Home/CuratedHomeView.swift b/Hemera/UI/Screens/Home/CuratedHomeView.swift index 9be2340..323362e 100644 --- a/Hemera/UI/Screens/Home/CuratedHomeView.swift +++ b/Hemera/UI/Screens/Home/CuratedHomeView.swift @@ -82,14 +82,7 @@ struct CuratedHomeView: View { .navigationTitle(Localization.home) .toolbar { if viewModel.isEditing { - ToolbarItem(placement: .cancellationAction) { - Button(Localization.cancel) { - withAnimation { - viewModel.exitEditMode(commit: false) - } - } - } - ToolbarItem(placement: .topBarLeading) { + ToolbarItemGroup(placement: .topBarLeading) { Button { withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { viewModel.performUndo() @@ -100,6 +93,17 @@ struct CuratedHomeView: View { .labelStyle(.iconOnly) } .disabled(!viewModel.canUndo) + + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + viewModel.performRedo() + } + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } label: { + Label(Localization.redo, systemImage: "arrow.uturn.forward") + .labelStyle(.iconOnly) + } + .disabled(!viewModel.canRedo) } ToolbarItem(placement: .topBarTrailing) { Button(Localization.done) { @@ -201,9 +205,9 @@ private extension CuratedHomeView { private extension CuratedHomeView { enum Localization { static let home = String(localized: "Home", comment: "Navigation title for the Home screen showing pinned entities") - static let cancel = String(localized: "Cancel", comment: "Button to cancel layout editing and discard changes") static let done = String(localized: "Done", comment: "Button to commit layout edits and leave edit mode on the Home screen") static let undo = String(localized: "Undo", comment: "Toolbar button to undo the last layout edit") + static let redo = String(localized: "Redo", comment: "Toolbar button to re-apply the last undone layout edit") static let editLayout = String(localized: "Edit Layout", comment: "Context menu action to enter tile layout editing mode") static let removeFromHome = String(localized: "Remove from Home", comment: "Context menu action to unpin an entity from the Home screen") static let openInHomeAssistant = String(localized: "Open in Home Assistant", comment: "Context menu action to open an entity in the Home Assistant web interface") diff --git a/Hemera/UI/Screens/Home/CuratedHomeViewModel.swift b/Hemera/UI/Screens/Home/CuratedHomeViewModel.swift index 072ac69..953a476 100644 --- a/Hemera/UI/Screens/Home/CuratedHomeViewModel.swift +++ b/Hemera/UI/Screens/Home/CuratedHomeViewModel.swift @@ -10,6 +10,7 @@ final class CuratedHomeViewModel { private(set) var isEditing = false private(set) var draftTiles: [Tile]? private(set) var undoStack: [EditAction] = [] + private(set) var redoStack: [EditAction] = [] private(set) var isFirstLoad = true var selectedTileID: Tile.ID? @@ -26,6 +27,10 @@ final class CuratedHomeViewModel { !undoStack.isEmpty } + var canRedo: Bool { + !redoStack.isEmpty + } + /// Captured at `enterEditMode` time so commit can map tile IDs back to /// entity IDs without re-querying the live data. private var entityIdByTileId: [Tile.ID: String] = [:] @@ -99,6 +104,7 @@ final class CuratedHomeViewModel { draftTiles = entries.map(\.tile) entityIdByTileId = Dictionary(uniqueKeysWithValues: entries.map { ($0.tile.id, $0.entityId) }) undoStack = [] + redoStack = [] selectedTileID = nil isEditing = true } @@ -119,6 +125,7 @@ final class CuratedHomeViewModel { draftTiles = nil entityIdByTileId = [:] undoStack = [] + redoStack = [] selectedTileID = nil isEditing = false } @@ -133,6 +140,7 @@ final class CuratedHomeViewModel { /// pre-reorder order. func recordReorderUndo(previousOrder: [Tile.ID]) { undoStack.append(.reorder(previousOrder: previousOrder)) + redoStack = [] } func resizeTile(_ tile: Tile, to targetSize: TileSize) { @@ -142,22 +150,38 @@ final class CuratedHomeViewModel { let oldSize = tiles[index].size guard oldSize != targetSize else { return } undoStack.append(.resize(tileID: tile.id, oldSize: oldSize)) + redoStack = [] tiles[index].size = targetSize draftTiles = tiles } func performUndo() { - guard let action = undoStack.popLast(), var tiles = draftTiles else { return } + guard let action = undoStack.popLast(), let inverse = apply(action) else { return } + redoStack.append(inverse) + } + + func performRedo() { + guard let action = redoStack.popLast(), let inverse = apply(action) else { return } + undoStack.append(inverse) + } + + /// Applies an edit action to the draft and returns its inverse, so undo + /// and redo are the same operation walking opposite stacks. + private func apply(_ action: EditAction) -> EditAction? { + guard var tiles = draftTiles else { return nil } + let inverse: EditAction switch action { case .resize(let tileID, let oldSize): - if let index = tiles.firstIndex(where: { $0.id == tileID }) { - tiles[index].size = oldSize - } + guard let index = tiles.firstIndex(where: { $0.id == tileID }) else { return nil } + inverse = .resize(tileID: tileID, oldSize: tiles[index].size) + tiles[index].size = oldSize case .reorder(let previousOrder): + inverse = .reorder(previousOrder: tiles.map(\.id)) let byID = Dictionary(uniqueKeysWithValues: tiles.map { ($0.id, $0) }) tiles = previousOrder.compactMap { byID[$0] } } draftTiles = tiles + return inverse } // MARK: - First-load Animation diff --git a/HemeraTests/UI/Screens/Home/CuratedHomeViewModelTests.swift b/HemeraTests/UI/Screens/Home/CuratedHomeViewModelTests.swift index 8c458f2..8474e9a 100644 --- a/HemeraTests/UI/Screens/Home/CuratedHomeViewModelTests.swift +++ b/HemeraTests/UI/Screens/Home/CuratedHomeViewModelTests.swift @@ -171,6 +171,67 @@ struct CuratedHomeViewModelTests { #expect(vm.draftTiles?.map(\.id) == [tileA.id, tileB.id]) } + @Test + func performRedo_afterUndoingResize_reappliesTheResize() { + let vm = makeViewModel() + let tile = makeTile(entityId: "light.a", size: .small) + vm.enterEditMode(seed: [(tile, "light.a")]) + vm.resizeTile(tile, to: .medium) + vm.performUndo() + #expect(vm.canRedo == true) + + vm.performRedo() + + #expect(vm.draftTiles?.first?.size == .medium) + #expect(vm.canRedo == false) + #expect(vm.canUndo == true) + } + + @Test + func performRedo_afterUndoingReorder_reappliesTheOrder() { + let vm = makeViewModel() + let tileA = makeTile(entityId: "light.a") + let tileB = makeTile(entityId: "light.b") + vm.enterEditMode(seed: [(tileA, "light.a"), (tileB, "light.b")]) + vm.recordReorderUndo(previousOrder: [tileA.id, tileB.id]) + vm.applyReorder([tileB, tileA]) + vm.performUndo() + + vm.performRedo() + + #expect(vm.draftTiles?.map(\.id) == [tileB.id, tileA.id]) + } + + @Test + func newEdit_afterUndo_clearsRedoStack() { + let vm = makeViewModel() + let tile = makeTile(entityId: "light.a", size: .small) + vm.enterEditMode(seed: [(tile, "light.a")]) + vm.resizeTile(tile, to: .medium) + vm.performUndo() + #expect(vm.canRedo == true) + + vm.resizeTile(tile, to: .large) + + #expect(vm.canRedo == false) + } + + @Test + func undoRedo_roundTrip_isLossless() { + let vm = makeViewModel() + let tile = makeTile(entityId: "light.a", size: .small) + vm.enterEditMode(seed: [(tile, "light.a")]) + vm.resizeTile(tile, to: .medium) + + vm.performUndo() + vm.performRedo() + vm.performUndo() + + #expect(vm.draftTiles?.first?.size == .small) + #expect(vm.canUndo == false) + #expect(vm.canRedo == true) + } + // MARK: - Remove From Home @Test From 9a46a610e8d48af5dfb0a97776db7544304b145d Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 2 Jul 2026 22:09:16 +0200 Subject: [PATCH 10/12] Animate the reorder shuffle during tile drag 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) --- Hemera/UI/TileGrid/SectionGrid.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Hemera/UI/TileGrid/SectionGrid.swift b/Hemera/UI/TileGrid/SectionGrid.swift index f558e48..61c0928 100644 --- a/Hemera/UI/TileGrid/SectionGrid.swift +++ b/Hemera/UI/TileGrid/SectionGrid.swift @@ -1,4 +1,5 @@ import SwiftUI +import Mortar import TileGridEngine /// Renders a single section of tiles using TileGridEngine for reordering. @@ -67,6 +68,7 @@ struct SectionGrid: View { content(tile) .frame(width: size.width, height: size.height) .offset(x: origin.x, y: origin.y) + .animation(Mortar.Motion.springSnappy, value: origin) .opacity(0.25) .allowsHitTesting(false) } @@ -108,6 +110,10 @@ struct SectionGrid: View { content(tile) .frame(width: size.width, height: size.height) .offset(x: visualOrigin.x, y: visualOrigin.y) + .animation( + isDragging || !isEditing ? nil : Mortar.Motion.springSnappy, + value: CGRect(origin: visualOrigin, size: size) + ) .scaleEffect(isDragging ? 1.03 : 1.0) .shadow(radius: isDragging ? 8 : 0) .zIndex(isDragging ? 1 : 0) From 80f1fe5aa9697586cdcefb3ebc295f827bc67bad Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 2 Jul 2026 22:22:02 +0200 Subject: [PATCH 11/12] Show the resize badge on every tile in edit mode 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) --- .../UI/Components/EditableTileModifier.swift | 21 ++++++------------- Hemera/UI/Screens/Home/CuratedHomeView.swift | 3 +-- .../Screens/Home/CuratedHomeViewModel.swift | 3 --- 3 files changed, 7 insertions(+), 20 deletions(-) diff --git a/Hemera/UI/Components/EditableTileModifier.swift b/Hemera/UI/Components/EditableTileModifier.swift index f2ef1f3..76e5e31 100644 --- a/Hemera/UI/Components/EditableTileModifier.swift +++ b/Hemera/UI/Components/EditableTileModifier.swift @@ -5,12 +5,11 @@ import TileGridEngine /// Combines wiggle animation and a resize button for tiles in edit mode. /// /// Mimics Apple's Home app: in edit mode, tiles wiggle and a small circular -/// resize button appears in the bottom-trailing corner. Tapping it toggles -/// the tile size between small and medium. +/// resize button appears in the bottom-trailing corner of every tile. +/// Tapping it toggles the tile size between small and medium. struct EditableTileModifier: ViewModifier { let tile: Tile let isEditing: Bool - @Binding var selectedTileID: Tile.ID? let onResize: (TileSize) -> Void let onTap: () -> Void @@ -24,26 +23,20 @@ struct EditableTileModifier: ViewModifier { : "arrow.down.right.and.arrow.up.left" } - private var isSelected: Bool { - selectedTileID == tile.id - } - func body(content: Content) -> some View { content .allowsHitTesting(!isEditing) .overlay(alignment: .bottomTrailing) { - if isEditing && isSelected { + if isEditing { resizeButton } } .wiggle(isWiggling: isEditing, seed: tile.id.hashValue) .contentShape(Rectangle()) .onTapGesture { - if isEditing { - withAnimation(.easeInOut(duration: Mortar.Motion.normal)) { - selectedTileID = isSelected ? nil : tile.id - } - } else { + // In edit mode the gesture deliberately swallows the tap so it + // doesn't bubble to the grid's tap-outside-to-exit handler. + if !isEditing { onTap() } } @@ -94,14 +87,12 @@ extension View { func editableTile( _ tile: Tile, isEditing: Bool, - selectedTileID: Binding, onResize: @escaping (TileSize) -> Void, onTap: @escaping () -> Void ) -> some View { modifier(EditableTileModifier( tile: tile, isEditing: isEditing, - selectedTileID: selectedTileID, onResize: onResize, onTap: onTap )) diff --git a/Hemera/UI/Screens/Home/CuratedHomeView.swift b/Hemera/UI/Screens/Home/CuratedHomeView.swift index 323362e..ebb5ace 100644 --- a/Hemera/UI/Screens/Home/CuratedHomeView.swift +++ b/Hemera/UI/Screens/Home/CuratedHomeView.swift @@ -4,7 +4,7 @@ import Mortar import TileGridEngine struct CuratedHomeView: View { - @Bindable var viewModel: CuratedHomeViewModel + let viewModel: CuratedHomeViewModel @Namespace private var overlayTransition @Query(sort: \HomeTile.sortOrder) private var homeTiles: [HomeTile] @@ -152,7 +152,6 @@ private extension CuratedHomeView { .editableTile( tile, isEditing: viewModel.isEditing, - selectedTileID: $viewModel.selectedTileID, onResize: { targetSize in withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { viewModel.resizeTile(tile, to: targetSize) diff --git a/Hemera/UI/Screens/Home/CuratedHomeViewModel.swift b/Hemera/UI/Screens/Home/CuratedHomeViewModel.swift index 953a476..06ecae3 100644 --- a/Hemera/UI/Screens/Home/CuratedHomeViewModel.swift +++ b/Hemera/UI/Screens/Home/CuratedHomeViewModel.swift @@ -12,7 +12,6 @@ final class CuratedHomeViewModel { private(set) var undoStack: [EditAction] = [] private(set) var redoStack: [EditAction] = [] private(set) var isFirstLoad = true - var selectedTileID: Tile.ID? let isDemoMode: Bool let haWebViewPresenter: HAWebViewPresenter @@ -105,7 +104,6 @@ final class CuratedHomeViewModel { entityIdByTileId = Dictionary(uniqueKeysWithValues: entries.map { ($0.tile.id, $0.entityId) }) undoStack = [] redoStack = [] - selectedTileID = nil isEditing = true } @@ -126,7 +124,6 @@ final class CuratedHomeViewModel { entityIdByTileId = [:] undoStack = [] redoStack = [] - selectedTileID = nil isEditing = false } From 0712aabd48a47e581f51d38c96f33daeb99a9931 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 2 Jul 2026 22:26:20 +0200 Subject: [PATCH 12/12] Add edit-layout changes to the unreleased changelog Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 312e9c3..0dc4c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added +- Undo and redo buttons in Home edit mode for stepping through layout changes. + +### Changed +- The Cancel button is gone from edit mode: tap Done or anywhere outside the grid to keep your changes, and use undo to revert individual edits. +- The resize badge now appears on every tile in edit mode, not just the selected one. +- Tiles wiggle uniformly and out of phase in edit mode, like the iOS Home Screen. + +### Fixed +- Tiles did not wiggle when entering edit mode. +- Tiles now glide smoothly into place while drag-reordering instead of jumping. +- Layout edits are kept when leaving the Home tab mid-edit instead of being silently discarded. +- The tile resize button is easier to tap and is now labeled for VoiceOver. + ## [1.1] - 2026-05-15 Initial release.