Add smooth ghost tail and crew studies
This commit is contained in:
parent
380c2cc1a8
commit
8537b53ec9
58
examples/portfolio-gallery/crew.html
Normal file
58
examples/portfolio-gallery/crew.html
Normal 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>— 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 — bg 97 · tile 79 · muted 40 · ink 15 · 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 — bg 3 · tile 21 · muted 60 · ink 85 · 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 · 16 proportions · 32 avatars</span>
|
||||
</footer>
|
||||
</div>
|
||||
<script type="module" src="/src/crew-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -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 }
|
||||
|
||||
87
examples/portfolio-gallery/src/crew-main.ts
Normal file
87
examples/portfolio-gallery/src/crew-main.ts
Normal 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)
|
||||
32
examples/portfolio-gallery/src/crew.css
Normal file
32
examples/portfolio-gallery/src/crew.css
Normal 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);
|
||||
}
|
||||
474
examples/portfolio-gallery/src/crew.ts
Normal file
474
examples/portfolio-gallery/src/crew.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
84
examples/portfolio-gallery/src/tails-main.ts
Normal file
84
examples/portfolio-gallery/src/tails-main.ts
Normal 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)
|
||||
247
examples/portfolio-gallery/src/tails.css
Normal file
247
examples/portfolio-gallery/src/tails.css
Normal 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;
|
||||
}
|
||||
}
|
||||
250
examples/portfolio-gallery/src/tails.ts
Normal file
250
examples/portfolio-gallery/src/tails.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
53
examples/portfolio-gallery/tails.html
Normal file
53
examples/portfolio-gallery/tails.html
Normal 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>— 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
|
||||
— 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 — bg 97 · tile 79 · muted 40 · ink 15 · 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 — bg 3 · tile 21 · muted 60 · ink 85 · 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 · 7 roles · 10 tails · 20 avatars</span>
|
||||
</footer>
|
||||
</div>
|
||||
<script type="module" src="/src/tails-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user