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