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.
This commit is contained in:
David Lafond 2026-08-24 22:30:53 -04:00
parent 8537b53ec9
commit 883b589a43
11 changed files with 711 additions and 39 deletions

View File

@ -24,7 +24,10 @@
<div class="roles">
<div class="ramp" data-mode="light">
<h3>Light &mdash; bg 97 &middot; tile 79 &middot; muted 40 &middot; ink 15 &middot; accent 0</h3>
<h3>
Light &mdash; bg 97 &middot; tile 79 &middot; muted 40 &middot; ink 15 &middot; accent
0
</h3>
<div class="swatches">
<div class="swatch" style="background: var(--light-bg)"></div>
<div class="swatch" style="background: var(--light-tile)"></div>
@ -34,7 +37,10 @@
</div>
</div>
<div class="ramp" data-mode="dark">
<h3>Dark &mdash; bg 3 &middot; tile 21 &middot; muted 60 &middot; ink 85 &middot; accent 100</h3>
<h3>
Dark &mdash; bg 3 &middot; tile 21 &middot; muted 60 &middot; ink 85 &middot; accent
100
</h3>
<div class="swatches">
<div class="swatch" style="background: var(--dark-bg)"></div>
<div class="swatch" style="background: var(--dark-tile)"></div>

View File

@ -0,0 +1,127 @@
<!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="#242424" />
<title>Davebot — pointer-following portfolio guide</title>
</head>
<body>
<main class="comparison" aria-label="Davebot portfolio header in light and dark mode">
<section class="portfolio" data-mode="light" aria-label="Light mode portfolio header">
<nav class="site-nav" aria-label="Light mode primary navigation">
<div class="brand">
<span>David Lafond</span>
<span class="brand-role">Product designer · Architect</span>
</div>
<div class="nav-links" aria-label="Portfolio sections">
<a href="#light-work">Work</a>
<span>Community</span>
<span>Play</span>
<span>About</span>
<span>Resume</span>
</div>
<button class="davebot" type="button" data-davebot>Davebot</button>
</nav>
<div class="davebot-avatar" data-davebot-avatar></div>
<span class="status-dot" aria-hidden="true"></span>
<div class="hero">
<h1>Hi, I’m David ~<br />a product designer who leads creative teams.</h1>
<dl class="timeline">
<div>
<dt>2026</dt>
<dd>Tableau Urban Innovation</dd>
<dd>Senior Designer</dd>
</div>
<div>
<dt>2025</dt>
<dd>Fluid Interfaces</dd>
<dd>Lead Product Designer</dd>
</div>
<div>
<dt>2025</dt>
<dd>Plumba Parking</dd>
<dd>Product Manager</dd>
</div>
<div>
<dt>2024</dt>
<dd>Serendipity City</dd>
<dd>Lead Product Designer</dd>
</div>
<div>
<dt>2023</dt>
<dd>Computational Design Exploratory</dd>
<dd>Lead Product Designer</dd>
</div>
</dl>
</div>
<div class="projects" id="light-work" aria-label="Selected work placeholders">
<div class="project"></div>
<div class="project"></div>
</div>
<p class="mode-caption">Light mode</p>
</section>
<section class="portfolio" data-mode="dark" aria-label="Dark mode portfolio header">
<nav class="site-nav" aria-label="Dark mode primary navigation">
<div class="brand">
<span>David Lafond</span>
<span class="brand-role">Product designer · Architect</span>
</div>
<div class="nav-links" aria-label="Portfolio sections">
<a href="#dark-work">Work</a>
<span>Community</span>
<span>Play</span>
<span>About</span>
<span>Resume</span>
</div>
<button class="davebot" type="button" data-davebot>Davebot</button>
</nav>
<div class="davebot-avatar" data-davebot-avatar></div>
<span class="status-dot" aria-hidden="true"></span>
<div class="hero">
<h1>Hi, I’m David ~<br />a product designer who leads creative teams.</h1>
<dl class="timeline">
<div>
<dt>2026</dt>
<dd>Tableau Urban Innovation</dd>
<dd>Senior Designer</dd>
</div>
<div>
<dt>2025</dt>
<dd>Fluid Interfaces</dd>
<dd>Lead Product Designer</dd>
</div>
<div>
<dt>2025</dt>
<dd>Plumba Parking</dd>
<dd>Product Manager</dd>
</div>
<div>
<dt>2024</dt>
<dd>Serendipity City</dd>
<dd>Lead Product Designer</dd>
</div>
<div>
<dt>2023</dt>
<dd>Computational Design Exploratory</dd>
<dd>Lead Product Designer</dd>
</div>
</dl>
</div>
<div class="projects" id="dark-work" aria-label="Selected work placeholders">
<div class="project"></div>
<div class="project"></div>
</div>
<p class="mode-caption">Dark mode</p>
</section>
</main>
<script type="module" src="/src/davebot-header-main.ts"></script>
</body>
</html>

View File

@ -23,9 +23,9 @@
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.
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>

View File

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

View File

@ -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',

View File

@ -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<ModeName, string> = {
light: MODES.dark.ink,
dark: MODES.light.ink,
}
const ROLE_LABEL: Record<BodyRole, string> = {
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({

View File

@ -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<HTMLElement>(`.portfolio[data-mode="${variant.mode}"]`)
const mount = panel?.querySelector<HTMLElement>('[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<HTMLElement>('[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<SVGSVGElement>('.bs-avatar svg')
const eyePaths = svg?.querySelectorAll<SVGPathElement>('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 })

View File

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

View File

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

View File

@ -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',
},
{

View File

@ -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
&mdash; 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.
</p>
<div class="roles">
<div class="ramp" data-mode="light">
<h3>Light &mdash; bg 97 &middot; tile 79 &middot; muted 40 &middot; ink 15 &middot; accent 0</h3>
<h3>
Light &mdash; bg 97 &middot; tile 79 &middot; muted 40 &middot; ink 15 &middot; accent
0
</h3>
<div class="swatches">
<div class="swatch" style="background: var(--light-bg)"></div>
<div class="swatch" style="background: var(--light-tile)"></div>
@ -29,7 +33,10 @@
</div>
</div>
<div class="ramp" data-mode="dark">
<h3>Dark &mdash; bg 3 &middot; tile 21 &middot; muted 60 &middot; ink 85 &middot; accent 100</h3>
<h3>
Dark &mdash; bg 3 &middot; tile 21 &middot; muted 60 &middot; ink 85 &middot; accent
100
</h3>
<div class="swatches">
<div class="swatch" style="background: var(--dark-bg)"></div>
<div class="swatch" style="background: var(--dark-tile)"></div>