From 8537b53ec9c3878993284414933e347b9bb80bd2 Mon Sep 17 00:00:00 2001 From: David Lafond Date: Mon, 24 Aug 2026 21:53:27 -0400 Subject: [PATCH] Add smooth ghost tail and crew studies --- examples/portfolio-gallery/crew.html | 58 +++ examples/portfolio-gallery/src/builder.ts | 119 +++++ examples/portfolio-gallery/src/crew-main.ts | 87 ++++ examples/portfolio-gallery/src/crew.css | 32 ++ examples/portfolio-gallery/src/crew.ts | 474 +++++++++++++++++++ examples/portfolio-gallery/src/tails-main.ts | 84 ++++ examples/portfolio-gallery/src/tails.css | 247 ++++++++++ examples/portfolio-gallery/src/tails.ts | 250 ++++++++++ examples/portfolio-gallery/tails.html | 53 +++ 9 files changed, 1404 insertions(+) create mode 100644 examples/portfolio-gallery/crew.html create mode 100644 examples/portfolio-gallery/src/crew-main.ts create mode 100644 examples/portfolio-gallery/src/crew.css create mode 100644 examples/portfolio-gallery/src/crew.ts create mode 100644 examples/portfolio-gallery/src/tails-main.ts create mode 100644 examples/portfolio-gallery/src/tails.css create mode 100644 examples/portfolio-gallery/src/tails.ts create mode 100644 examples/portfolio-gallery/tails.html diff --git a/examples/portfolio-gallery/crew.html b/examples/portfolio-gallery/crew.html new file mode 100644 index 0000000..a49e5f4 --- /dev/null +++ b/examples/portfolio-gallery/crew.html @@ -0,0 +1,58 @@ + + + + + + + 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. +

+ +
+
+

Light — bg 97 · tile 79 · muted 40 · ink 15 · accent 0

+
+
+
+
+
+
+
+
+
+

Dark — bg 3 · tile 21 · muted 60 · ink 85 · accent 100

+
+
+
+
+
+
+
+
+
+
+ +
+ +
+ Rendered with @bible-strong/avatar-web + Design System 01 · 16 proportions · 32 avatars +
+
+ + + 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. +

+ +
+
+

Light — bg 97 · tile 79 · muted 40 · ink 15 · accent 0

+
+
+
+
+
+
+
+
+
+

Dark — bg 3 · tile 21 · muted 60 · ink 85 · accent 100

+
+
+
+
+
+
+
+
+
+
+ +
+ +
+ Rendered with @bible-strong/avatar-web + Design System 01 · 7 roles · 10 tails · 20 avatars +
+
+ + +