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 }}
+
+
{{ installCommand }}
+