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:
David Lafond 2026-08-24 22:30:53 -04:00
parent 8537b53ec9
commit 883b589a43
11 changed files with 711 additions and 39 deletions

View File

@ -24,7 +24,10 @@
<div class="roles"> <div class="roles">
<div class="ramp" data-mode="light"> <div class="ramp" data-mode="light">
<h3>Light &mdash; bg 97 &middot; tile 79 &middot; muted 40 &middot; ink 15 &middot; accent 0</h3> <h3>
Light &mdash; bg 97 &middot; tile 79 &middot; muted 40 &middot; ink 15 &middot; 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 &mdash; bg 3 &middot; tile 21 &middot; muted 60 &middot; ink 85 &middot; accent 100</h3> <h3>
Dark &mdash; bg 3 &middot; tile 21 &middot; muted 60 &middot; ink 85 &middot; 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>

View 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>

View File

@ -23,9 +23,9 @@
Same round head and the same monochrome values from Page 3 &mdash; but every one now Same round head and the same monochrome values from Page 3 &mdash; 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>

View File

@ -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' },
}, },

View File

@ -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',

View File

@ -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({

View 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 })

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

View 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) }
})

View File

@ -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',
}, },
{ {

View File

@ -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
&mdash; no chain of spheres, no seam. Every ghost is generated twice from identical &mdash; 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 &mdash; bg 97 &middot; tile 79 &middot; muted 40 &middot; ink 15 &middot; accent 0</h3> <h3>
Light &mdash; bg 97 &middot; tile 79 &middot; muted 40 &middot; ink 15 &middot; 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 &mdash; bg 3 &middot; tile 21 &middot; muted 60 &middot; ink 85 &middot; accent 100</h3> <h3>
Dark &mdash; bg 3 &middot; tile 21 &middot; muted 60 &middot; ink 85 &middot; 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>