Add Grokbot HTML explorations: pip, ghosts, gallery builder

This commit is contained in:
David Lafond 2026-08-24 19:21:01 -04:00
parent 289908323a
commit 380c2cc1a8
9 changed files with 945 additions and 0 deletions

View 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 &mdash; 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>

View 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 &mdash; 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&rsquo;s signature &ldquo;curious&rdquo; 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>

View File

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

View 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)

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

View 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,
}
})

View 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)

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

View 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,
}
})