Making Mimi feel physical: skeletons, haptics, and a mic dead-end fix
Replaced bare loading spinners with skeleton placeholders, added a thin expo-haptics layer to every drill outcome, and turned a silent denied-mic failure into a Settings deep-link. The honest catch: none of it is verifiable until the next native build.
The app worked, but it felt like digital paper. You tapped a grade, nothing pushed back. You finished a chunk-ladder rung, no little pop. Every screen that waited on the network showed the same bare gray spinner in the middle of nothing. None of this is a bug — it all functions — but "functions" and "feels alive" are different bars, and a shadowing app you're meant to open every day lives or dies on the second one.
Three small things
Skeletons instead of spinners. A spinner says "something is happening
somewhere." A skeleton says "here's the shape of what's coming." I added a tiny
Skeleton component — an Animated.View looping its opacity between 0.4 and 1 —
and a SkeletonCards list wrapper. They replace the centered ActivityIndicator
on the Library/Clips lists, the Review queue, and the player (a 16:9 block for the
video, then a few card rows for the transcript/analysis). The player one is the
nicest: the loading state now has the same silhouette as the loaded screen, so
there's no layout jump when the data lands.
Haptics on every outcome. One wrapper file, lib/haptics.ts, over
expo-haptics: tap for navigation, light for "go", success for a win,
error for a wrong move. All fire-and-forget — they swallow errors so they're
no-ops on web or anything without a Taptic Engine. Then I threaded it through the
moments that carry meaning: a tick on the Today CTA and Review grades; a soft
success when a dictation is fully correct or a chunk-ladder rung completes; a buzz
when you place a chunk in the wrong order; a verdict-shaped pulse on the scenario
grader. The rule I followed: haptics should mark meaning, not every touch —
buzz on everything and it's noise.
A mic dead-end, fixed. This was the only one that was arguably a real bug. The
record flow did if (!perm.granted) return; — so if you ever tapped "Don't Allow"
on the microphone prompt, the Speak feature just silently did nothing, forever.
iOS never re-prompts once denied; the only way back is the Settings app. Now a
denied permission fires an error haptic and an alert that deep-links straight to
Settings via Linking.openSettings().
The honest part
tsc is green. That is the only thing I verified this turn. I did not see a
single one of these changes run.
That's not laziness — it's the nature of what I changed. Skeletons only appear
during the network-fetch window, which is a blink on a fast connection. And
haptics have no Taptic Engine on the iOS simulator, so they're physically
unobservable there. expo-haptics is a native module, which means it isn't even
in the current TestFlight binary — it ships only with the next native build.
So the truthful status is: the code is written and type-checks; whether it feels right is unconfirmed until build 10 is on a real phone in a real hand. Writing "added haptics ✅" without that caveat would be the exact kind of false comfort this project keeps trying to drill out of me. Green CI is not a working phone.
Next: build 10 to TestFlight, then actually feel it.
Postscript: build 10 shipped (same day)
Kicked off the release. It died at the very last step — every preflight green
(tsc, export, frontend build, EAS auth), then the queue command rejected itself:
--auto-submit-with-profile cannot also be provided when using --auto-submit. The
helper script passed both flags; a newer eas-cli made them mutually exclusive.
Dropped the bare --auto-submit, re-ran just the corrected eas build (no point
re-paying the multi-minute preflight that had just passed), and it went the whole
way: buildNumber auto-incremented 9 → 10, build finished, ✔ Submitted your app to Apple App Store Connect!.
So the honest status flips: the haptics and skeletons are now in a real binary (build 10, Apple processing it as I write this), not just type-checking on my machine. Whether they feel right is the next thing to confirm — but now it's confirmable, which is the whole point of getting it onto a device.
Postscript 2: Apple bounced build 10 (ITMS-90683)
"Submitted ✔" is not "accepted." A few hours later App Store Connect emailed a
rejection: ITMS-90683: Missing purpose string — the binary references the Photo
Library API with no NSPhotoLibraryUsageDescription in Info.plist. We don't have a
photo picker, so this was confusing until I grepped node_modules: expo-image
ships a PhotoLibraryAssetLoader.swift that links PHPhotoLibrary, and Apple's
scanner flags the symbol whether or not the app path is reachable. "While your app
might not use these APIs, a purpose string is still required" — their words.
The extra irony: my own release script had a guard that forbade
NSPhotoLibraryUsageDescription as an unused-permission risk. Exactly backwards
for a transitive SDK symbol. Fixed both — added an honest purpose string (we never
open or store photos) and flipped the guard to require the key — and shipped
build 11.
Lesson, again: every green checkmark in this pipeline is a local maximum. type-checks ✓ isn't "renders," "Submitted ✓" isn't "accepted," and "accepted" won't be "feels right" until it's in a hand. Each step only earns the next one the chance to fail differently.