+
+
+
+ Crew - sixteen ghosts, no two proportioned alike
+
+
+
+
+
Crew — sixteen ghosts, no two proportioned alike
+
+ Every ghost has arms and a tail, and no two share a set of proportions. The head's
+ width-to-height ratio, the eye scale and spacing, and the reach of the arms and tail all
+ change from one to the next. The tail sits well behind the body, so it recedes rather than
+ reading as a fifth limb. Each figure is generated twice from identical geometry: light is
+ the tonal inverse of dark.
+
+
+ The printed measurements are derived from the geometry that renders, not written by hand,
+ and every reach is quoted as a fraction of that ghost's own head width.
+
+
+
+
diff --git a/examples/portfolio-gallery/src/builder.ts b/examples/portfolio-gallery/src/builder.ts
index dca29b0..1303203 100644
--- a/examples/portfolio-gallery/src/builder.ts
+++ b/examples/portfolio-gallery/src/builder.ts
@@ -21,6 +21,7 @@ export type NodeSurface = {
height: number
depth: number
roundness: number
+ morphRoundness?: number
tipRoundness?: number
baseRoundness?: number
}
@@ -31,6 +32,22 @@ export type BodyNode = {
rotation: [number, number, number]
}
+// Mirrors FOCAL_LENGTH in @bible-strong/avatar-core's geometry: the engine
+// projects every node with `scale = FOCAL_LENGTH / (FOCAL_LENGTH - z)`. Depth
+// helpers below divide by that scale so their arguments stay screen-space.
+const FOCAL_LENGTH = 620
+
+// Exact radius of an ellipse of the given full width/height along `angleDeg`.
+// Heads are not all spherical, so the depth helpers need the real distance to
+// the surface in the direction they are working; assuming width/2 would leave a
+// tail or an arm floating clear of a tall head, or swallowed by a wide one.
+export const surfaceRadius = (width: number, height: number, angleDeg: number): number => {
+ const a = width / 2
+ const b = height / 2
+ const t = (angleDeg * Math.PI) / 180
+ return (a * b) / Math.hypot(b * Math.cos(t), a * Math.sin(t))
+}
+
export type EyeShape = { w: number; h: number; y: number; angle: number; spacing: number }
export type CharacterConfig = {
@@ -177,6 +194,108 @@ export const wisp = (opts: {
return nodes
}
+// A tail as ONE smooth volume, not a chain.
+//
+// A cone is the only primitive that tapers. Its profile is revolved, and three
+// roundness controls shape it: `baseRoundness` rounds the wide end,
+// `tipRoundness` softens the point, and `morphRoundness` bends the silhouette
+// toward an ellipsoid. That last one matters most — a raw cone has straight
+// sides that meet the body's curve at a sharp inflection, which reads as a map
+// pin or a speech bubble. A convex (high-morph) profile leaves the body
+// tangentially and reads as a droplet instead.
+//
+// The geometry is expressed as protrusion and burial rather than raw position
+// and length, so a seamless join is structural: `bury` places the wide base
+// past the body centre, where it cannot show an edge, and `protrude` sets how
+// far only the tapering end clears the surface.
+//
+// `angleDeg` is the direction the tip points, in screen space: 0 = right,
+// 90 = down, 180 = left, -90 = up.
+//
+// `depth` sits the whole volume behind the head. That matters for two reasons.
+// The engine only files a node in `backPaths` when its rotated depth stays under
+// its own radius, so a shallow `z` flips between front and back as the head
+// tilts during animation; a clearly negative depth pins the tail behind the body
+// for every pose. The engine also projects nodes with
+// `scale = FOCAL / (FOCAL - z)`, so receding a node shrinks it AND pulls its
+// centre inward — left alone that would quietly eat the `protrude` budget. The
+// local geometry is therefore divided back out by that scale, which keeps
+// `protrude` and `bury` promises about SCREEN space at any depth.
+export const tail = (opts: {
+ angleDeg: number
+ protrude: number
+ width: number
+ bury?: number
+ bodyRadius?: number
+ tip?: number
+ base?: number
+ morph?: number
+ depth?: number
+}): BodyNode => {
+ const {
+ angleDeg,
+ protrude,
+ width,
+ bury = 24,
+ bodyRadius = 94,
+ tip = 0.45,
+ base = 1,
+ morph = 1.2,
+ depth = -90,
+ } = opts
+ const scale = FOCAL_LENGTH / (FOCAL_LENGTH - depth)
+ // Screen-space intent, then undo the projection so it survives the recession.
+ const tipAt = (bodyRadius + protrude) / scale
+ const baseAt = -bury / scale
+ const length = tipAt - baseAt
+ const centre = (tipAt + baseAt) / 2
+ const radians = (angleDeg * Math.PI) / 180
+ // An unrotated cone points up (-Y), i.e. -90deg, so the roll needed to aim
+ // the tip along `angleDeg` is angleDeg + 90.
+ return nd(
+ ns('cone', Math.round(width / scale), Math.round(length), Math.round(width / scale), 0.5, {
+ morphRoundness: morph,
+ tipRoundness: tip,
+ baseRoundness: base,
+ }),
+ [Math.round(Math.cos(radians) * centre), Math.round(Math.sin(radians) * centre), depth],
+ [0, 0, angleDeg + 90]
+ )
+}
+
+// A mirrored pair of soft arms.
+//
+// Capsules rather than cones, so the outer end stays blunt and mitt-like. The
+// inner end sinks below the body surface and shares the body fill, so each arm
+// merges into the mass instead of butting against it. `angleDeg` is measured
+// downward from horizontal, so 0 is straight out and positive droops.
+export const arms = (opts: {
+ angleDeg: number
+ protrude: number
+ width: number
+ sink?: number
+ bodyRadius?: number
+ depth?: number
+}): BodyNode[] => {
+ const { angleDeg, protrude, width, sink = 34, bodyRadius = 94, depth = -30 } = opts
+ const scale = FOCAL_LENGTH / (FOCAL_LENGTH - depth)
+ const outerAt = (bodyRadius + protrude) / scale
+ const innerAt = (bodyRadius - sink) / scale
+ const length = outerAt - innerAt
+ const centre = (outerAt + innerAt) / 2
+ const w = Math.round(width / scale)
+ return [-1, 1].map(side => {
+ // Right arm aims along +angleDeg; the left mirrors across the vertical axis.
+ const theta = side === 1 ? angleDeg : 180 - angleDeg
+ const radians = (theta * Math.PI) / 180
+ return nd(
+ ns('capsule', w, Math.round(length), w, 1),
+ [Math.round(Math.cos(radians) * centre), Math.round(Math.sin(radians) * centre), depth],
+ [0, 0, theta + 90]
+ )
+ })
+}
+
// --- Expression + animation generation ------------------------------------
type EyeValue = { width: number; height: number; x: number; y: number; angle: number }
diff --git a/examples/portfolio-gallery/src/crew-main.ts b/examples/portfolio-gallery/src/crew-main.ts
new file mode 100644
index 0000000..52a41c9
--- /dev/null
+++ b/examples/portfolio-gallery/src/crew-main.ts
@@ -0,0 +1,87 @@
+import { createAvatar } from '@bible-strong/avatar-web'
+
+import { crewMembers, type CrewMember } from './crew'
+import './crew.css'
+
+const grid = document.querySelector('#grid')
+if (!grid) throw new Error('Crew grid was not found.')
+
+const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+
+const buildCard = (member: CrewMember, index: number): void => {
+ const card = document.createElement('article')
+ card.className = 'card'
+ card.style.setProperty('--i', String(index))
+
+ const pairRow = document.createElement('div')
+ pairRow.className = 'pair'
+
+ for (const variant of member.variants) {
+ // Each tile carries its own mode scope, so the seven roles resolve from the
+ // mode rather than from anything the card overrides locally.
+ const tile = document.createElement('div')
+ tile.className = 'tile'
+ tile.dataset.mode = variant.mode
+
+ const mount = document.createElement('div')
+ mount.className = 'avatar'
+ tile.append(mount)
+
+ const modeTag = document.createElement('span')
+ modeTag.className = 'mode-tag'
+ modeTag.textContent = variant.mode
+ tile.append(modeTag)
+
+ pairRow.append(tile)
+
+ // Inferred (not annotated `| undefined`) so the narrowing after the catch
+ // survives into the hover closures, matching the sibling gallery pages.
+ let controller
+ try {
+ controller = createAvatar(mount, {
+ definition: variant.definition,
+ defaultAnimation: reduceMotion ? undefined : 'idle',
+ autoplay: !reduceMotion,
+ size: '100%',
+ ariaLabel: `${member.name}, a ghost in ${variant.mode} mode — ${member.note}`,
+ onError: error => console.error(`[${member.id}/${variant.mode}]`, error),
+ })
+ } catch (error) {
+ tile.classList.add('is-failed')
+ tile.dataset.error = error instanceof Error ? error.message : String(error)
+ continue
+ }
+
+ // Hover the whole card so both modes react together — the point of the
+ // pairing is that structure is identical and only value changes.
+ if (!reduceMotion) {
+ card.addEventListener('pointerenter', () => controller.play('curious'))
+ card.addEventListener('pointerleave', () => controller.play('idle'))
+ }
+ }
+
+ const meta = document.createElement('div')
+ meta.className = 'meta'
+
+ const name = document.createElement('h2')
+ name.textContent = member.name
+
+ const ratio = document.createElement('span')
+ ratio.className = 'ratio'
+ ratio.textContent = member.ratio
+ name.append(ratio)
+
+ const note = document.createElement('p')
+ note.className = 'note'
+ note.textContent = member.note
+
+ const metrics = document.createElement('p')
+ metrics.className = 'metrics'
+ metrics.textContent = member.metrics
+
+ meta.append(name, note, metrics)
+ card.append(pairRow, meta)
+ grid.append(card)
+}
+
+crewMembers.forEach(buildCard)
diff --git a/examples/portfolio-gallery/src/crew.css b/examples/portfolio-gallery/src/crew.css
new file mode 100644
index 0000000..41fb3bb
--- /dev/null
+++ b/examples/portfolio-gallery/src/crew.css
@@ -0,0 +1,32 @@
+/* Reuses the Design System 01 shell from the tails page; only the framing that
+ this proportions study needs differs. */
+@import './tails.css';
+
+/* Bodies range from 150 to 210 units wide and arms reach past them, so the
+ avatar box is smaller here to keep every silhouette inside its tile. */
+.avatar {
+ width: 66%;
+ height: 66%;
+}
+
+.meta h2 {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ justify-content: space-between;
+}
+
+.ratio {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 400;
+ color: var(--ink-muted);
+}
+
+.meta .metrics {
+ margin: 0;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ line-height: 1.5;
+ color: var(--ink-muted);
+}
diff --git a/examples/portfolio-gallery/src/crew.ts b/examples/portfolio-gallery/src/crew.ts
new file mode 100644
index 0000000..8e9d58f
--- /dev/null
+++ b/examples/portfolio-gallery/src/crew.ts
@@ -0,0 +1,474 @@
+import type { AvatarDefinition } from '@bible-strong/avatar-web'
+
+import { arms, buildDefinition, sf, surfaceRadius, tail, type CharacterConfig } from './builder'
+
+// Sixteen ghosts, each with different proportions from every other.
+//
+// Three things change per variant and nothing is shared but the colour system:
+// the head's width-to-height ratio, the eye scale and set, and the reach of the
+// arms and tail. Every measurement is expressed against that variant's own head
+// width, so a small ghost with long arms and a large ghost with short arms stay
+// legibly different rather than converging on one silhouette.
+//
+// The tail sits well behind the body. The engine files a node in `backPaths`
+// only while its rotated depth stays under its own radius, so a shallow depth
+// flips front-to-back as the head tilts; these depths pin it behind for every
+// pose, and the helper compensates for the perspective shrink that recession
+// causes.
+
+export const MODES = {
+ dark: {
+ bg: '#0b0b0b',
+ tile: '#323232',
+ ink: '#d4d4d4',
+ inkMuted: '#909090',
+ accent: '#ffffff',
+ border: '#ffffff1f',
+ tint: '#80808050',
+ },
+ light: {
+ bg: '#f6f6f6',
+ tile: '#c4c4c4',
+ ink: '#262626',
+ inkMuted: '#5e5e5e',
+ accent: '#000000',
+ border: '#0000001f',
+ tint: '#80808050',
+ },
+} as const
+
+export type ModeName = keyof typeof MODES
+export type BodyRole = 'accent' | 'ink' | 'inkMuted'
+
+const ROLE_LABEL: Record = {
+ accent: 'accent',
+ ink: 'ink',
+ inkMuted: 'ink-muted',
+}
+
+type Proportions = {
+ id: string
+ name: string
+ note: string
+ role: BodyRole
+ headW: number
+ headH: number
+ eyeW: number
+ eyeH: number
+ eyeY: number
+ eyeSpacing: number
+ armAngle: number
+ armProtrude: number
+ armWidth: number
+ tailAngle: number
+ tailProtrude: number
+ tailWidth: number
+ tailMorph: number
+ tailTip: number
+ tailDepth: number
+}
+
+const crew: Proportions[] = [
+ {
+ id: 'round',
+ name: 'Round',
+ note: 'The baseline: equal width and height',
+ role: 'accent',
+ headW: 186,
+ headH: 186,
+ eyeW: 24,
+ eyeH: 28,
+ eyeY: -20,
+ eyeSpacing: 40,
+ armAngle: 26,
+ armProtrude: 30,
+ armWidth: 44,
+ tailAngle: 90,
+ tailProtrude: 48,
+ tailWidth: 124,
+ tailMorph: 1.35,
+ tailTip: 0.5,
+ tailDepth: -90,
+ },
+ {
+ id: 'tall',
+ name: 'Tall',
+ note: 'Narrow and upright, eyes carried high',
+ role: 'ink',
+ headW: 162,
+ headH: 208,
+ eyeW: 22,
+ eyeH: 30,
+ eyeY: -30,
+ eyeSpacing: 36,
+ armAngle: 22,
+ armProtrude: 28,
+ armWidth: 40,
+ tailAngle: 90,
+ tailProtrude: 44,
+ tailWidth: 112,
+ tailMorph: 1.35,
+ tailTip: 0.45,
+ tailDepth: -100,
+ },
+ {
+ id: 'wide',
+ name: 'Wide',
+ note: 'Broad and low, arms set far out',
+ role: 'ink',
+ headW: 210,
+ headH: 168,
+ eyeW: 26,
+ eyeH: 26,
+ eyeY: -14,
+ eyeSpacing: 48,
+ armAngle: 32,
+ armProtrude: 32,
+ armWidth: 48,
+ tailAngle: 90,
+ tailProtrude: 40,
+ tailWidth: 132,
+ tailMorph: 1.4,
+ tailTip: 0.55,
+ tailDepth: -90,
+ },
+ {
+ id: 'petite',
+ name: 'Petite',
+ note: 'Smallest of the crew, everything scaled down',
+ role: 'inkMuted',
+ headW: 150,
+ headH: 154,
+ eyeW: 20,
+ eyeH: 24,
+ eyeY: -16,
+ eyeSpacing: 32,
+ armAngle: 24,
+ armProtrude: 24,
+ armWidth: 36,
+ tailAngle: 86,
+ tailProtrude: 40,
+ tailWidth: 100,
+ tailMorph: 1.3,
+ tailTip: 0.45,
+ tailDepth: -80,
+ },
+ {
+ id: 'giant',
+ name: 'Giant',
+ note: 'Largest body, longest reach in every direction',
+ role: 'accent',
+ headW: 208,
+ headH: 206,
+ eyeW: 30,
+ eyeH: 34,
+ eyeY: -22,
+ eyeSpacing: 50,
+ armAngle: 28,
+ armProtrude: 36,
+ armWidth: 54,
+ tailAngle: 92,
+ tailProtrude: 54,
+ tailWidth: 142,
+ tailMorph: 1.4,
+ tailTip: 0.5,
+ tailDepth: -110,
+ },
+ {
+ id: 'egg',
+ name: 'Egg',
+ note: 'Tapered upward, eyes near the crown',
+ role: 'ink',
+ headW: 170,
+ headH: 198,
+ eyeW: 22,
+ eyeH: 28,
+ eyeY: -34,
+ eyeSpacing: 36,
+ armAngle: 20,
+ armProtrude: 26,
+ armWidth: 40,
+ tailAngle: 88,
+ tailProtrude: 46,
+ tailWidth: 114,
+ tailMorph: 1.5,
+ tailTip: 0.5,
+ tailDepth: -95,
+ },
+ {
+ id: 'squat',
+ name: 'Squat',
+ note: 'Flattened, with the widest droop to the arms',
+ role: 'inkMuted',
+ headW: 202,
+ headH: 160,
+ eyeW: 26,
+ eyeH: 24,
+ eyeY: -10,
+ eyeSpacing: 46,
+ armAngle: 38,
+ armProtrude: 30,
+ armWidth: 50,
+ tailAngle: 94,
+ tailProtrude: 36,
+ tailWidth: 128,
+ tailMorph: 1.45,
+ tailTip: 0.6,
+ tailDepth: -85,
+ },
+ {
+ id: 'longarm',
+ name: 'Longarm',
+ note: 'Modest body, arms reaching nearly straight out',
+ role: 'accent',
+ headW: 172,
+ headH: 178,
+ eyeW: 23,
+ eyeH: 27,
+ eyeY: -18,
+ eyeSpacing: 38,
+ armAngle: 12,
+ armProtrude: 56,
+ armWidth: 34,
+ tailAngle: 90,
+ tailProtrude: 42,
+ tailWidth: 112,
+ tailMorph: 1.35,
+ tailTip: 0.45,
+ tailDepth: -95,
+ },
+ {
+ id: 'stubarm',
+ name: 'Stubarm',
+ note: 'Heavy body, arms barely clearing it',
+ role: 'ink',
+ headW: 198,
+ headH: 192,
+ eyeW: 27,
+ eyeH: 29,
+ eyeY: -20,
+ eyeSpacing: 46,
+ armAngle: 42,
+ armProtrude: 16,
+ armWidth: 54,
+ tailAngle: 90,
+ tailProtrude: 46,
+ tailWidth: 132,
+ tailMorph: 1.35,
+ tailTip: 0.5,
+ tailDepth: -100,
+ },
+ {
+ id: 'highset',
+ name: 'Highset',
+ note: 'Eyes pushed to the very top of a tall head',
+ role: 'inkMuted',
+ headW: 168,
+ headH: 202,
+ eyeW: 22,
+ eyeH: 26,
+ eyeY: -42,
+ eyeSpacing: 34,
+ armAngle: 24,
+ armProtrude: 30,
+ armWidth: 38,
+ tailAngle: 92,
+ tailProtrude: 48,
+ tailWidth: 110,
+ tailMorph: 1.3,
+ tailTip: 0.45,
+ tailDepth: -105,
+ },
+ {
+ id: 'lowset',
+ name: 'Lowset',
+ note: 'Eyes near the middle of a broad head',
+ role: 'accent',
+ headW: 200,
+ headH: 176,
+ eyeW: 26,
+ eyeH: 28,
+ eyeY: -4,
+ eyeSpacing: 48,
+ armAngle: 34,
+ armProtrude: 28,
+ armWidth: 50,
+ tailAngle: 88,
+ tailProtrude: 42,
+ tailWidth: 130,
+ tailMorph: 1.45,
+ tailTip: 0.55,
+ tailDepth: -90,
+ },
+ {
+ id: 'wideset',
+ name: 'Wideset',
+ note: 'Widest eye spacing relative to the head',
+ role: 'ink',
+ headW: 194,
+ headH: 182,
+ eyeW: 24,
+ eyeH: 28,
+ eyeY: -18,
+ eyeSpacing: 60,
+ armAngle: 30,
+ armProtrude: 30,
+ armWidth: 46,
+ tailAngle: 90,
+ tailProtrude: 44,
+ tailWidth: 124,
+ tailMorph: 1.35,
+ tailTip: 0.5,
+ tailDepth: -95,
+ },
+ {
+ id: 'closeset',
+ name: 'Closeset',
+ note: 'Narrowest eye spacing of the crew',
+ role: 'inkMuted',
+ headW: 180,
+ headH: 188,
+ eyeW: 24,
+ eyeH: 28,
+ eyeY: -20,
+ eyeSpacing: 26,
+ armAngle: 26,
+ armProtrude: 28,
+ armWidth: 42,
+ tailAngle: 90,
+ tailProtrude: 46,
+ tailWidth: 118,
+ tailMorph: 1.35,
+ tailTip: 0.5,
+ tailDepth: -95,
+ },
+ {
+ id: 'bigeye',
+ name: 'Bigeye',
+ note: 'Largest eyes, filling much of the face',
+ role: 'accent',
+ headW: 184,
+ headH: 190,
+ eyeW: 36,
+ eyeH: 40,
+ eyeY: -16,
+ eyeSpacing: 48,
+ armAngle: 28,
+ armProtrude: 28,
+ armWidth: 44,
+ tailAngle: 90,
+ tailProtrude: 44,
+ tailWidth: 120,
+ tailMorph: 1.4,
+ tailTip: 0.5,
+ tailDepth: -90,
+ },
+ {
+ id: 'pinhole',
+ name: 'Pinhole',
+ note: 'Smallest eyes, set in a plain round head',
+ role: 'ink',
+ headW: 190,
+ headH: 186,
+ eyeW: 14,
+ eyeH: 16,
+ eyeY: -22,
+ eyeSpacing: 38,
+ armAngle: 26,
+ armProtrude: 30,
+ armWidth: 44,
+ tailAngle: 90,
+ tailProtrude: 46,
+ tailWidth: 124,
+ tailMorph: 1.35,
+ tailTip: 0.5,
+ tailDepth: -90,
+ },
+ {
+ id: 'deepset',
+ name: 'Deepset',
+ note: 'Tail pushed furthest back, so it recedes most',
+ role: 'ink',
+ headW: 188,
+ headH: 184,
+ eyeW: 25,
+ eyeH: 29,
+ eyeY: -20,
+ eyeSpacing: 42,
+ armAngle: 26,
+ armProtrude: 32,
+ armWidth: 46,
+ tailAngle: 90,
+ tailProtrude: 58,
+ tailWidth: 130,
+ tailMorph: 1.35,
+ tailTip: 0.5,
+ tailDepth: -170,
+ },
+]
+
+export type CrewVariant = {
+ mode: ModeName
+ body: string
+ tile: string
+ definition: AvatarDefinition
+}
+
+export type CrewMember = {
+ id: string
+ name: string
+ note: string
+ roleLabel: string
+ // Derived from the geometry rather than written by hand, so the printed
+ // proportions cannot drift away from what actually renders.
+ ratio: string
+ metrics: string
+ variants: CrewVariant[]
+}
+
+export const crewMembers: CrewMember[] = crew.map(p => {
+ const variants = (Object.keys(MODES) as ModeName[]).map((mode): CrewVariant => {
+ const palette = MODES[mode]
+ const body = palette[p.role]
+ const config: CharacterConfig = {
+ id: `${p.id}-${mode}`,
+ name: p.name,
+ 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,
+ eye: { w: p.eyeW, h: p.eyeH, y: p.eyeY, spacing: p.eyeSpacing },
+ nodes: [
+ tail({
+ angleDeg: p.tailAngle,
+ protrude: p.tailProtrude,
+ width: p.tailWidth,
+ morph: p.tailMorph,
+ tip: p.tailTip,
+ depth: p.tailDepth,
+ bodyRadius: surfaceRadius(p.headW, p.headH, p.tailAngle),
+ }),
+ ...arms({
+ angleDeg: p.armAngle,
+ protrude: p.armProtrude,
+ width: p.armWidth,
+ bodyRadius: surfaceRadius(p.headW, p.headH, p.armAngle),
+ }),
+ ],
+ signature: 'curious',
+ }
+ return { mode, body, tile: palette.tile, definition: buildDefinition(config) }
+ })
+
+ const frac = (value: number) => (value / p.headW).toFixed(2)
+ return {
+ id: p.id,
+ name: p.name,
+ note: p.note,
+ roleLabel: ROLE_LABEL[p.role],
+ ratio: `${(p.headW / p.headH).toFixed(2)}:1`,
+ metrics: `${p.headW}\u00d7${p.headH} · eye-set ${frac(p.eyeSpacing)} · eye ${frac(p.eyeW)} · arms ${frac(p.armProtrude)} · tail ${frac(p.tailProtrude)} @ ${p.tailDepth}`,
+ variants,
+ }
+})
diff --git a/examples/portfolio-gallery/src/tails-main.ts b/examples/portfolio-gallery/src/tails-main.ts
new file mode 100644
index 0000000..b3d1bbf
--- /dev/null
+++ b/examples/portfolio-gallery/src/tails-main.ts
@@ -0,0 +1,84 @@
+import { createAvatar } from '@bible-strong/avatar-web'
+
+import { ghostPairs, type GhostPair } from './tails'
+import './tails.css'
+
+const grid = document.querySelector('#grid')
+if (!grid) throw new Error('Tail grid was not found.')
+
+const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+
+const buildCard = (pair: GhostPair, index: number): void => {
+ const card = document.createElement('article')
+ card.className = 'card'
+ card.style.setProperty('--i', String(index))
+
+ const pairRow = document.createElement('div')
+ pairRow.className = 'pair'
+
+ for (const variant of pair.variants) {
+ // Each tile carries its own mode scope, so the seven roles resolve from the
+ // mode rather than from anything the card overrides locally.
+ const tile = document.createElement('div')
+ tile.className = 'tile'
+ tile.dataset.mode = variant.mode
+
+ const mount = document.createElement('div')
+ mount.className = 'avatar'
+ tile.append(mount)
+
+ const modeTag = document.createElement('span')
+ modeTag.className = 'mode-tag'
+ modeTag.textContent = variant.mode
+ tile.append(modeTag)
+
+ pairRow.append(tile)
+
+ // Inferred (not annotated `| undefined`) so the narrowing after the catch
+ // survives into the hover closures, matching the sibling gallery pages.
+ let controller
+ try {
+ controller = createAvatar(mount, {
+ definition: variant.definition,
+ defaultAnimation: reduceMotion ? undefined : 'idle',
+ autoplay: !reduceMotion,
+ size: '100%',
+ ariaLabel: `${pair.name}, a ghost in ${variant.mode} mode — ${pair.note}`,
+ onError: error => console.error(`[${pair.id}/${variant.mode}]`, error),
+ })
+ } catch (error) {
+ tile.classList.add('is-failed')
+ tile.dataset.error = error instanceof Error ? error.message : String(error)
+ continue
+ }
+
+ // Hover the whole card so both modes react together — the point of the
+ // pairing is that structure is identical and only value changes.
+ if (!reduceMotion) {
+ card.addEventListener('pointerenter', () => controller.play('curious'))
+ card.addEventListener('pointerleave', () => controller.play('idle'))
+ }
+ }
+
+ const meta = document.createElement('div')
+ meta.className = 'meta'
+
+ const name = document.createElement('h2')
+ name.textContent = pair.name
+
+ const note = document.createElement('p')
+ note.className = 'note'
+ note.textContent = pair.note
+
+ const role = document.createElement('p')
+ role.className = 'role'
+ const light = pair.variants.find(v => v.mode === 'light')
+ const dark = pair.variants.find(v => v.mode === 'dark')
+ role.textContent = `body: ${pair.roleLabel} · ${light?.body} / ${dark?.body}`
+
+ meta.append(name, note, role)
+ card.append(pairRow, meta)
+ grid.append(card)
+}
+
+ghostPairs.forEach(buildCard)
diff --git a/examples/portfolio-gallery/src/tails.css b/examples/portfolio-gallery/src/tails.css
new file mode 100644
index 0000000..8ded7d2
--- /dev/null
+++ b/examples/portfolio-gallery/src/tails.css
@@ -0,0 +1,247 @@
+@import url('https://fonts.bunny.net/css?family=geist:400,500,600|geist-mono:400,500');
+
+/* Design System 01 — colour, two modes, one structure.
+ Seven roles per mode. Light is the tonal inverse of dark: L*(light) = 100 - L*(dark).
+ Alpha values are identical across modes and `tint` is shared. No per-mode exceptions. */
+:root {
+ --dark-bg: #0b0b0b; /* L* 3 */
+ --dark-tile: #323232; /* L* 21 */
+ --dark-ink: #d4d4d4; /* L* 85 */
+ --dark-ink-muted: #909090; /* L* 60 */
+ --dark-accent: #ffffff; /* L* 100 */
+ --dark-border: #ffffff1f; /* 12% */
+ --dark-tint: #80808050; /* shared */
+
+ --light-bg: #f6f6f6; /* L* 97 */
+ --light-tile: #c4c4c4; /* L* 79 */
+ --light-ink: #262626; /* L* 15 */
+ --light-ink-muted: #5e5e5e; /* L* 40 */
+ --light-accent: #000000; /* L* 0 */
+ --light-border: #0000001f; /* 12% */
+ --light-tint: #80808050; /* shared */
+
+ --font-sans: 'Geist', system-ui, sans-serif;
+ --font-mono: 'Geist Mono', ui-monospace, monospace;
+}
+
+/* A mode is a scope, not a theme override: every role is bound in one place. */
+[data-mode='light'] {
+ --bg: var(--light-bg);
+ --tile: var(--light-tile);
+ --ink: var(--light-ink);
+ --ink-muted: var(--light-ink-muted);
+ --accent: var(--light-accent);
+ --border: var(--light-border);
+ --tint: var(--light-tint);
+}
+
+[data-mode='dark'] {
+ --bg: var(--dark-bg);
+ --tile: var(--dark-tile);
+ --ink: var(--dark-ink);
+ --ink-muted: var(--dark-ink-muted);
+ --accent: var(--dark-accent);
+ --border: var(--dark-border);
+ --tint: var(--dark-tint);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+}
+
+.shell {
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: 72px 32px 96px;
+}
+
+header {
+ border-bottom: 1px solid var(--border);
+ padding-bottom: 28px;
+ margin-bottom: 44px;
+}
+
+h1 {
+ font-size: 30px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ margin: 0 0 10px;
+}
+
+h1 em {
+ font-style: normal;
+ color: var(--ink-muted);
+}
+
+.lede {
+ margin: 0;
+ max-width: 62ch;
+ color: var(--ink-muted);
+ font-size: 15px;
+ line-height: 1.6;
+}
+
+/* Role swatches — proof the two ramps mirror. */
+.roles {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0;
+ margin-top: 28px;
+ border: 1px solid var(--border);
+}
+
+.ramp {
+ background: var(--bg);
+ padding: 14px 16px 16px;
+}
+
+.ramp + .ramp {
+ border-left: 1px solid var(--border);
+}
+
+.ramp h3 {
+ margin: 0 0 10px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 500;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: var(--ink-muted);
+}
+
+.swatches {
+ display: flex;
+ gap: 6px;
+}
+
+.swatch {
+ flex: 1;
+ height: 34px;
+ border: 1px solid var(--border);
+}
+
+#grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
+ gap: 30px 26px;
+}
+
+.card {
+ animation: rise 460ms cubic-bezier(0.16, 1, 0.3, 1) both;
+ animation-delay: calc(var(--i, 0) * 45ms);
+}
+
+@keyframes rise {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+}
+
+/* The pair: identical geometry, one tile per mode, hairline shared. */
+.pair {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ border: 1px solid var(--border);
+}
+
+.tile {
+ position: relative;
+ aspect-ratio: 1;
+ background: var(--tile);
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+}
+
+.tile + .tile {
+ border-left: 1px solid var(--border);
+}
+
+.tile.is-failed::after {
+ content: 'failed';
+ position: absolute;
+ inset: auto 0 50% 0;
+ text-align: center;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--ink);
+}
+
+.avatar {
+ width: 74%;
+ height: 74%;
+}
+
+.avatar svg {
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+}
+
+.mode-tag {
+ position: absolute;
+ left: 8px;
+ bottom: 6px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-muted);
+}
+
+.meta {
+ padding-top: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.meta h2 {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+}
+
+.meta .note {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--ink-muted);
+}
+
+.meta .role {
+ margin: 0;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--ink-muted);
+}
+
+footer {
+ margin-top: 68px;
+ padding-top: 20px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 24px;
+ justify-content: space-between;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--ink-muted);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .card {
+ animation: none;
+ opacity: 1;
+ }
+}
diff --git a/examples/portfolio-gallery/src/tails.ts b/examples/portfolio-gallery/src/tails.ts
new file mode 100644
index 0000000..c145ecb
--- /dev/null
+++ b/examples/portfolio-gallery/src/tails.ts
@@ -0,0 +1,250 @@
+import type { AvatarDefinition } from '@bible-strong/avatar-web'
+
+import { buildDefinition, sf, tail, type CharacterConfig } from './builder'
+
+// Ghosts whose tail is ONE smooth volume.
+//
+// A single cone node does the work. `bury` sinks its wide, rounded base past
+// the head's centre so no edge can ever show, and `morph` bends the profile
+// convex so it leaves the sphere tangentially — a straight-sided cone meets the
+// curve at a sharp inflection and reads as a map pin instead of a tail. Body and
+// tail share one fill, so the result is a single continuous silhouette: no chain
+// of spheres, no seam.
+//
+// Colour comes from Design System 01. Seven roles per mode; light is the tonal
+// inverse of dark (L*light = 100 - L*dark), so every ghost is generated twice
+// from identical geometry and the contrast relationships mirror exactly.
+
+export const MODES = {
+ dark: {
+ bg: '#0b0b0b',
+ tile: '#323232',
+ ink: '#d4d4d4',
+ inkMuted: '#909090',
+ accent: '#ffffff',
+ border: '#ffffff1f',
+ tint: '#80808050',
+ },
+ light: {
+ bg: '#f6f6f6',
+ tile: '#c4c4c4',
+ ink: '#262626',
+ inkMuted: '#5e5e5e',
+ accent: '#000000',
+ border: '#0000001f',
+ tint: '#80808050',
+ },
+} as const
+
+export type ModeName = keyof typeof MODES
+
+// Which colour role paints the ghost's body. Each is a different rung of the
+// system's contrast ladder, and each mirrors across modes.
+export type BodyRole = 'accent' | 'ink' | 'inkMuted'
+
+const ROLE_LABEL: Record = {
+ accent: 'accent',
+ ink: 'ink',
+ inkMuted: 'ink-muted',
+}
+
+const GHOST_EYE = { w: 24, h: 28, y: -20, spacing: 40 }
+const HEAD = sf('sphere', 186, 186, 186, 1)
+
+type Shape = {
+ id: string
+ name: string
+ note: string
+ role: BodyRole
+ angleDeg: number
+ protrude: number
+ width: number
+ bury: number
+ tip: number
+ morph: number
+}
+
+const shapes: Shape[] = [
+ {
+ id: 'drop',
+ name: 'Drop',
+ note: 'Straight down, even taper',
+ role: 'accent',
+ angleDeg: 90,
+ protrude: 46,
+ width: 128,
+ bury: 26,
+ tip: 0.5,
+ morph: 1.35,
+ },
+ {
+ id: 'lean',
+ name: 'Lean',
+ note: 'Angled down and to the right',
+ role: 'accent',
+ angleDeg: 62,
+ protrude: 48,
+ width: 124,
+ bury: 26,
+ tip: 0.45,
+ morph: 1.3,
+ },
+ {
+ id: 'sweep',
+ name: 'Sweep',
+ note: 'Shallow angle, reaches further out',
+ role: 'ink',
+ angleDeg: 32,
+ protrude: 56,
+ width: 116,
+ bury: 30,
+ tip: 0.4,
+ morph: 1.3,
+ },
+ {
+ id: 'mirror',
+ name: 'Mirror',
+ note: 'Lean, flipped to the left',
+ role: 'ink',
+ angleDeg: 118,
+ protrude: 48,
+ width: 124,
+ bury: 26,
+ tip: 0.45,
+ morph: 1.3,
+ },
+ {
+ id: 'stub',
+ name: 'Stub',
+ note: 'Short and broad, barely clears the head',
+ role: 'inkMuted',
+ angleDeg: 90,
+ protrude: 36,
+ width: 116,
+ bury: 22,
+ tip: 0.6,
+ morph: 1.4,
+ },
+ {
+ id: 'taper',
+ name: 'Taper',
+ note: 'Longest reach, narrowest tip',
+ role: 'accent',
+ angleDeg: 70,
+ protrude: 60,
+ width: 106,
+ bury: 34,
+ tip: 0.3,
+ morph: 1.35,
+ },
+ {
+ id: 'teardrop',
+ name: 'Teardrop',
+ note: 'Maximum morph — a pure rounded droplet',
+ role: 'ink',
+ angleDeg: 90,
+ protrude: 50,
+ width: 134,
+ bury: 24,
+ tip: 0.55,
+ morph: 1.8,
+ },
+ {
+ id: 'slant',
+ name: 'Slant',
+ note: 'Almost horizontal, as if drifting sideways',
+ role: 'inkMuted',
+ angleDeg: 14,
+ protrude: 52,
+ width: 118,
+ bury: 28,
+ tip: 0.42,
+ morph: 1.3,
+ },
+ {
+ id: 'hook',
+ name: 'Hook',
+ note: 'Steeper left angle, tucked under the head',
+ role: 'ink',
+ angleDeg: 130,
+ protrude: 50,
+ width: 120,
+ bury: 28,
+ tip: 0.4,
+ morph: 1.3,
+ },
+ {
+ id: 'wide',
+ name: 'Wide',
+ note: 'Broadest base, gentlest taper',
+ role: 'accent',
+ angleDeg: 90,
+ protrude: 44,
+ width: 150,
+ bury: 22,
+ tip: 0.6,
+ morph: 1.5,
+ },
+]
+
+export type GhostVariant = {
+ mode: ModeName
+ body: string
+ eyes: string
+ tile: string
+ definition: AvatarDefinition
+}
+
+export type GhostPair = {
+ id: string
+ name: string
+ note: string
+ role: BodyRole
+ roleLabel: string
+ // Same geometry, one definition per mode.
+ variants: GhostVariant[]
+}
+
+export const ghostPairs: GhostPair[] = shapes.map(shape => {
+ const variants = (Object.keys(MODES) as ModeName[]).map((mode): GhostVariant => {
+ const palette = MODES[mode]
+ const body = palette[shape.role]
+ const config: CharacterConfig = {
+ id: `${shape.id}-${mode}`,
+ name: shape.name,
+ role: shape.note,
+ surface: HEAD,
+ body,
+ // Eyes take the mode's background, so they read as cut-outs in both modes.
+ eyes: palette.bg,
+ eye: GHOST_EYE,
+ nodes: [
+ tail({
+ angleDeg: shape.angleDeg,
+ protrude: shape.protrude,
+ width: shape.width,
+ bury: shape.bury,
+ tip: shape.tip,
+ morph: shape.morph,
+ }),
+ ],
+ signature: 'curious',
+ }
+ return {
+ mode,
+ body,
+ eyes: palette.bg,
+ tile: palette.tile,
+ definition: buildDefinition(config),
+ }
+ })
+
+ return {
+ id: shape.id,
+ name: shape.name,
+ note: shape.note,
+ role: shape.role,
+ roleLabel: ROLE_LABEL[shape.role],
+ variants,
+ }
+})
diff --git a/examples/portfolio-gallery/tails.html b/examples/portfolio-gallery/tails.html
new file mode 100644
index 0000000..7d1beca
--- /dev/null
+++ b/examples/portfolio-gallery/tails.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+ Tails - one smooth volume, two modes
+
+
+
+
+
Tails — one smooth volume, two modes
+
+ 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.
+