diff --git a/examples/portfolio-gallery/ghosts.html b/examples/portfolio-gallery/ghosts.html
new file mode 100644
index 0000000..2fed463
--- /dev/null
+++ b/examples/portfolio-gallery/ghosts.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+ Ghosts — ten tails in the Page 3 monochrome system
+
+
+
+
+
+
+
+
+ Rendered with @bible-strong/avatar-web
+ Values: ditto - aug 23 · Page 3
+
+
+
+
+
diff --git a/examples/portfolio-gallery/pip.html b/examples/portfolio-gallery/pip.html
new file mode 100644
index 0000000..6e95c02
--- /dev/null
+++ b/examples/portfolio-gallery/pip.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+ Pip — 10 variations in the Page 3 monochrome system
+
+
+
+
+
+
+
+
+ Rendered with @bible-strong/avatar-web
+ Values: ditto - aug 23 · Page 3
+
+
+
+
+
diff --git a/examples/portfolio-gallery/src/builder.ts b/examples/portfolio-gallery/src/builder.ts
index 9f25316..dca29b0 100644
--- a/examples/portfolio-gallery/src/builder.ts
+++ b/examples/portfolio-gallery/src/builder.ts
@@ -140,6 +140,43 @@ export const pod = (
rot: [number, number, number] = [0, 0, 0]
): BodyNode => nd(ns('capsule', w, h, Math.min(w, h), 1), [x, y, z], rot)
+// A tapering, curving chain of soft lobes — a ghost's tail.
+//
+// Lobes are sampled along a quadratic Bezier from `from` to `to`, with the
+// control point pushed `bow` units perpendicular to the chord (sign flips the
+// curve). Because every lobe carries the primary body colour, the chain merges
+// into one continuous wisp that tapers from `startSize` down to `endSize`.
+export const wisp = (opts: {
+ from: [number, number]
+ to: [number, number]
+ bow?: number
+ startSize: number
+ endSize: number
+ count?: number
+ z?: number
+}): BodyNode[] => {
+ const { from, to, bow = 0, startSize, endSize, count = 10, z = -8 } = opts
+ const [x0, y0] = from
+ const [x1, y1] = to
+ const dx = x1 - x0
+ const dy = y1 - y0
+ const len = Math.hypot(dx, dy) || 1
+ const cx = (x0 + x1) / 2 + (-dy / len) * bow
+ const cy = (y0 + y1) / 2 + (dx / len) * bow
+
+ const nodes: BodyNode[] = []
+ for (let i = 0; i < count; i += 1) {
+ const t = count === 1 ? 0 : i / (count - 1)
+ const mt = 1 - t
+ const x = mt * mt * x0 + 2 * mt * t * cx + t * t * x1
+ const y = mt * mt * y0 + 2 * mt * t * cy + t * t * y1
+ // Slightly eased taper: holds volume near the body, thins fast at the tip.
+ const size = startSize + (endSize - startSize) * Math.pow(t, 0.85)
+ nodes.push(lobe(Math.round(size), Math.round(x), Math.round(y), z))
+ }
+ return nodes
+}
+
// --- Expression + animation generation ------------------------------------
type EyeValue = { width: number; height: number; x: number; y: number; angle: number }
diff --git a/examples/portfolio-gallery/src/ghosts-main.ts b/examples/portfolio-gallery/src/ghosts-main.ts
new file mode 100644
index 0000000..2bf9b6b
--- /dev/null
+++ b/examples/portfolio-gallery/src/ghosts-main.ts
@@ -0,0 +1,69 @@
+import { createAvatar } from '@bible-strong/avatar-web'
+
+import { EMOTE_LABELS } from './builder'
+import { ghosts, type GhostVariant } from './ghosts'
+import './ghosts.css'
+
+const grid = document.querySelector('#grid')
+if (!grid) throw new Error('Ghost grid was not found.')
+
+const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+
+const buildCard = (ghost: GhostVariant, index: number) => {
+ const card = document.createElement('article')
+ card.className = `card is-${ghost.mode}`
+ card.style.setProperty('--ground', ghost.ground)
+ card.style.setProperty('--accent', ghost.accent)
+ card.style.setProperty('--i', String(index))
+
+ const tile = document.createElement('div')
+ tile.className = 'tile'
+ const mount = document.createElement('div')
+ mount.className = 'avatar'
+ tile.appendChild(mount)
+
+ const meta = document.createElement('div')
+ meta.className = 'meta'
+ const name = document.createElement('h2')
+ name.textContent = ghost.name
+ const tail = document.createElement('p')
+ tail.className = 'note'
+ tail.textContent = ghost.tail
+ const token = document.createElement('p')
+ token.className = 'token'
+ token.textContent = `${ghost.role} · ${EMOTE_LABELS[ghost.signature].toUpperCase()}`
+ meta.append(name, tail, token)
+
+ card.append(tile, meta)
+ grid.appendChild(card)
+
+ // `createAvatar` throws on an invalid definition (for example more than the
+ // 16 body nodes the schema allows). Contain it per card so one bad ghost
+ // cannot stop the rest of the grid from rendering.
+ let controller
+ try {
+ controller = createAvatar(mount, {
+ definition: ghost.definition,
+ defaultAnimation: reduceMotion ? undefined : 'idle',
+ autoplay: !reduceMotion,
+ size: '100%',
+ ariaLabel: `${ghost.name}, a ghost — ${ghost.tail}`,
+ onError: error => console.error(`[ghost:${ghost.id}]`, error),
+ })
+ } catch (error) {
+ console.error(`[ghost:${ghost.id}] failed to build`, error)
+ card.classList.add('is-failed')
+ return
+ }
+
+ if (reduceMotion) controller.setExpression('neutral')
+
+ // Hover plays each ghost's own signature emote. The engine's slowDrift keeps
+ // them bobbing at rest, so no CSS float is layered on top of it.
+ if (!reduceMotion) {
+ card.addEventListener('pointerenter', () => controller.play(ghost.signature))
+ card.addEventListener('pointerleave', () => controller.play('idle'))
+ }
+}
+
+ghosts.forEach(buildCard)
diff --git a/examples/portfolio-gallery/src/ghosts.css b/examples/portfolio-gallery/src/ghosts.css
new file mode 100644
index 0000000..d9eb633
--- /dev/null
+++ b/examples/portfolio-gallery/src/ghosts.css
@@ -0,0 +1,17 @@
+/* Reuses the monochrome Page 3 shell; only the tail-specific framing differs. */
+@import './pip.css';
+
+/* Tails reach well outside the head, so the avatar box is smaller here to keep
+ the whole silhouette inside its tile. */
+.avatar {
+ width: 58%;
+ height: 58%;
+}
+
+.meta {
+ gap: 5px;
+}
+
+.meta .note {
+ margin: 0;
+}
diff --git a/examples/portfolio-gallery/src/ghosts.ts b/examples/portfolio-gallery/src/ghosts.ts
new file mode 100644
index 0000000..766cc25
--- /dev/null
+++ b/examples/portfolio-gallery/src/ghosts.ts
@@ -0,0 +1,249 @@
+import {
+ buildDefinition,
+ lobe,
+ nd,
+ ns,
+ sf,
+ wisp,
+ type BodyNode,
+ type Character,
+ type CharacterConfig,
+ type EmoteKey,
+ type Surface,
+} from './builder'
+
+// Ten ghosts, built from Pip. Each keeps the same round head and round eyes and
+// stays inside the Page 3 monochrome system; the variable on this page is the
+// TAIL. Tails are `wisp` chains — tapering lobes swept along a Bezier — so they
+// merge with the body into one continuous silhouette.
+//
+// Reference cues taken from the sketch: round body, round eyes set high, two
+// small arm nubs, and a wavy tail trailing off to one side. Two ghosts also wear
+// a wide flat cone as a straw hat, a direct nod to the drawing.
+
+const GHOST_EYE = { w: 24, h: 28, y: -18, spacing: 40 }
+
+// Two small arm nubs at mid-height, as in the sketch.
+const arms = (size: number, x: number, y: number): BodyNode[] => [
+ lobe(size, -x, y, -6),
+ lobe(size, x, y, -6),
+]
+
+// A wide, almost flat cone reads as a conical straw hat.
+const strawHat = (width: number, y: number): BodyNode =>
+ nd(ns('cone', width, 70, width, 0.9, { baseRoundness: 1, tipRoundness: 0.55 }), [0, y, 8])
+
+type Ghost = {
+ id: string
+ name: string
+ tail: string
+ body: string
+ eyes: string
+ ground: string
+ mode: 'light' | 'dark'
+ signature: EmoteKey
+ surface: Surface
+ nodes: BodyNode[]
+}
+
+export type GhostVariant = Character & {
+ ground: string
+ mode: 'light' | 'dark'
+ tail: string
+}
+
+const ghostList: Ghost[] = [
+ {
+ id: 'trail',
+ name: 'Trail',
+ tail: 'One long sweep, bowed out to the right',
+ body: '#0a0a0a',
+ eyes: '#f6f6f6',
+ ground: '#f6f6f6',
+ mode: 'light',
+ signature: 'curious',
+ surface: sf('sphere', 186, 186, 186, 1),
+ nodes: [
+ ...arms(52, 92, 26),
+ ...wisp({ from: [20, 84], to: [150, 130], bow: 54, startSize: 78, endSize: 20, count: 13 }),
+ ],
+ },
+ {
+ id: 'curl',
+ name: 'Curl',
+ tail: 'Sweeps out, then hooks back up at the tip',
+ body: '#2a2a2a',
+ eyes: '#f2f2f2',
+ ground: '#ffffff',
+ mode: 'light',
+ signature: 'hello',
+ surface: sf('sphere', 190, 190, 190, 1),
+ nodes: [
+ ...arms(52, 94, 26),
+ ...wisp({ from: [10, 88], to: [110, 120], bow: 40, startSize: 80, endSize: 46, count: 7 }),
+ ...wisp({ from: [110, 120], to: [150, 58], bow: 40, startSize: 44, endSize: 24, count: 7 }),
+ ],
+ },
+ {
+ id: 'sliver',
+ name: 'Sliver',
+ tail: 'Short and shy, barely leaves the body',
+ body: '#4d4d4d',
+ eyes: '#ffffff',
+ ground: '#f6f6f6',
+ mode: 'light',
+ signature: 'shy',
+ surface: sf('capsule', 176, 212, 176, 1),
+ nodes: [
+ ...wisp({ from: [0, 100], to: [92, 138], bow: 30, startSize: 66, endSize: 20, count: 10 }),
+ ],
+ },
+ {
+ id: 'comet',
+ name: 'Comet',
+ tail: 'Long and nearly straight, low bow',
+ body: '#7a7a7a',
+ eyes: '#141414',
+ ground: '#ffffff',
+ mode: 'light',
+ signature: 'excited',
+ surface: sf('sphere', 180, 180, 180, 1),
+ nodes: [
+ ...arms(48, 90, 24),
+ ...wisp({ from: [16, 80], to: [176, 108], bow: 18, startSize: 74, endSize: 22, count: 13 }),
+ ],
+ },
+ {
+ id: 'drift',
+ name: 'Drift',
+ tail: 'Mirrored — trails off to the left',
+ body: '#b5b5b5',
+ eyes: '#141414',
+ ground: '#f6f6f6',
+ mode: 'light',
+ signature: 'sleepy',
+ surface: sf('sphere', 186, 186, 186, 1),
+ nodes: [
+ ...arms(52, 92, 26),
+ ...wisp({
+ from: [-20, 86],
+ to: [-150, 126],
+ bow: -54,
+ startSize: 76,
+ endSize: 20,
+ count: 13,
+ }),
+ ],
+ },
+ {
+ id: 'ribbon',
+ name: 'Ribbon',
+ tail: 'Two opposed bows — a full S-curve',
+ body: '#ffffff',
+ eyes: '#0a0a0a',
+ ground: '#0a0a0a',
+ mode: 'dark',
+ signature: 'look-around',
+ surface: sf('sphere', 184, 184, 184, 1),
+ nodes: [
+ ...arms(50, 91, 25),
+ ...wisp({ from: [8, 84], to: [96, 116], bow: 44, startSize: 76, endSize: 48, count: 7 }),
+ ...wisp({ from: [96, 116], to: [164, 146], bow: -44, startSize: 46, endSize: 26, count: 7 }),
+ ],
+ },
+ {
+ id: 'coil',
+ name: 'Coil',
+ tail: 'Tight curl that loops back over itself',
+ body: '#f2f2f2',
+ eyes: '#141414',
+ ground: '#000000',
+ mode: 'dark',
+ signature: 'curious',
+ surface: sf('sphere', 188, 188, 188, 1),
+ nodes: [
+ ...arms(52, 93, 26),
+ ...wisp({ from: [10, 86], to: [120, 112], bow: 30, startSize: 78, endSize: 48, count: 7 }),
+ ...wisp({ from: [120, 112], to: [126, 44], bow: 54, startSize: 46, endSize: 26, count: 7 }),
+ ],
+ },
+ {
+ id: 'kasa',
+ name: 'Kasa',
+ tail: 'Sketch homage — straw hat over a bowed tail',
+ body: '#e0e0e0',
+ eyes: '#2a2a2a',
+ ground: '#141414',
+ mode: 'dark',
+ signature: 'hello',
+ surface: sf('sphere', 182, 182, 182, 1),
+ nodes: [
+ strawHat(252, -116),
+ ...arms(50, 90, 28),
+ ...wisp({ from: [14, 80], to: [142, 120], bow: 46, startSize: 72, endSize: 20, count: 12 }),
+ ],
+ },
+ {
+ id: 'twin',
+ name: 'Twin',
+ tail: 'Two tails, one either side',
+ body: '#a8a8a8',
+ eyes: '#0a0a0a',
+ ground: '#0a0a0a',
+ mode: 'dark',
+ signature: 'excited',
+ surface: sf('sphere', 186, 186, 186, 1),
+ nodes: [
+ ...wisp({
+ from: [-16, 88],
+ to: [-112, 122],
+ bow: -44,
+ startSize: 62,
+ endSize: 26,
+ count: 8,
+ }),
+ ...wisp({ from: [26, 88], to: [122, 122], bow: 44, startSize: 62, endSize: 26, count: 8 }),
+ ],
+ },
+ {
+ id: 'puff',
+ name: 'Puff',
+ tail: 'Stubby and thick — almost no taper',
+ body: '#4d4d4d',
+ eyes: '#bcbcbc',
+ ground: '#000000',
+ mode: 'dark',
+ signature: 'surprise',
+ surface: sf('sphere', 196, 196, 196, 1),
+ nodes: [
+ ...arms(54, 96, 28),
+ ...wisp({ from: [0, 92], to: [86, 124], bow: 26, startSize: 90, endSize: 34, count: 7 }),
+ ],
+ },
+]
+
+export const ghosts: GhostVariant[] = ghostList.map(g => {
+ const role = `BODY ${g.body.toUpperCase()} · EYES ${g.eyes.toUpperCase()}`
+ const config: CharacterConfig = {
+ id: g.id,
+ name: g.name,
+ role,
+ surface: g.surface,
+ body: g.body,
+ eyes: g.eyes,
+ eye: GHOST_EYE,
+ nodes: g.nodes,
+ signature: g.signature,
+ }
+ return {
+ id: g.id,
+ name: g.name,
+ role,
+ accent: g.body,
+ signature: g.signature,
+ definition: buildDefinition(config),
+ ground: g.ground,
+ mode: g.mode,
+ tail: g.tail,
+ }
+})
diff --git a/examples/portfolio-gallery/src/pip-main.ts b/examples/portfolio-gallery/src/pip-main.ts
new file mode 100644
index 0000000..ea3e2bb
--- /dev/null
+++ b/examples/portfolio-gallery/src/pip-main.ts
@@ -0,0 +1,58 @@
+import { createAvatar } from '@bible-strong/avatar-web'
+
+import { pipVariants, type PipVariant } from './pip'
+import './pip.css'
+
+const grid = document.querySelector('#grid')
+if (!grid) throw new Error('Pip variation grid was not found.')
+
+const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+
+const buildCard = (variant: PipVariant, index: number) => {
+ const card = document.createElement('article')
+ card.className = `card is-${variant.mode}`
+ card.style.setProperty('--ground', variant.ground)
+ card.style.setProperty('--accent', variant.accent)
+ card.style.setProperty('--i', String(index))
+
+ const tile = document.createElement('div')
+ tile.className = 'tile'
+ const mount = document.createElement('div')
+ mount.className = 'avatar'
+ tile.appendChild(mount)
+
+ const meta = document.createElement('div')
+ meta.className = 'meta'
+ const name = document.createElement('h2')
+ name.textContent = variant.name
+ const token = document.createElement('p')
+ token.className = 'token'
+ token.textContent = variant.role
+ const note = document.createElement('p')
+ note.className = 'note'
+ note.textContent = variant.note
+ meta.append(name, token, note)
+
+ card.append(tile, meta)
+ grid.appendChild(card)
+
+ const controller = createAvatar(mount, {
+ definition: variant.definition,
+ defaultAnimation: reduceMotion ? undefined : 'idle',
+ autoplay: !reduceMotion,
+ size: '100%',
+ ariaLabel: `Pip in ${variant.name} — ${variant.role}`,
+ onError: error => console.error(`[pip:${variant.id}]`, error),
+ })
+ if (reduceMotion) controller.setExpression('neutral')
+
+ // Hover previews Pip's signature emote. The hovered card is the focal one;
+ // the rest of the grid is left alone (dimming every sibling on a hover this
+ // frequent fails the 50-use test).
+ if (!reduceMotion) {
+ card.addEventListener('pointerenter', () => controller.play('curious'))
+ card.addEventListener('pointerleave', () => controller.play('idle'))
+ }
+}
+
+pipVariants.forEach(buildCard)
diff --git a/examples/portfolio-gallery/src/pip.css b/examples/portfolio-gallery/src/pip.css
new file mode 100644
index 0000000..5745416
--- /dev/null
+++ b/examples/portfolio-gallery/src/pip.css
@@ -0,0 +1,264 @@
+@import url('https://fonts.bunny.net/css?family=geist:400,500,600|geist-mono:400,500');
+
+/* The monochrome system in use on Paper page 3. No hue anywhere. */
+:root {
+ --v-000: #000000;
+ --v-0a: #0a0a0a; /* dark artboard ground */
+ --v-14: #141414;
+ --v-2a: #2a2a2a;
+ --v-4d: #4d4d4d;
+ --v-7a: #7a7a7a; /* workhorse gray */
+ --v-a8: #a8a8a8;
+ --v-b5: #b5b5b5;
+ --v-bc: #bcbcbc;
+ --v-e0: #e0e0e0;
+ --v-f2: #f2f2f2;
+ --v-f6: #f6f6f6; /* light artboard ground */
+ --v-fff: #ffffff;
+
+ --page: var(--v-f6);
+ --surface: var(--v-fff);
+ --ink: var(--v-0a);
+ --ink-muted: #7a7a7a;
+ --border: #0a0a0a14;
+ --font-body: 'Geist', system-ui, sans-serif;
+ --font-mono: 'Geist Mono', ui-monospace, monospace;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+}
+
+body {
+ background: var(--page);
+ color: var(--ink);
+ font-family: var(--font-body);
+ -webkit-font-smoothing: antialiased;
+}
+
+.wrap {
+ max-width: 1320px;
+ margin: 0 auto;
+ padding: clamp(40px, 6vw, 88px) clamp(20px, 4vw, 56px) 88px;
+}
+
+header.masthead {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ padding-bottom: clamp(32px, 5vw, 60px);
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ font-family: var(--font-mono);
+ font-size: 11.5px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--ink-muted);
+}
+
+.brand svg {
+ width: 15px;
+ height: 15px;
+ color: var(--v-0a);
+}
+
+h1 {
+ font-weight: 500;
+ font-size: clamp(30px, 4.6vw, 54px);
+ line-height: 1.04;
+ letter-spacing: -0.03em;
+ margin: 0;
+ max-width: 22ch;
+}
+
+/* Emphasis carried by value, not hue. */
+h1 em {
+ font-style: normal;
+ color: var(--v-7a);
+}
+
+.lede {
+ max-width: 64ch;
+ margin: 0;
+ font-size: 16px;
+ line-height: 1.55;
+ color: var(--ink-muted);
+}
+
+.lede code {
+ font-family: var(--font-mono);
+ font-size: 0.9em;
+}
+
+/* The source ramp, darkest to lightest. */
+.swatches {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 6px;
+}
+
+.swatch {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ color: var(--ink-muted);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 3px 8px 3px 4px;
+}
+
+.swatch i {
+ width: 12px;
+ height: 12px;
+ border-radius: 999px;
+ background: var(--sw);
+ box-shadow: inset 0 0 0 1px #0000001f;
+}
+
+#grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(212px, 1fr));
+ gap: clamp(14px, 1.6vw, 22px);
+}
+
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 14px 14px 18px;
+ border-radius: 20px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ transition:
+ transform 300ms cubic-bezier(0.16, 1, 0.3, 1),
+ box-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1);
+ will-change: transform;
+}
+
+.card.is-dark {
+ background: var(--v-0a);
+ border-color: #ffffff1f;
+}
+
+.card:hover {
+ transform: translateY(-5px);
+ box-shadow: 0 22px 44px -26px #0a0a0a5c;
+}
+
+.tile {
+ aspect-ratio: 1;
+ border-radius: 14px;
+ display: grid;
+ place-items: center;
+ background: var(--ground);
+ overflow: hidden;
+ box-shadow: inset 0 0 0 1px #0a0a0a0f;
+}
+
+.card.is-dark .tile {
+ box-shadow: inset 0 0 0 1px #ffffff14;
+}
+
+.avatar {
+ width: 76%;
+ height: 76%;
+ display: block;
+}
+
+.avatar svg {
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+}
+
+.meta {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 0 3px;
+}
+
+.meta h2 {
+ font-weight: 500;
+ font-size: 17px;
+ letter-spacing: -0.015em;
+ margin: 0;
+ color: var(--ink);
+}
+
+.card.is-dark .meta h2 {
+ color: var(--v-f2);
+}
+
+.token {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ letter-spacing: 0.03em;
+ color: var(--ink-muted);
+ margin: 0;
+}
+
+.note {
+ font-size: 12px;
+ line-height: 1.4;
+ color: #0a0a0a8a;
+ margin: 2px 0 0;
+}
+
+.card.is-dark .note {
+ color: #f2f2f28a;
+}
+
+footer.colophon {
+ margin-top: clamp(40px, 6vw, 76px);
+ padding-top: 24px;
+ border-top: 1px solid var(--border);
+ font-family: var(--font-mono);
+ font-size: 11.5px;
+ color: var(--ink-muted);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 20px;
+ justify-content: space-between;
+}
+
+/* Staged entrance — runs once, so a keyframe is correct here. */
+@keyframes card-enter {
+ from {
+ opacity: 0;
+ transform: translateY(9px);
+ filter: blur(5px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ filter: blur(0);
+ }
+}
+
+.card {
+ opacity: 0;
+ animation: card-enter 440ms cubic-bezier(0.16, 1, 0.3, 1) both;
+ animation-delay: calc(var(--i, 0) * 50ms);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .card {
+ animation: none;
+ opacity: 1;
+ transition: none;
+ }
+}
diff --git a/examples/portfolio-gallery/src/pip.ts b/examples/portfolio-gallery/src/pip.ts
new file mode 100644
index 0000000..8aa9019
--- /dev/null
+++ b/examples/portfolio-gallery/src/pip.ts
@@ -0,0 +1,155 @@
+import { buildDefinition, sf, type Character, type CharacterConfig } from './builder'
+
+// Ten variations of Pip in the monochrome system actually used on Page 3 of the
+// Paper file "ditto - aug 23" (WORK1 / COMMUNITY1 / PLAY1 / ABOUT1, Dark + Light).
+//
+// Verified grounds, read from the artboards themselves:
+// dark artboards backgroundColor #0A0A0A
+// light artboards backgroundColor #F6F6F6
+//
+// Values in use on the page (Paper "Selection colors"), darkest to lightest:
+// #000000 #0A0A0A #141414 #2A2A2A #4D4D4D
+// #7A7A7A (the workhorse — by far the most-used) #9E9E9E #A8A8A8
+// #B2B2B2 #B5B5B5 #BCBCBC #C0C0C0
+// #E0E0E0 #F2F2F2 #F6F6F6 #FFFFFF
+//
+// Pip's geometry is held constant, so with hue removed the only variable left
+// is value. Each card is one rung of the ramp: five on the light ground, five
+// on the dark ground.
+
+const PIP_SURFACE = sf('sphere', 236, 236, 236, 1)
+const PIP_EYE = { w: 26, h: 46, y: -6, spacing: 40 }
+
+export type PipMode = 'light' | 'dark'
+
+export type PipVariant = Character & { ground: string; mode: PipMode; note: string }
+
+type Recipe = {
+ id: string
+ name: string
+ body: string
+ eyes: string
+ ground: string
+ mode: PipMode
+ note: string
+}
+
+const recipes: Recipe[] = [
+ // --- Light ground -------------------------------------------------------
+ {
+ id: 'ink',
+ name: 'Ink',
+ body: '#000000',
+ eyes: '#f6f6f6',
+ ground: '#f6f6f6',
+ mode: 'light',
+ note: 'Eyes match the ground — they read as cut-outs.',
+ },
+ {
+ id: 'graphite',
+ name: 'Graphite',
+ body: '#2a2a2a',
+ eyes: '#f2f2f2',
+ ground: '#ffffff',
+ mode: 'light',
+ note: 'Softer than pure black, still fully legible.',
+ },
+ {
+ id: 'slate',
+ name: 'Slate',
+ body: '#4d4d4d',
+ eyes: '#ffffff',
+ ground: '#f6f6f6',
+ mode: 'light',
+ note: 'Mid-dark body; the safest all-round light-mode pick.',
+ },
+ {
+ id: 'gray',
+ name: 'Gray',
+ body: '#7a7a7a',
+ eyes: '#141414',
+ ground: '#ffffff',
+ mode: 'light',
+ note: 'The page workhorse. Eye value flips dark here to hold contrast.',
+ },
+ {
+ id: 'silver',
+ name: 'Silver',
+ body: '#b5b5b5',
+ eyes: '#141414',
+ ground: '#f6f6f6',
+ mode: 'light',
+ note: 'Recedes into the page — good for an ambient or disabled state.',
+ },
+ // --- Dark ground -------------------------------------------------------
+ {
+ id: 'paper',
+ name: 'Paper',
+ body: '#ffffff',
+ eyes: '#0a0a0a',
+ ground: '#0a0a0a',
+ mode: 'dark',
+ note: 'The Ink card inverted; eyes again match the ground.',
+ },
+ {
+ id: 'bone',
+ name: 'Bone',
+ body: '#f2f2f2',
+ eyes: '#141414',
+ ground: '#000000',
+ mode: 'dark',
+ note: 'Off-white on true black — maximum weight without pure white.',
+ },
+ {
+ id: 'mist',
+ name: 'Mist',
+ body: '#e0e0e0',
+ eyes: '#2a2a2a',
+ ground: '#141414',
+ mode: 'dark',
+ note: 'Slightly dimmed body; sits back from Bone.',
+ },
+ {
+ id: 'ash',
+ name: 'Ash',
+ body: '#a8a8a8',
+ eyes: '#0a0a0a',
+ ground: '#0a0a0a',
+ mode: 'dark',
+ note: 'Mid-gray on dark; the dark-mode equivalent of Slate.',
+ },
+ {
+ id: 'shadow',
+ name: 'Shadow',
+ body: '#4d4d4d',
+ eyes: '#bcbcbc',
+ ground: '#000000',
+ mode: 'dark',
+ note: 'The quiet extreme — body barely separates from the ground.',
+ },
+]
+
+export const pipVariants: PipVariant[] = recipes.map(r => {
+ const role = `BODY ${r.body.toUpperCase()} · EYES ${r.eyes.toUpperCase()}`
+ const config: CharacterConfig = {
+ id: r.id,
+ name: r.name,
+ role,
+ surface: PIP_SURFACE,
+ body: r.body,
+ eyes: r.eyes,
+ eye: PIP_EYE,
+ signature: 'curious', // Pip's own signature emote, held constant.
+ }
+ return {
+ id: r.id,
+ name: r.name,
+ role,
+ accent: r.body,
+ signature: 'curious',
+ definition: buildDefinition(config),
+ ground: r.ground,
+ mode: r.mode,
+ note: r.note,
+ }
+})