Daeseon Yoo
Back to project
·UX retro·4 min

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.