From 380c2cc1a822e5eeac2dcf2beafaaa3a574a4153 Mon Sep 17 00:00:00 2001 From: David Lafond Date: Mon, 24 Aug 2026 19:21:01 -0400 Subject: [PATCH] Add Grokbot HTML explorations: pip, ghosts, gallery builder --- examples/portfolio-gallery/ghosts.html | 41 +++ examples/portfolio-gallery/pip.html | 55 ++++ examples/portfolio-gallery/src/builder.ts | 37 +++ examples/portfolio-gallery/src/ghosts-main.ts | 69 +++++ examples/portfolio-gallery/src/ghosts.css | 17 ++ examples/portfolio-gallery/src/ghosts.ts | 249 +++++++++++++++++ examples/portfolio-gallery/src/pip-main.ts | 58 ++++ examples/portfolio-gallery/src/pip.css | 264 ++++++++++++++++++ examples/portfolio-gallery/src/pip.ts | 155 ++++++++++ 9 files changed, 945 insertions(+) create mode 100644 examples/portfolio-gallery/ghosts.html create mode 100644 examples/portfolio-gallery/pip.html create mode 100644 examples/portfolio-gallery/src/ghosts-main.ts create mode 100644 examples/portfolio-gallery/src/ghosts.css create mode 100644 examples/portfolio-gallery/src/ghosts.ts create mode 100644 examples/portfolio-gallery/src/pip-main.ts create mode 100644 examples/portfolio-gallery/src/pip.css create mode 100644 examples/portfolio-gallery/src/pip.ts diff --git a/examples/portfolio-gallery/ghosts.html b/examples/portfolio-gallery/ghosts.html new file mode 100644 index 0000000..2fed463 --- /dev/null +++ b/examples/portfolio-gallery/ghosts.html @@ -0,0 +1,41 @@ + + + + + + + Ghosts — ten tails in the Page 3 monochrome system + + +
+
+ + + Ghosts · ten tails + +

Pip grew a tail and floated off.

+

+ 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. +

+
+ +
+ +
+ Rendered with @bible-strong/avatar-web + Values: ditto - aug 23 · Page 3 +
+
+ + + diff --git a/examples/portfolio-gallery/pip.html b/examples/portfolio-gallery/pip.html new file mode 100644 index 0000000..6e95c02 --- /dev/null +++ b/examples/portfolio-gallery/pip.html @@ -0,0 +1,55 @@ + + + + + + + Pip — 10 variations in the Page 3 monochrome system + + +
+
+ + + Pip · 10 variations + +

One Pip, ten values, no hue anywhere.

+

+ 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 + #F6F6F6 and five on the dark ground #0A0A0A. Hover any one to + see Pip’s signature “curious” emote. +

+
+ 000000 + 0A0A0A + 141414 + 2A2A2A + 4D4D4D + 7A7A7A + A8A8A8 + B5B5B5 + BCBCBC + E0E0E0 + F2F2F2 + F6F6F6 + FFFFFF +
+
+ +
+ +
+ Rendered with @bible-strong/avatar-web + Values: ditto - aug 23 · Page 3 +
+
+ + + diff --git a/examples/portfolio-gallery/src/builder.ts b/examples/portfolio-gallery/src/builder.ts index 9f25316..dca29b0 100644 --- a/examples/portfolio-gallery/src/builder.ts +++ b/examples/portfolio-gallery/src/builder.ts @@ -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 } diff --git a/examples/portfolio-gallery/src/ghosts-main.ts b/examples/portfolio-gallery/src/ghosts-main.ts new file mode 100644 index 0000000..2bf9b6b --- /dev/null +++ b/examples/portfolio-gallery/src/ghosts-main.ts @@ -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('#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) diff --git a/examples/portfolio-gallery/src/ghosts.css b/examples/portfolio-gallery/src/ghosts.css new file mode 100644 index 0000000..d9eb633 --- /dev/null +++ b/examples/portfolio-gallery/src/ghosts.css @@ -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; +} diff --git a/examples/portfolio-gallery/src/ghosts.ts b/examples/portfolio-gallery/src/ghosts.ts new file mode 100644 index 0000000..766cc25 --- /dev/null +++ b/examples/portfolio-gallery/src/ghosts.ts @@ -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, + } +}) diff --git a/examples/portfolio-gallery/src/pip-main.ts b/examples/portfolio-gallery/src/pip-main.ts new file mode 100644 index 0000000..ea3e2bb --- /dev/null +++ b/examples/portfolio-gallery/src/pip-main.ts @@ -0,0 +1,58 @@ +import { createAvatar } from '@bible-strong/avatar-web' + +import { pipVariants, type PipVariant } from './pip' +import './pip.css' + +const grid = document.querySelector('#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) diff --git a/examples/portfolio-gallery/src/pip.css b/examples/portfolio-gallery/src/pip.css new file mode 100644 index 0000000..5745416 --- /dev/null +++ b/examples/portfolio-gallery/src/pip.css @@ -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; + } +} diff --git a/examples/portfolio-gallery/src/pip.ts b/examples/portfolio-gallery/src/pip.ts new file mode 100644 index 0000000..8aa9019 --- /dev/null +++ b/examples/portfolio-gallery/src/pip.ts @@ -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, + } +})