Say it in the bakery's words, and stop rendering the spreadsheet

The page still read like the source: a grid of cells saying "6+6", "4 dz", "B&G cake", "12 items". That is
a note to whoever is baking, not an offer to whoever is buying — and a table of those cells is a picture
of the spreadsheet it came from.

THE WORDS (V6, guarded on V4/V5's exact text so anything already reworded in the admin is left alone):
cells become sentences — "A dozen", "A dozen, in two flavors", "Eighteen, in two or three flavors", "A
6-inch cake", "Two pans", "Four dozen", "Eight inch". Each table gets the line of copy its blurb field
always had room for. The parties table had the same offset the weddings one did — quantities on the
"Cupcakes" line, an empty "Sugar cookies" line below it — so it is merged the same way, and no line on the
page is blank the whole way across any more. Notes read as sentences too: "Everything is baked in sixes,
so each item comes in multiples of six."

THE LOOK: one card per size instead of a price matrix. The size, the price, then a ticked list of what you
get, in a three-up grid that stacks on a phone — so there is now ONE rendering rather than a table for
wide screens and cards for narrow ones. A table is still the honest shape for a price matrix, and it is
how the bakery keeps these; it just isn't how you sell them.

The per-card button went away again after seeing it rendered: all three led to the same place (an enquiry
is about the table, not the size) and on the tables whose sizes are headcounts it read "Ask about the
15–20 people". One pill per table now.

53 tests, updated to assert the new wording — including that no cell anywhere still matches "dz", "B&G" or
"in cake", and that the price grid is gone.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-07-26 17:24:59 -05:00
co-authored by Claude Opus 5
parent cf1254f901
commit 0d534a0e80
5 changed files with 235 additions and 112 deletions
@@ -0,0 +1,126 @@
-- The catering tables in the bakery's voice instead of the spreadsheet's shorthand.
--
-- V4 kept the source wording deliberately: it was a spreadsheet written for the people who bake from it,
-- and inventing copy for somebody else's prices is not a migration's job. But the page is read by
-- customers, and "B&G cake", "2 pans", "4 dz" and "6+6" are notes-to-self, not an offer. So this rewrites
-- the cells as sentences, fills in the blurb each table always had room for, and merges the parties
-- lines the same way V5 merged the wedding ones — "Cupcakes" quantified as "1 dz sugar cookies or
-- cupcakes" with an empty "Sugar cookies" line beneath it is the same offset, and the same fix.
--
-- Guarded on the exact text V4 and V5 left, as V5 was: anything the bakery has already reworded in the
-- admin is left exactly as they wrote it. Their wording wins over mine.
-- --- names and the line under each heading ----------------------------------------------------------
update catering_package set name = 'Office boxes'
where name = 'Office';
update catering_package set blurb = 'Mini pastries for a morning meeting, boxed and ready to collect.'
where name = 'Office boxes' and blurb is null;
update catering_package set blurb = 'A cake, and something to hand round, sized to your guest list.'
where name = 'Parties' and blurb is null;
update catering_package set blurb = 'A cake for the couple, and dessert for everyone else.'
where name = 'Weddings' and blurb is null;
-- --- office boxes -----------------------------------------------------------------------------------
update catering_row_value v set value = 'A dozen'
from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '12 items';
update catering_row_value v set value = 'Eighteen'
from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '18 items';
update catering_row_value v set value = 'Two dozen'
from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '24 items';
-- "6+6" is six of one flavour and six of another, which is worth saying out loud.
update catering_row_value v set value = 'A dozen, in two flavors'
from catering_row r where r.id = v.row_id and r.label = 'Mini scones' and v.value = '6+6';
update catering_row_value v set value = 'Eighteen, in two or three flavors'
from catering_row r where r.id = v.row_id and r.label = 'Mini scones' and v.value = '6+6+6 or 12+6';
update catering_row_value v set value = 'Two dozen, in up to four flavors'
from catering_row r where r.id = v.row_id and r.label = 'Mini scones'
and v.value = '6+6+6+6 or 12+6+6 or 12+12';
update catering_package_note set body = 'Everything is baked in sixes, so each item comes in multiples of six.'
where body = 'Minimum of 6 items per baked good. Flavors can''t be mixed and matched unless you''re ordering a large quantity.';
insert into catering_package_note (package_id, position, body)
select p.id, 1, 'Mixing flavors within one item needs a larger order — ask us and we will tell you.'
from catering_package p
where p.name = 'Office boxes'
and not exists (select 1 from catering_package_note n where n.package_id = p.id and n.position = 1);
-- --- parties ----------------------------------------------------------------------------------------
update catering_row_value v set value = 'A 6-inch cake'
from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '6 in cake';
update catering_row_value v set value = 'An 8-inch cake'
from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '8 in cake';
update catering_row_value v set value = 'A 10-inch cake'
from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '10 in cake';
-- Same offset as the wedding table: the quantities on the "Cupcakes" line were always
-- cupcakes-or-sugar-cookies, and the "Sugar cookies" line below carried nothing at all.
update catering_row r set label = 'Cupcakes or sugar cookies'
from catering_package p
where p.id = r.package_id and p.name = 'Parties' and r.label = 'Cupcakes';
update catering_row_value v set value = 'A dozen, either one'
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies'
and v.value = '1 dz sugar cookies or cupcakes';
update catering_row_value v set value = 'Eighteen, your choice'
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies'
and v.value = '1.5 dz your choice';
update catering_row_value v set value = 'Two dozen, your choice'
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies'
and v.value = '2 dz your choice';
delete from catering_row r
using catering_package p
where p.id = r.package_id and p.name = 'Parties' and r.label = 'Sugar cookies'
and not exists (select 1 from catering_row_value v where v.row_id = r.id and v.value <> '');
update catering_package_note set body = 'An extra dozen is $20.'
where body = 'Add an extra dozen for $20.';
update catering_package_note
set body = 'Cake and cupcake flavors can be different once you are ordering a dozen cupcakes or more.'
where body = 'Cake and cupcake flavors can''t be mixed unless you order at least 1 dz of cupcakes.';
-- --- weddings ---------------------------------------------------------------------------------------
-- The label said it and the cell repeated it. The cell now carries the size instead.
update catering_row r set label = 'Bride & groom cake'
from catering_package p
where p.id = r.package_id and p.name = 'Weddings' and r.label = 'Bride & groom cake (8 in)';
update catering_row_value v set value = 'Eight inch'
from catering_row r where r.id = v.row_id and r.label = 'Bride & groom cake' and v.value = 'B&G cake';
update catering_row r set label = 'Sheet cakes or 12×17 bars'
from catering_package p
where p.id = r.package_id and p.name = 'Weddings' and r.label = 'Sheet cakes or 12x17 bars';
update catering_row_value v set value = 'Two pans'
from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '2 pans';
update catering_row_value v set value = 'Three pans'
from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '3 pans';
update catering_row_value v set value = 'Four pans'
from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '4 pans';
update catering_row_value v set value = 'Four dozen'
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '4 dz';
update catering_row_value v set value = 'Five dozen'
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '5 dz';
update catering_row_value v set value = 'Six dozen'
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '6 dz';
-- --- the page's own terms ---------------------------------------------------------------------------
update catering_note
set body = 'These are a guide rather than a menu. We are happy to make changes, though the price may change with them.'
where body = 'We''re happy to make changes — the price may change with them.';
update catering_note
set body = 'If we can''t do something, we will tell you.'
where body = 'If we can''t do something we''ll tell you. These tables are mostly here to give you an idea of what''s possible.';
+63 -90
View File
@@ -4,9 +4,9 @@
<div th:fragment="content">
<!--/*
A short photographic band rather than the plain cream header the other pages use. This is the one
page that is selling something, and the same treatment as the homepage hero — blurred photo, sage
wash — carries the brand rather than inventing a new look for it.
A short photographic band rather than the plain cream header the other pages use. This is the one page
that is selling something, and the same treatment as the homepage hero — blurred photo, sage wash —
carries the brand rather than inventing a new look for it.
*/-->
<section class="relative flex items-center py-14 md:py-20 bg-bakery-900 text-white overflow-hidden">
<img th:src="${photos.of('gallery/Cakes.webp')}" alt=""
@@ -16,7 +16,7 @@
<div class="relative container mx-auto px-4 w-full min-w-0">
<div class="max-w-3xl mx-auto text-center">
<p class="text-xs sm:text-sm uppercase tracking-[0.2em] text-bakery-200">To take away</p>
<p class="text-xs sm:text-sm uppercase tracking-[0.2em] text-bakery-200">Order ahead</p>
<h1 class="mt-3 font-adbhashitha text-4xl md:text-5xl text-bakery-50">Goodie boxes &amp; catering</h1>
<p class="mt-5 text-base sm:text-lg text-bakery-100 text-balance leading-relaxed">
Boxes for the office, packages for a party, and cakes and desserts for a wedding.
@@ -30,7 +30,7 @@
<p class="max-w-2xl mx-auto text-center text-bakery-800 leading-relaxed">
Every one of these is a starting point. Tell us the date, the number of people and what you had in
mind, and we will work from it.
mind, and we will bake to it.
</p>
<!--/* The branch from the wordmark, as a divider. It is a mask over currentColor, so it takes the
@@ -39,107 +39,80 @@
<span class="mark mark-r w-12 h-12 text-bakery-400"></span>
</div>
<div class="mt-10 max-w-5xl mx-auto space-y-10 md:space-y-14">
<div class="mt-12 max-w-6xl mx-auto space-y-16 md:space-y-20">
<!--/*
One card per table, and each table is rendered twice from the same model with only one visible:
One block per table, and each size within it is a card: the price, then what you get for it.
- A real <table> from `md` up. These are prices in columns; a table is what that is, and a screen
reader announces the size and the item together because of the row and column headers.
- Stacked cards below `md`. A four-column price table on a phone is either an illegible squeeze
or a sideways scroll, and this page is mostly read on phones.
This replaced a real <table>. A table is the honest shape for a price matrix, and it is how the
bakery keeps these — but it is also exactly what the source spreadsheet looks like, and reading a
spreadsheet back to a customer is not an offer. Cards say "here is what you get" three times over
instead of asking someone to find the cell where a row meets a column, and they need no separate
phone rendering, because a stack of cards IS the phone rendering.
Both walk the same rows, so they cannot drift.
Each card walks the same rows in the same order and takes the cell for its own column — the
alignment the aggregate guarantees.
*/-->
<section th:each="table, tbl : ${menu.packages}"
class="bg-white rounded-3xl shadow-xs overflow-hidden">
<header class="px-6 pt-7 pb-5 md:px-10 md:pt-8 text-center border-b border-bakery-100">
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900" th:text="${table.name}">Office</h2>
<p th:if="${table.blurb}" class="mt-2 text-bakery-700 max-w-xl mx-auto" th:text="${table.blurb}">Blurb</p>
<section th:each="table : ${menu.packages}">
<header class="max-w-2xl mx-auto text-center">
<h2 class="font-adbhashitha text-3xl md:text-4xl text-bakery-900" th:text="${table.name}">Office boxes</h2>
<p th:if="${table.blurb}" class="mt-3 text-bakery-700 leading-relaxed" th:text="${table.blurb}">
Mini pastries for a morning meeting.
</p>
</header>
<!--/* Wide: the table. The header band carries the price; the rows are what you get for it. */-->
<div class="hidden md:block px-6 lg:px-10 py-2">
<table class="w-full border-collapse text-left">
<caption class="sr-only" th:text="|${table.name} — what each size includes and what it costs|">Sizes</caption>
<thead>
<tr>
<!--/* A blank corner: the caption above says what the table is, and every row names
itself, so a label here only competed with the prices. */-->
<th scope="col" class="w-52 py-5 pr-6"><span class="sr-only">What you get</span></th>
<th th:each="tier : ${table.tiers}" scope="col"
class="py-6 px-4 text-center align-bottom border-b-2 border-bakery-200">
<span class="block text-xs uppercase tracking-[0.15em] text-bakery-600" th:text="${tier.label}">Small</span>
<span th:if="${tier.price}" class="mt-2 block font-adbhashitha text-3xl text-bakery-900"
th:text="${tier.price}">$24</span>
<!--/* No price means "ask us" — say so rather than leaving a hole in the column. */-->
<span th:unless="${tier.price}" class="mt-2 block font-adbhashitha text-xl text-bakery-700">Ask us</span>
</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${table.rows}" class="align-top even:bg-bakery-50/60">
<th scope="row" class="py-5 pr-6 pl-2 font-medium text-bakery-900" th:text="${row.label}">Mini muffins</th>
<td th:each="value : ${row.values}" class="py-5 px-4 text-center text-bakery-800">
<span th:if="${!#strings.isEmpty(value)}" th:text="${value}">12 items</span>
<!--/* A cell the bakery hasn't filled in. A dash reads as "nothing here"; an empty cell
reads as a broken page. */-->
<span th:if="${#strings.isEmpty(value)}" class="text-bakery-400" aria-hidden="true">&ndash;</span>
</td>
</tr>
</tbody>
</table>
</div>
<!--/* Three sizes is the common case; a table with one or two shouldn't stretch to fill the row. */-->
<div class="mt-8 grid gap-6 md:gap-8"
th:classappend="${#lists.size(table.tiers) == 1} ? 'max-w-sm mx-auto'
: (${#lists.size(table.tiers) == 2} ? 'sm:grid-cols-2 max-w-3xl mx-auto'
: 'sm:grid-cols-2 lg:grid-cols-3')">
<article th:each="tier, t : ${table.tiers}"
class="flex flex-col bg-white rounded-3xl shadow-xs p-6 md:p-8
transition-shadow duration-300 hover:shadow-lg">
<p class="text-xs uppercase tracking-[0.15em] text-bakery-600" th:text="${tier.label}">Small</p>
<p th:if="${tier.price}" class="mt-2 font-adbhashitha text-4xl text-bakery-900" th:text="${tier.price}">$24</p>
<!--/* No price means "ask us" — say so rather than leaving a hole in the card. */-->
<p th:unless="${tier.price}" class="mt-2 font-adbhashitha text-2xl text-bakery-700">Ask us</p>
<!--/* Narrow: one card per size. */-->
<div class="md:hidden px-5 py-6 space-y-4">
<div th:each="tier, t : ${table.tiers}" class="rounded-2xl border border-bakery-200 overflow-hidden">
<div class="flex items-baseline justify-between gap-3 bg-bakery-50 px-4 py-3">
<h3 class="text-xs uppercase tracking-[0.15em] text-bakery-600" th:text="${tier.label}">Small</h3>
<span th:if="${tier.price}" class="font-adbhashitha text-2xl text-bakery-900 whitespace-nowrap"
th:text="${tier.price}">$24</span>
<span th:unless="${tier.price}" class="font-adbhashitha text-lg text-bakery-700 whitespace-nowrap">Ask us</span>
</div>
<!--/* The cell for this size on each line — t.index picks this column out of every row, which
is exactly the alignment the aggregate guarantees. Lines with nothing in this column are
still listed: they are part of what's in the box, and the bakery just hasn't said how
many yet. */-->
<dl class="divide-y divide-bakery-100">
<div th:each="row : ${table.rows}" class="flex justify-between gap-4 px-4 py-2.5 text-sm">
<dt class="text-bakery-900" th:text="${row.label}">Mini muffins</dt>
<dd class="text-bakery-800 text-right">
<span th:if="${!#strings.isEmpty(row.values[t.index])}" th:text="${row.values[t.index]}">12 items</span>
<span th:if="${#strings.isEmpty(row.values[t.index])}" class="text-bakery-400">&ndash;</span>
</dd>
</div>
</dl>
</div>
<ul class="mt-6 space-y-3 text-bakery-800">
<li th:each="row : ${table.rows}" class="flex gap-3">
<!--/* A tick, not a bullet: this is a list of what you get, not prose. */-->
<svg class="mt-1 h-4 w-4 shrink-0 text-bakery-400" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<path d="M20 6L9 17l-5-5" />
</svg>
<span>
<span th:text="${row.label}">Mini muffins</span>
<!--/* A line the bakery hasn't quantified for this size still belongs in the box; it
simply appears without a number, rather than as an empty cell in a grid. */-->
<span th:if="${!#strings.isEmpty(row.values[t.index])}" class="text-bakery-600"
th:text="|&mdash; ${row.values[t.index]}|">&mdash; a dozen</span>
</span>
</li>
</ul>
</article>
</div>
<!--/*
The small print and the enquiry link share one tinted footer. As two more full-width bands
they made every card read as four stacked stripes.
The small print, then one link for the whole table.
The link is quiet rather than a filled button: three filled buttons down a page would shout,
and the loud call to action belongs once, at the end.
A button on every card was the obvious pricing-page move and it was wrong twice over: all
three cards led to the same place, since an enquiry is about the table rather than the size,
and the label read "Ask about the 1520 people" on the tables whose sizes are headcounts.
*/-->
<div class="px-6 md:px-10 py-6 bg-bakery-50/70 border-t border-bakery-100
flex flex-col gap-5 md:flex-row md:items-end md:justify-between">
<div th:if="${!#lists.isEmpty(table.notes)}" class="md:max-w-2xl">
<div class="mt-8 max-w-2xl mx-auto text-center">
<div th:if="${!#lists.isEmpty(table.notes)}">
<h3 class="text-xs uppercase tracking-[0.15em] text-bakery-500 font-medium">Good to know</h3>
<ul class="mt-3 space-y-2 text-sm text-bakery-700 list-disc pl-5 marker:text-bakery-300">
<li th:each="note : ${table.notes}" th:text="${note}">Minimum of 6 items per baked good.</li>
<ul class="mt-3 space-y-2 text-sm text-bakery-700">
<li th:each="note : ${table.notes}" th:text="${note}">Everything is baked in sixes.</li>
</ul>
</div>
<!--/* One text run, so the underline is continuous: as flex children with a gap it came out
as "Ask about" and "office" separately underlined. The arrow stays outside it. */-->
<a th:href="|/contact?about=${#uris.escapeQueryParam(table.name)}|"
class="shrink-0 text-bakery-700 hover:text-bakery-900 font-medium transition-colors whitespace-nowrap">
<span class="underline underline-offset-4"
th:text="|Ask about ${#strings.toLowerCase(table.name)}|">Ask about office</span>
<span aria-hidden="true">&rarr;</span>
</a>
class="mt-6 inline-block border border-bakery-300 hover:bg-bakery-100 text-bakery-800
px-7 py-3 rounded-full font-medium tracking-wide transition-colors"
th:text="|Ask about ${#strings.toLowerCase(table.name)}|">Ask about office boxes</a>
</div>
</section>
@@ -160,7 +133,7 @@
<div class="max-w-2xl mx-auto text-center">
<h2 class="font-adbhashitha text-2xl md:text-3xl">Before you order</h2>
<ul th:if="${!#lists.isEmpty(menu.notes)}" class="mt-6 space-y-3 text-bakery-100 leading-relaxed">
<li th:each="note : ${menu.notes}" th:text="${note}">Prices may change.</li>
<li th:each="note : ${menu.notes}" th:text="${note}">These are a guide rather than a menu.</li>
</ul>
<div class="mt-10 flex flex-col sm:flex-row gap-3 justify-center">
<a href="/contact"