--- name: mobile-device-orientation-ui description: Build and debug mobile web UIs that use device orientation, compass heading, geolocation, and circular bearing/azimuth visualizations. version: 1.0.0 author: Hermes Agent license: MIT metadata: hermes: tags: [mobile, ios, safari, device-orientation, compass, geolocation, ui, debugging] related_skills: [systematic-debugging, coding-quality-workflow, pwa-configuration] --- # Mobile Device Orientation UI ## When to Use Use this when building or debugging a mobile web feature that depends on: - `DeviceOrientationEvent`, `deviceorientation`, or `deviceorientationabsolute` - iPhone/Safari compass heading, `webkitCompassHeading`, or motion/orientation permission prompts - geolocation + orientation combined flows - compass, sky-map, AR-lite, bearing, azimuth, heading, or circular dial UI - “turn left/right/aligned” guidance based on phone direction ## Core Model Separate these concepts explicitly: 1. **Sensor heading** — the raw/derived phone compass heading, normalized to `0..360` for readouts and calculations. 2. **World targets** — fixed azimuths/bearings in the real world, such as planets, places, or cardinal directions. 3. **Visual heading** — the heading value used for CSS transforms. This may need to be *unwrapped* across `359° ↔ 0°` so animation remains smooth. 4. **Instruction math** — shortest angular delta between target azimuth and current heading. 5. **Pitch/altitude alignment** — for physical phone tilt, compare phone pitch to the target's **local apparent altitude above the horizon**, not to raw ecliptic latitude. Ecliptic latitude can be shown as symbolic/astrological metadata, but AR-lite sky pointing uses altitude/azimuth. Do not let a display fallback like `heading ?? 0` drive real alignment instructions. If no heading event has arrived yet, show a waiting/pending state even if location and target data are loaded. Apply the same rule to pitch: show “waiting for phone pitch” until a real `beta`/pitch reading arrives. ## iOS Permission Flow On iOS/Safari, `DeviceOrientationEvent.requestPermission()` must be called during the active user gesture. Recommended pattern for one button that needs both compass and location: ```js const enable = async () => { const compassPromise = compass.request(); // starts immediately from tap const locationPromise = geo.request(); const [okCompass, okLocation] = await Promise.all([compassPromise, locationPromise]); }; ``` Pitfall: if the code awaits geolocation first and only then asks for compass, the location prompt can consume the user gesture. The compass permission may silently fail or be marked denied. ## Heading Derivation Typical browser logic: ```js if (Number.isFinite(event.webkitCompassHeading)) { heading = normalizeAngle(event.webkitCompassHeading); } else if (Number.isFinite(event.alpha)) { heading = normalizeAngle(360 - event.alpha); } ``` Use `webkitCompassHeading` on iOS when available. Track accuracy if provided (`webkitCompassAccuracy`) and warn that readings can drift near magnets or indoors. ## Circular UI Rules For a phone-pointing compass/sky finder: - The fixed needle/crosshair represents “the phone is pointing this way.” It should usually stay fixed at the top/centerline. - World objects rotate underneath the fixed needle by `targetAzimuth - heading`. - Cardinal marks are world objects too: - `N = 0°` - `E = 90°` - `S = 180°` - `W = 270°` - If planets/targets rotate but N/E/S/W are pinned to the screen, the UI feels wrong: it becomes a static compass cross with moving targets. - Keep labels/glyphs upright by applying the inverse rotation after radial placement. Example radial transform: ```js function polarStyle(azimuth, visualHeading, radius) { const rotation = azimuth - visualHeading; return { transform: `rotate(${rotation}deg) translateY(${radius}) rotate(${-rotation}deg)`, }; } ``` ## 0°/360° Wraparound Animation CSS transforms can spin the long way around when values jump across North: - real motion: `359° → 0°` should be `+1°` - CSS interpolation may see `-359deg → 0deg` and animate a full rotation Maintain an unwrapped visual heading for transforms: ```js function shortestAngleDelta(next, prev) { return ((((next - prev) % 360) + 540) % 360) - 180; } setVisualHeading((prevVisual) => { const nextVisual = prevVisual + shortestAngleDelta(sensorHeading, previousSensorHeading.current); previousSensorHeading.current = sensorHeading; return nextVisual; }); ``` Use normalized heading for readouts and alignment math; use unwrapped visual heading for animated transforms. ## Planet Pitch / Altitude Alignment When building a PWA sky-finder panel, combine: - Target azimuth ↔ phone heading for left/right guidance. - Target local apparent altitude ↔ phone pitch for tilt guidance. Recommended UI pattern: - First identify the user-facing app and existing compass UI, then rebuild the feature natively in that UX. Do not blindly copy a prototype panel from a neighboring/internal app unless the user explicitly asks for that; preserving the host app’s interaction model matters for mobile sky/ritual experiences. - One `Enable sky align` button that starts orientation permission and location/sky-data fetch in parallel from the same user gesture. - A vertical meter with a `0°` horizon line, target altitude marker, and phone pitch marker. - A calibration button such as “Calibrate horizon” or “Set current pitch as horizon” because browser `beta` axes and natural phone grip vary. Put it near the enable flow, not hidden behind a secondary control. - Explicit waiting states for missing pitch/heading data. - Capture pitch/tilt independently from heading: update `beta`/`gamma` before returning on missing heading so pitch UI can become ready while compass heading is still pending/noisy. - Optional mobile feedback: small haptic pulse on horizon calibration and a stronger one-shot pulse on full lock, using `navigator.vibrate()` opportunistically with no hard dependency. - Full-lock affordance: add a glow/pulse class when both azimuth and pitch are aligned so the user gets visual confirmation even when vibration is unsupported. - Approximate lock tolerances, e.g. around `±6°` pitch and `±3..8°` heading, rather than exact alignment. See `references/pwa-planet-pitch-alignment.md` for a concise implementation pattern and helper formulas. ## Debugging Checklist 1. Confirm permission state separately for location and compass. 2. Confirm target data loads independently from compass data. 3. If target data loads but heading is missing, show “waiting for compass,” not alignment instructions. 4. Log or display whether heading events are arriving. 5. Test direct Safari/PWA separately from in-app browsers; iOS webviews may behave differently. 6. Cross North slowly and verify no full-spin wraparound. 7. Rotate through each cardinal direction and verify N/E/S/W move with the targets under the fixed needle. ## References - `references/sky-compass-ios-wraparound.md` — session-derived notes from the Astral Hermes planet finder implementation and iPhone field feedback.