Pizza builder
Rung one of the ladder: a form anyone can read, using every core mechanism. Style drives
half the form — deep dish forbids thin crust and unlocks extra sauce; calzone removes the
crust choice and half-and-half entirely (the page has no conditionals for that:
every control is a flat <Field>, and the form decides). The crust's options depend on size and gluten-free (with your pick remembered per size); the
topping list is projected into a per-size coverage budget with an oven_physics note when something gets dropped; calories, bake time, and price
are computed fields. Persisted to localStorage — reload the
page and your pizza survives.
style
size
crust
toppings
Coverage budget
0 / 6
- calories
- 900
- bake time
- 9 min
- price
- $12.00
Try: pick toppings past the budget, shrink the size, toggle half-and-half back and forth,
reload the page.
view source — pizza-form.ts (141 lines)
import { z } from 'zod';
import { defineGraph } from '$lib/index.js';
import { boolOf, enumOf, slider, textOf } from '$lib/defs/index.js';
// Demo ladder, rung 1: every core mechanism on a form anyone can read. Each
// field is ONE function of (c, ext) returning its whole definition — or
// null when it doesn't exist this pass. Conditional options, gates, budgets:
// just computed.
export const SIZES = {
small: { label: 'Small 10"', budget: 4, baseCalories: 600, basePrice: 9 },
medium: { label: 'Medium 12"', budget: 6, baseCalories: 900, basePrice: 12 },
large: { label: 'Large 14"', budget: 8, baseCalories: 1200, basePrice: 15 },
} as const;
export type Size = keyof typeof SIZES;
export const TOPPINGS = [
{ id: 'cheese', label: 'Extra cheese', weight: 1, calories: 90 },
{ id: 'pepperoni', label: 'Pepperoni', weight: 1, calories: 110 },
{ id: 'mushrooms', label: 'Mushrooms', weight: 1, calories: 25 },
{ id: 'onions', label: 'Red onion', weight: 1, calories: 25 },
{ id: 'pineapple', label: 'Pineapple', weight: 1, calories: 40 },
{ id: 'ham', label: 'Ham', weight: 1, calories: 70 },
{ id: 'meatballs', label: 'Meatballs', weight: 2, calories: 160 },
{ id: 'chicken', label: 'Grilled chicken', weight: 2, calories: 120 },
] as const;
const byId = new Map(TOPPINGS.map((t) => [t.id as string, t]));
const weightOf = (ids: string[]) => ids.reduce((sum, id) => sum + (byId.get(id)?.weight ?? 0), 0);
const caloriesOf = (ids: string[]) =>
ids.reduce((sum, id) => sum + (byId.get(id)?.calories ?? 0), 0);
export type Crust = 'thin' | 'hand-tossed' | 'stuffed';
/** A topping picker capped by a coverage budget: trims from the end, noted. */
const toppingsDef = (budget: number) => ({
input: z.array(z.string()).optional(),
output: z.array(z.string()),
default: [] as string[],
meta: { budget },
correct: (picked: string[]) => {
const kept: string[] = [];
let used = 0;
for (const id of picked) {
const weight = byId.get(id)?.weight ?? 0;
if (used + weight > budget) continue;
kept.push(id);
used += weight;
}
return kept.length === picked.length
? undefined
: {
value: kept,
reason: 'oven_physics',
detail: { dropped: picked.filter((id) => !kept.includes(id)), budget },
};
},
});
export const pizzaForm = defineGraph()
.field('style', enumOf({
options: [
{ value: 'classic', label: 'Classic' },
{ value: 'deep-dish', label: 'Deep dish' },
{ value: 'calzone', label: 'Calzone' },
],
default: 'classic',
}))
.field('size', enumOf({
options: (Object.keys(SIZES) as Size[]).map((value) => ({ value, label: SIZES[value].label })),
default: 'medium',
}))
.field('glutenFree', boolOf())
.field('crust', (c) => {
if (c.style === 'calzone') return null;
// The whole crust node, computed: options, memory scope, and the
// availability rule — gate disables AND corrects with the reason.
return {
...enumOf<Crust>({
options: [
{ value: 'thin', label: 'Thin' },
{ value: 'hand-tossed', label: 'Hand-tossed' },
{ value: 'stuffed', label: 'Stuffed' },
],
default: 'hand-tossed',
gate: {
thin: !c.glutenFree && c.style === 'deep-dish' && 'deep_dish_needs_structure',
'hand-tossed': c.glutenFree && 'gluten_free_forces_thin',
stuffed:
(c.glutenFree && 'gluten_free_forces_thin') ||
(c.size === 'small' && 'not_available_for_size'),
},
}),
// Remembered per size: your large stuffed pick survives trying a small.
scope: c.size,
};
})
.field('extraSauce', (c) => (c.style === 'deep-dish' ? boolOf() : null))
.field('halfAndHalf', (c) => (c.style !== 'calzone' ? boolOf() : null))
.field('toppings', (c) => {
if (c.halfAndHalf) return null;
const budget = c.style === 'calzone' ? Math.max(2, SIZES[c.size].budget - 1) : SIZES[c.size].budget;
return toppingsDef(budget);
})
.field('toppingsLeft', (c) =>
c.halfAndHalf ? toppingsDef(Math.max(2, Math.floor(SIZES[c.size].budget / 2))) : null
)
.field('toppingsRight', (c) =>
c.halfAndHalf ? toppingsDef(Math.max(2, Math.floor(SIZES[c.size].budget / 2))) : null
)
.computed('budgetUsed', (c) =>
weightOf([...(c.toppings ?? []), ...(c.toppingsLeft ?? []), ...(c.toppingsRight ?? [])])
)
.computed('budgetTotal', (c) => {
const base = c.style === 'calzone' ? Math.max(2, SIZES[c.size].budget - 1) : SIZES[c.size].budget;
return c.halfAndHalf ? Math.max(2, Math.floor(SIZES[c.size].budget / 2)) * 2 : base;
})
.computed('calories', (c) => {
const all = [...(c.toppings ?? []), ...(c.toppingsLeft ?? []), ...(c.toppingsRight ?? [])];
return (
SIZES[c.size].baseCalories +
Math.round(caloriesOf(all) * (c.halfAndHalf ? 0.5 : 1)) +
(c.extraSauce === true ? 80 : 0)
);
})
.computed('bakeMinutes', (c) =>
9 +
(c.crust === 'stuffed' ? 3 : 0) +
(c.style === 'deep-dish' ? 8 : c.style === 'calzone' ? 4 : 0) +
Math.ceil((c.budgetUsed ?? 0) / 2)
)
.computed('price', (c) =>
Math.round(
(SIZES[c.size].basePrice +
(c.budgetUsed ?? 0) * 1.25 +
(c.crust === 'stuffed' ? 2 : 0) +
(c.style === 'deep-dish' ? 3 : 0) +
(c.extraSauce === true ? 1 : 0)) *
100
) / 100
);