From 99a6c60e3696699ce2bed0316e53f40e3902946e Mon Sep 17 00:00:00 2001
From: Bailey Dixon
Date: Mon, 20 Apr 2026 19:28:28 -0400
Subject: [PATCH] feat(docs): MorphingSphere embed on user site + mobile hero +
copy-button pin
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.../relay/ui/components/MorphingSphereCore.kt | 28 +-
preview/web/sphere.js | 16 +-
user-docs/.vitepress/config.mts | 7 +-
.../.vitepress/theme/components/HeroDemo.vue | 9 +-
.../theme/components/InstallSection.vue | 15 +-
.../theme/components/SphereMark.vue | 460 ++++++++++++++++++
user-docs/.vitepress/theme/custom.css | 15 +
user-docs/.vitepress/theme/index.ts | 3 +-
8 files changed, 532 insertions(+), 21 deletions(-)
create mode 100644 user-docs/.vitepress/theme/components/SphereMark.vue
diff --git a/app/src/main/kotlin/com/hermesandroid/relay/ui/components/MorphingSphereCore.kt b/app/src/main/kotlin/com/hermesandroid/relay/ui/components/MorphingSphereCore.kt
index 1a01c2fd..23c3f829 100644
--- a/app/src/main/kotlin/com/hermesandroid/relay/ui/components/MorphingSphereCore.kt
+++ b/app/src/main/kotlin/com/hermesandroid/relay/ui/components/MorphingSphereCore.kt
@@ -138,7 +138,20 @@ data class SphereFrame(
val toolCallBurst: Float,
val voiceAmplitude: Float,
val voiceMode: Boolean,
- val voiceRadiusScale: Float
+ val voiceRadiusScale: Float,
+ // Gaze bias — lets callers aim the sphere's "eye" (bright spot) at a
+ // specific direction without moving the sphere body. `lightAngleBlend`
+ // blends between natural rotation (0) and the bias override (1).
+ // Defaults keep the original behavior for every existing caller.
+ val lightAngleBiasX: Float = 0f,
+ val lightAngleBiasY: Float = 0f,
+ val lightAngleBlend: Float = 0f,
+ // Shadow contrast — darkens distBrightness on the hemisphere facing away
+ // from the light, making the bright spot ("eye") clearly distinguishable
+ // from the shadow side. 0 = uniform pearl shading (legacy behavior, full
+ // parity preserved); 1 = shadow side fully uses directionalLight to scale
+ // the distBrightness (strong Lambertian contrast).
+ val shadowStrength: Float = 0f
)
/** What to draw at one grid cell. RGB + alpha in 0..1. */
@@ -208,8 +221,11 @@ fun forEachSphereCell(frame: SphereFrame, onCell: (SphereCell) -> Unit) {
val noiseJitter1 = fbm(t * 0.05f + 7.3f, 1.7f) * 0.5f
val noiseJitter2 = fbm(3.1f, t * 0.04f + 13.7f) * 0.5f
- val lightAngle1 = t * frame.lightSpeedX + noiseJitter1
- val lightAngle2 = t * frame.lightSpeedY + noiseJitter2
+ val naturalAngle1 = t * frame.lightSpeedX + noiseJitter1
+ val naturalAngle2 = t * frame.lightSpeedY + noiseJitter2
+ val blend = frame.lightAngleBlend.coerceIn(0f, 1f)
+ val lightAngle1 = naturalAngle1 * (1f - blend) + frame.lightAngleBiasX * blend
+ val lightAngle2 = naturalAngle2 * (1f - blend) + frame.lightAngleBiasY * blend
val lx = sin(lightAngle1) * 0.65f
val ly = cos(lightAngle2) * 0.65f
val lz = sqrt((1f - lx * lx - ly * ly).coerceAtLeast(0.01f))
@@ -284,7 +300,11 @@ fun forEachSphereCell(frame: SphereFrame, onCell: (SphereCell) -> Unit) {
val heartbeatFx = heartbeat * 0.05f * (1f - normDist * normDist)
- val brightness = distWeight * distBrightness +
+ // Shadow modulation — leave distBrightness alone on the lit
+ // side (directionalLight=1 → factor=1), dim it on the shadow
+ // side (directionalLight=0 → factor = 1 - shadowStrength).
+ val shadowFactor = 1f - frame.shadowStrength * (1f - directionalLight)
+ val brightness = distWeight * distBrightness * shadowFactor +
frame.lightInfluence * directionalLight +
heartbeatFx
val charNoise = structural + turbulence + radialFlow + ripple
diff --git a/preview/web/sphere.js b/preview/web/sphere.js
index f6a0e54c..a4acbb0c 100644
--- a/preview/web/sphere.js
+++ b/preview/web/sphere.js
@@ -136,8 +136,14 @@ export function forEachSphereCell(frame, onCell) {
const noiseJitter1 = fbm(t * 0.05 + 7.3, 1.7) * 0.5;
const noiseJitter2 = fbm(3.1, t * 0.04 + 13.7) * 0.5;
- const lightAngle1 = t * frame.lightSpeedX + noiseJitter1;
- const lightAngle2 = t * frame.lightSpeedY + noiseJitter2;
+ const naturalAngle1 = t * frame.lightSpeedX + noiseJitter1;
+ const naturalAngle2 = t * frame.lightSpeedY + noiseJitter2;
+ // Gaze bias — mirrors MorphingSphereCore.kt. Defaults keep original behavior.
+ const biasX = frame.lightAngleBiasX ?? 0;
+ const biasY = frame.lightAngleBiasY ?? 0;
+ const blend = clamp(frame.lightAngleBlend ?? 0, 0, 1);
+ const lightAngle1 = naturalAngle1 * (1 - blend) + biasX * blend;
+ const lightAngle2 = naturalAngle2 * (1 - blend) + biasY * blend;
const lx = Math.sin(lightAngle1) * 0.65;
const ly = Math.cos(lightAngle2) * 0.65;
const lz = Math.sqrt(atLeast(1 - lx * lx - ly * ly, 0.01));
@@ -198,7 +204,11 @@ export function forEachSphereCell(frame, onCell) {
const heartbeatFx = heartbeat * 0.05 * (1 - normDist * normDist);
- const brightness = distWeight * distBrightness + frame.lightInfluence * directionalLight + heartbeatFx;
+ // Shadow modulation — mirrors MorphingSphereCore.kt. shadowStrength
+ // defaults to 0 (legacy pearl shading preserved byte-for-byte).
+ const shadowStrength = frame.shadowStrength ?? 0;
+ const shadowFactor = 1 - shadowStrength * (1 - directionalLight);
+ const brightness = distWeight * distBrightness * shadowFactor + frame.lightInfluence * directionalLight + heartbeatFx;
const charNoise = structural + turbulence + radialFlow + ripple;
// Kotlin: `(t * 0.3f + col * 0.17f + row * 0.13f).toInt()` — truncation toward zero.
diff --git a/user-docs/.vitepress/config.mts b/user-docs/.vitepress/config.mts
index a5b26746..db34cf40 100644
--- a/user-docs/.vitepress/config.mts
+++ b/user-docs/.vitepress/config.mts
@@ -6,9 +6,10 @@ export default defineConfig({
description: 'Android app for Hermes Agent — chat, control, and connect from your phone',
head: [
- // Favicon
- ['link', { rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' }],
- ['link', { rel: 'apple-touch-icon', href: '/logo.svg' }],
+ // Favicon — base path is NOT auto-applied to head entries in VitePress,
+ // so hard-prefix with /hermes-relay/ to match the GitHub Pages deploy.
+ ['link', { rel: 'icon', type: 'image/svg+xml', href: '/hermes-relay/logo.svg' }],
+ ['link', { rel: 'apple-touch-icon', href: '/hermes-relay/logo.svg' }],
// Canonical
['link', { rel: 'canonical', href: 'https://codename-11.github.io/hermes-relay/' }],
diff --git a/user-docs/.vitepress/theme/components/HeroDemo.vue b/user-docs/.vitepress/theme/components/HeroDemo.vue
index 016e22ac..d9fefb86 100644
--- a/user-docs/.vitepress/theme/components/HeroDemo.vue
+++ b/user-docs/.vitepress/theme/components/HeroDemo.vue
@@ -70,7 +70,8 @@ onBeforeUnmount(() => {
0 0 0 1px var(--vp-c-divider),
0 30px 60px -20px rgba(0, 0, 0, 0.5),
0 0 80px -10px var(--vp-c-brand-soft);
- width: 280px;
+ width: clamp(180px, 62vw, 280px);
+ max-height: 70vh;
margin: 0 auto;
box-sizing: border-box;
overflow: hidden;
@@ -124,14 +125,8 @@ onBeforeUnmount(() => {
opacity: 0;
}
-@media (max-width: 960px) {
- .hero-demo-frame {
- width: 240px;
- }
-}
@media (max-width: 640px) {
.hero-demo-frame {
- width: 200px;
padding: 8px;
border-radius: 30px;
}
diff --git a/user-docs/.vitepress/theme/components/InstallSection.vue b/user-docs/.vitepress/theme/components/InstallSection.vue
index f23786ea..cbb7e2e1 100644
--- a/user-docs/.vitepress/theme/components/InstallSection.vue
+++ b/user-docs/.vitepress/theme/components/InstallSection.vue
@@ -46,7 +46,9 @@ async function copy(key: string, text: string) {
-
{{ installCommand }}
+