+
+
+
+ Davebot gaze lab - tune how hard he looks at the cursor
+
+
+
+
+
Davebot gaze lab
+
+ Move the cursor anywhere on the page. Davebot's eyes point at it, and the settings on the
+ right change how hard he locks on. Values save as you drag, and the portfolio header reads
+ the same values, so tune here and reload that page to see it in place.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/portfolio-gallery/src/davebot-gaze-main.ts b/examples/portfolio-gallery/src/davebot-gaze-main.ts
new file mode 100644
index 0000000..f0933f6
--- /dev/null
+++ b/examples/portfolio-gallery/src/davebot-gaze-main.ts
@@ -0,0 +1,288 @@
+import { createAvatar } from '@bible-strong/avatar-web'
+
+import { davebotHeaderVariants, HEADER_MODES, type HeaderMode } from './davebot-header'
+import {
+ createGazeTracker,
+ GAZE_DEFAULTS,
+ GAZE_LIMITS,
+ GAZE_PRESETS,
+ loadGazeSettings,
+ saveGazeSettings,
+ type GazeSettings,
+} from './davebotGaze'
+import './davebot-gaze.css'
+
+const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+
+const stage = document.querySelector('#stage')
+const scales = document.querySelector('#scales')
+const controls = document.querySelector('#controls')
+const presetRow = document.querySelector('#presets')
+const deflectionBar = document.querySelector('#deflection-bar')
+const deflectionValue = document.querySelector('#deflection-value')
+const output = document.querySelector('#settings-json')
+const copyButton = document.querySelector('#copy')
+const resetButton = document.querySelector('#reset')
+
+if (
+ !stage ||
+ !scales ||
+ !controls ||
+ !presetRow ||
+ !deflectionBar ||
+ !deflectionValue ||
+ !output ||
+ !copyButton ||
+ !resetButton
+) {
+ throw new Error('Davebot gaze lab shell is incomplete.')
+}
+
+let settings = loadGazeSettings()
+const gaze = createGazeTracker(settings)
+
+const mount = (host: HTMLElement, mode: HeaderMode): void => {
+ const variant = davebotHeaderVariants.find(candidate => candidate.mode === mode)
+ if (!variant) throw new Error(`Davebot variant for ${mode} mode was not found.`)
+
+ const controller = createAvatar(host, {
+ definition: variant.definition,
+ defaultAnimation: reduceMotion ? undefined : 'idle',
+ autoplay: !reduceMotion,
+ size: '100%',
+ ariaLabel: `Davebot in ${mode} mode`,
+ onError: error => console.error(`[davebot-gaze/${mode}]`, error),
+ })
+
+ if (reduceMotion) controller.setExpression('neutral')
+ if (!gaze.track(host)) throw new Error(`Davebot eye paths for ${mode} mode were not found.`)
+}
+
+// Two sizes matter. The large stage makes the feel judgeable; the chip row
+// shows the same settings at the size Davebot actually ships at, where the
+// 300-unit viewBox compresses into roughly 46 screen px.
+for (const mode of Object.keys(HEADER_MODES) as HeaderMode[]) {
+ const tile = document.createElement('figure')
+ tile.className = 'tile'
+ tile.dataset.mode = mode
+
+ const host = document.createElement('div')
+ host.className = 'tile-avatar'
+ tile.append(host)
+
+ const caption = document.createElement('figcaption')
+ caption.textContent = `${mode} mode`
+ tile.append(caption)
+
+ stage.append(tile)
+ mount(host, mode)
+
+ const chip = document.createElement('div')
+ chip.className = 'chip'
+ chip.dataset.mode = mode
+
+ const chipHost = document.createElement('div')
+ chipHost.className = 'chip-avatar'
+ chip.append(chipHost)
+
+ const chipLabel = document.createElement('span')
+ chipLabel.textContent = `${mode} at header size`
+ chip.append(chipLabel)
+
+ scales.append(chip)
+ mount(chipHost, mode)
+}
+
+type Field = {
+ key: keyof GazeSettings
+ label: string
+ unit: string
+ hint: string
+}
+
+const FIELDS: Field[] = [
+ {
+ key: 'travel',
+ label: 'Travel',
+ unit: 'units',
+ hint: 'Furthest the pupils move. The head silhouette starts clipping them past 48.',
+ },
+ {
+ key: 'depth',
+ label: 'Depth',
+ unit: 'px',
+ hint: 'How far away Davebot feels. Lower values lock on to a near cursor much harder.',
+ },
+ {
+ key: 'smoothing',
+ label: 'Smoothing',
+ unit: '',
+ hint: 'Zero pins the eyes to the cursor. Raise it to let them trail behind.',
+ },
+ {
+ key: 'convergence',
+ label: 'Convergence',
+ unit: '',
+ hint: 'How far the eyes cross inward when the cursor is close to him.',
+ },
+ {
+ key: 'lean',
+ label: 'Lean',
+ unit: 'px',
+ hint: 'Whole-body shift toward the cursor. A little sells the look; a lot reads as sliding.',
+ },
+]
+
+// Show exactly as many decimals as the step can express, so a 0.5 step reads
+// "3.0" rather than a falsely precise "3.00".
+const format = (key: keyof GazeSettings, value: number): string => {
+ const step = GAZE_LIMITS[key].step
+ if (step >= 1) return String(Math.round(value))
+ return value.toFixed(step < 0.1 ? 2 : 1)
+}
+
+type Control = {
+ field: Field
+ input: HTMLInputElement
+ readout: HTMLElement
+}
+
+const buildControl = (field: Field): Control => {
+ const limit = GAZE_LIMITS[field.key]
+
+ const row = document.createElement('div')
+ row.className = 'field'
+
+ const label = document.createElement('label')
+ label.className = 'field-head'
+ label.htmlFor = `gaze-${field.key}`
+
+ const name = document.createElement('span')
+ name.textContent = field.label
+
+ // A range input already announces its own value, so this readout is visual
+ // only and must not become a second announced label.
+ const readout = document.createElement('span')
+ readout.className = 'field-value'
+ readout.setAttribute('aria-hidden', 'true')
+
+ label.append(name, readout)
+
+ const input = document.createElement('input')
+ input.type = 'range'
+ input.id = `gaze-${field.key}`
+ input.min = String(limit.min)
+ input.max = String(limit.max)
+ input.step = String(limit.step)
+ input.value = String(settings[field.key])
+ input.setAttribute('aria-describedby', `hint-${field.key}`)
+
+ const hint = document.createElement('p')
+ hint.className = 'field-hint'
+ hint.id = `hint-${field.key}`
+ hint.textContent = field.hint
+
+ row.append(label, input, hint)
+ controls.append(row)
+
+ return { field, input, readout }
+}
+
+const controlList: Control[] = FIELDS.map(buildControl)
+
+const syncReadouts = (): void => {
+ for (const control of controlList) {
+ const value = format(control.field.key, settings[control.field.key])
+ control.readout.textContent = control.field.unit ? `${value} ${control.field.unit}` : value
+ }
+
+ output.textContent = JSON.stringify(settings, null, 2)
+
+ const active = Object.entries(GAZE_PRESETS).find(([, preset]) =>
+ controlList.every(control => preset[control.field.key] === settings[control.field.key])
+ )
+ for (const button of presetRow.querySelectorAll('button')) {
+ button.setAttribute('aria-pressed', String(button.dataset.preset === active?.[0]))
+ }
+}
+
+// The deflection meter mirrors the tracker's own settling loop: it runs while
+// the value is still changing and then stops, so an idle page costs no frames.
+let deflectionFrame: number | null = null
+let lastDeflection = -1
+
+const paintDeflection = (): void => {
+ deflectionFrame = null
+ const value = gaze.getDeflection()
+ const percent = Math.round(value * 100)
+ deflectionBar.style.setProperty('--fill', `${Math.min(percent, 100)}%`)
+ deflectionValue.textContent = `${percent}%`
+
+ if (Math.abs(value - lastDeflection) > 0.001) {
+ lastDeflection = value
+ deflectionFrame = requestAnimationFrame(paintDeflection)
+ }
+}
+
+const requestDeflection = (): void => {
+ lastDeflection = -1
+ if (deflectionFrame === null) deflectionFrame = requestAnimationFrame(paintDeflection)
+}
+
+const apply = (next: GazeSettings, syncInputs: boolean): void => {
+ gaze.setSettings(next)
+ // Read back the engine's clamped values so the panel can never display a
+ // number the tracker is not actually using.
+ settings = gaze.getSettings()
+ saveGazeSettings(settings)
+
+ if (syncInputs) {
+ for (const control of controlList) {
+ control.input.value = String(settings[control.field.key])
+ }
+ }
+
+ syncReadouts()
+ requestDeflection()
+}
+
+for (const control of controlList) {
+ control.input.addEventListener('input', () => {
+ apply({ ...settings, [control.field.key]: Number(control.input.value) }, false)
+ })
+}
+
+for (const [label, preset] of Object.entries(GAZE_PRESETS)) {
+ const button = document.createElement('button')
+ button.type = 'button'
+ button.dataset.preset = label
+ button.textContent = label
+ button.addEventListener('click', () => apply(preset, true))
+ presetRow.append(button)
+}
+
+resetButton.addEventListener('click', () => apply(GAZE_DEFAULTS, true))
+
+copyButton.addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(JSON.stringify(settings, null, 2))
+ copyButton.textContent = 'Copied'
+ } catch {
+ // Clipboard access is denied outside a secure context. The JSON is already
+ // on screen, so selecting it is a working fallback rather than a dead end.
+ const range = document.createRange()
+ range.selectNodeContents(output)
+ const selection = window.getSelection()
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ copyButton.textContent = 'Selected'
+ }
+ window.setTimeout(() => {
+ copyButton.textContent = 'Copy JSON'
+ }, 1400)
+})
+
+window.addEventListener('pointermove', requestDeflection, { passive: true })
+
+syncReadouts()
+requestDeflection()
diff --git a/examples/portfolio-gallery/src/davebot-gaze.css b/examples/portfolio-gallery/src/davebot-gaze.css
new file mode 100644
index 0000000..2d0815c
--- /dev/null
+++ b/examples/portfolio-gallery/src/davebot-gaze.css
@@ -0,0 +1,335 @@
+@import url('https://fonts.bunny.net/css?family=geist:400,500,600|geist-mono:400,500');
+
+/* Design System 01, monochrome. The lab is a tool, so it stays neutral and
+ lets the two Davebot tiles carry the only real contrast on the page. */
+:root {
+ --page: #f6f6f6;
+ --panel: #ffffff;
+ --ink: #262626;
+ --ink-muted: #5e5e5e;
+ --ink-faint: #909090;
+ --line: #0000001f;
+ --line-strong: #0000003d;
+ --font: 'Geist', system-ui, sans-serif;
+ --font-mono: 'Geist Mono', ui-monospace, monospace;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ color-scheme: light;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--page);
+ color: var(--ink);
+ font-family: var(--font);
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+}
+
+.shell {
+ margin: 0 auto;
+ max-width: 1180px;
+ padding: 40px 28px 72px;
+}
+
+header h1 {
+ margin: 0;
+ font-size: 26px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+}
+
+header p {
+ margin: 8px 0 0;
+ max-width: 62ch;
+ color: var(--ink-muted);
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.layout {
+ display: grid;
+ gap: 28px;
+ margin-top: 32px;
+ grid-template-columns: minmax(0, 1fr) 340px;
+ align-items: start;
+}
+
+@media (max-width: 940px) {
+ .layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* Stage */
+
+.stage {
+ display: grid;
+ gap: 20px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.tile {
+ display: grid;
+ gap: 14px;
+ justify-items: center;
+ margin: 0;
+ padding: 52px 24px 24px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+}
+
+.tile[data-mode='light'] {
+ background: #f6f6f6;
+}
+
+.tile[data-mode='dark'] {
+ background: #0b0b0b;
+ border-color: #ffffff1f;
+}
+
+.tile-avatar {
+ width: 100%;
+ max-width: 330px;
+ aspect-ratio: 1;
+ /* Lean is a compositor-only translate, so promote the layer once. */
+ will-change: transform;
+}
+
+.tile-avatar .bs-avatar,
+.tile-avatar svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.tile figcaption {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-faint);
+}
+
+.tile[data-mode='dark'] figcaption {
+ color: #909090;
+}
+
+.scales {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 20px;
+}
+
+.chip {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ font-size: 12px;
+ color: var(--ink-muted);
+}
+
+.chip[data-mode='light'] {
+ background: #f6f6f6;
+}
+
+.chip[data-mode='dark'] {
+ background: #0b0b0b;
+ border-color: #ffffff1f;
+ color: #909090;
+}
+
+.chip-avatar {
+ flex: 0 0 auto;
+ width: 46px;
+ height: 46px;
+ will-change: transform;
+}
+
+.chip-avatar .bs-avatar,
+.chip-avatar svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+/* Panel */
+
+.panel {
+ padding: 22px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: var(--panel);
+ position: sticky;
+ top: 28px;
+}
+
+.panel h2 {
+ margin: 0 0 4px;
+ font-size: 13px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ink-faint);
+}
+
+.presets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 14px 0 22px;
+}
+
+button {
+ padding: 7px 12px;
+ border: 1px solid var(--line-strong);
+ border-radius: 999px;
+ background: transparent;
+ color: var(--ink);
+ font-family: inherit;
+ font-size: 12px;
+ cursor: pointer;
+ transition:
+ background-color 140ms cubic-bezier(0.16, 1, 0.3, 1),
+ color 140ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+button:hover {
+ background: #0000000a;
+}
+
+button[aria-pressed='true'] {
+ background: var(--ink);
+ border-color: var(--ink);
+ color: #f6f6f6;
+}
+
+button:focus-visible,
+input:focus-visible {
+ outline: 2px solid var(--ink);
+ outline-offset: 2px;
+}
+
+.field {
+ margin-bottom: 18px;
+}
+
+.field-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.field-value {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--ink-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.field input[type='range'] {
+ width: 100%;
+ margin: 8px 0 0;
+ accent-color: var(--ink);
+}
+
+.field-hint {
+ margin: 6px 0 0;
+ font-size: 11.5px;
+ line-height: 1.5;
+ color: var(--ink-faint);
+}
+
+/* Meter */
+
+.meter {
+ margin: 24px 0 18px;
+ padding-top: 18px;
+ border-top: 1px solid var(--line);
+}
+
+.meter-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.meter-head span:last-child {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--ink-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.bar {
+ --fill: 0%;
+ margin-top: 8px;
+ height: 5px;
+ border-radius: 999px;
+ background: #00000014;
+ overflow: hidden;
+}
+
+.bar::after {
+ content: '';
+ display: block;
+ width: var(--fill);
+ height: 100%;
+ border-radius: inherit;
+ background: var(--ink);
+}
+
+.actions {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 14px;
+}
+
+pre {
+ margin: 0;
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #f6f6f6;
+ font-family: var(--font-mono);
+ font-size: 11.5px;
+ line-height: 1.55;
+ color: var(--ink-muted);
+ overflow-x: auto;
+}
+
+footer {
+ margin-top: 36px;
+ padding-top: 18px;
+ border-top: 1px solid var(--line);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ink-faint);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ button {
+ transition: none;
+ }
+}
diff --git a/examples/portfolio-gallery/src/davebot-header-main.ts b/examples/portfolio-gallery/src/davebot-header-main.ts
index 286ee7a..170dc11 100644
--- a/examples/portfolio-gallery/src/davebot-header-main.ts
+++ b/examples/portfolio-gallery/src/davebot-header-main.ts
@@ -1,21 +1,14 @@
import { createAvatar } from '@bible-strong/avatar-web'
import { davebotHeaderVariants } from './davebot-header'
+import { createGazeTracker, loadGazeSettings } from './davebotGaze'
import './davebot-header.css'
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
-const maxGazeOffset = reduceMotion ? 6 : 9
-const fullGazeDistance = 240
-type GazeTarget = {
- mount: HTMLElement
- left: SVGTransform
- right: SVGTransform
- centerX: number
- centerY: number
-}
-
-const gazeTargets: GazeTarget[] = []
+// Values tuned in davebot-gaze.html are persisted, so the header reflects
+// whatever feel was last chosen in the lab.
+const gaze = createGazeTracker(loadGazeSettings())
for (const variant of davebotHeaderVariants) {
const panel = document.querySelector(`.portfolio[data-mode="${variant.mode}"]`)
@@ -42,104 +35,7 @@ for (const variant of davebotHeaderVariants) {
davebot.addEventListener('blur', () => controller.play('idle'))
davebot.addEventListener('click', () => controller.play('hello'))
- const svg = mount.querySelector('.bs-avatar svg')
- const eyePaths = svg?.querySelectorAll('g[clip-path] > path')
- if (!svg || !eyePaths || eyePaths.length !== 2) {
+ if (!gaze.track(mount)) {
throw new Error(`Davebot eye paths for ${variant.mode} mode were not found.`)
}
-
- // The engine repaints each eye's path data during blinks and expressions, but
- // does not touch this SVG transform list. One persistent transform per eye
- // therefore layers high-frequency gaze over normal playback without
- // regenerating paths or resetting the animation timeline.
- const left = svg.createSVGTransform()
- const right = svg.createSVGTransform()
- left.setTranslate(0, 0)
- right.setTranslate(0, 0)
- // Chromium clones both the appended object and appendItem's return value.
- // Only getItem() yields a live transform handle whose later setTranslate()
- // calls update the eye path.
- eyePaths[0].transform.baseVal.appendItem(left)
- eyePaths[1].transform.baseVal.appendItem(right)
- const attachedLeft = eyePaths[0].transform.baseVal.getItem(0)
- const attachedRight = eyePaths[1].transform.baseVal.getItem(0)
- gazeTargets.push({ mount, left: attachedLeft, right: attachedRight, centerX: 0, centerY: 0 })
}
-
-let pointerX = 0
-let pointerY = 0
-let pointerPresent = false
-let geometryDirty = true
-let gazeFrame: number | null = null
-
-const updateCenters = (): void => {
- for (const target of gazeTargets) {
- const rect = target.mount.getBoundingClientRect()
- target.centerX = rect.left + rect.width / 2
- target.centerY = rect.top + rect.height / 2
- }
- geometryDirty = false
-}
-
-const paintGaze = (): void => {
- gazeFrame = null
- if (geometryDirty) updateCenters()
-
- for (const target of gazeTargets) {
- let x = 0
- let y = 0
- if (pointerPresent) {
- const deltaX = pointerX - target.centerX
- const deltaY = pointerY - target.centerY
- const distance = Math.hypot(deltaX, deltaY)
- if (distance > 0) {
- const offset = maxGazeOffset * Math.min(distance / fullGazeDistance, 1)
- x = (deltaX / distance) * offset
- y = (deltaY / distance) * offset
- }
- }
- target.left.setTranslate(x, y)
- target.right.setTranslate(x, y)
- }
-}
-
-const requestGazePaint = (): void => {
- if (gazeFrame === null) gazeFrame = requestAnimationFrame(paintGaze)
-}
-
-window.addEventListener(
- 'pointermove',
- event => {
- if (event.pointerType === 'touch') return
- pointerX = event.clientX
- pointerY = event.clientY
- pointerPresent = true
- requestGazePaint()
- },
- { passive: true }
-)
-
-window.addEventListener('pointerout', event => {
- if (event.relatedTarget !== null) return
- pointerPresent = false
- requestGazePaint()
-})
-
-window.addEventListener('blur', () => {
- pointerPresent = false
- requestGazePaint()
-})
-
-document.addEventListener('visibilitychange', () => {
- if (!document.hidden) return
- pointerPresent = false
- requestGazePaint()
-})
-
-const markGeometryDirty = (): void => {
- geometryDirty = true
- requestGazePaint()
-}
-
-new ResizeObserver(markGeometryDirty).observe(document.documentElement)
-window.addEventListener('scroll', markGeometryDirty, { passive: true })
diff --git a/examples/portfolio-gallery/src/davebotGaze.ts b/examples/portfolio-gallery/src/davebotGaze.ts
new file mode 100644
index 0000000..26c5d7f
--- /dev/null
+++ b/examples/portfolio-gallery/src/davebotGaze.ts
@@ -0,0 +1,297 @@
+// Davebot's cursor gaze.
+//
+// The engine owns playback, blinking and body drift. Gaze is layered on top by
+// translating the two existing eye paths, which sit inside a group clipped to
+// the head silhouette — so the head outline bounds the pupils and extreme
+// deflection clips naturally instead of floating outside the body.
+//
+// Direction is exact: the offset is the unit vector to the pointer. Magnitude
+// uses the angle to the pointer rather than a linear distance ramp:
+//
+// gain = distance / hypot(distance, depth) = sin(atan2(distance, depth))
+//
+// That is the projection of a real eyeball rotating to face a target `depth`
+// away, so it saturates — once the pointer is well past the knee the eye holds
+// near-full deflection and keeps pointing straight at it, which is what reads
+// as "looking at you". A linear ramp instead keeps drifting and reads as vague.
+
+export type GazeSettings = {
+ /** Maximum pupil displacement, in SVG user units (viewBox is 300 wide). */
+ travel: number
+ /** Perceived distance to Davebot in screen px; sets how fast gaze saturates. */
+ depth: number
+ /** Exponential follow, 0 = locked to the pointer, 0.9 = slow and floaty. */
+ smoothing: number
+ /** Inward eye rotation for near targets, 0 = parallel eyes, 1 = strongly crossed. */
+ convergence: number
+ /** Whole-body lean toward the pointer at full deflection, in screen px. */
+ lean: number
+}
+
+export const GAZE_DEFAULTS: GazeSettings = {
+ travel: 30,
+ depth: 260,
+ smoothing: 0.18,
+ convergence: 0.35,
+ lean: 3,
+}
+
+// `travel` stops at 48 because that is the measured distance from Davebot's
+// outer eye edge to the head silhouette; past it the clip starts cutting.
+export const GAZE_LIMITS: Record = {
+ travel: { min: 0, max: 48, step: 1 },
+ depth: { min: 40, max: 900, step: 10 },
+ smoothing: { min: 0, max: 0.9, step: 0.01 },
+ convergence: { min: 0, max: 1, step: 0.01 },
+ lean: { min: 0, max: 12, step: 0.5 },
+}
+
+export const GAZE_PRESETS: Record = {
+ Subtle: { travel: 14, depth: 460, smoothing: 0.34, convergence: 0.15, lean: 1 },
+ Balanced: GAZE_DEFAULTS,
+ 'Locked on': { travel: 44, depth: 120, smoothing: 0.06, convergence: 0.5, lean: 6 },
+}
+
+const STORAGE_KEY = 'davebot-gaze-settings'
+
+const clamp = (value: number, min: number, max: number): number =>
+ Math.min(Math.max(value, min), max)
+
+const sanitize = (raw: Partial | null | undefined): GazeSettings => {
+ const merged = { ...GAZE_DEFAULTS, ...(raw ?? {}) }
+ const keys = Object.keys(GAZE_DEFAULTS) as (keyof GazeSettings)[]
+ const result = { ...GAZE_DEFAULTS }
+ for (const key of keys) {
+ const value = Number(merged[key])
+ const limit = GAZE_LIMITS[key]
+ result[key] = Number.isFinite(value) ? clamp(value, limit.min, limit.max) : GAZE_DEFAULTS[key]
+ }
+ return result
+}
+
+export const loadGazeSettings = (): GazeSettings => {
+ try {
+ const stored = window.localStorage.getItem(STORAGE_KEY)
+ return stored ? sanitize(JSON.parse(stored) as Partial) : { ...GAZE_DEFAULTS }
+ } catch {
+ // A blocked or corrupt store must never stop Davebot from rendering.
+ return { ...GAZE_DEFAULTS }
+ }
+}
+
+export const saveGazeSettings = (settings: GazeSettings): void => {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
+ } catch {
+ // Persisting the tuning is a convenience, not a requirement.
+ }
+}
+
+type Eye = {
+ transform: SVGTransform
+ /** +1 when moving right takes this eye toward the face centre, -1 otherwise. */
+ inward: number
+}
+
+type Target = {
+ mount: HTMLElement
+ eyes: Eye[]
+ centerX: number
+ centerY: number
+ currentX: number
+ currentY: number
+ targetX: number
+ targetY: number
+}
+
+export type GazeTracker = {
+ /** Registers one rendered avatar. Returns false when its eyes are missing. */
+ track: (mount: HTMLElement) => boolean
+ setSettings: (next: GazeSettings) => void
+ getSettings: () => GazeSettings
+ /** Current deflection as a fraction of `travel`, for live readouts. */
+ getDeflection: () => number
+ destroy: () => void
+}
+
+export const createGazeTracker = (initial: GazeSettings): GazeTracker => {
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ let settings = sanitize(initial)
+ const targets: Target[] = []
+
+ let pointerX = 0
+ let pointerY = 0
+ let pointerPresent = false
+ let geometryDirty = true
+ let frame: number | null = null
+
+ const measure = (): void => {
+ for (const target of targets) {
+ const rect = target.mount.getBoundingClientRect()
+ target.centerX = rect.left + rect.width / 2
+ target.centerY = rect.top + rect.height / 2
+ }
+ geometryDirty = false
+ }
+
+ const paint = (): void => {
+ frame = null
+ if (geometryDirty) measure()
+
+ // With smoothing the eyes keep easing after the pointer stops, so the loop
+ // must persist until every target has settled.
+ let moving = false
+
+ for (const target of targets) {
+ let nextX = 0
+ let nextY = 0
+ let gain = 0
+
+ if (pointerPresent) {
+ const deltaX = pointerX - target.centerX
+ const deltaY = pointerY - target.centerY
+ const distance = Math.hypot(deltaX, deltaY)
+ if (distance > 0.0001) {
+ gain = distance / Math.hypot(distance, settings.depth)
+ const reach = settings.travel * gain
+ nextX = (deltaX / distance) * reach
+ nextY = (deltaY / distance) * reach
+ }
+ }
+
+ target.targetX = nextX
+ target.targetY = nextY
+
+ const follow = reduceMotion ? 1 : 1 - settings.smoothing
+ target.currentX += (target.targetX - target.currentX) * follow
+ target.currentY += (target.targetY - target.currentY) * follow
+
+ if (
+ Math.abs(target.targetX - target.currentX) > 0.01 ||
+ Math.abs(target.targetY - target.currentY) > 0.01
+ ) {
+ moving = true
+ }
+
+ // Near targets converge; distant targets stay parallel. Nearness is read
+ // from the SMOOTHED offset, not the raw gain, so convergence eases with
+ // the eyes instead of jumping when the pointer teleports.
+ const settled =
+ settings.travel > 0
+ ? Math.min(Math.hypot(target.currentX, target.currentY) / settings.travel, 1)
+ : 0
+ const converge = pointerPresent
+ ? settings.convergence * settings.travel * 0.35 * (1 - settled)
+ : 0
+
+ for (const eye of target.eyes) {
+ eye.transform.setTranslate(target.currentX + converge * eye.inward, target.currentY)
+ }
+
+ if (reduceMotion || settings.lean === 0) {
+ target.mount.style.transform = ''
+ } else {
+ const leanScale = settings.travel > 0 ? settings.lean / settings.travel : 0
+ const leanX = target.currentX * leanScale
+ const leanY = target.currentY * leanScale
+ target.mount.style.transform = `translate3d(${leanX.toFixed(2)}px, ${leanY.toFixed(2)}px, 0)`
+ }
+ }
+
+ if (moving) frame = requestAnimationFrame(paint)
+ }
+
+ const request = (): void => {
+ if (frame === null) frame = requestAnimationFrame(paint)
+ }
+
+ const onPointerMove = (event: PointerEvent): void => {
+ if (event.pointerType === 'touch') return
+ pointerX = event.clientX
+ pointerY = event.clientY
+ pointerPresent = true
+ request()
+ }
+
+ const release = (): void => {
+ pointerPresent = false
+ request()
+ }
+
+ const onPointerOut = (event: PointerEvent): void => {
+ if (event.relatedTarget !== null) return
+ release()
+ }
+
+ const onVisibility = (): void => {
+ if (document.hidden) release()
+ }
+
+ const markDirty = (): void => {
+ geometryDirty = true
+ request()
+ }
+
+ window.addEventListener('pointermove', onPointerMove, { passive: true })
+ window.addEventListener('pointerout', onPointerOut)
+ window.addEventListener('blur', release)
+ document.addEventListener('visibilitychange', onVisibility)
+ window.addEventListener('scroll', markDirty, { passive: true })
+ const observer = new ResizeObserver(markDirty)
+ observer.observe(document.documentElement)
+
+ return {
+ track: mount => {
+ const svg = mount.querySelector('svg')
+ const paths = svg?.querySelectorAll('g[clip-path] > path')
+ if (!svg || !paths || paths.length !== 2) return false
+
+ const eyes: Eye[] = []
+ for (const path of paths) {
+ const transform = svg.createSVGTransform()
+ transform.setTranslate(0, 0)
+ // Chromium clones both the appended object and appendItem's return
+ // value; only getItem yields the live handle that setTranslate updates.
+ path.transform.baseVal.appendItem(transform)
+ const attached = path.transform.baseVal.getItem(path.transform.baseVal.numberOfItems - 1)
+ // Which way is the face centre for this eye, read from its own geometry
+ // rather than assuming the engine's path order.
+ const box = path.getBBox()
+ const centre = box.x + box.width / 2
+ eyes.push({ transform: attached, inward: centre > 0 ? -1 : 1 })
+ }
+
+ targets.push({
+ mount,
+ eyes,
+ centerX: 0,
+ centerY: 0,
+ currentX: 0,
+ currentY: 0,
+ targetX: 0,
+ targetY: 0,
+ })
+ markDirty()
+ return true
+ },
+ setSettings: next => {
+ settings = sanitize(next)
+ request()
+ },
+ getSettings: () => ({ ...settings }),
+ getDeflection: () => {
+ const target = targets[0]
+ if (!target || settings.travel === 0) return 0
+ return Math.hypot(target.currentX, target.currentY) / settings.travel
+ },
+ destroy: () => {
+ if (frame !== null) cancelAnimationFrame(frame)
+ window.removeEventListener('pointermove', onPointerMove)
+ window.removeEventListener('pointerout', onPointerOut)
+ window.removeEventListener('blur', release)
+ document.removeEventListener('visibilitychange', onVisibility)
+ window.removeEventListener('scroll', markDirty)
+ observer.disconnect()
+ },
+ }
+}