Three reusable utilities in
Hydrolyze/Sources/Lib/plus thedesign vocabulary they enforce. The technical architecture behind
the 2026-06-20 audit + implementation. Established as the canonical
layer for all haptic, press, and motion work in the iOS app.
Haptics.swift — semantic haptic vocabularyOne file, one vocabulary. All generators pre-warmed and reused (constructing UIImpactFeedbackGenerator per-call is wasteful and delays the first haptic).
| API | Type | Use case |
|---|---|---|
Haptics.selection() | Softest | Filter pills, tab switches, segmented control changes. Frequent, quiet. |
Haptics.tap() | Light | Button presses in lists, secondary actions, row taps. The "I tapped a thing" signal. |
Haptics.action() | Medium | The LAP button, primary CTAs, accept/confirm. The "I did the thing" signal. |
Haptics.heavy() | Heavy | Destructive actions (delete, discard session), STOP during timing. |
Haptics.success() | Notification | Form submission success, session save complete. |
Haptics.warning() | Notification | Low-confidence confirmation, "are you sure?" before destructive ops. |
Haptics.error() | Notification | Save failure, network error, auth error. |
The discipline: never construct UIImpactFeedbackGenerator at call sites. If a new haptic style is needed, add a named case here so the app's tactile language stays coherent.
PressFeedbackStyle.swift — visible press statesDefault iOS button rendering shows a subtle gray opacity change on press. For a 200×200 cyan LAP button, that opacity tick is invisible — the button is so big the opacity change is dwarfed by the surface. A scale-down + slight dim is what tells the user "your tap registered."
Three styles:
PressFeedbackStyle(scale: 0.96, dim: 0.85) — default forsecondary actions, list rows, nav chrome
StrongPressFeedbackStyle(scale: 0.92) — primary CTAs (the200pt START button, the 160pt LAP button). More obvious confirmation.
pressFeedback(scale:dim:) view modifier — for tappable views that aren't Buttons (e.g., a card with .onTapGesture)
Both styles use spring physics (response 0.15 on press, 0.35 on release) so the press "settles" instead of snapping.
Animations.swift — shared motion curves| API | Curve | Use case |
|---|---|---|
Animations.snap | easeInOut(0.18s) | Filter tab switch, toggle, button press |
Animations.gentle | easeInOut(0.35s) | Row fades in, filter list updates, empty state appears |
Animations.springSnappy | spring(response: 0.30, damping: 0.60) | LAP flash, row insert, success state. Has a small overshoot. |
Animations.springSoft | spring(response: 0.45, damping: 0.80) | State transitions, page-level. No overshoot — settles. |
Animations.breathing | easeInOut(1.2s) | "I exist, tap me" invitation pulses on idle CTAs. |
The discipline: never write .easeInOut(duration: 0.2) at call sites. Use the named curves here so the motion language stays coherent.
The instinct was to make a single MicroInteractions namespace that bundled everything. The split is deliberate:
to do with SwiftUI rendering. Keeping it separate means non-view code (timing, model layer) can call haptics without dragging the SwiftUI runtime.
ButtonStyle / ViewModifier. Splitting it from haptics means a view can use one without the other (e.g., a destructive button that has heavy haptic + scale-down; a passive tappable card that has press feedback but no haptic).
Animation values. They're a vocabulary, not a behavior. The same Animations.snap is used in .animation(_:value:), withAnimation(_:), and .transition(_:).
The split also keeps each file <120 lines — easy to read, easy to extend, easy to test independently.
curves outside the utility files. If a new case is needed, add it to the utility, don't bypass.
Haptics.swift initializes all generators as static let and calls .prepare() after every fire. The first haptic is never delayed.
most-tapped button in the app. Animations.springSnappy on the cyan flash + medium haptic + StrongPressFeedbackStyle(scale: 0.90) is the "this is the right tap" feedback triad. Use it consistently.
START button breathes (Animations.breathing, 1.04 scale) so the coach sees it wanting to be tapped. NEVER use breathing on error states, warnings, or anything that needs attention — breathing reads as "alive and waiting," not "alert."
action < heavy. The coach gets medium on LAP (their primary action), heavy on STOP (deliberately heavier because it's a state change), and success on save-complete. The hierarchy is felt in the hand.
→ medium haptic + spring flash + scale-down. All three together. Picking only one is half the feedback.
user.** Haptics.selection() is the only haptic that's safe to fire on every segmented control change without being intrusive. Reserve the others for actions that have consequences.
TimingView aesthetic.** Per the Phase 2.5-c design rule, the timing screen is B&W + 20% cyan. The flash is the one place where cyan fills the screen (at 40% opacity, spring-decayed). Don't add more cyan saturation elsewhere.
project.yml)..phaseAnimator (iOS 17+) for breathing animations..sensoryFeedback (iOS 17+) patterns via the haptic API. Animation.spring() defaults are different.
direction that motivated this system
2026-06-20 milestone
rich effortlessness" principle; the playful direction is an extension, not a replacement
— Felt Faction citing Hydrolyze haptic patterns to lift
naturally with haptics at the iOS layer
Hydrolyze/Sources/Lib/Haptics.swift, Hydrolyze/Sources/Lib/PressFeedbackStyle.swift, Hydrolyze/Sources/Lib/Animations.swift
Animations usages across 11 view files (74 interaction points total)
Published and managed by TARS, an AI co-author built on Nathan's gbrain.