[ Parable build guide ]
How Mise was built
A meal-kit brand whose entire pitch is sequence — so the site’s signature moments are two working kitchen tools: a recipe stepper that choreographs a real recipe, and a ratio slider that rescales it the way a cook would, not the way a calculator would.
The idea
Mise en place is the discipline of having everything prepped, portioned and in its place before the heat goes on. That’s also exactly what a meal-kit sells. So instead of describing the product, the page is the product: the hero board shows this week’s bowls settling into place, the stepper walks you through the actual rigatoni card packed in the box, and the slider rescales its actual ingredient list. Nothing on the page is decoration pretending to be function — the promise is playable.
The stack
SvelteKit with adapter-static, prerendered to plain HTML — no server, no runtime
beyond the component code itself. Svelte 5’s runes are a natural fit for kitchen state: $state holds what’s checked and what’s ticking, $derived answers
“which step is live?” — and the DOM follows. Type is Fraunces (a warm, food-editorial serif
with real italics), Manrope for body, and Space Mono for the metadata that runs through a
kitchen’s world: weights, timings, route days.
Signature technique № 1 — the recipe stepper
Six steps, each a real checkbox. The trick is that there is no step machine — the “active” step is derived, not stored. Check any box and the first unchecked step becomes the active one, lifts three pixels on the “proof” ease, gets its tomato rail, and everything behind it dims:
let done = $state(steps.map(() => false));
let timers = $state(steps.map((s) =>
s.timer ? { left: s.timer, running: false, finished: false } : null));
// The whole choreography is one line: the active step is simply
// the first box left unchecked. Check it, and the next one lifts.
const active = $derived(done.findIndex((d) => !d));
const doneCount = $derived(done.filter(Boolean).length);
const allDone = $derived(doneCount === steps.length); Timed steps carry a timer chip with an SVG progress ring — the timers are real setInterval countdowns, so the page genuinely cooks alongside you. When one
finishes it flips green, chimes visually, and announces itself through an aria-live status region for screen-reader users.
Signature technique № 2 — the ratio slider
A servings slider from 1 to 6 that rescales every quantity in the ingredient list — but rounding is where the craft lives. Nobody weighs 217 g of pasta or measures 0.83 tsp of chilli. Quantities snap to quarters, grams snap to steps a scale can actually show, and three teaspoons fold into a tablespoon:
const QUARTERS = { 0.25: '¼', 0.5: '½', 0.75: '¾' };
export function grams(g) {
const step = g < 100 ? 5 : g < 500 ? 10 : 25; // kitchen-honest rounding
return `${Math.max(step, Math.round(g / step) * step)} g`;
}
export function spoons(tsp) {
if (tsp >= 3) return `${frac(tsp / 3)} tbsp`; // 3 tsp fold into 1 tbsp
return `${frac(tsp)} tsp`;
}
/* Fixed lines never scale — salt is "to taste" at any table size. */
export function scaleLine(ing, servings) {
if (ing.fixed) return ing.fixed;
const raw = (ing.qty * servings) / ing.per;
switch (ing.unit) {
case 'g': return grams(raw);
case 'tsp': return spoons(raw);
case 'clove': return count(raw, 'clove');
case 'bunch': return bunches(raw);
}
} The best detail is the lines that refuse to scale: pasta water stays “one good ladleful” and salt stays “to taste — taste it” at any table size, because that’s how kitchens actually work. The pan advice under the dial changes with the head-count too — at five plates it tells you to blister the tomatoes in two batches, because char needs elbow room.
Details that matter
- Content is never hidden without JS. Every reveal and the clipped hero
lines are gated behind an
html.jsclass set synchronously inapp.html— kill JavaScript and the whole page reads top to bottom. - Reduced motion is honoured all the way down. The bowls render pre-settled as one static frame, reveals resolve instantly, the stepper swaps states without lifting, the counters print their final number — and the slider still rescales, because that’s function, not motion.
- The checkboxes are real checkboxes. Visually-hidden inputs with styled
labels, so the stepper is keyboard-operable and every state is announced natively. Focus
rings are drawn with
:focus-visibleon the custom box. - One source of truth. The hero bowls, the stepper card and the slider’s
ingredient list all read the same rigatoni object from
recipes.js— the site practices its own mise en place. - Light and dark are both first-class. All tokens flip on
:root[data-theme], the toggle persists tolocalStorage(mise-theme), and an inline head script applies it before first paint — no flash.
The delivery-check form is a demo — it validates and confirms in place but checks nothing and sends nothing. Wire it to a real coverage lookup before taking orders.
Ship it on GitHub Pages
The two settings that make a framework site work on project Pages: build into /docs, and prefix every path with the repo name (dev keeps it empty so localhost
still works):
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
// prerender straight into /docs for GitHub Pages
adapter: adapter({ pages: 'docs', assets: 'docs', fallback: null }),
// every asset + link gets the repo prefix in production
paths: { base: process.env.NODE_ENV === 'production' ? '/mise' : '' }
}
}; Then build, commit docs/, and point Pages at it:
npm run build # prerenders → docs/ (index + guide)
git add -A && git commit -m "Mise — recipe-box showcase"
gh repo create bswxyz/mise --public --source . --push
# GitHub → Settings → Pages → Branch: main · Folder: /docs A static/.nojekyll file rides along into docs/ so Pages serves
SvelteKit’s _app/ directory untouched. That’s the whole deploy.