diff --git a/examples/portfolio-gallery/davebot-header.html b/examples/portfolio-gallery/davebot-header.html
new file mode 100644
index 0000000..f44ecf7
--- /dev/null
+++ b/examples/portfolio-gallery/davebot-header.html
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
Davebot — pointer-following portfolio guide
+
+
+
+
+
+
+
+
+
+
+
Hi, I’m David ~
a product designer who leads creative teams.
+
+
+
- 2026
+ - Tableau Urban Innovation
+ - Senior Designer
+
+
+
- 2025
+ - Fluid Interfaces
+ - Lead Product Designer
+
+
+
- 2025
+ - Plumba Parking
+ - Product Manager
+
+
+
- 2024
+ - Serendipity City
+ - Lead Product Designer
+
+
+
- 2023
+ - Computational Design Exploratory
+ - Lead Product Designer
+
+
+
+
+
+ Light mode
+
+
+
+
+
+
+
+
+
+
Hi, I’m David ~
a product designer who leads creative teams.
+
+
+
- 2026
+ - Tableau Urban Innovation
+ - Senior Designer
+
+
+
- 2025
+ - Fluid Interfaces
+ - Lead Product Designer
+
+
+
- 2025
+ - Plumba Parking
+ - Product Manager
+
+
+
- 2024
+ - Serendipity City
+ - Lead Product Designer
+
+
+
- 2023
+ - Computational Design Exploratory
+ - Lead Product Designer
+
+
+
+
+
+ Dark mode
+
+
+
+
+
diff --git a/examples/portfolio-gallery/ghosts.html b/examples/portfolio-gallery/ghosts.html
index 2fed463..60a619b 100644
--- a/examples/portfolio-gallery/ghosts.html
+++ b/examples/portfolio-gallery/ghosts.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.
diff --git a/examples/portfolio-gallery/src/builder.ts b/examples/portfolio-gallery/src/builder.ts
index 1303203..9e03ad4 100644
--- a/examples/portfolio-gallery/src/builder.ts
+++ b/examples/portfolio-gallery/src/builder.ts
@@ -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' },
},
diff --git a/examples/portfolio-gallery/src/characters.ts b/examples/portfolio-gallery/src/characters.ts
index dfdd4a4..0577c16 100644
--- a/examples/portfolio-gallery/src/characters.ts
+++ b/examples/portfolio-gallery/src/characters.ts
@@ -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',
diff --git a/examples/portfolio-gallery/src/crew.ts b/examples/portfolio-gallery/src/crew.ts
index 8e9d58f..7fcd18e 100644
--- a/examples/portfolio-gallery/src/crew.ts
+++ b/examples/portfolio-gallery/src/crew.ts
@@ -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
= {
+ light: MODES.dark.ink,
+ dark: MODES.light.ink,
+}
const ROLE_LABEL: Record = {
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({
diff --git a/examples/portfolio-gallery/src/davebot-header-main.ts b/examples/portfolio-gallery/src/davebot-header-main.ts
new file mode 100644
index 0000000..286ee7a
--- /dev/null
+++ b/examples/portfolio-gallery/src/davebot-header-main.ts
@@ -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(`.portfolio[data-mode="${variant.mode}"]`)
+ const mount = panel?.querySelector('[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('[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('.bs-avatar svg')
+ const eyePaths = svg?.querySelectorAll('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 })
diff --git a/examples/portfolio-gallery/src/davebot-header.css b/examples/portfolio-gallery/src/davebot-header.css
new file mode 100644
index 0000000..7ee7c37
--- /dev/null
+++ b/examples/portfolio-gallery/src/davebot-header.css
@@ -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;
+ }
+}
diff --git a/examples/portfolio-gallery/src/davebot-header.ts b/examples/portfolio-gallery/src/davebot-header.ts
new file mode 100644
index 0000000..61a4f2e
--- /dev/null
+++ b/examples/portfolio-gallery/src/davebot-header.ts
@@ -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) }
+})
diff --git a/examples/portfolio-gallery/src/showcase.ts b/examples/portfolio-gallery/src/showcase.ts
index 739f6c5..9d6c3a9 100644
--- a/examples/portfolio-gallery/src/showcase.ts
+++ b/examples/portfolio-gallery/src/showcase.ts
@@ -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',
},
{
diff --git a/examples/portfolio-gallery/tails.html b/examples/portfolio-gallery/tails.html
index 7d1beca..7d01c05 100644
--- a/examples/portfolio-gallery/tails.html
+++ b/examples/portfolio-gallery/tails.html
@@ -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.
-
Light — bg 97 · tile 79 · muted 40 · ink 15 · accent 0
+
+ Light — bg 97 · tile 79 · muted 40 · ink 15 · accent
+ 0
+
-
Dark — bg 3 · tile 21 · muted 60 · ink 85 · accent 100
+
+ Dark — bg 3 · tile 21 · muted 60 · ink 85 · accent
+ 100
+