LineUp.
A virtual line-up for restaurants. Two apps, one queue — the merchant stand that runs the line, and the client phone that makes the wait survivable. Every piece below is rebuilt from the app source.
Play with it using the following link.
Four numbers that have to be read from two metres away, without becoming four cards competing for the same glance. Dividers do the work: one hairline between stats, and bare paper either side of it. Only the live count takes the accent — the rest are ink, because if everything is emphasised nothing is.
Colour
Type
Stat value
34 / 750 / -.03emUnit
14 / 600Stat label
13 / 600Source
export function Stat({ label, value, sub, accent, first, big = 34 }) { return ( <div className="la-stat" style={{ padding: first ? '0 26px 0 0' : '0 26px' }}> <div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}> <span style={{ fontSize: big, fontWeight: 750, letterSpacing: '-.03em', lineHeight: 1, color: accent ? 'var(--accent)' : 'var(--ink)' }}>{value}</span> {sub && <span style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink-2)' }}>{sub}</span>} </div> <div style={{ fontSize: 13, fontWeight: 600, marginTop: 7, color: 'var(--ink-2)' }}>{label}</div> </div> );}/* dividers, not boxes — shared/tokens.css */.la-stat + .la-stat { border-left: 1px solid var(--line); }The same row, re-cut for one hand. The position marker drops to 16px, the party size from 27 to 24, and the quoted wait folds out of its own column into the subtitle — so the row still carries size, name, status and wait, but in one column instead of five.
The action button loses 2px of height and the no-show button disappears entirely: on a phone the whole row is the target, and it opens the party sheet where destructive actions live with room around them.
Colour
Type
Party size
24 / 750 — was 27Guest name
15.5 / 650 — was 16Row subtitle
12.5 / 400Status pill
10 / 700 — was 11Source
// The same row, re-cut for one hand. Position drops to 16px, the party size to// 24, and the note folds up into the wait so the row stays one line of meaning.<div className={'qrow tappable' + (ready ? ' upnext' : '')} style={{ minHeight: 64, padding: '0 4px', borderBottom: last ? 'none' : '1px solid var(--line)' }}> {ready && <div style={{ position: 'absolute', left: -4, top: 10, bottom: 10, width: 3, borderRadius: 99, background: 'var(--accent)' }} />} <div style={{ fontSize: 12.5, color: 'var(--ink-2)', marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> {`${p.quotedMin} min wait`}{p.note ? ` · ${p.note}` : ''} </div> {/* 34px, not 36 — and the row itself opens the detail sheet */} <button className="btn btn-seat btn-eq" style={{ height: 34, fontSize: 13.5 }}> <Icon name="check" size={15} color="#fff" /> Seat </button></div>Three numbers instead of four, at 28px instead of 34. No-shows is the one that goes — it is the stat you review at the end of a shift, while the other three are the ones you glance at between parties, so it loses its slot first.
The labels shorten with the space: "Average wait" becomes "Avg wait", "Seated today" becomes "Seated", and the unit drops from "min" to "m". Same component, same dividers.
Colour
Type
Stat value
28 / 750 — was 34Stat label
12 / 600Unit
unit shortenedSource
{/* three stats, not four — No-shows loses its slot before the others do */}<div className="la-stats" style={{ marginTop: 16, paddingBottom: 18, borderBottom: '1px solid var(--line)' }}> <Stat label="In line" value={stats.inLine} accent big={28} pad="0 18px" first grow /> <Stat label="Avg wait" value={stats.avgWaitMin} sub="m" big={28} pad="0 18px" grow /> <Stat label="Seated" value={stats.seatedToday} big={28} pad="0 18px" grow /></div>Scannable, and genuinely single-use. The venue's Durable Object mints a token on an alarm and the code rotates on it; a reused, expired or over-scanned token is rejected with "scan the live code". The ring around the plate is the token's remaining life, painted on clockwise from the middle of the left edge over exactly the milliseconds it has left.
It is drawn by hand rather than dropped in as a library image, so the modules match the rest of the product: 84% squares with rounded corners, concentric rounded finder frames, and a floating centre chip. The chip is why the matrix is generated at error-correction H — it covers real data, and H is what keeps it readable anyway.
Colour
Type
Wordmark
8.2% of the code / 800 / -.03emSource
// High EC when the centre chip covers part of the matrix.const qr = QRCode.create(data, { errorCorrectionLevel: logo ? 'H' : 'M' });// data module — 84% of the cell, rounded<rect x={(c + quiet) * unit + unit * 0.08} y={(r + quiet) * unit + unit * 0.08} width={unit * 0.84} height={unit * 0.84} rx={unit * 0.28} fill={color} />// finder — three concentric rounded squares<rect width={unit * 7} rx={unit * 1.6} fill={color} /><rect width={unit * 5} rx={unit} fill="#fff" /><rect width={unit * 3} rx={unit * 0.7} fill={color} />Three destinations, so they are all on screen at once and the underline slides between them rather than appearing and disappearing. The indicator's position and width are measured from the live button on every change, so it fits the label instead of a guessed-at fixed width.
Colour
Type
Tab label
15 / 650Source
.tab { font-size: 15px; font-weight: 650; color: var(--ink-3); transition: color .2s ease; }.tab.active { color: var(--ink); }.tab-underline { position: absolute; bottom: 0; height: 2px; border-radius: 2px; background: var(--accent); transition: left .3s cubic-bezier(.4, 0, .2, 1), width .3s cubic-bezier(.4, 0, .2, 1);}The tablet's three tabs collapse to two, and the sliding underline becomes a sliding pill. QR display leaves the tabs for a full-screen takeover on a phone, reached from the action bar, because a phone turned around to face a room is showing the code and nothing else.
The thumb is a shared layout id, so it springs from one side to the other rather than cross-fading. Same idea as the underline, sized for a control you hit with a thumb.
Colour
Type
Switch label
13.5 / 700 / -.01emSource
// A sliding pill, not an underline: two destinations on a phone, and the thumb// is a shared layout id so it springs between them instead of cross-fading.<div style={{ display: 'inline-flex', background: 'var(--wash)', borderRadius: 999, padding: 3 }}> {tabs.map((t) => ( <button style={{ height: 32, padding: '0 15px', fontSize: 13.5, fontWeight: 700, color: active ? 'var(--ink)' : 'var(--ink-2)' }}> {active && ( <motion.span layoutId="m-tab" style={{ position: 'absolute', inset: 0, borderRadius: 999, background: 'var(--paper)', boxShadow: '0 1px 3px rgba(0,0,0,.12)' }} transition={{ type: 'spring', stiffness: 520, damping: 40 }} /> )} <span style={{ position: 'relative', zIndex: 1 }}>{t.label}</span> </button> ))}</div>The tablet promotes "Add party" into the stat strip, up at the top of a screen you reach across. On a phone that strip is full and the top of the screen is a reach — both actions dock to the bottom edge, which is where a thumb already is.
Two equal halves, 50px tall: the quiet one on the wash, the additive one on the accent. The queue above them keeps every other pixel.
Colour
Type
Button label
15 / 600Source
{/* The tablet promotes "Add party" into the stat strip; the phone has no room up there, so both actions dock to the bottom edge where a thumb lives. */}<div style={{ flexShrink: 0, padding: '12px 22px', borderTop: '1px solid var(--line)', display: 'flex', gap: 12 }}> <button onClick={() => setView('qr')} className="la-btn" style={{ flex: 1, height: 50, fontSize: 15, background: 'var(--wash)', color: 'var(--ink)' }}> <Icon name="qr" size={18} /> QR code </button> <button onClick={() => setAddOpen(true)} className="la-btn la-btn-primary" style={{ flex: 1, height: 50, fontSize: 15 }}> <AnimatedIcon name="plus" size={18} color="#fff" stroke={2.2} /> Add party </button></div>The merchant side commits on the first tap. Notify, seat and no-show all just happen, and the toast carries the escape hatch for a few seconds afterwards. A dialog costs a tap on every action to protect against the rare mistake; an Undo costs a tap only when the mistake actually happens — and a host with a queue at the door already has both hands full.
Colour
Type
Toast message
13.5 / 600Undo label
12 / 650Source
const toast = useCallback((opts: ToastOptions) => { const id = ++idRef.current; setItems((list) => [...list.slice(-2), { id, ...opts }]); // cap at 3 on screen // an undoable toast lingers longer, because it is the only way back const ttl = opts.duration ?? (opts.undo ? 6000 : 3800); timers.current[id] = setTimeout(() => dismiss(id), ttl);}, [dismiss]);Tapping anywhere on a queue row opens this. The number is the whole dialog — 40px, with 52px targets either side, sized for a thumb on a tablet that is bolted to a stand rather than held.
Notify, seat and no-show repeat here as plain circles. By the time a host has opened a party they have already decided what they are doing; the job of this layer is to make the target big enough to hit on the first try.
Colour
Type
Wait
104 / 800 / -.05emDialog title
21 / 800 / -.02emDialog subtitle
13.5 / 600Source
{/* big sliding number flanked by − / + */}<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, margin: '22px 0 8px' }}> <CircleBtn icon="minus" color="var(--ink)" bg="var(--wash)" size={64} iconSize={28} title="Less time" /> <SlideValue value={val} fontSize={104} weight={800} lineHeight={1.04} letterSpacing="-.05em" duration={0.42} /> <CircleBtn icon="plus" color="#fff" bg="var(--accent)" size={64} iconSize={28} title="More time" /></div>// tapping stops, then saves itselftimer.current = setTimeout(() => commit(), 1000);The QR belongs at the doorway; the link belongs everywhere else — the phone call, the Instagram bio, the sandwich board that hasn't been printed yet. A venue can be taking a line before it has any hardware pointed at the street.
Colour
Type
Sheet title
21 / 800 / -.02emSheet subtitle
13.5 / 600Button label
15.5 / 600Source
<div style={{ display: 'flex', gap: 12, marginTop: 22 }}> <button onClick={share} className="la-btn" style={{ flex: 1, height: 52, fontSize: 15.5, background: 'var(--wash)', color: 'var(--ink)' }}> <Icon name="share" size={18} /> Share link </button> <button onClick={shareCode} className="la-btn la-btn-primary" style={{ flex: 1, height: 52, fontSize: 15.5 }}> <Icon name="qr" size={18} color="#fff" /> Share code </button></div>The client app is one screen deep, top to bottom. This is the whole product for twenty minutes, so the count gets 156px and everything else gets out of the way. When it changes, the digit rolls over like an odometer, so a guest glancing back sees that something moved rather than having to remember what the old number was.
"Groups ahead" rather than "position", because position 4 sounds like a queue you are stuck in and three groups ahead sounds like a countable thing that is emptying.
Colour
Type
Count
156 / 800Count label
15 / 600 / .04emEstimated wait
16 / 500Source
const heroFont = compact ? 128 : 156;<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ink-2)', letterSpacing: '.04em', textTransform: 'uppercase' }}> Groups ahead of you</div>{/* odometer, so a change is visibly a change */}<SlideNumber value={ahead} fontSize={heroFont} weight={800} /><div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 16, fontWeight: 500, color: 'var(--ink-2)' }}> <LottieIcon data={clockAnim} color={ACCENT_LIGHT} size={22} loop /> <FlipValue flipKey={view?.etaMin ?? 0}> {minutesWord(view?.etaMin ?? 0)} left </FlipValue></div>
A progress bar with a character walking the leading edge of the fill. It glides to each new position on the same 1.2s curve as the bar itself, so the two read as one movement rather than a bar and a sticker that follows it. Reduced motion freezes the stride on a single frame and leaves the character standing on the bar.
The companion is picked deterministically by hashing the party id, so a guest keeps the same one for their entire wait — and tapping it launches the mini-game, which is the only place in the product that deliberately spends attention rather than saving it.
Colour
Type
Stage labels
12 / 400Play hint
11 / 700 / .02emSource
// same seed → same companion for the whole waitfunction pickWalker(seed: string) { let h = 0; for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0; return WALKERS[Math.abs(h) % WALKERS.length];}<div style={{ position: 'absolute', top: 0, width: size, height: size, // centred on the fill front, but never clipped off either end left: `clamp(${size / 2}px, ${clamped}%, calc(100% - ${size / 2}px))`, transform: 'translateX(-50%)', transition: still ? 'none' : `left 1.2s ${EASE}`, // matches the bar}}> <Lottie animationData={data} loop={!still} /></div>Shared by both apps — the guest sets their party size with it, the host adjusts a quoted wait with it. The value slides vertically between numbers rather than cutting, and the singular/plural noun beside it is part of the component, so "1 guest" reads as English.
Colour
Type
Value
40 / 700 / -.02emNoun
15 / 500Field label
13 / 600Source
<button className="la-step" disabled={dis} style={{ width: 52, height: 52, borderRadius: 'calc(var(--radius) * .8)', border: '1px solid var(--line)', background: 'var(--paper)' }}> <Icon name={ic} size={20} /></button><SlideNumber value={value} fontSize={40} weight={700} lineHeight={1.22} letterSpacing="-.02em" duration={0.42} /><span style={{ fontSize: 15, color: 'var(--ink-2)' }}> {value === 1 ? 'guest' : 'guests'}</span>One underline, drawn straight on the paper. On focus the label turns accent and a second rule grows in from the left on a transform, laid over the resting border, because a scaleX runs on the compositor and reads as the field waking up rather than repainting.
Inputs are floored at 16px across the whole product, which is the size below which iOS Safari zooms the page in when a control takes focus.
Colour
Type
Input value
16 / 500 — the iOS floorField label
13 / 600, accent on focusSource
{/* accent rule grows in from the left on a transform, not a colour swap */}<span aria-hidden style={{ position: 'absolute', left: 0, right: 0, bottom: -1.5, height: 1.5, background: 'var(--accent)', transformOrigin: 'left center', transform: `scaleX(${focused ? 1 : 0})`, transition: 'transform .32s cubic-bezier(.22,.61,.36,1)', willChange: 'transform',}} />/* iOS zooms the page when a focused control is under 16px — shared/tokens.css */input, textarea, select { font-size: 16px; }Both permissions are asked inline, in the form, with the reason attached — never as a bare browser prompt on arrival. Location is how the venue knows you are actually at the door; notifications are the entire promise of being allowed to leave it.
Granting flips the row to a check, so the form doubles as the status display. The location check is a haversine distance against the venue radius, run server-side in the Durable Object rather than trusted from the client.
Colour
Type
Permission name
13.5 / 650Reason
11.5 / 500Grant action
12.5 / 700Source
// asked in-line with the reason attached, never as a bare prompt on arrival<PermRow icon="pin" title="Share location" reason="Confirms you're at the venue" state={geo} onAsk={askLocation}/>// and verified server-side, not trusted from the client — server/src/geo.tsconst dist = haversine(coords, venue);if (!env.ALLOW_GEO_BYPASS && dist > venue.radiusM) return reject('too_far');The one state that has to survive being glanced at from a pocket, so it is the loudest thing the client app ever does: a 92px halo with a ring that pops on arrival, a 38px headline, and the progress bar pushed to its penultimate stop.
It arrives as a real Web Push — VAPID, RFC 8291, implemented against WebCrypto with no dependencies — so it lands with the app closed and the phone face down. Everything below it is deliberately quiet; the only other thing on screen is where to check in.
Colour
Type
Headline
38 / 800 / -.035emBody
16.5 / 400, max-width 280Source
<div style={{ position: 'relative', width: 92, height: 92, marginBottom: 22 }}> <div ref={ringRef} style={{ position: 'absolute', inset: 0, borderRadius: '50%', border: '2px solid var(--accent-bright)' }} /> <div ref={iconRef} style={{ width: 92, height: 92, borderRadius: '50%', background: 'var(--accent-soft)' }}> <LottieIcon data={bellAnim} color={ACCENT_LIGHT} size={52} playKey={celebrateKey} /> </div></div><div style={{ fontSize: 38, fontWeight: 800, letterSpacing: '-.035em', lineHeight: 1.04 }}>You’re next</div>The dashed border is doing real work: it says ticket, and a ticket is the one object in a restaurant everybody already knows how to hand over. It exists so the host can match a phone to a name without asking for one, and so a guest who arrives to a different host than the one who seated them is still expected.
Colour
Type
Code
40 / 800 / +.04emCaption
12.5 / 600Source
{/* dashed, because a ticket is the one object everybody knows how to hand over */}<div style={{ marginTop: 30, padding: '20px 26px', borderRadius: 'var(--radius)', border: '1px dashed var(--line)', display: 'flex', alignItems: 'stretch', gap: 20 }}> <div> <div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--ink-2)' }}> Check-in code </div> <FlipValue flipKey={view?.checkinCode} style={{ fontSize: 40, fontWeight: 800, letterSpacing: '.04em', fontVariantNumeric: 'tabular-nums' }}> {view?.checkinCode} </FlipValue> </div> <div style={{ width: 1, alignSelf: 'stretch', background: 'var(--line)' }} /> ...</div>The only confirmation in the entire product, and the shape of it is the argument. Leaving is genuinely unrecoverable — the spot is gone and you rejoin at the back — so the cost is spelled out in the two numbers the guest already cares about, and the safe option is the one that looks like a button.
The destructive option is a slider you have to drag. It cannot be hit by a mis-tap, it snaps back if you let go early, and it is shaped as a rounded rectangle rather than a pill so it stacks cleanly under the primary above it.
Colour
Type
Sheet title
24 / 750 / -.02emBody
15.5 / 400Slider label
15.5 / 700Source
const onMove = (e: React.PointerEvent) => { if (!dragging || confirmed) return; const nx = Math.max(0, Math.min(maxX.current, e.clientX - startX.current)); setX(nx); if (nx >= maxX.current - 2) { setConfirmed(true); setTimeout(onConfirm, 260); // let the thumb settle, then fire }};const onUp = () => { setDragging(false); setX(0); }; // snap back// the safe option is the one shaped like a button — Waiting.tsx<button className="la-btn la-btn-primary" style={{ height: 54, fontSize: 16 }}> Stay in line</button><SlideToConfirm onConfirm={onConfirm} label="Slide to leave the line" />LineUp puts a restaurant's line on two screens instead of a clipboard. A guest scans a code at the door, joins in two taps, and carries their place in line out into the neighbourhood; the host runs the queue from the counter. Nobody has to ask how much longer — both sides are looking at the same number at the same moment. It is live today as a self-initiated project, built end to end and deployed — two installable apps, one edge backend, and notifications that really arrive.
What a guest sees
The code by the door is alive. A new one is minted every 60 seconds, a ring sweeping out the time beside it, so a photo of it texted across town goes stale before it is useful. A spent or expired code answers in a sentence a person can act on — scan the live code at the counter — rather than an error number. Joining is a name and a party size; the app checks you are actually near the venue, and that is the whole form.
Then the screen goes calm on purpose: a dark standby with one number on it — your place in line — a live estimate, and a small walking avocado pacing out the progress. When the host seats a table, that number moves in about 25 milliseconds; you watch the line advance rather than refreshing it. And if the wait drags, the avocado is a toy: tap it and it lifts off the progress bar into an endless runner whose obstacles are hand-drawn pixel-art Ottawa — a bus, a truck, a Canada goose, Parliament.
Two moments have to reach a phone that is locked in a pocket: you're next and your table's ready. Both arrive as real push notifications — screen off, app closed. (On iOS that only works for an installed app, so the client notices and says "add to Home Screen first" instead of failing silently.) Leaving the line, meanwhile, is a slide rather than a button: it cannot be hit by a stray thumb, and it snaps back if you hesitate.
What the host sees
A tablet dashboard sized for arm's length: tall tappable rows, one per party, each carrying the name, the size, the wait so far, and exactly one next action — Notify, Seat, No-show. A stat strip keeps the day's shape in the corner of the eye — in line, average wait, seated today, no-shows — and when tables are turning slowly, the host raises the base wait from settings and every guest's estimate updates as they watch. The other face of the app is the full-screen QR display with its countdown ring, pointed at the door.
Below about 900 pixels the same app reshapes into a phone UI — the queue folds into denser rows, the stats into a three-up strip, the actions into a bottom bar — because plenty of hosts run the line from a doorway with a phone in one hand. The component shelf above shows both renderings, each at its real size.
How it works
Two React PWAs talk to one Cloudflare Worker, and each venue's queue lives in its own Durable Object — a single authority for that line, so two guests joining in the same instant can never scramble the order. The object holds both apps' live WebSockets, rotates the QR token on a 60-second alarm, and persists to D1, a schema of three tables. The push notifications are 131 dependency-free lines of WebCrypto, written from scratch because push libraries assume Node and a Worker isn't Node. All told, 8,065 lines across 68 files.
The speed is measured rather than claimed — the API is public, so these are real samples against the deployed URL, timed from a laptop whose nearest Cloudflare colo is Montreal:
| Path | p50 | p95 | Worst |
|---|---|---|---|
| Worker only | 22 ms | 30 ms | 38 ms |
| Worker → object → D1, warm | 50 ms | 59 ms | 69 ms |
| Worker → object → D1, cold | 299 ms | 386 ms | 426 ms |
| WebSocket ping → tick | 25 ms | — | 36 ms |
The single object costs about 28 ms over a bare Worker, and buys strict ordering plus a broadcast that it fans out itself; once a socket is open, an update lands in 25 ms — the difference between an app that feels live and a page that polls. The wait estimate itself is kept deliberately simple — a base the host controls, plus seven minutes per group ahead — because a legible number that moves the moment something happens beats a clever one that arrives late. A real venue would fit that slope from its own event log, which the schema already keeps.
One design language, two densities
Both apps import the same tokens and the same primitives — stepper, lined field, pill, stat, progress, switch, wordmark — and every token is defined once. The difference in feel is density alone: the merchant stand runs 64-pixel rows and a four-up stat strip; the client app spends its space on one 156-pixel number and a lot of warm paper. One blue accent does every job that needs emphasis — green appears exactly once, on the seated confirmation, and red exactly once, on leaving. The whole system is 149 lines of shared tokens. Hand-written CSS, hand-rolled primitives.
The client also ships its sense of humour: thirteen Lottie walking characters at 278 KB of JSON, against nine pixel-art Ottawa sprites for the runner game that total 1,702 bytes. 278 KB of walking vegetables next to 1.7 KB of Ottawa is still the most honest summary of this project available.
How far it goes
It stops at a demo. The QR admits 120 joins an hour, sized for a café rather than a stadium; the location gate is switched off in production so the demo stays openable; and the wait math is a straight line on purpose. Writing this page also turned up a real bug — an idle heartbeat that quietly kept every venue that ever existed warm, running queries to tell nobody nothing — which is its own small argument for writing a project up after building it.
Walker artwork by Jeffrey Christopher.