Add Davebot cursor-following portfolio header
Rename the portfolio mascot from Bigeye to Davebot and give him the canonical inverse inks: light mode uses a #262626 body with #d4d4d4 eyes, dark mode mirrors that exactly. Eyes follow the pointer by translating the two existing eye paths with one persistent SVG transform each, batched into a single animation frame. Playback, blinking and body drift stay owned by avatar-web, so gaze layers on top without resetting the timeline or regenerating paths. Also fix Prettier violations in earlier portfolio-gallery files.
This commit is contained in:
parent
8537b53ec9
commit
883b589a43
@ -24,7 +24,10 @@
|
|||||||
|
|
||||||
<div class="roles">
|
<div class="roles">
|
||||||
<div class="ramp" data-mode="light">
|
<div class="ramp" data-mode="light">
|
||||||
<h3>Light — bg 97 · tile 79 · muted 40 · ink 15 · accent 0</h3>
|
<h3>
|
||||||
|
Light — bg 97 · tile 79 · muted 40 · ink 15 · accent
|
||||||
|
0
|
||||||
|
</h3>
|
||||||
<div class="swatches">
|
<div class="swatches">
|
||||||
<div class="swatch" style="background: var(--light-bg)"></div>
|
<div class="swatch" style="background: var(--light-bg)"></div>
|
||||||
<div class="swatch" style="background: var(--light-tile)"></div>
|
<div class="swatch" style="background: var(--light-tile)"></div>
|
||||||
@ -34,7 +37,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ramp" data-mode="dark">
|
<div class="ramp" data-mode="dark">
|
||||||
<h3>Dark — bg 3 · tile 21 · muted 60 · ink 85 · accent 100</h3>
|
<h3>
|
||||||
|
Dark — bg 3 · tile 21 · muted 60 · ink 85 · accent
|
||||||
|
100
|
||||||
|
</h3>
|
||||||
<div class="swatches">
|
<div class="swatches">
|
||||||
<div class="swatch" style="background: var(--dark-bg)"></div>
|
<div class="swatch" style="background: var(--dark-bg)"></div>
|
||||||
<div class="swatch" style="background: var(--dark-tile)"></div>
|
<div class="swatch" style="background: var(--dark-tile)"></div>
|
||||||
|
|||||||
127
examples/portfolio-gallery/davebot-header.html
Normal file
127
examples/portfolio-gallery/davebot-header.html
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<!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="#242424" />
|
||||||
|
<title>Davebot — pointer-following portfolio guide</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="comparison" aria-label="Davebot portfolio header in light and dark mode">
|
||||||
|
<section class="portfolio" data-mode="light" aria-label="Light mode portfolio header">
|
||||||
|
<nav class="site-nav" aria-label="Light mode primary navigation">
|
||||||
|
<div class="brand">
|
||||||
|
<span>David Lafond</span>
|
||||||
|
<span class="brand-role">Product designer · Architect</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-links" aria-label="Portfolio sections">
|
||||||
|
<a href="#light-work">Work</a>
|
||||||
|
<span>Community</span>
|
||||||
|
<span>Play</span>
|
||||||
|
<span>About</span>
|
||||||
|
<span>Resume</span>
|
||||||
|
</div>
|
||||||
|
<button class="davebot" type="button" data-davebot>Davebot</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="davebot-avatar" data-davebot-avatar></div>
|
||||||
|
<span class="status-dot" aria-hidden="true"></span>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1>Hi, I’m David ~<br />a product designer who leads creative teams.</h1>
|
||||||
|
<dl class="timeline">
|
||||||
|
<div>
|
||||||
|
<dt>2026</dt>
|
||||||
|
<dd>Tableau Urban Innovation</dd>
|
||||||
|
<dd>Senior Designer</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>2025</dt>
|
||||||
|
<dd>Fluid Interfaces</dd>
|
||||||
|
<dd>Lead Product Designer</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>2025</dt>
|
||||||
|
<dd>Plumba Parking</dd>
|
||||||
|
<dd>Product Manager</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>2024</dt>
|
||||||
|
<dd>Serendipity City</dd>
|
||||||
|
<dd>Lead Product Designer</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>2023</dt>
|
||||||
|
<dd>Computational Design Exploratory</dd>
|
||||||
|
<dd>Lead Product Designer</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="projects" id="light-work" aria-label="Selected work placeholders">
|
||||||
|
<div class="project"></div>
|
||||||
|
<div class="project"></div>
|
||||||
|
</div>
|
||||||
|
<p class="mode-caption">Light mode</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="portfolio" data-mode="dark" aria-label="Dark mode portfolio header">
|
||||||
|
<nav class="site-nav" aria-label="Dark mode primary navigation">
|
||||||
|
<div class="brand">
|
||||||
|
<span>David Lafond</span>
|
||||||
|
<span class="brand-role">Product designer · Architect</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-links" aria-label="Portfolio sections">
|
||||||
|
<a href="#dark-work">Work</a>
|
||||||
|
<span>Community</span>
|
||||||
|
<span>Play</span>
|
||||||
|
<span>About</span>
|
||||||
|
<span>Resume</span>
|
||||||
|
</div>
|
||||||
|
<button class="davebot" type="button" data-davebot>Davebot</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="davebot-avatar" data-davebot-avatar></div>
|
||||||
|
<span class="status-dot" aria-hidden="true"></span>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1>Hi, I’m David ~<br />a product designer who leads creative teams.</h1>
|
||||||
|
<dl class="timeline">
|
||||||
|
<div>
|
||||||
|
<dt>2026</dt>
|
||||||
|
<dd>Tableau Urban Innovation</dd>
|
||||||
|
<dd>Senior Designer</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>2025</dt>
|
||||||
|
<dd>Fluid Interfaces</dd>
|
||||||
|
<dd>Lead Product Designer</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>2025</dt>
|
||||||
|
<dd>Plumba Parking</dd>
|
||||||
|
<dd>Product Manager</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>2024</dt>
|
||||||
|
<dd>Serendipity City</dd>
|
||||||
|
<dd>Lead Product Designer</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>2023</dt>
|
||||||
|
<dd>Computational Design Exploratory</dd>
|
||||||
|
<dd>Lead Product Designer</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="projects" id="dark-work" aria-label="Selected work placeholders">
|
||||||
|
<div class="project"></div>
|
||||||
|
<div class="project"></div>
|
||||||
|
</div>
|
||||||
|
<p class="mode-caption">Dark mode</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="/src/davebot-header-main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -23,9 +23,9 @@
|
|||||||
Same round head and the same monochrome values from Page 3 — but every one now
|
Same round head and the same monochrome values from Page 3 — but every one now
|
||||||
trails a tail. Tails are built in the engine itself, as a chain of tapering lobes swept
|
trails a tail. Tails are built in the engine itself, as a chain of tapering lobes swept
|
||||||
along a curve, so each one merges into a single continuous silhouette rather than being
|
along a curve, so each one merges into a single continuous silhouette rather than being
|
||||||
drawn on afterwards. The variable across these ten cards is the tail: its length, its
|
drawn on afterwards. The variable across these ten cards is the tail: its length, its bow,
|
||||||
bow, which way it sweeps, and how fast it thins. Two wear a straw hat, after the sketch.
|
which way it sweeps, and how fast it thins. Two wear a straw hat, after the sketch. Hover
|
||||||
Hover any ghost to see its own emote.
|
any ghost to see its own emote.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@ -64,14 +64,7 @@ export type CharacterConfig = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type EmoteKey =
|
export type EmoteKey =
|
||||||
| 'idle'
|
'idle' | 'hello' | 'curious' | 'look-around' | 'excited' | 'surprise' | 'sleepy' | 'shy'
|
||||||
| 'hello'
|
|
||||||
| 'curious'
|
|
||||||
| 'look-around'
|
|
||||||
| 'excited'
|
|
||||||
| 'surprise'
|
|
||||||
| 'sleepy'
|
|
||||||
| 'shy'
|
|
||||||
|
|
||||||
// Ordered emote repertoire every character can perform. `idle` is the resting
|
// Ordered emote repertoire every character can perform. `idle` is the resting
|
||||||
// loop; the rest are triggerable one-shot or looping emotes.
|
// loop; the rest are triggerable one-shot or looping emotes.
|
||||||
@ -345,7 +338,14 @@ export const buildDefinition = (c: CharacterConfig): AvatarDefinition => {
|
|||||||
motion: MotionKind,
|
motion: MotionKind,
|
||||||
perspective = 1
|
perspective = 1
|
||||||
) =>
|
) =>
|
||||||
expression(head, eye(ew, eh, ex, ey, angle), eye(ew, eh, ex, ey, angle), sp, motion, perspective)
|
expression(
|
||||||
|
head,
|
||||||
|
eye(ew, eh, ex, ey, angle),
|
||||||
|
eye(ew, eh, ex, ey, angle),
|
||||||
|
sp,
|
||||||
|
motion,
|
||||||
|
perspective
|
||||||
|
)
|
||||||
|
|
||||||
const expressions = {
|
const expressions = {
|
||||||
// resting + gaze
|
// resting + gaze
|
||||||
@ -402,8 +402,20 @@ export const buildDefinition = (c: CharacterConfig): AvatarDefinition => {
|
|||||||
maxIntervalMs: 5400,
|
maxIntervalMs: 5400,
|
||||||
durationMs: 140,
|
durationMs: 140,
|
||||||
}
|
}
|
||||||
const fastBlink = { ...blink, initialDelayMs: 400, minIntervalMs: 900, maxIntervalMs: 1900, durationMs: 110 }
|
const fastBlink = {
|
||||||
const slowBlink = { ...blink, initialDelayMs: 300, minIntervalMs: 1400, maxIntervalMs: 2800, durationMs: 300 }
|
...blink,
|
||||||
|
initialDelayMs: 400,
|
||||||
|
minIntervalMs: 900,
|
||||||
|
maxIntervalMs: 1900,
|
||||||
|
durationMs: 110,
|
||||||
|
}
|
||||||
|
const slowBlink = {
|
||||||
|
...blink,
|
||||||
|
initialDelayMs: 300,
|
||||||
|
minIntervalMs: 1400,
|
||||||
|
maxIntervalMs: 2800,
|
||||||
|
durationMs: 300,
|
||||||
|
}
|
||||||
|
|
||||||
type Trans = 'spring' | 'smooth' | 'snappy'
|
type Trans = 'spring' | 'smooth' | 'snappy'
|
||||||
const step = (expression: string, holdMs: number, transitionMs: number, transition: Trans) => ({
|
const step = (expression: string, holdMs: number, transitionMs: number, transition: Trans) => ({
|
||||||
@ -474,10 +486,7 @@ export const buildDefinition = (c: CharacterConfig): AvatarDefinition => {
|
|||||||
},
|
},
|
||||||
surprise: {
|
surprise: {
|
||||||
playbackMode: 'once' as const,
|
playbackMode: 'once' as const,
|
||||||
steps: [
|
steps: [step('wide', 820, 150, 'snappy'), step('neutral', 1200, 460, 'spring')],
|
||||||
step('wide', 820, 150, 'snappy'),
|
|
||||||
step('neutral', 1200, 460, 'spring'),
|
|
||||||
],
|
|
||||||
blink,
|
blink,
|
||||||
metadata: { label: `${c.name} gasps`, group: 'ensemble' },
|
metadata: { label: `${c.name} gasps`, group: 'ensemble' },
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,11 +1,4 @@
|
|||||||
import {
|
import { antenna, buildEnsemble, ears, sf, type Character, type CharacterConfig } from './builder'
|
||||||
antenna,
|
|
||||||
buildEnsemble,
|
|
||||||
ears,
|
|
||||||
sf,
|
|
||||||
type Character,
|
|
||||||
type CharacterConfig,
|
|
||||||
} from './builder'
|
|
||||||
|
|
||||||
// The original twenty characters for Rachel Chen's portfolio: warm coral
|
// The original twenty characters for Rachel Chen's portfolio: warm coral
|
||||||
// (#e65f2e) accents on a cool near-white field, slate ink, and soft cool tints.
|
// (#e65f2e) accents on a cool near-white field, slate ink, and soft cool tints.
|
||||||
@ -13,7 +6,6 @@ import {
|
|||||||
const ink = '#2a2f3a'
|
const ink = '#2a2f3a'
|
||||||
const cream = '#f4f1ea'
|
const cream = '#f4f1ea'
|
||||||
|
|
||||||
|
|
||||||
const configs: CharacterConfig[] = [
|
const configs: CharacterConfig[] = [
|
||||||
{
|
{
|
||||||
id: 'pip',
|
id: 'pip',
|
||||||
|
|||||||
@ -39,6 +39,12 @@ export const MODES = {
|
|||||||
|
|
||||||
export type ModeName = keyof typeof MODES
|
export type ModeName = keyof typeof MODES
|
||||||
export type BodyRole = 'accent' | 'ink' | 'inkMuted'
|
export type BodyRole = 'accent' | 'ink' | 'inkMuted'
|
||||||
|
type EyeColorRole = 'background' | 'inverseInk'
|
||||||
|
|
||||||
|
const INVERSE_INK: Record<ModeName, string> = {
|
||||||
|
light: MODES.dark.ink,
|
||||||
|
dark: MODES.light.ink,
|
||||||
|
}
|
||||||
|
|
||||||
const ROLE_LABEL: Record<BodyRole, string> = {
|
const ROLE_LABEL: Record<BodyRole, string> = {
|
||||||
accent: 'accent',
|
accent: 'accent',
|
||||||
@ -51,6 +57,7 @@ type Proportions = {
|
|||||||
name: string
|
name: string
|
||||||
note: string
|
note: string
|
||||||
role: BodyRole
|
role: BodyRole
|
||||||
|
eyeColor?: EyeColorRole
|
||||||
headW: number
|
headW: number
|
||||||
headH: number
|
headH: number
|
||||||
eyeW: number
|
eyeW: number
|
||||||
@ -343,10 +350,11 @@ const crew: Proportions[] = [
|
|||||||
tailDepth: -95,
|
tailDepth: -95,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bigeye',
|
id: 'davebot',
|
||||||
name: 'Bigeye',
|
name: 'Davebot',
|
||||||
note: 'Largest eyes, filling much of the face',
|
note: 'Davebot — largest eyes, filling much of the face',
|
||||||
role: 'accent',
|
role: 'ink',
|
||||||
|
eyeColor: 'inverseInk',
|
||||||
headW: 184,
|
headW: 184,
|
||||||
headH: 190,
|
headH: 190,
|
||||||
eyeW: 36,
|
eyeW: 36,
|
||||||
@ -436,8 +444,9 @@ export const crewMembers: CrewMember[] = crew.map(p => {
|
|||||||
role: p.note,
|
role: p.note,
|
||||||
surface: sf('sphere', p.headW, p.headH, p.headW, 1),
|
surface: sf('sphere', p.headW, p.headH, p.headW, 1),
|
||||||
body,
|
body,
|
||||||
// Eyes take the mode's background, so they read as cut-outs in both modes.
|
// Davebot uses the opposite mode's ink for his eyes; the other crew
|
||||||
eyes: palette.bg,
|
// members keep background-colour cut-outs.
|
||||||
|
eyes: p.eyeColor === 'inverseInk' ? INVERSE_INK[mode] : palette.bg,
|
||||||
eye: { w: p.eyeW, h: p.eyeH, y: p.eyeY, spacing: p.eyeSpacing },
|
eye: { w: p.eyeW, h: p.eyeH, y: p.eyeY, spacing: p.eyeSpacing },
|
||||||
nodes: [
|
nodes: [
|
||||||
tail({
|
tail({
|
||||||
|
|||||||
145
examples/portfolio-gallery/src/davebot-header-main.ts
Normal file
145
examples/portfolio-gallery/src/davebot-header-main.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { createAvatar } from '@bible-strong/avatar-web'
|
||||||
|
|
||||||
|
import { davebotHeaderVariants } from './davebot-header'
|
||||||
|
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[] = []
|
||||||
|
|
||||||
|
for (const variant of davebotHeaderVariants) {
|
||||||
|
const panel = document.querySelector<HTMLElement>(`.portfolio[data-mode="${variant.mode}"]`)
|
||||||
|
const mount = panel?.querySelector<HTMLElement>('[data-davebot-avatar]')
|
||||||
|
if (!panel || !mount) throw new Error(`Davebot mount for ${variant.mode} mode was not found.`)
|
||||||
|
|
||||||
|
const controller = createAvatar(mount, {
|
||||||
|
definition: variant.definition,
|
||||||
|
defaultAnimation: reduceMotion ? undefined : 'idle',
|
||||||
|
autoplay: !reduceMotion,
|
||||||
|
size: '100%',
|
||||||
|
ariaLabel: `Davebot, the portfolio guide in ${variant.mode} mode`,
|
||||||
|
onError: error => console.error(`[davebot-header/${variant.mode}]`, error),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (reduceMotion) controller.setExpression('neutral')
|
||||||
|
|
||||||
|
const davebot = panel.querySelector<HTMLElement>('[data-davebot]')
|
||||||
|
if (!davebot) throw new Error(`Davebot control for ${variant.mode} mode was not found.`)
|
||||||
|
|
||||||
|
davebot.addEventListener('pointerenter', () => controller.play('curious'))
|
||||||
|
davebot.addEventListener('pointerleave', () => controller.play('idle'))
|
||||||
|
davebot.addEventListener('focus', () => controller.play('curious'))
|
||||||
|
davebot.addEventListener('blur', () => controller.play('idle'))
|
||||||
|
davebot.addEventListener('click', () => controller.play('hello'))
|
||||||
|
|
||||||
|
const svg = mount.querySelector<SVGSVGElement>('.bs-avatar svg')
|
||||||
|
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.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 })
|
||||||
300
examples/portfolio-gallery/src/davebot-header.css
Normal file
300
examples/portfolio-gallery/src/davebot-header.css
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
@import url('https://fonts.bunny.net/css?family=geist:400,500,600|geist-mono:400,500');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: 'Geist', system-ui, sans-serif;
|
||||||
|
color: #d4d4d4;
|
||||||
|
background: #242424;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
background: #242424;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison {
|
||||||
|
min-height: 100svh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: clamp(14px, 1.5vw, 24px);
|
||||||
|
padding: clamp(14px, 2vw, 30px);
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio[data-mode='light'] {
|
||||||
|
--page: #f6f6f6;
|
||||||
|
--ink: #262626;
|
||||||
|
--muted: #5e5e5e;
|
||||||
|
--project: #c4c4c4;
|
||||||
|
--border: #0000001f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio[data-mode='dark'] {
|
||||||
|
--page: #0b0b0b;
|
||||||
|
--ink: #d4d4d4;
|
||||||
|
--muted: #909090;
|
||||||
|
--project: #323232;
|
||||||
|
--border: #ffffff1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: clamp(420px, 49vw, 610px);
|
||||||
|
padding: clamp(16px, 2vw, 28px);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--page);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
min-height: 26px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: clamp(16px, 2vw, 32px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-family: 'Geist Mono', ui-monospace, monospace;
|
||||||
|
font-size: clamp(7px, 0.52vw, 9px);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.nav-links,
|
||||||
|
.davebot {
|
||||||
|
text-box: trim-both cap alphabetic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-role {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: clamp(10px, 1.2vw, 22px);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a,
|
||||||
|
.nav-links span {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:first-child {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.davebot {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.davebot::before {
|
||||||
|
content: '+';
|
||||||
|
margin-inline-end: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.davebot:hover,
|
||||||
|
.davebot:focus-visible {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.davebot:focus-visible {
|
||||||
|
outline: 1px solid var(--ink);
|
||||||
|
outline-offset: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.davebot-avatar {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
/* Same right edge as DAVEbot; the character is its visual endpoint. */
|
||||||
|
inset-inline-end: clamp(17px, 2vw, 29px);
|
||||||
|
top: clamp(47px, 4.5vw, 62px);
|
||||||
|
width: clamp(34px, 3.2vw, 46px);
|
||||||
|
height: clamp(34px, 3.2vw, 46px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.davebot-avatar .bs-avatar,
|
||||||
|
.davebot-avatar svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: clamp(52px, 4.9vw, 68px);
|
||||||
|
inset-inline-start: 70%;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.15fr) minmax(210px, 0.85fr);
|
||||||
|
align-items: end;
|
||||||
|
gap: clamp(22px, 4vw, 64px);
|
||||||
|
margin-top: clamp(108px, 12vw, 164px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
max-width: 13ch;
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(27px, 2.8vw, 43px);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 0.93;
|
||||||
|
letter-spacing: -0.055em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1.5fr) minmax(0, 1fr);
|
||||||
|
gap: 6px 16px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(7px, 0.56vw, 10px);
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline div {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline dt,
|
||||||
|
.timeline dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline dd:last-child {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: clamp(10px, 1vw, 16px);
|
||||||
|
margin-top: clamp(18px, 2vw, 30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project {
|
||||||
|
height: clamp(154px, 17vw, 224px);
|
||||||
|
background: var(--project);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-caption {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: clamp(16px, 2vw, 28px);
|
||||||
|
bottom: 10px;
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Geist Mono', ui-monospace, monospace;
|
||||||
|
font-size: 8px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.comparison {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio {
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav,
|
||||||
|
.timeline {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
margin-top: 132px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.comparison {
|
||||||
|
padding: 0;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio {
|
||||||
|
min-height: 600px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-role {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.davebot-avatar {
|
||||||
|
inset-inline-end: 18px;
|
||||||
|
top: 52px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: start;
|
||||||
|
margin-top: 142px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects {
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
examples/portfolio-gallery/src/davebot-header.ts
Normal file
72
examples/portfolio-gallery/src/davebot-header.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import type { AvatarDefinition } from '@bible-strong/avatar-web'
|
||||||
|
|
||||||
|
import { arms, buildDefinition, sf, surfaceRadius, tail, type CharacterConfig } from './builder'
|
||||||
|
|
||||||
|
export const HEADER_MODES = {
|
||||||
|
light: {
|
||||||
|
page: '#f6f6f6',
|
||||||
|
ink: '#262626',
|
||||||
|
muted: '#5e5e5e',
|
||||||
|
project: '#c4c4c4',
|
||||||
|
border: '#0000001f',
|
||||||
|
body: '#262626',
|
||||||
|
eyes: '#d4d4d4',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
page: '#0b0b0b',
|
||||||
|
ink: '#d4d4d4',
|
||||||
|
muted: '#909090',
|
||||||
|
project: '#323232',
|
||||||
|
border: '#ffffff1f',
|
||||||
|
body: '#d4d4d4',
|
||||||
|
eyes: '#262626',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type HeaderMode = keyof typeof HEADER_MODES
|
||||||
|
|
||||||
|
const HEAD_WIDTH = 184
|
||||||
|
const HEAD_HEIGHT = 190
|
||||||
|
const ARM_ANGLE = 28
|
||||||
|
const TAIL_ANGLE = 90
|
||||||
|
|
||||||
|
export type DavebotHeaderVariant = {
|
||||||
|
mode: HeaderMode
|
||||||
|
definition: AvatarDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
export const davebotHeaderVariants: DavebotHeaderVariant[] = (
|
||||||
|
Object.keys(HEADER_MODES) as HeaderMode[]
|
||||||
|
).map((mode): DavebotHeaderVariant => {
|
||||||
|
const palette = HEADER_MODES[mode]
|
||||||
|
const config: CharacterConfig = {
|
||||||
|
id: `davebot-header-${mode}`,
|
||||||
|
name: 'Davebot',
|
||||||
|
role: 'Davebot portfolio guide',
|
||||||
|
surface: sf('sphere', HEAD_WIDTH, HEAD_HEIGHT, HEAD_WIDTH, 1),
|
||||||
|
body: palette.body,
|
||||||
|
eyes: palette.eyes,
|
||||||
|
eye: { w: 36, h: 40, y: -16, spacing: 48 },
|
||||||
|
nodes: [
|
||||||
|
tail({
|
||||||
|
angleDeg: TAIL_ANGLE,
|
||||||
|
protrude: 44,
|
||||||
|
width: 120,
|
||||||
|
morph: 1.4,
|
||||||
|
tip: 0.5,
|
||||||
|
depth: -100,
|
||||||
|
bodyRadius: surfaceRadius(HEAD_WIDTH, HEAD_HEIGHT, TAIL_ANGLE),
|
||||||
|
}),
|
||||||
|
...arms({
|
||||||
|
angleDeg: ARM_ANGLE,
|
||||||
|
protrude: 28,
|
||||||
|
width: 44,
|
||||||
|
depth: -36,
|
||||||
|
bodyRadius: surfaceRadius(HEAD_WIDTH, HEAD_HEIGHT, ARM_ANGLE),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
signature: 'curious',
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode, definition: buildDefinition(config) }
|
||||||
|
})
|
||||||
@ -56,7 +56,12 @@ const configs: CharacterConfig[] = [
|
|||||||
body: '#e65f2e',
|
body: '#e65f2e',
|
||||||
eyes: ink,
|
eyes: ink,
|
||||||
eye: { w: 24, h: 44, y: -6, spacing: 40 },
|
eye: { w: 24, h: 44, y: -6, spacing: 40 },
|
||||||
nodes: [lobe(96, -92, -58, -8), lobe(90, 96, -50, -8), lobe(92, -88, 64, -8), lobe(86, 92, 72, -8)],
|
nodes: [
|
||||||
|
lobe(96, -92, -58, -8),
|
||||||
|
lobe(90, 96, -50, -8),
|
||||||
|
lobe(92, -88, 64, -8),
|
||||||
|
lobe(86, 92, 72, -8),
|
||||||
|
],
|
||||||
signature: 'hello',
|
signature: 'hello',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -14,12 +14,16 @@
|
|||||||
Ten tail iterations. Each tail is a single cone node whose wide, rounded base is buried
|
Ten tail iterations. Each tail is a single cone node whose wide, rounded base is buried
|
||||||
inside the head, so body and tail share one fill and read as one continuous silhouette
|
inside the head, so body and tail share one fill and read as one continuous silhouette
|
||||||
— no chain of spheres, no seam. Every ghost is generated twice from identical
|
— no chain of spheres, no seam. Every ghost is generated twice from identical
|
||||||
geometry: light is the tonal inverse of dark, so the contrast relationships mirror exactly.
|
geometry: light is the tonal inverse of dark, so the contrast relationships mirror
|
||||||
|
exactly.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="roles">
|
<div class="roles">
|
||||||
<div class="ramp" data-mode="light">
|
<div class="ramp" data-mode="light">
|
||||||
<h3>Light — bg 97 · tile 79 · muted 40 · ink 15 · accent 0</h3>
|
<h3>
|
||||||
|
Light — bg 97 · tile 79 · muted 40 · ink 15 · accent
|
||||||
|
0
|
||||||
|
</h3>
|
||||||
<div class="swatches">
|
<div class="swatches">
|
||||||
<div class="swatch" style="background: var(--light-bg)"></div>
|
<div class="swatch" style="background: var(--light-bg)"></div>
|
||||||
<div class="swatch" style="background: var(--light-tile)"></div>
|
<div class="swatch" style="background: var(--light-tile)"></div>
|
||||||
@ -29,7 +33,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ramp" data-mode="dark">
|
<div class="ramp" data-mode="dark">
|
||||||
<h3>Dark — bg 3 · tile 21 · muted 60 · ink 85 · accent 100</h3>
|
<h3>
|
||||||
|
Dark — bg 3 · tile 21 · muted 60 · ink 85 · accent
|
||||||
|
100
|
||||||
|
</h3>
|
||||||
<div class="swatches">
|
<div class="swatches">
|
||||||
<div class="swatch" style="background: var(--dark-bg)"></div>
|
<div class="swatch" style="background: var(--dark-bg)"></div>
|
||||||
<div class="swatch" style="background: var(--dark-tile)"></div>
|
<div class="swatch" style="background: var(--dark-tile)"></div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user