Add smooth ghost tail and crew studies

This commit is contained in:
David Lafond 2026-08-24 21:53:27 -04:00
parent 380c2cc1a8
commit 8537b53ec9
9 changed files with 1404 additions and 0 deletions

View File

@ -0,0 +1,58 @@
<!doctype html>
<html lang="en" data-mode="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f6f6f6" />
<title>Crew - sixteen ghosts, no two proportioned alike</title>
</head>
<body>
<div class="shell">
<header>
<h1>Crew <em>&mdash; sixteen ghosts, no two proportioned alike</em></h1>
<p class="lede">
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.
</p>
<p class="lede">
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.
</p>
<div class="roles">
<div class="ramp" data-mode="light">
<h3>Light &mdash; bg 97 &middot; tile 79 &middot; muted 40 &middot; ink 15 &middot; accent 0</h3>
<div class="swatches">
<div class="swatch" style="background: var(--light-bg)"></div>
<div class="swatch" style="background: var(--light-tile)"></div>
<div class="swatch" style="background: var(--light-ink-muted)"></div>
<div class="swatch" style="background: var(--light-ink)"></div>
<div class="swatch" style="background: var(--light-accent)"></div>
</div>
</div>
<div class="ramp" data-mode="dark">
<h3>Dark &mdash; bg 3 &middot; tile 21 &middot; muted 60 &middot; ink 85 &middot; accent 100</h3>
<div class="swatches">
<div class="swatch" style="background: var(--dark-bg)"></div>
<div class="swatch" style="background: var(--dark-tile)"></div>
<div class="swatch" style="background: var(--dark-ink-muted)"></div>
<div class="swatch" style="background: var(--dark-ink)"></div>
<div class="swatch" style="background: var(--dark-accent)"></div>
</div>
</div>
</div>
</header>
<main id="grid"></main>
<footer>
<span>Rendered with @bible-strong/avatar-web</span>
<span>Design System 01 &middot; 16 proportions &middot; 32 avatars</span>
</footer>
</div>
<script type="module" src="/src/crew-main.ts"></script>
</body>
</html>

View File

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

View File

@ -0,0 +1,87 @@
import { createAvatar } from '@bible-strong/avatar-web'
import { crewMembers, type CrewMember } from './crew'
import './crew.css'
const grid = document.querySelector<HTMLElement>('#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)

View File

@ -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);
}

View File

@ -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<BodyRole, string> = {
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,
}
})

View File

@ -0,0 +1,84 @@
import { createAvatar } from '@bible-strong/avatar-web'
import { ghostPairs, type GhostPair } from './tails'
import './tails.css'
const grid = document.querySelector<HTMLElement>('#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)

View File

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

View File

@ -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<BodyRole, string> = {
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,
}
})

View File

@ -0,0 +1,53 @@
<!doctype html>
<html lang="en" data-mode="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f6f6f6" />
<title>Tails - one smooth volume, two modes</title>
</head>
<body>
<div class="shell">
<header>
<h1>Tails <em>&mdash; one smooth volume, two modes</em></h1>
<p class="lede">
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
&mdash; no chain of spheres, no seam. Every ghost is generated twice from identical
geometry: light is the tonal inverse of dark, so the contrast relationships mirror exactly.
</p>
<div class="roles">
<div class="ramp" data-mode="light">
<h3>Light &mdash; bg 97 &middot; tile 79 &middot; muted 40 &middot; ink 15 &middot; accent 0</h3>
<div class="swatches">
<div class="swatch" style="background: var(--light-bg)"></div>
<div class="swatch" style="background: var(--light-tile)"></div>
<div class="swatch" style="background: var(--light-ink-muted)"></div>
<div class="swatch" style="background: var(--light-ink)"></div>
<div class="swatch" style="background: var(--light-accent)"></div>
</div>
</div>
<div class="ramp" data-mode="dark">
<h3>Dark &mdash; bg 3 &middot; tile 21 &middot; muted 60 &middot; ink 85 &middot; accent 100</h3>
<div class="swatches">
<div class="swatch" style="background: var(--dark-bg)"></div>
<div class="swatch" style="background: var(--dark-tile)"></div>
<div class="swatch" style="background: var(--dark-ink-muted)"></div>
<div class="swatch" style="background: var(--dark-ink)"></div>
<div class="swatch" style="background: var(--dark-accent)"></div>
</div>
</div>
</div>
</header>
<main id="grid"></main>
<footer>
<span>Rendered with @bible-strong/avatar-web</span>
<span>Design System 01 &middot; 7 roles &middot; 10 tails &middot; 20 avatars</span>
</footer>
</div>
<script type="module" src="/src/tails-main.ts"></script>
</body>
</html>