Marrowbone. ← The site Index

Build notes

A restaurant page that serves a Friday browser and a function organiser at the same time.

Marrowbone is a fictional forty-eight seat wood-fired restaurant in Mount Hawthorn with a sixteen-seat back room, built as an industry demonstration. The page has one job — a booking enquiry — and two very different people trying to do it: someone deciding where to eat on Friday, who needs the hours, the shape of the menu and the phone number inside five seconds, and someone organising a thirtieth for thirty people, who needs a number they can take back to the group before they will send anything. Nothing on the page is split into "for diners" and "for functions"; the same sections answer both, in the order each of them asks.

The palette

A dark dining room, not the cream-and-terracotta default.

Restaurant design collapses towards cream paper, a high-contrast serif and a terracotta accent almost every time. This one is the opposite: the ground is the colour of a dark room at seven o'clock, the ink is warm cream, and the only accent is brass — the light on the glassware and the oven mouth. Every ratio below was measured with WCAG arithmetic and is quoted with its hex from the same row of the wave's contrast run.

GROUNDDeep#10261F — the page. Carries cream at 13.65:1.
PANELPanel#17342B — cards and the estimate. Cream at 11.53:1.
INKCream#F2EDE4 — body ink, 13.65:1 on deep and 11.53:1 on panel. Also the ground of the one light band, where deep ink reads at 13.65:1.
SECONDMuted cream#C6C7B8 — secondary copy, 9.30:1 on deep and 7.85:1 on panel.
ACCENTBrass#E4A24A — accent ink and marks at 7.25:1 on deep and 6.12:1 on panel; as a button fill it carries deep ink at 7.25:1.
FIELDField green#83A793 — input borders, drawn seats and separators. 6.00:1 on deep, 5.06:1 on panel.
HAIRLINERule#3B5A4C — hairlines only, 2.09:1 on deep. It is not a glyph colour, which Pass 1 had to enforce.
ON BRASSDeep on brass#10261F — the label inside every brass button and the price badge, 7.25:1.

Where each token may not go

Brass is forbidden on cream (1.88:1) and muted cream is forbidden on cream (1.47:1), so the light review band uses exactly one ink — deep, at 13.65:1 — and gets its hierarchy from weight and size instead of a second colour. The hairline token is forbidden as text anywhere; when the trust strip's separator dots were set in it they measured 2.09:1 and simply were not there.

The type

Bodoni Moda for the room, Karla for the work.

Bodoni Moda carries the wordmark and every section head. It is the one moment of bravery: a high-contrast didone is what a menu is set in, and against a dark ground its thin strokes read as engraving rather than as a wedding invitation. It is used at 500 and 600 only, never below about 20px, and never for anything a customer has to read at speed.

Karla does everything else — body copy at 17px on mobile, the price anchors, the estimate's controls, the form. Prices and figures run on tabular numerals so a column of dollars stacks straight; a fee table with wandering digits reads amateur, and this page has one in the menu grid and another in the estimate. The one place the two faces meet is the estimate card, where a Bodoni heading sits directly above a Karla control set, which is exactly the split between "this is a restaurant" and "this is a tool".

The proof element

The function estimate, and the room it draws.

Three controls — guests, menu, drinks — one result. No instructions, no wizard, no "get a quote" gate. Beside the controls is a plan of the actual room, drawn in SVG: twelve four-tops out the front, two long eight-seat tables in the back room behind their door, and the wood oven against the far wall. Seats fill with brass as the guest count rises, and which room they fill in is the second read.

The arithmetic

ControlValuesEffect
Guests10 – 80, in onesMultiplier
MenuCanapés · two courses · three courses · banquet$38 / $62 / $78 / $85 a head
DrinksBeverage package on or off+ $45 a head
Per headmenu + (drinks ? 45 : 0)
Totalguests × per head
Friday & Saturday exclusive-use minimum$4,000
Guests that reach itceil(4,000 ÷ per head)

Every rate is a whole number of dollars and the guest count is an integer, so there is no floating-point step anywhere between a control and a printed figure — the harness asserts Number.isInteger on every field the model returns, not just on the total.

The figures the page ships

ScenarioPer headTotalAgainst the minimum
Rest state — 30 guests, two courses, with drinks$107$3,210$790 under; 38 guests reaches it
38 guests, two courses, with drinks$107$4,066Clears it by $66
A birthday — 12 guests, canapés, no drinks$38$456No count on the slider reaches it
The full room — 80 guests, banquet, with drinks$130$10,400Clears it by $6,400
The floor — canapés, no drinks$38= the services-grid anchor

The estimate's cheapest per-head figure and the menu grid's "from $38" are the same number by construction. A page whose calculator undercuts its own price list argues with itself, and a function organiser will notice.

The nudge, and why it is not a refusal

At the rest state the card reads "$790 under the Friday exclusive-use minimum; 38 guests reaches it". That is the single most useful sentence on the page for the person organising the party, because it converts a wall into an instruction. Underneath it, in quieter type, is the part that stops it reading as a rejection: under the minimum you can still book — you simply share the floor with the rest of the restaurant — and midweek there is no minimum at all. Nothing on this page tells a customer no.

There is one branch where no guest count on the slider can reach $4,000: canapés with no beverage package, at $38 a head, where the arithmetic wants 106 people and the room holds 80. Rather than printing an unreachable number the card says so plainly, gives the full-room figure at that menu, and points at the two honest ways out — a longer menu, or book it midweek where the minimum does not exist. A note that recommends a value has to be checked against the values that are actually selectable.

Which room your booking lands in

GuestsThe drawingWhat the caption says
up to 16Back room fills, front stays darkThe back room — no hire fee, no minimum midweek
17 – 48Dining room fills, tables countedn of forty-eight seats across ⌈n ÷ 4⌉ tables
49 – 64Front full, back room fillingThe whole room
65 – 8064 seated, the rest standing in the aislesStanding — and on a seated menu, a nudge towards a second sitting

Sixty-four is every chair the fictional room owns. The estimate still prices eighty on a banquet, because that is a real thing a venue quotes, but the drawing refuses to pretend eighty people sit down: above sixty-four it seats what it can, stands the rest, and the caption suggests a phone call about a second sitting. The drawing is where the honesty lives.

How it is drawn

The plan is static SVG in the markup — every table, all sixty-four seats and the sixteen standing places are written into the document, not inserted by script. An SVG scene assembled after the page is live can go unpainted in headless Chrome while every state assert stays green, so nothing here is built at runtime; the only thing JavaScript does is add and remove one class per seat. Seats light in a short cascade (4ms per seat out the front, 6ms in the back, capped at 190ms) using a transition delay written once at load — never a per-frame job, and zeroed entirely under prefers-reduced-motion.

The only motion on the page is the oven's ember, a six-second CSS keyframe whose opacity never falls below 0.55, so the fire is visibly alight at any capture instant and the thumbnail needs no phase arithmetic. It pauses while the tab is hidden. There is no requestAnimationFrame loop and no frame accumulator anywhere on the page — a frame-counted animation is roughly two and a half seconds of motion inside a seven-second headless capture, which is how a preview ends up showing an empty room.

For the prospect

Why this converts.

The primary call to action is above the fold at 375px next to a phone number that dials on tap, because this decision is made on a phone, usually half-made already, and the only thing between that person and a booking is a second tap. The estimate sits in the hero rather than three sections down, because the function organiser's real objection is not price but uncertainty — they will not send an enquiry until they have a figure they can put in a group chat, and giving it away for free costs nothing and buys the enquiry. The form asks for six things and no more: name, phone, email, what it is for, a date and a guest count, because every extra field is another reason to close the tab, and everything else can be settled in the callback the form promises. The trust strip is three plain facts rather than badges — open Wednesday to Sunday, forty-eight seats plus a sixteen-seat back room, in Mount Hawthorn since 2016 — because a neighbourhood restaurant earns trust with specifics a competitor cannot copy, not with stars. And the minimum-spend nudge tells a customer they are $790 short and exactly how many guests would fix it, which turns the one number most venues bury into the reason the enquiry gets sent.

Conversion decisions

The choices behind the layout.

Why the estimate is in the hero and not after the menu

The prescribed section order runs masthead, hero, menu grid, proof element. At 1200×750 — the size of the preview image this page is judged on — a hero plus a six-tile menu grid consumes the whole frame before the drawing appears, and a preview with no proof element in it is a preview of a headline. Putting the estimate in the hero's second column resolves both: the drawn room lands inside the first 750 pixels, and on a phone the sections still stack in the prescribed order, because the second column falls directly beneath the hero copy. The menu grid then follows immediately, which is also where the Friday browser expects it.

Why the price badge is docked, not overlaid

A badge floated over artwork covers the drawing at some width — usually the one nobody screenshots. Docking it directly beneath the plan, inside the same bordered container, keeps the figure attached to the drawing while making the collision impossible by construction. It is asserted as a rectangle-intersection test at 375, 1000 and 1200 pixels at both 1× and 2× pixel ratio rather than trusted.

Why the phone leads on mobile and the form leads for functions

Two calls to action, deliberately unequal by context. The masthead carries a tap-to-dial number at every width; the sticky bar at the bottom of a phone screen carries a real tel: link and an anchor straight to the form. The hero's primary button goes to the form and the secondary dials, because the person reading the headline could be either customer — but the estimate's own button goes to the form only, and carries the guest count, the booking type and the priced scenario down with it. An enquiry that arrives already saying "56 people, three courses with drinks, $6,888" is a warm lead; one that says "hi, do you do functions" is not.

Why the confirmation never pretends to send

Submitting a valid form hides the form, greets the sender by first name and says the restaurant would call back before service the following evening — then says plainly that nothing left the browser, because this is a demonstration. No spinner, no fake network delay, no invented reference number. A demo that mimes a round-trip it is not making is teaching the prospect the wrong thing about how we build.

What the form does not do

  • No pattern attributes. Modern Chrome compiles them as v-flag regexes, where an unescaped hyphen, bracket or slash is a parse error that kills validation silently while the field still looks fine. Validation here is plain JavaScript with a visible message per field.
  • No pre-filled fields at rest. The estimate only writes into the form when its button is clicked, so the form is genuinely empty on load and an empty submit is a real test.
  • No deposit, no card, no account. Six fields and a phone call.

Search

Local SEO we would implement for the real business.

Marrowbone is fictional, so this page and the site both emit only WebPage, CreativeWork, WebSite and BreadcrumbList markup naming the build a demonstration. Publishing Restaurant and Review data for a restaurant that does not exist would put invented opening hours, an invented address and invented ratings into a real search index. Below is exactly what we would ship for a real venue — as a code sample only. It is not live on either page.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Restaurant",
  "name": "Marrowbone",
  "servesCuisine": ["Modern Australian", "Wood-fired"],
  "priceRange": "$$$",
  "url": "https://marrowbone.com.au/",
  "telephone": "+61 8 9000 0444",
  "image": ["https://marrowbone.com.au/img/room-1600.jpg"],
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "— the venue's real street address —",
    "addressLocality": "Mount Hawthorn",
    "addressRegion": "WA",
    "postalCode": "6016",
    "addressCountry": "AU"
  },
  "geo": { "@type": "GeoCoordinates", "latitude": "—", "longitude": "—" },
  "openingHoursSpecification": [
    { "@type": "OpeningHoursSpecification",
      "dayOfWeek": ["Wednesday","Thursday","Friday","Saturday"],
      "opens": "17:00", "closes": "22:00" },
    { "@type": "OpeningHoursSpecification",
      "dayOfWeek": "Sunday", "opens": "12:00", "closes": "20:00" }
  ],
  "acceptsReservations": "https://marrowbone.com.au/book",
  "hasMenu": {
    "@type": "Menu",
    "name": "This week's menu",
    "url": "https://marrowbone.com.au/menu"
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.7", "reviewCount": "312"
  },
  "review": [{
    "@type": "Review",
    "author": { "@type": "Person", "name": "— a real reviewer —" },
    "datePublished": "2026-06-18",
    "reviewRating": { "@type": "Rating", "ratingValue": "5" },
    "reviewBody": "— their own words, synced from the review feed —"
  }]
}
</script>

Every value above has to come from the venue: the rating and review count synced from the Google Business Profile rather than typed, the hours from the same source as the profile so the two never disagree, and the menu URL pointing at a page that is actually updated weekly. The rest of the local work is unglamorous and matters more than the markup — one Google Business Profile with the categories right and the function attributes filled in, the name, address and phone identical everywhere they appear, a page that loads in under a second on a phone on 4G, and the review feed answered.

For a real venue

What would change.

  • The booking system. The form here is a demonstration. A real Perth venue this size is on SevenRooms, Now Book It or Obee — the table side would hand off to the provider's widget or API so availability is live, and the function side would keep this form and post into the same system as an enquiry with the priced scenario attached, so the manager opens one record, not two.
  • The weekly menu. The menu changes on Wednesday in the fiction, and the page says so, which is why no dish on it is presented as this week's list. A real build gives the venue a single field to paste the week's menu into and dates it on the page, because a stale menu is the fastest way for a restaurant site to lose trust.
  • Photography. Every visual here is drawn — SVG, CSS and gradients, no photographs at all, which is the point of the collection. A real venue replaces the room plan's neighbours with their own room, their oven and three or four plates, shot properly and once a season. The plan itself stays: it is more useful to a function organiser than any photograph, because it answers "will we fit".
  • The reviews. The three on the page are written examples on a page declared fictional, and they are deliberately not marked up. A real venue pulls them from the Google Business Profile and only then ships the Review schema above.
  • The numbers. Per-head prices, the beverage package and the minimum spend are the venue's own and would be maintained in one place, with the estimate and the menu grid reading from it so the two can never drift apart.

The build

Three passes, and what each one found.

Pass 1 — Craft

  • The separators in the trust strip were invisible. They were set in the hairline token #3B5A4C, which measures 2.09:1 on the ground — correct for a rule, useless as a glyph. The line read as three phrases with unexplained gaps in it. Moved to the field token at 6.00:1. Only a screenshot found this; every text assert was green.
  • Cream text on the cream band. The base rule .honest b{color:cream} out-specified the light band's override, so the opening sentence of the review disclaimer — "These are written examples, not collected reviews." — rendered cream on cream and was simply a blank space. Fixed by forcing every bold inside the light band back to deep ink.
  • A 375px overflow the scrollWidth probe would half-miss. A grid item's automatic minimum is its min-content size, so the hero column resolved to 393px inside a 335px container and the page measured 413px wide at a 375px viewport. Pinned min-width:0 on every grid child.
  • The headline broke mid-word at "forty-eight", leaving a hyphen hanging at the end of a line of 51px Bodoni. Wrapped in a nowrap span.
  • The trust line fell under the sticky bar at 375. Reordered the hero so the trust strip clears it and moved the worked example ($3,210) below the strip; the value proposition, both calls to action and the trust line are now all above the fold on a 375×667 screen.
  • Map labels collided with street lines and with each other. Gave every label a ground-coloured halo (paint-order:stroke fill) and re-laid all ten labels; the Oxford Street label had been running off the bottom of the viewBox and through "LEEDERVILLE".
  • The plan legend said "Oven, far wall", which is true of the room and wrong of the drawing — the oven is at the top left of the plan. Reworded.
  • The masthead contradicted the footer. "Wed–Sun, 5pm till late" against a footer listing Sunday as 12pm–8pm. The Sunday lunch is a better detail than the simpler hours line, so the masthead changed.
  • Docked the price badge beneath the drawing at every width rather than overlaying it, and gave section anchors a scroll-margin-top so they do not land under the sticky masthead.

Pass 2 — Depth

  • The mark on the guest track. A brass tick now sits on the slider at the guest count that reaches the Friday minimum for the menu currently selected — 38 with two courses and drinks, 49 on canapés with drinks, gone entirely on canapés without them. The organiser can see how far the handle has to move. It is positioned with a CSS calc() against the thumb inset, so it survives any resize without a listener, and it disappears the moment the count is met.
  • The drawing now says which room you are in. Up to sixteen it fills the back room only; seventeen to forty-eight the dining room; beyond that both; beyond sixty-four the aisles. The caption names the room and, on a seated menu above sixty-four, says that sixty-four is every chair and suggests a second sitting.
  • Seats light in a cascade rather than snapping together — 4ms per seat out the front and 6ms in the back, capped at 190ms, written once at load as a transition delay and zeroed under reduced motion.
  • Drove the whole grid against an independent oracle. All 568 cells — 71 guest counts × 4 menus × 2 drink states — reimplemented from the spec in separate code and compared against the live page's per-head, total, shortfall, surplus and guests-to-minimum. Zero mismatches, zero non-integer fields, and the seated/standing split reconciled at every count.
  • Rapid-fire drive. Five control changes dispatched in a single evaluate with no settle between them: the end state is consistent and no console error fires, so nothing is holding a stale closure over the previous scenario.

Pass 3 — Hardening

  • Badge versus drawing, measured. A rectangle-intersection assert between the price badge's box and the bounding box of every drawn table, seat and standing place, at 375, 1000 and 1200 pixels wide and at both 1× and 2× device pixel ratio. Never intersects.
  • Keyboard-only drive of the estimate. Arrow keys move the menu radio group and the guest slider, space toggles the beverage package, and the FAQ opens from its summary — each re-pricing correctly. Focus outlines were then measured on every control, including the sticky bar at 375, and all paint a 2px brass ring at 7.25:1 on the dark ground.
  • The seven-second capture, looked at. The 1200×750 frame was rendered and inspected, not just asserted: the room plan and the price badge are both in it, the page is at rest, and re-reading every probe field eight seconds later returns an identical object.
  • Removed one ornament — the second ring drawn around the map pin. The brass dot and its label are enough.
  • Re-derived every number in body copy against the shipped model: twelve four-tops make forty-eight, two eight-seat tables make sixteen, sixty-four is the seated total the form's error message quotes, thirty guests across eight tables is ⌈30 ÷ 4⌉, and $3,210 in the hero is 30 × ($62 + $45).
  • Mechanical checks: node --check on the extracted script, a curly-quote-in-attribute scan across both pages, parsed JSON-LD against the allowlist, a positive sweep confirming every phone number on the page uses the reserved 9000 exchange, and zero console errors at every width tested.

Marrowbone is a fictional business — a restaurant and function-venue industry demonstration designed & built by Sapience Analytics, part of the Generative Assets collection. The brand, the room, the menu, the reviews, the phone number, the ABN and the licence numbers are invented for the demonstration; the prices are written to be plausible for a small Perth restaurant in 2026, and every figure the estimate produces is checked against an independently reimplemented model before the page ships. ← Back to the site Index Sapience Analytics