[ Parable build guide ]
How Conche was built
A chocolate maker's whole craft is invisible by the time you hold the bar. Conche makes it visible twice: an origin flavor-wheel whose arc geometry is computed at build time, and a tempering curve that draws itself out of the batch log as you scroll.
The idea
Bean-to-bar makers talk like winemakers — origin, harvest, percentages, conching hours, tempering to the tenth of a degree. The usual move is to say it in prose and hope. We decided to plot it instead. The flavor wheel borrows the format every specialty-coffee and wine taster already trusts, then makes it answer a question prose can't: which of these eighteen notes does this origin actually hit? Hover Madagascar and raspberry lights up; hover Vietnam and the wheel goes leather and smoke. The tempering section does the same for craft: the curve is the recipe, so the curve is the graphic.
The stack — why Astro
- Astro 5, no client framework. Bars, origins, stockists and tasting notes are typed arrays in frontmatter, rendered to plain HTML at build time. The shipped page carries one small vanilla script — theme, reveals, the wheel's highlighting — and nothing else.
- SVG math at build time. The wheel's 24 arc segments are trigonometry, and
trigonometry doesn't need to run in the browser. A helper in the component's frontmatter
emits finished
pathstrings; the client gets geometry, not a chart library. - Playfair Display / Karla / Space Mono. A high-contrast serif with confectionery-label heritage for display, a warm grotesque for body, and a typewriter mono for everything a maker would log — temperatures, microns, lot numbers.
Signature technique — a flavor wheel from pure trigonometry
Every segment of the wheel is one annular-sector path: two arcs and two radial lines. The helper lives in the component frontmatter, so it executes once, at build:
// FlavorWheel.astro — runs at build time, ships as plain paths
const P = (r, a) => {
const rad = ((a - 90) * Math.PI) / 180; // 0° = top, clockwise
return [CX + r * Math.cos(rad), CY + r * Math.sin(rad)];
};
function ringSeg(r0, r1, a0, a1) {
const [x0, y0] = P(r1, a0), [x1, y1] = P(r1, a1);
const [x2, y2] = P(r0, a1), [x3, y3] = P(r0, a0);
const large = a1 - a0 > 180 ? 1 : 0;
return `M${x0} ${y0} A${r1} ${r1} 0 ${large} 1 ${x1} ${y1} ` +
`L${x2} ${y2} A${r0} ${r0} 0 ${large} 0 ${x3} ${y3} Z`;
} Each note segment carries data-origins="madagascar tanzania" — computed from the
origin data, so the wheel can never disagree with the copy. The client script is then almost
embarrassingly small: on hover, focus or tap of an origin, toggle a .lit class on
matching segments and .has-active on the wheel; CSS does the dimming, the gold
stroke, and the hub swap. Buttons are real <button>s with
aria-pressed, the caption is an aria-live region, and Escape clears
the selection.
Signature technique #2 — a curve that draws itself
The tempering chart is one SVG path with pathLength="1", which lets CSS treat the
whole curve as a unit dash. Off-screen it's fully offset; when an
IntersectionObserver adds .is-in, the offset transitions to zero and
the batch appears to plot itself — melt, cool-and-seed, rework — with the temperature callouts
fading in phase by phase:
.js .tfig .tpath { stroke-dasharray: 1; stroke-dashoffset: 1; }
.js .tfig.is-in .tpath {
stroke-dashoffset: 0;
transition: stroke-dashoffset 2.8s var(--ease) .15s;
} No canvas, no requestAnimationFrame, no library — the browser's compositor does the drawing, and there is nothing to pause when it scrolls away because nothing loops.
Details that matter
- Content is never hidden without JS. Reveals, the hero rise and the hero
bar's snapped piece are all gated behind a
.jsclass set synchronously in<head>. No JavaScript? The page reads top to bottom, bar already snapped. - Reduced motion is one honest frame. Under
prefers-reduced-motionthe curve renders fully drawn, labels visible; the snap piece sits in its final position; counters print their targets. Nothing animates, nothing is missing. - The ease is in character. One named curve —
cubic-bezier(.77, 0, .16, 1), "the temper": slow melt in, clean snap out — runs every large move on the page. - Both themes are the product. Light is the cream wrapper with gold foil;
dark is the inside of a 70% bar. All of it is tokens flipped on
:root[data-theme], persisted underconche-theme. - Every internal link goes through the base. The site deploys under
/conche/, so hrefs are built fromimport.meta.env.BASE_URL— hardcode the subpath and the next rename breaks every link.
The batch-list form is a demo — it validates and confirms in place but sends and stores nothing. Wire it to your own endpoint (Buttondown, Formspree, a serverless function) before collecting real addresses.
Ship it on GitHub Pages
Astro builds into ./docs, and Pages serves main + /docs
directly — no Actions workflow needed:
// astro.config.mjs
export default defineConfig({
site: 'https://bswxyz.github.io',
base: '/conche',
outDir: './docs',
}); npm run build
git add -A && git commit -m "build"
gh repo create bswxyz/conche --public --source . --push
gh api --method POST /repos/bswxyz/conche/pages \
-f 'source[branch]=main' -f 'source[path]=/docs' A .nojekyll in public/ rides along into the build so Pages serves
the output untouched.