Sunday Batch build notes Back to the site · Index

How the Sunday Batch page was built

A prepared-meal kitchen in Osborne Park with one job on its landing page: build a box and order it. The visitor is a shift worker, a FIFO partner running the house alone, or somebody training five mornings a week who keeps buying lunch — all of whom will divide the total by the number of meals before they decide. So the page does the division for them, on screen, including the delivery fee, and then tells them when not to pay it.

Why this converts

The visitor arrives already doing arithmetic, so the headline answers it in the first line — five dinners, $14.90 each — and the box builder sits beside that headline on a desktop and one scroll below it on a phone, which means the price is reachable before any scepticism sets in. The delivery fee gets its own row in the ledger instead of hiding inside a total, and on the smallest box the card volunteers that the fee works out at $1.98 a meal and offers a button that removes it, which costs the kitchen $9.90 every time somebody takes it and buys more trust than any testimonial on the page. The marginal line does the same job in the other direction: it prints what the next meals cost — $11.90 each from five to ten, $10.60 from ten to fourteen — both under every headline price, so the upsell is arithmetic the visitor can check rather than a nudge. The form asks for six things and no more, because a prepared-meal order is confirmed by phone and every extra field is a reason to leave; the box builder pre-fills it, so a visitor who has already decided is one tap from done. Everything else on the page — the full price list with nothing withheld, the macros measured in our own kitchen, the allergen answer that says no when the honest answer is no — exists to make the one number in the badge believable.

The proof element: the box builder

Three controls — box size (5 · 10 · 14), portion (regular or large) and fulfilment (Sunday pickup or delivery). Twelve reachable states, all twelve driven in a headless browser and asserted against an oracle written separately from the page's own code.

The arithmetic, in full: the per-meal rate falls with the box ($14.90 / $13.40 / $12.60); a large portion adds a flat $2.50 a meal; Sunday pickup is free; delivery is $9.90 and free once the food comes to $120 or more. Money is held as integer cents and divided once at display, so nothing in the model can produce a figure like 1839.9999999999998; the harness asserts Number.isInteger on every cent field the model returns, including the per-meal figures and both marginal steps.

Every cell the builder can reach

Weekly total, then the per-meal figure the badge prints beside it.
BoxPortionSunday pickupDeliveredPer meal, pickupPer meal, delivered
5Regular$74.50$84.40$14.90$16.88
5Large$87.00$96.90$17.40$19.38
10Regular$134.00$134.00$13.40$13.40
10Large$159.00$159.00$15.90$15.90
14Regular$176.40$176.40$12.60$12.60
14Large$211.40$211.40$15.10$15.10

Delivery is only ever charged on the five-meal box: every other cell clears the $120 free-delivery line, which is why the ten- and fourteen-meal rows are identical for pickup and delivery. When the fee is waived the ledger still prints it, struck through, beside the $0.00 — a visitor should be able to see the number they did not pay.

The give-away

$9.90 on five meals is $1.98 a meal. That is more than the rice, the pan and the gas together, and it is the dearest single thing in a small box. The card says so in words whenever the visitor selects a five-meal delivery, and offers a button that switches to Sunday pickup and takes the $9.90 straight off the total. It is a food business arguing against its own delivery fee, in the one state where the fee is genuinely bad value.

The second give-away is the marginal line. Stepping from five meals to ten adds five meals at $11.90 each; ten to fourteen adds four at $10.60 each. Both are below every headline price on the page, and both are printed as the box changes size, so the visitor watches the marginal cost fall instead of being told that it does. The nine empty containers drawn under the filled ones are exactly the meals that line prices.

Palette

Argued away from the food-page defaults: no cream-and-terracotta trattoria, no neon macro tracker. Olive and chalk is a working kitchen in low light; the single orange is the lid on the container, and it appears on the lids, the selected chips and nothing else. Every ratio below is measured with WCAG arithmetic and quoted from the same row of the sweep as its hex.

  • paper #FAF7F0page ground
  • panel #ECE7DAcards, tiles, the builder
  • band #2C3620steel bench in low light
  • chip #E4713Athe tray lid · takes ink only
  • ink #1C1F1615.62:1 on paper · 13.53:1 on panel · 5.35:1 on chip
  • muted #4E54427.35:1 on paper · 6.37:1 on panel
  • rust #9A3B126.52:1 on paper · 5.86:1 on mapLand
  • paperOnDark #FAF7F011.86:1 on band · 14.6:1 on bandHover
  • mutedOnDark #CBD1C0minted here — 8.11:1 on band, 9.99:1 on bandHover
  • rule #C9C2AE1.66:1 on paper · 1.44:1 on panel (hairline)
  • grain #D9C89Cminted — rice and couscous, scene fill
  • greens #6E8A4Aminted — beans and greens, scene fill
  • braise #7A4526minted — the slow-cooked protein, scene fill
  • steamHair #BCBDB2minted — olive at 30% over paper, 1.77:1 (hairline)

Four colours were minted during the build and every one was added to the wave's contrast sweep and re-measured rather than eyeballed. The three food fills are declared as grounds, not tokens: nothing is drawn on top of them, and the sweep says why — body ink measures 2.15:1 on the braise fill and 4.29:1 on the greens, so a label there would have failed. The steam is measured at its lowest animated opacity, not its highest, because the worst frame is the one that has to pass.

Type

Familjen Grotesk at 600–700 for the display: a little wide, a little friendly, and it holds a long headline at 375px without turning into signage. IBM Plex Sans for body, chosen because the macro figures and the ledger sit at 13–15px and Plex keeps digits legible there. The menu items get their own scale — meal name at display weight, the per-serve macro line beneath it in muted small caps — so the list reads as a menu rather than a table. Tabular numerals throughout the badge and the ledger, so the total does not jitter when it changes.

Techniques

No libraries, no build step, no raster images. The containers are an inline SVG: two symbols in <defs> (a regular container and a deeper one for the large portion), an empty dashed slot, and fourteen positions that reference them with <use>. All fourteen slots are always in the frame; the state changes which of the three variants at each position is showing.

Those variants are toggled with setAttribute('hidden','') and removeAttribute('hidden') against a [hidden]{display:none!important} guard — never the .hidden IDL property, which is an HTMLElement property and does nothing at all on an SVG element. That failure is invisible to every state probe, because the state is correct and only the pixels are wrong, so the harness counts rendered boxes rather than reading the model.

The only animation on the page is three steam wisps over the containers. It is driven from wall-clock time (performance.now() minus a start stamp), never from a per-frame delta accumulator — headless Chrome services animation frames only when a frame is requested, so a capped per-frame accumulator can advance two seconds of animation over seven seconds of wall clock and photograph a page that has not arrived yet. Opacity never falls below 0.30, so the signature is visibly alive at any capture instant without tuning the loop's phase to the screenshot schedule. The loop stops on document.hidden and never starts at all when prefers-reduced-motion: reduce is set — that path renders a settled frame at 0.45 instead of a blank one.

The service-area map is inline SVG with sixteen suburbs, a dashed delivery boundary and the kitchen marked at Osborne Park. Every dot and every label bounding box is asserted inside the ellipse it claims — a suburb listed as a delivery suburb but drawn outside the boundary is the page arguing with itself — and no two labels are allowed to overlap.

Iteration log

Pass 1 — Craft

  • The hidden-slot design left the drawing 48px tall in the 1200×750 thumbnail frame, under the 120px floor, because only five containers rendered at rest. Rebuilt so that all fourteen slots are always drawn and the unfilled ones are dashed outlines: 216px of drawn subject in frame, and the empty slots became the thing the marginal line prices.
  • The first steam wisps were 14 user units tall on a 330-unit canvas and read in the capture as three stray question marks. Redrawn as 32-unit double curls centred on containers two, three and four.
  • With large portions selected, the taller containers overlapped the row above at a 70-unit row pitch. Row pitch increased to 88 and the whole scene re-cut to a 390-unit canvas; the steam band now clears the tallest container variant by 3 units.
  • A bulk find-and-replace over the container y coordinates silently moved two map values as well — Scarborough's label dropped 46 units away from its own dot and Stirling's dot moved 28 units. Caught by looking at a rendered map, not by any assert; the map was rebuilt from a coordinate table and an assert now checks every dot against its label.
  • The masthead's sub-label hide rule at 600px was written against .mark span, which also matched the wrapper holding the wordmark — it would have hidden the business name on every phone. Re-scoped to a dedicated class.
  • --mapLabel was used in the map SVG and never declared in :root; the small type on the olive band was an undeclared literal. Both are now tokens, and the literal was added to the contrast sweep and measured at 8.11:1.
  • The phone field's format hint knocked the two-column form grid out of alignment for the rows beneath it; removed, because the error message already says the same thing at the moment it matters.
  • The price badge carried a one-pixel shadow that was invisible at every width against a panel ground, and the map's ocean shape reached 18 units further east than the delivery boundary's left edge. Both were cut here.

Pass 2 — Depth

  • Added the struck-through waived fee: when the box clears $120 the delivery row prints $9.90 struck through beside $0.00, so the visitor sees the number they did not pay instead of a bare zero. Driven and asserted in all four free-delivery cells.
  • Tied the drawing to the copy: a caption under the containers counts what is filled and what is empty, and the marginal line names those same empty slots. The caption ships a pinned short form at 600px and below (“Five filled, nine empty.”) and the full form above it, so the two can never drift.
  • Drove the give-away end to end rather than checking that the text exists: select five meals and delivery, assert the fee is $990 cents, dispatch a real click on the switch button, assert the fulfilment flips to pickup and the total drops to $74.50. The button is the give-away; a string that says so is not.
  • Drove the twelve-cell grid a second time counting rendered containers rather than reading the model — filled count, empty count, and that the unused portion variant renders zero boxes. This is the check that would have caught the SVG hidden trap if it had been made.
  • Added a rapid-fire drive: six control changes dispatched in one evaluate with no settle between, then a single assert that the state and the drawing agree. Nothing in this page defers a rebuild, and now that is tested rather than assumed.

Pass 3 — Hardening

  • 375 / 768 / 1440 all clean: no element right edge past the viewport, no horizontal scroll, and the wordmark's right edge measured against the call pill's left edge rather than trusting scrollWidth.
  • 375×667 fold measured against the sticky bar, not the viewport: the h1, the primary CTA and the trust line each finish above the bar, and the primary CTA is at least 44px tall.
  • The phone field's pattern escapes (, ) and - inside its character class, because the attribute compiles under the v flag and a v-invalid pattern is silently discarded rather than reported — the field then accepts anything. Proved behaviourally: “ring me maybe” is rejected and (08) 9000 0434 is accepted.
  • Form driven three ways — empty (five required fields marked, confirmation stays hidden), junk in the phone field, and filled (confirmation appears, form hides, summary prints $159.00 and $15.90). The confirmation is a local state change; nothing is sent anywhere and no network round trip is faked.
  • prefers-reduced-motion honoured in CSS and checked with matchMedia before the loop is ever scheduled; the reduced path paints a settled frame rather than nothing.
  • The probe was re-read eight seconds after the seven-second thumbnail instant and every numeric field was identical — the page is genuinely at rest when the sales asset is captured.
  • Ornament removed (the Chanel rule): the decorative dot in front of the trust line. It was doing nothing that the panel ground and the bold lead-in were not already doing, and the trust line is the last thing above the fold at 375 — the cheapest place on the page to spend a reader's attention badly.

What this page declined to publish

  • Any food-safety rule, standard, code or regulator. The FAQ answers how long the meals keep and how to reheat them as our own instruction for our own food — five days from the Saturday cook date — and says so in the answer. No Act, no code, no standard number, no authority is named or paraphrased anywhere on either page.
  • The name of the local government the kitchen is registered with. The trust line says “council-registered kitchen” with a visibly patterned number (Reg. FB0000) rather than naming a real council, because a fictional business should not attach itself to a real regulator's register.
  • Any nutrition or dietary guideline. The macros are the kitchen's own measurements from its own batch and are labelled as such under the menu. The page gives no dietary advice and cites no guideline.
  • Any real street address. Suburb level only, on both pages. A case-insensitive scan for street|road|avenue|terrace|parade|crescent|drive|highway|way|court|close|lane across both files returns hits only inside the script — the identifiers giveaway and Element.closest and a code comment reading “the give-away”. No user-visible text matches.
  • Structured data about the business. The only JSON-LD on the site is a WebPage block that names the page a demonstration. The LocalBusiness and Review schema we would ship for a real client is below, as a code sample only.
  • Two per-meal figures the wave oracle does not name. check-w55.mjs enumerates all twelve totals but only six of the twelve per-meal divisions. The five-large-pickup cell prints $17.40 and both ten-large cells print $15.90; both are the licensed total divided by the licensed meal count, both were re-derived in this build's own oracle, and both were reported back rather than shipped quietly.

Local SEO we would implement for the real business

None of this ships on the demonstration page, because fictional structured data in a real index is dishonest markup. For a real prepared-meal kitchen it is the first thing we would add, alongside a verified business profile, consistent name-address-phone across every directory, and a review feed the schema reads from rather than a hand-typed block.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FoodEstablishment",
  "name": "Sunday Batch",
  "description": "Prepared meals cooked Saturday for collection Sunday, Osborne Park.",
  "url": "https://sundaybatch.com.au/",
  "telephone": "+61 8 XXXX XXXX",
  "priceRange": "$$",
  "servesCuisine": ["Australian", "Thai", "Middle Eastern"],
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "the real one, once it is a real business",
    "addressLocality": "Osborne Park",
    "addressRegion": "WA",
    "postalCode": "6017",
    "addressCountry": "AU"
  },
  "geo": { "@type": "GeoCoordinates", "latitude": -31.90, "longitude": 115.81 },
  "openingHoursSpecification": [
    { "@type": "OpeningHoursSpecification",
      "dayOfWeek": "Sunday", "opens": "09:00", "closes": "13:00" }
  ],
  "areaServed": [
    { "@type": "City", "name": "Osborne Park" },
    { "@type": "City", "name": "Innaloo" },
    { "@type": "City", "name": "Balcatta" }
  ],
  "hasOfferCatalog": {
    "@type": "OfferCatalog",
    "name": "Weekly boxes",
    "itemListElement": [
      { "@type": "Offer", "name": "5-meal box",
        "price": "74.50", "priceCurrency": "AUD" },
      { "@type": "Offer", "name": "10-meal box",
        "price": "134.00", "priceCurrency": "AUD" },
      { "@type": "Offer", "name": "14-meal box",
        "price": "176.40", "priceCurrency": "AUD" }
    ]
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.8", "reviewCount": "137"
  },
  "review": [
    { "@type": "Review",
      "author": { "@type": "Person", "name": "a real, consenting customer" },
      "reviewRating": { "@type": "Rating", "ratingValue": "5" },
      "reviewBody": "their words, pulled from the review feed, never written by us" }
  ]
}
</script>

Two notes a real client should hear before this goes live. The rating and review counts must be read from a live feed the business actually controls, not typed in, because a stale or invented aggregate rating is the fastest way to lose a rich result. And FoodEstablishment is the honest type for a kitchen that sells prepared food for collection; if the business later adds a shopfront and seating, the type changes with it.

What would change for a real client

  • Payments and the Sunday counter. Square for the card terminal at pickup and for the payment link we would text on Friday, with the order reference carried through so the Sunday run sheet reconciles itself instead of being ticked by hand.
  • Recurring boxes. Shopify subscriptions for the customers who want the same box every week — the page deliberately sells a one-off box, but roughly the same shape of business always ends up wanting a standing order, and it wants pause-and-resume rather than a lock-in.
  • Wholesale and cafe orders. Ordermentum if the kitchen starts supplying cafes or gyms, which is where a Saturday cook day usually finds its next margin: standing wholesale orders, credit terms and delivery runs that a retail cart cannot model.
  • Their photography and their menu. The containers here are drawn, which is the collection's constraint, not a recommendation. A real kitchen shoots the food. The weekly menu would come out of the page and into a small admin the kitchen edits on a Thursday, with the macros and the allergen list as fields rather than as markup.

Conversion decisions

  • The proof element sits beside the headline on a desktop and one scroll below it on a phone. A prospect scanning a gallery of thumbnails should see the answer to their question, not a menu of tiles — and the DOM order is hero, price list, builder, so the reading order and the tab order are the same on a phone.
  • Six form fields. Name, phone, suburb, box size, portion, pickup or delivery, plus a free-text note and one optional checkbox. No email field: this order is confirmed by phone, and a field we would not use is a field that costs us the lead.
  • The sticky bar owns a real tel: link and a real in-page anchor, not a word that sounds like a call to action. It is tested by what it does, not by what it says.
  • The full price list is on the page. Six tiles, nothing withheld, no “contact us for pricing” — the trade that hides the per-meal figure is the one this page is competing against.