Add Davebot gaze lab and make his eyes lock on to the cursor

Gaze magnitude now comes from the angle to the pointer rather than a
linear distance ramp:

  gain = distance / hypot(distance, depth)

That is the projection of an eyeball rotating to face a target `depth`
away, so it saturates. Once the pointer passes the knee the eyes hold
near-full deflection and keep pointing straight at it, which is what
reads as being looked at. Measured angle error against the true cursor
direction is 0 degrees.

Default travel rises from 9 to 30 SVG units. The eye group is clipped to
the head silhouette and the outer eye edge sits 48 units from it, so 30
stays clear while the slider can explore up to the clip. At the shipping
46px render this moves the pupils 4.5 screen px instead of 1.4.

Extract the tracker into davebotGaze.ts and add davebot-gaze.html, a
tuning lab with live controls for travel, depth, smoothing, convergence
and lean. Settings persist, so the header reads whatever the lab last
saved. Sliders are generated from the engine's own limits and read back
its clamped values, so the panel cannot display a number the tracker is
not using.
This commit is contained in:
David Lafond 2026-08-24 22:41:40 -04:00
parent 883b589a43
commit b5246b1f7f
5 changed files with 976 additions and 109 deletions

View File

@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f6f6f6" />
<title>Davebot gaze lab - tune how hard he looks at the cursor</title>
</head>
<body>
<div class="shell">
<header>
<h1>Davebot gaze lab</h1>
<p>
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.
</p>
</header>
<div class="layout">
<main>
<section class="stage" id="stage" aria-label="Davebot at review size"></section>
<section class="scales" id="scales" aria-label="Davebot at shipping size"></section>
</main>
<aside class="panel">
<h2>Feel</h2>
<div class="presets" id="presets"></div>
<div id="controls"></div>
<div class="meter">
<div class="meter-head">
<span>Deflection</span>
<span id="deflection-value">0%</span>
</div>
<div class="bar" id="deflection-bar"></div>
</div>
<div class="actions">
<button type="button" id="copy">Copy JSON</button>
<button type="button" id="reset">Reset</button>
</div>
<pre><code id="settings-json"></code></pre>
</aside>
</div>
<footer>Design System 01 &middot; gaze tuning &middot; values persist locally</footer>
</div>
<script type="module" src="/src/davebot-gaze-main.ts"></script>
</body>
</html>

View File

@ -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<HTMLElement>('#stage')
const scales = document.querySelector<HTMLElement>('#scales')
const controls = document.querySelector<HTMLElement>('#controls')
const presetRow = document.querySelector<HTMLElement>('#presets')
const deflectionBar = document.querySelector<HTMLElement>('#deflection-bar')
const deflectionValue = document.querySelector<HTMLElement>('#deflection-value')
const output = document.querySelector<HTMLElement>('#settings-json')
const copyButton = document.querySelector<HTMLButtonElement>('#copy')
const resetButton = document.querySelector<HTMLButtonElement>('#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<HTMLButtonElement>('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()

View File

@ -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;
}
}

View File

@ -1,21 +1,14 @@
import { createAvatar } from '@bible-strong/avatar-web' import { createAvatar } from '@bible-strong/avatar-web'
import { davebotHeaderVariants } from './davebot-header' import { davebotHeaderVariants } from './davebot-header'
import { createGazeTracker, loadGazeSettings } from './davebotGaze'
import './davebot-header.css' import './davebot-header.css'
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const maxGazeOffset = reduceMotion ? 6 : 9
const fullGazeDistance = 240
type GazeTarget = { // Values tuned in davebot-gaze.html are persisted, so the header reflects
mount: HTMLElement // whatever feel was last chosen in the lab.
left: SVGTransform const gaze = createGazeTracker(loadGazeSettings())
right: SVGTransform
centerX: number
centerY: number
}
const gazeTargets: GazeTarget[] = []
for (const variant of davebotHeaderVariants) { for (const variant of davebotHeaderVariants) {
const panel = document.querySelector<HTMLElement>(`.portfolio[data-mode="${variant.mode}"]`) const panel = document.querySelector<HTMLElement>(`.portfolio[data-mode="${variant.mode}"]`)
@ -42,104 +35,7 @@ for (const variant of davebotHeaderVariants) {
davebot.addEventListener('blur', () => controller.play('idle')) davebot.addEventListener('blur', () => controller.play('idle'))
davebot.addEventListener('click', () => controller.play('hello')) davebot.addEventListener('click', () => controller.play('hello'))
const svg = mount.querySelector<SVGSVGElement>('.bs-avatar svg') if (!gaze.track(mount)) {
const eyePaths = svg?.querySelectorAll<SVGPathElement>('g[clip-path] > path')
if (!svg || !eyePaths || eyePaths.length !== 2) {
throw new Error(`Davebot eye paths for ${variant.mode} mode were not found.`) 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 })

View File

@ -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<keyof GazeSettings, { min: number; max: number; step: number }> = {
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<string, GazeSettings> = {
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<GazeSettings> | 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<GazeSettings>) : { ...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<SVGSVGElement>('svg')
const paths = svg?.querySelectorAll<SVGPathElement>('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()
},
}
}