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
|
height: number
|
||||||
depth: number
|
depth: number
|
||||||
roundness: number
|
roundness: number
|
||||||
|
morphRoundness?: number
|
||||||
tipRoundness?: number
|
tipRoundness?: number
|
||||||
baseRoundness?: number
|
baseRoundness?: number
|
||||||
}
|
}
|
||||||
@ -31,6 +32,22 @@ export type BodyNode = {
|
|||||||
rotation: [number, number, number]
|
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 EyeShape = { w: number; h: number; y: number; angle: number; spacing: number }
|
||||||
|
|
||||||
export type CharacterConfig = {
|
export type CharacterConfig = {
|
||||||
@ -177,6 +194,108 @@ export const wisp = (opts: {
|
|||||||
return nodes
|
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 ------------------------------------
|
// --- Expression + animation generation ------------------------------------
|
||||||
|
|
||||||
type EyeValue = { width: number; height: number; x: number; y: number; angle: number }
|
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