Docs site: - Add SphereMark.vue in home-hero-after slot (above Install block). Imports preview/web/sphere.js directly so MorphingSphereCore.kt stays the single source of truth across app / preview / docs. Eye-only gaze tracking — the sphere body stays anchored while the bright spot tracks the pointer. Scroll- tracking is the always-on baseline, anchored to .install-section's top edge so the eye is already looking down by the time install enters the viewport; cursor-tracking overlays on top via a rectangular detection band (full viewport width × container height, linear falloff). Inputs EMA-smoothed (180 ms direction / 280 ms proximity), asin/acos capped at ±0.9 for stable mid-slope trig, cursor coords scaled (not unit-vector) so the gaze doesn't snap through zero as the sphere scrolls past the pointer. prefers-reduced- motion, IntersectionObserver (off-screen pause), and ResizeObserver aware. - HeroDemo.vue: replace three breakpoint widths with clamp(180px, 62vw, 280px) and max-height: 70vh so the phone frame can't dominate tall narrow viewports. - custom.css: override VitePress's fixed 320x320 image-container + negative image margins below 960 px so the 9:16 phone frame stops overflowing and pulling main text onto the video. - InstallSection.vue: split .install-code into a positioning context wrapping .install-code-scroll so the Copy button stops sliding out of view with long overflowing one-liners. - config.mts: prefix favicon href with /hermes-relay/ (VitePress's base isn't auto-applied to head entries, so /logo.svg was 404'ing). Core algorithm (backward-compatible, mirrored in sphere.js and kotlin): - SphereFrame gains lightAngleBiasX / lightAngleBiasY / lightAngleBlend (default 0f). Light-angle computation blends between natural t * lightSpeedX rotation (blend=0) and the caller-supplied bias (blend=1). Lets the docs sphere aim its eye at the cursor / scroll target without moving the body. - SphereFrame gains shadowStrength (default 0f). Scales distBrightness by (1 − shadowStrength * (1 − directionalLight)) — lit hemisphere untouched, shadow hemisphere dimmed. Docs sphere uses 0.6 so the eye reads clearly against the shadow side. Android composable doesn't set it; legacy pearl shading preserved byte-for-byte, parity test stays green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MorphingSphere — browser preview
A zero-dependency browser harness for iterating on the ASCII morphing sphere without Android Studio or an emulator.
Run
# From repo root:
python3 -m http.server 8000 --directory preview/web
# then open http://localhost:8000/
Or open preview/web/index.html directly in a browser — the ES module import
requires file:// access (works in Firefox, blocked in Chrome); the local
server path above works everywhere.
Controls
- State — Idle · Thinking · Streaming · Listening · Speaking · Error
- Voice mode — expands the sphere radius ~8%
- Voice amp — 0..1, modulates Listening/Speaking behavior
- Intensity — general activity boost
- Tool burst — transient spike (ripples, turbulence)
- Pause — freezes animation; Space toggles
- Reset t — restart the time origin
- Keyboard: 1..6 picks a state
How this relates to the Kotlin source
sphere.js is a line-for-line mirror of
app/src/main/kotlin/com/hermesandroid/relay/ui/components/MorphingSphereCore.kt.
When you change the algorithm:
- Edit
MorphingSphereCore.kt(the source of truth) - Mirror the same change in
sphere.js - Reload the browser to verify
- Run the parity harness to confirm (see below)
This keeps Android + browser visually consistent. The only deliberate differences are:
- Font — Android uses
FontFamily.Monospace; browser uses the OS default mono. Glyph widths and antialiasing differ slightly. Bundle JetBrains Mono if you need pixel parity across surfaces. - 32-bit integer math — the hash function uses
Math.imul+|0to match Kotlin'sIntoverflow semantics exactly. .mod(n)— Kotlin's floored-positive modulo vs JS%sign-preserving; the port handles this in the data ring code.- Numerical precision — Kotlin runs on
Float(32-bit), JS onNumber(64-bit double). Color and alpha values can drift at the 3rd decimal in compound expressions. Sub-perceptible, but thefullchecksum will pick it up; use thestructchecksum for the hard parity gate.
Parity harness
Two sides, same fixtures, same FNV-1a tuple hash — matching struct
checksums prove the algorithm renders the same (row, col, char) grid on
both platforms.
# JS side (from repo root):
node preview/web/parity-check.mjs --checksum-only
# Kotlin side (from repo root):
./gradlew :app:testGooglePlayDebugUnitTest \
--tests "*MorphingSphereCoreParityTest*" -i
Both runs print per-fixture struct=... and full=... digests plus zone
histograms. Diff the two outputs — if struct matches across all 8
fixtures you're in algorithmic parity. full drift on voice-modulated
fixtures is expected Float-vs-Double precision noise.