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="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="swatch" style="background: var(--light-bg)"></div>
|
||||
<div class="swatch" style="background: var(--light-tile)"></div>
|
||||
@ -34,7 +37,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<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="swatch" style="background: var(--dark-bg)"></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
|
||||
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
|
||||
drawn on afterwards. The variable across these ten cards is the tail: its length, its
|
||||
bow, which way it sweeps, and how fast it thins. Two wear a straw hat, after the sketch.
|
||||
Hover any ghost to see its own emote.
|
||||
drawn on afterwards. The variable across these ten cards is the tail: its length, its bow,
|
||||
which way it sweeps, and how fast it thins. Two wear a straw hat, after the sketch. Hover
|
||||
any ghost to see its own emote.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
@ -64,14 +64,7 @@ export type CharacterConfig = {
|
||||
}
|
||||
|
||||
export type EmoteKey =
|
||||
| 'idle'
|
||||
| 'hello'
|
||||
| 'curious'
|
||||
| 'look-around'
|
||||
| 'excited'
|
||||
| 'surprise'
|
||||
| 'sleepy'
|
||||
| 'shy'
|
||||
'idle' | 'hello' | 'curious' | 'look-around' | 'excited' | 'surprise' | 'sleepy' | 'shy'
|
||||
|
||||
// Ordered emote repertoire every character can perform. `idle` is the resting
|
||||
// loop; the rest are triggerable one-shot or looping emotes.
|
||||
@ -345,7 +338,14 @@ export const buildDefinition = (c: CharacterConfig): AvatarDefinition => {
|
||||
motion: MotionKind,
|
||||
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 = {
|
||||
// resting + gaze
|
||||
@ -402,8 +402,20 @@ export const buildDefinition = (c: CharacterConfig): AvatarDefinition => {
|
||||
maxIntervalMs: 5400,
|
||||
durationMs: 140,
|
||||
}
|
||||
const fastBlink = { ...blink, initialDelayMs: 400, minIntervalMs: 900, maxIntervalMs: 1900, durationMs: 110 }
|
||||
const slowBlink = { ...blink, initialDelayMs: 300, minIntervalMs: 1400, maxIntervalMs: 2800, durationMs: 300 }
|
||||
const fastBlink = {
|
||||
...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'
|
||||
const step = (expression: string, holdMs: number, transitionMs: number, transition: Trans) => ({
|
||||
@ -474,10 +486,7 @@ export const buildDefinition = (c: CharacterConfig): AvatarDefinition => {
|
||||
},
|
||||
surprise: {
|
||||
playbackMode: 'once' as const,
|
||||
steps: [
|
||||
step('wide', 820, 150, 'snappy'),
|
||||
step('neutral', 1200, 460, 'spring'),
|
||||
],
|
||||
steps: [step('wide', 820, 150, 'snappy'), step('neutral', 1200, 460, 'spring')],
|
||||
blink,
|
||||
metadata: { label: `${c.name} gasps`, group: 'ensemble' },
|
||||
},
|
||||
|
||||
@ -1,11 +1,4 @@
|
||||
import {
|
||||
antenna,
|
||||
buildEnsemble,
|
||||
ears,
|
||||
sf,
|
||||
type Character,
|
||||
type CharacterConfig,
|
||||
} from './builder'
|
||||
import { antenna, buildEnsemble, ears, sf, type Character, type CharacterConfig } from './builder'
|
||||
|
||||
// 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.
|
||||
@ -13,7 +6,6 @@ import {
|
||||
const ink = '#2a2f3a'
|
||||
const cream = '#f4f1ea'
|
||||
|
||||
|
||||
const configs: CharacterConfig[] = [
|
||||
{
|
||||
id: 'pip',
|
||||
|
||||
@ -39,6 +39,12 @@ export const MODES = {
|
||||
|
||||
export type ModeName = keyof typeof MODES
|
||||
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> = {
|
||||
accent: 'accent',
|
||||
@ -51,6 +57,7 @@ type Proportions = {
|
||||
name: string
|
||||
note: string
|
||||
role: BodyRole
|
||||
eyeColor?: EyeColorRole
|
||||
headW: number
|
||||
headH: number
|
||||
eyeW: number
|
||||
@ -343,10 +350,11 @@ const crew: Proportions[] = [
|
||||
tailDepth: -95,
|
||||
},
|
||||
{
|
||||
id: 'bigeye',
|
||||
name: 'Bigeye',
|
||||
note: 'Largest eyes, filling much of the face',
|
||||
role: 'accent',
|
||||
id: 'davebot',
|
||||
name: 'Davebot',
|
||||
note: 'Davebot — largest eyes, filling much of the face',
|
||||
role: 'ink',
|
||||
eyeColor: 'inverseInk',
|
||||
headW: 184,
|
||||
headH: 190,
|
||||
eyeW: 36,
|
||||
@ -436,8 +444,9 @@ export const crewMembers: CrewMember[] = crew.map(p => {
|
||||
role: p.note,
|
||||
surface: sf('sphere', p.headW, p.headH, p.headW, 1),
|
||||
body,
|
||||
// Eyes take the mode's background, so they read as cut-outs in both modes.
|
||||
eyes: palette.bg,
|
||||
// Davebot uses the opposite mode's ink for his eyes; the other crew
|
||||
// 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 },
|
||||
nodes: [
|
||||
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',
|
||||
eyes: ink,
|
||||
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',
|
||||
},
|
||||
{
|
||||
|
||||
@ -14,12 +14,16 @@
|
||||
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
|
||||
— 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>
|
||||
|
||||
<div class="roles">
|
||||
<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="swatch" style="background: var(--light-bg)"></div>
|
||||
<div class="swatch" style="background: var(--light-tile)"></div>
|
||||
@ -29,7 +33,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<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="swatch" style="background: var(--dark-bg)"></div>
|
||||
<div class="swatch" style="background: var(--dark-tile)"></div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user