From 883b589a436910395859ece054e41583e432479a Mon Sep 17 00:00:00 2001 From: David Lafond Date: Mon, 24 Aug 2026 22:30:53 -0400 Subject: [PATCH] Add Davebot cursor-following portfolio header Rename the portfolio mascot from Bigeye to Davebot and give him the canonical inverse inks: light mode uses a #262626 body with #d4d4d4 eyes, dark mode mirrors that exactly. Eyes follow the pointer by translating the two existing eye paths with one persistent SVG transform each, batched into a single animation frame. Playback, blinking and body drift stay owned by avatar-web, so gaze layers on top without resetting the timeline or regenerating paths. Also fix Prettier violations in earlier portfolio-gallery files. --- examples/portfolio-gallery/crew.html | 10 +- .../portfolio-gallery/davebot-header.html | 127 ++++++++ examples/portfolio-gallery/ghosts.html | 6 +- examples/portfolio-gallery/src/builder.ts | 39 ++- examples/portfolio-gallery/src/characters.ts | 10 +- examples/portfolio-gallery/src/crew.ts | 21 +- .../src/davebot-header-main.ts | 145 +++++++++ .../portfolio-gallery/src/davebot-header.css | 300 ++++++++++++++++++ .../portfolio-gallery/src/davebot-header.ts | 72 +++++ examples/portfolio-gallery/src/showcase.ts | 7 +- examples/portfolio-gallery/tails.html | 13 +- 11 files changed, 711 insertions(+), 39 deletions(-) create mode 100644 examples/portfolio-gallery/davebot-header.html create mode 100644 examples/portfolio-gallery/src/davebot-header-main.ts create mode 100644 examples/portfolio-gallery/src/davebot-header.css create mode 100644 examples/portfolio-gallery/src/davebot-header.ts diff --git a/examples/portfolio-gallery/crew.html b/examples/portfolio-gallery/crew.html index a49e5f4..f471d11 100644 --- a/examples/portfolio-gallery/crew.html +++ b/examples/portfolio-gallery/crew.html @@ -24,7 +24,10 @@
-

Light — bg 97 · tile 79 · muted 40 · ink 15 · accent 0

+

+ Light — bg 97 · tile 79 · muted 40 · ink 15 · accent + 0 +

@@ -34,7 +37,10 @@
-

Dark — bg 3 · tile 21 · muted 60 · ink 85 · accent 100

+

+ Dark — bg 3 · tile 21 · muted 60 · ink 85 · accent + 100 +

diff --git a/examples/portfolio-gallery/davebot-header.html b/examples/portfolio-gallery/davebot-header.html new file mode 100644 index 0000000..f44ecf7 --- /dev/null +++ b/examples/portfolio-gallery/davebot-header.html @@ -0,0 +1,127 @@ + + + + + + + Davebot — pointer-following portfolio guide + + +
+
+ + +
+ + +
+

Hi, I’m David ~
a product designer who leads creative teams.

+
+
+
2026
+
Tableau Urban Innovation
+
Senior Designer
+
+
+
2025
+
Fluid Interfaces
+
Lead Product Designer
+
+
+
2025
+
Plumba Parking
+
Product Manager
+
+
+
2024
+
Serendipity City
+
Lead Product Designer
+
+
+
2023
+
Computational Design Exploratory
+
Lead Product Designer
+
+
+
+ +
+
+
+
+

Light mode

+
+ +
+ + +
+ + +
+

Hi, I’m David ~
a product designer who leads creative teams.

+
+
+
2026
+
Tableau Urban Innovation
+
Senior Designer
+
+
+
2025
+
Fluid Interfaces
+
Lead Product Designer
+
+
+
2025
+
Plumba Parking
+
Product Manager
+
+
+
2024
+
Serendipity City
+
Lead Product Designer
+
+
+
2023
+
Computational Design Exploratory
+
Lead Product Designer
+
+
+
+ +
+
+
+
+

Dark mode

+
+
+ + + diff --git a/examples/portfolio-gallery/ghosts.html b/examples/portfolio-gallery/ghosts.html index 2fed463..60a619b 100644 --- a/examples/portfolio-gallery/ghosts.html +++ b/examples/portfolio-gallery/ghosts.html @@ -23,9 +23,9 @@ 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. + 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.

diff --git a/examples/portfolio-gallery/src/builder.ts b/examples/portfolio-gallery/src/builder.ts index 1303203..9e03ad4 100644 --- a/examples/portfolio-gallery/src/builder.ts +++ b/examples/portfolio-gallery/src/builder.ts @@ -64,14 +64,7 @@ export type CharacterConfig = { } export type EmoteKey = - | 'idle' - | 'hello' - | 'curious' - | 'look-around' - | 'excited' - | 'surprise' - | 'sleepy' - | 'shy' + 'idle' | 'hello' | 'curious' | 'look-around' | 'excited' | 'surprise' | 'sleepy' | 'shy' // Ordered emote repertoire every character can perform. `idle` is the resting // loop; the rest are triggerable one-shot or looping emotes. @@ -345,7 +338,14 @@ export const buildDefinition = (c: CharacterConfig): AvatarDefinition => { motion: MotionKind, perspective = 1 ) => - expression(head, eye(ew, eh, ex, ey, angle), eye(ew, eh, ex, ey, angle), sp, motion, perspective) + expression( + head, + eye(ew, eh, ex, ey, angle), + eye(ew, eh, ex, ey, angle), + sp, + motion, + perspective + ) const expressions = { // resting + gaze @@ -402,8 +402,20 @@ export const buildDefinition = (c: CharacterConfig): AvatarDefinition => { maxIntervalMs: 5400, durationMs: 140, } - const fastBlink = { ...blink, initialDelayMs: 400, minIntervalMs: 900, maxIntervalMs: 1900, durationMs: 110 } - const slowBlink = { ...blink, initialDelayMs: 300, minIntervalMs: 1400, maxIntervalMs: 2800, durationMs: 300 } + const fastBlink = { + ...blink, + initialDelayMs: 400, + minIntervalMs: 900, + maxIntervalMs: 1900, + durationMs: 110, + } + const slowBlink = { + ...blink, + initialDelayMs: 300, + minIntervalMs: 1400, + maxIntervalMs: 2800, + durationMs: 300, + } type Trans = 'spring' | 'smooth' | 'snappy' const step = (expression: string, holdMs: number, transitionMs: number, transition: Trans) => ({ @@ -474,10 +486,7 @@ export const buildDefinition = (c: CharacterConfig): AvatarDefinition => { }, surprise: { playbackMode: 'once' as const, - steps: [ - step('wide', 820, 150, 'snappy'), - step('neutral', 1200, 460, 'spring'), - ], + steps: [step('wide', 820, 150, 'snappy'), step('neutral', 1200, 460, 'spring')], blink, metadata: { label: `${c.name} gasps`, group: 'ensemble' }, }, diff --git a/examples/portfolio-gallery/src/characters.ts b/examples/portfolio-gallery/src/characters.ts index dfdd4a4..0577c16 100644 --- a/examples/portfolio-gallery/src/characters.ts +++ b/examples/portfolio-gallery/src/characters.ts @@ -1,11 +1,4 @@ -import { - antenna, - buildEnsemble, - ears, - sf, - type Character, - type CharacterConfig, -} from './builder' +import { antenna, buildEnsemble, ears, sf, type Character, type CharacterConfig } from './builder' // The original twenty characters for Rachel Chen's portfolio: warm coral // (#e65f2e) accents on a cool near-white field, slate ink, and soft cool tints. @@ -13,7 +6,6 @@ import { const ink = '#2a2f3a' const cream = '#f4f1ea' - const configs: CharacterConfig[] = [ { id: 'pip', diff --git a/examples/portfolio-gallery/src/crew.ts b/examples/portfolio-gallery/src/crew.ts index 8e9d58f..7fcd18e 100644 --- a/examples/portfolio-gallery/src/crew.ts +++ b/examples/portfolio-gallery/src/crew.ts @@ -39,6 +39,12 @@ export const MODES = { export type ModeName = keyof typeof MODES export type BodyRole = 'accent' | 'ink' | 'inkMuted' +type EyeColorRole = 'background' | 'inverseInk' + +const INVERSE_INK: Record = { + light: MODES.dark.ink, + dark: MODES.light.ink, +} const ROLE_LABEL: Record = { accent: 'accent', @@ -51,6 +57,7 @@ type Proportions = { name: string note: string role: BodyRole + eyeColor?: EyeColorRole headW: number headH: number eyeW: number @@ -343,10 +350,11 @@ const crew: Proportions[] = [ tailDepth: -95, }, { - id: 'bigeye', - name: 'Bigeye', - note: 'Largest eyes, filling much of the face', - role: 'accent', + id: 'davebot', + name: 'Davebot', + note: 'Davebot — largest eyes, filling much of the face', + role: 'ink', + eyeColor: 'inverseInk', headW: 184, headH: 190, eyeW: 36, @@ -436,8 +444,9 @@ export const crewMembers: CrewMember[] = crew.map(p => { 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, + // Davebot uses the opposite mode's ink for his eyes; the other crew + // members keep background-colour cut-outs. + eyes: p.eyeColor === 'inverseInk' ? INVERSE_INK[mode] : palette.bg, eye: { w: p.eyeW, h: p.eyeH, y: p.eyeY, spacing: p.eyeSpacing }, nodes: [ tail({ diff --git a/examples/portfolio-gallery/src/davebot-header-main.ts b/examples/portfolio-gallery/src/davebot-header-main.ts new file mode 100644 index 0000000..286ee7a --- /dev/null +++ b/examples/portfolio-gallery/src/davebot-header-main.ts @@ -0,0 +1,145 @@ +import { createAvatar } from '@bible-strong/avatar-web' + +import { davebotHeaderVariants } from './davebot-header' +import './davebot-header.css' + +const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches +const maxGazeOffset = reduceMotion ? 6 : 9 +const fullGazeDistance = 240 + +type GazeTarget = { + mount: HTMLElement + left: SVGTransform + right: SVGTransform + centerX: number + centerY: number +} + +const gazeTargets: GazeTarget[] = [] + +for (const variant of davebotHeaderVariants) { + const panel = document.querySelector(`.portfolio[data-mode="${variant.mode}"]`) + const mount = panel?.querySelector('[data-davebot-avatar]') + if (!panel || !mount) throw new Error(`Davebot mount for ${variant.mode} mode was not found.`) + + const controller = createAvatar(mount, { + definition: variant.definition, + defaultAnimation: reduceMotion ? undefined : 'idle', + autoplay: !reduceMotion, + size: '100%', + ariaLabel: `Davebot, the portfolio guide in ${variant.mode} mode`, + onError: error => console.error(`[davebot-header/${variant.mode}]`, error), + }) + + if (reduceMotion) controller.setExpression('neutral') + + const davebot = panel.querySelector('[data-davebot]') + if (!davebot) throw new Error(`Davebot control for ${variant.mode} mode was not found.`) + + davebot.addEventListener('pointerenter', () => controller.play('curious')) + davebot.addEventListener('pointerleave', () => controller.play('idle')) + davebot.addEventListener('focus', () => controller.play('curious')) + davebot.addEventListener('blur', () => controller.play('idle')) + davebot.addEventListener('click', () => controller.play('hello')) + + const svg = mount.querySelector('.bs-avatar svg') + const eyePaths = svg?.querySelectorAll('g[clip-path] > path') + if (!svg || !eyePaths || eyePaths.length !== 2) { + throw new Error(`Davebot eye paths for ${variant.mode} mode were not found.`) + } + + // The engine repaints each eye's path data during blinks and expressions, but + // does not touch this SVG transform list. One persistent transform per eye + // therefore layers high-frequency gaze over normal playback without + // regenerating paths or resetting the animation timeline. + const left = svg.createSVGTransform() + const right = svg.createSVGTransform() + left.setTranslate(0, 0) + right.setTranslate(0, 0) + // Chromium clones both the appended object and appendItem's return value. + // Only getItem() yields a live transform handle whose later setTranslate() + // calls update the eye path. + eyePaths[0].transform.baseVal.appendItem(left) + eyePaths[1].transform.baseVal.appendItem(right) + const attachedLeft = eyePaths[0].transform.baseVal.getItem(0) + const attachedRight = eyePaths[1].transform.baseVal.getItem(0) + gazeTargets.push({ mount, left: attachedLeft, right: attachedRight, centerX: 0, centerY: 0 }) +} + +let pointerX = 0 +let pointerY = 0 +let pointerPresent = false +let geometryDirty = true +let gazeFrame: number | null = null + +const updateCenters = (): void => { + for (const target of gazeTargets) { + const rect = target.mount.getBoundingClientRect() + target.centerX = rect.left + rect.width / 2 + target.centerY = rect.top + rect.height / 2 + } + geometryDirty = false +} + +const paintGaze = (): void => { + gazeFrame = null + if (geometryDirty) updateCenters() + + for (const target of gazeTargets) { + let x = 0 + let y = 0 + if (pointerPresent) { + const deltaX = pointerX - target.centerX + const deltaY = pointerY - target.centerY + const distance = Math.hypot(deltaX, deltaY) + if (distance > 0) { + const offset = maxGazeOffset * Math.min(distance / fullGazeDistance, 1) + x = (deltaX / distance) * offset + y = (deltaY / distance) * offset + } + } + target.left.setTranslate(x, y) + target.right.setTranslate(x, y) + } +} + +const requestGazePaint = (): void => { + if (gazeFrame === null) gazeFrame = requestAnimationFrame(paintGaze) +} + +window.addEventListener( + 'pointermove', + event => { + if (event.pointerType === 'touch') return + pointerX = event.clientX + pointerY = event.clientY + pointerPresent = true + requestGazePaint() + }, + { passive: true } +) + +window.addEventListener('pointerout', event => { + if (event.relatedTarget !== null) return + pointerPresent = false + requestGazePaint() +}) + +window.addEventListener('blur', () => { + pointerPresent = false + requestGazePaint() +}) + +document.addEventListener('visibilitychange', () => { + if (!document.hidden) return + pointerPresent = false + requestGazePaint() +}) + +const markGeometryDirty = (): void => { + geometryDirty = true + requestGazePaint() +} + +new ResizeObserver(markGeometryDirty).observe(document.documentElement) +window.addEventListener('scroll', markGeometryDirty, { passive: true }) diff --git a/examples/portfolio-gallery/src/davebot-header.css b/examples/portfolio-gallery/src/davebot-header.css new file mode 100644 index 0000000..7ee7c37 --- /dev/null +++ b/examples/portfolio-gallery/src/davebot-header.css @@ -0,0 +1,300 @@ +@import url('https://fonts.bunny.net/css?family=geist:400,500,600|geist-mono:400,500'); + +:root { + font-family: 'Geist', system-ui, sans-serif; + color: #d4d4d4; + background: #242424; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + background: #242424; +} + +.comparison { + min-height: 100svh; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: clamp(14px, 1.5vw, 24px); + padding: clamp(14px, 2vw, 30px); + align-content: center; +} + +.portfolio[data-mode='light'] { + --page: #f6f6f6; + --ink: #262626; + --muted: #5e5e5e; + --project: #c4c4c4; + --border: #0000001f; +} + +.portfolio[data-mode='dark'] { + --page: #0b0b0b; + --ink: #d4d4d4; + --muted: #909090; + --project: #323232; + --border: #ffffff1f; +} + +.portfolio { + position: relative; + isolation: isolate; + min-width: 0; + min-height: clamp(420px, 49vw, 610px); + padding: clamp(16px, 2vw, 28px); + overflow: hidden; + background: var(--page); + color: var(--ink); +} + +.site-nav { + min-height: 26px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: clamp(16px, 2vw, 32px); + border-bottom: 1px solid var(--border); + font-family: 'Geist Mono', ui-monospace, monospace; + font-size: clamp(7px, 0.52vw, 9px); + line-height: 1; + letter-spacing: -0.02em; + text-transform: uppercase; +} + +.brand, +.nav-links, +.davebot { + text-box: trim-both cap alphabetic; +} + +.brand { + display: flex; + gap: 10px; + white-space: nowrap; +} + +.brand-role { + color: var(--muted); +} + +.nav-links { + display: flex; + justify-content: flex-end; + gap: clamp(10px, 1.2vw, 22px); + min-width: 0; +} + +.nav-links a, +.nav-links span { + color: var(--muted); + text-decoration: none; +} + +.nav-links a:first-child { + color: var(--ink); +} + +.davebot { + appearance: none; + border: 0; + padding: 0; + color: var(--muted); + background: transparent; + font: inherit; + line-height: inherit; + letter-spacing: inherit; + text-transform: inherit; + cursor: pointer; + white-space: nowrap; +} + +.davebot::before { + content: '+'; + margin-inline-end: 5px; +} + +.davebot:hover, +.davebot:focus-visible { + color: var(--ink); +} + +.davebot:focus-visible { + outline: 1px solid var(--ink); + outline-offset: 5px; +} + +.davebot-avatar { + position: absolute; + z-index: 2; + /* Same right edge as DAVEbot; the character is its visual endpoint. */ + inset-inline-end: clamp(17px, 2vw, 29px); + top: clamp(47px, 4.5vw, 62px); + width: clamp(34px, 3.2vw, 46px); + height: clamp(34px, 3.2vw, 46px); +} + +.davebot-avatar .bs-avatar, +.davebot-avatar svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; +} + +.status-dot { + position: absolute; + top: clamp(52px, 4.9vw, 68px); + inset-inline-start: 70%; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ink); +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(210px, 0.85fr); + align-items: end; + gap: clamp(22px, 4vw, 64px); + margin-top: clamp(108px, 12vw, 164px); +} + +.hero h1 { + max-width: 13ch; + margin: 0; + font-size: clamp(27px, 2.8vw, 43px); + font-weight: 500; + line-height: 0.93; + letter-spacing: -0.055em; +} + +.timeline { + display: grid; + grid-template-columns: auto minmax(0, 1.5fr) minmax(0, 1fr); + gap: 6px 16px; + margin: 0; + font-size: clamp(7px, 0.56vw, 10px); + line-height: 1.2; + color: var(--muted); +} + +.timeline div { + display: contents; +} + +.timeline dt, +.timeline dd { + margin: 0; +} + +.timeline dd:last-child { + color: var(--ink); +} + +.projects { + display: grid; + grid-template-columns: 1fr 1fr; + gap: clamp(10px, 1vw, 16px); + margin-top: clamp(18px, 2vw, 30px); +} + +.project { + height: clamp(154px, 17vw, 224px); + background: var(--project); +} + +.mode-caption { + position: absolute; + inset-inline-start: clamp(16px, 2vw, 28px); + bottom: 10px; + margin: 0; + font-family: 'Geist Mono', ui-monospace, monospace; + font-size: 8px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +@media (max-width: 980px) { + .comparison { + grid-template-columns: 1fr; + } + + .portfolio { + min-height: 520px; + } + + .site-nav, + .timeline { + font-size: 9px; + } + + .hero { + margin-top: 132px; + } +} + +@media (max-width: 620px) { + .comparison { + padding: 0; + gap: 1px; + } + + .portfolio { + min-height: 600px; + padding: 18px; + } + + .site-nav { + grid-template-columns: minmax(0, 1fr) auto; + } + + .nav-links { + display: none; + } + + .brand-role { + display: none; + } + + .davebot-avatar { + inset-inline-end: 18px; + top: 52px; + width: 44px; + height: 44px; + } + + .status-dot { + display: none; + } + + .hero { + grid-template-columns: 1fr; + align-items: start; + margin-top: 142px; + } + + .hero h1 { + font-size: 38px; + } + + .projects { + margin-top: 28px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + } +} diff --git a/examples/portfolio-gallery/src/davebot-header.ts b/examples/portfolio-gallery/src/davebot-header.ts new file mode 100644 index 0000000..61a4f2e --- /dev/null +++ b/examples/portfolio-gallery/src/davebot-header.ts @@ -0,0 +1,72 @@ +import type { AvatarDefinition } from '@bible-strong/avatar-web' + +import { arms, buildDefinition, sf, surfaceRadius, tail, type CharacterConfig } from './builder' + +export const HEADER_MODES = { + light: { + page: '#f6f6f6', + ink: '#262626', + muted: '#5e5e5e', + project: '#c4c4c4', + border: '#0000001f', + body: '#262626', + eyes: '#d4d4d4', + }, + dark: { + page: '#0b0b0b', + ink: '#d4d4d4', + muted: '#909090', + project: '#323232', + border: '#ffffff1f', + body: '#d4d4d4', + eyes: '#262626', + }, +} as const + +export type HeaderMode = keyof typeof HEADER_MODES + +const HEAD_WIDTH = 184 +const HEAD_HEIGHT = 190 +const ARM_ANGLE = 28 +const TAIL_ANGLE = 90 + +export type DavebotHeaderVariant = { + mode: HeaderMode + definition: AvatarDefinition +} + +export const davebotHeaderVariants: DavebotHeaderVariant[] = ( + Object.keys(HEADER_MODES) as HeaderMode[] +).map((mode): DavebotHeaderVariant => { + const palette = HEADER_MODES[mode] + const config: CharacterConfig = { + id: `davebot-header-${mode}`, + name: 'Davebot', + role: 'Davebot portfolio guide', + surface: sf('sphere', HEAD_WIDTH, HEAD_HEIGHT, HEAD_WIDTH, 1), + body: palette.body, + eyes: palette.eyes, + eye: { w: 36, h: 40, y: -16, spacing: 48 }, + nodes: [ + tail({ + angleDeg: TAIL_ANGLE, + protrude: 44, + width: 120, + morph: 1.4, + tip: 0.5, + depth: -100, + bodyRadius: surfaceRadius(HEAD_WIDTH, HEAD_HEIGHT, TAIL_ANGLE), + }), + ...arms({ + angleDeg: ARM_ANGLE, + protrude: 28, + width: 44, + depth: -36, + bodyRadius: surfaceRadius(HEAD_WIDTH, HEAD_HEIGHT, ARM_ANGLE), + }), + ], + signature: 'curious', + } + + return { mode, definition: buildDefinition(config) } +}) diff --git a/examples/portfolio-gallery/src/showcase.ts b/examples/portfolio-gallery/src/showcase.ts index 739f6c5..9d6c3a9 100644 --- a/examples/portfolio-gallery/src/showcase.ts +++ b/examples/portfolio-gallery/src/showcase.ts @@ -56,7 +56,12 @@ const configs: CharacterConfig[] = [ body: '#e65f2e', eyes: ink, eye: { w: 24, h: 44, y: -6, spacing: 40 }, - nodes: [lobe(96, -92, -58, -8), lobe(90, 96, -50, -8), lobe(92, -88, 64, -8), lobe(86, 92, 72, -8)], + nodes: [ + lobe(96, -92, -58, -8), + lobe(90, 96, -50, -8), + lobe(92, -88, 64, -8), + lobe(86, 92, 72, -8), + ], signature: 'hello', }, { diff --git a/examples/portfolio-gallery/tails.html b/examples/portfolio-gallery/tails.html index 7d1beca..7d01c05 100644 --- a/examples/portfolio-gallery/tails.html +++ b/examples/portfolio-gallery/tails.html @@ -14,12 +14,16 @@ 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. + geometry: light is the tonal inverse of dark, so the contrast relationships mirror + exactly.

-

Light — bg 97 · tile 79 · muted 40 · ink 15 · accent 0

+

+ Light — bg 97 · tile 79 · muted 40 · ink 15 · accent + 0 +

@@ -29,7 +33,10 @@
-

Dark — bg 3 · tile 21 · muted 60 · ink 85 · accent 100

+

+ Dark — bg 3 · tile 21 · muted 60 · ink 85 · accent + 100 +