Files
hermes-relay/preview/web
Bailey DixonandClaude Opus 4.7 99a6c60e36 feat(docs): MorphingSphere embed on user site + mobile hero + copy-button pin
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>
2026-04-20 19:28:28 -04:00
..

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:

  1. Edit MorphingSphereCore.kt (the source of truth)
  2. Mirror the same change in sphere.js
  3. Reload the browser to verify
  4. 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 + |0 to match Kotlin's Int overflow 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 on Number (64-bit double). Color and alpha values can drift at the 3rd decimal in compound expressions. Sub-perceptible, but the full checksum will pick it up; use the struct checksum 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.