Commit Graph
58 Commits
Author SHA1 Message Date
austinandClaude Opus 5 537273cb62 A tab icon you can actually see, and photo uploads that accept a photo
build-and-publish / build (pull_request) Successful in 2m14s
Two unrelated things the bakery hit on the same afternoon.

THE FAVICON was not missing, it was unusable. The head pointed rel=icon at the 1000x1000 logo
PNGs, so a browser fetched 71 KB to paint 16 square pixels, and the mark is a vine branch drawn in
hairlines -- strokes thinner than one pixel at that size -- which arrives as a grey smudge. Replaced
with a real icon set built from one leaf of that branch, filled rather than stroked, because at 16px
a silhouette survives and an outline does not. A midrib was drawn first and cut the leaf into two
pale slivers at tab size, so it went; the tilt, the two points and the stem carry the shape. The SVG
answers prefers-color-scheme itself, which a .ico cannot, so the dark tab strip gets sage on
bakery-900 instead of a glowing cream tile. The .ico is listed first on purpose: a browser takes the
last format it understands, so reversing the two would hand Chrome the bitmap.

PHOTO UPLOADS failed on anything over 1 MB, which is every photo a phone takes. The cause was an
absence: nothing configured spring.servlet.multipart, so Boot's 1 MB default applied and the
container rejected the file with FileSizeLimitExceededException before it reached
ProductPhotoService -- the class whose entire job is turning "whatever came off a phone" into a
resized webp. The pipeline could never run on the input it was written for. Now 15 MB a file and
60 MB a request, the latter because the file input is `multiple`.

The failure was also ugly, and that is fixed separately: parsed eagerly, an over-sized part throws
from inside Tomcat's parameter parsing where no @ExceptionHandler can reach it, so the request died
as a 500 and then died again forwarding to /error, because that forward re-parsed the same too-large
request (the paired "Exception Processing [ErrorPage...]" lines in the log). resolve-lazily moves the
throw into argument binding, where AdminController now catches it and returns the same `problem`
flash the domain's other refusals use. max-swallow-size lets the body be discarded so the browser
receives that redirect rather than a connection reset.

The multipart numbers are asserted rather than trusted, because a default that was never set is
exactly the kind of thing that comes back silently.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-27 18:45:46 -05:00
austinandClaude Opus 5 1d07272aea Prices with costs behind them, and five columns that promised more than they held
build-and-publish / build (pull_request) Successful in 2m4s
The catering tables were priced before anyone put a cost against them. Working back from the
bakery's own goods-plus-labour figures gives a model that reproduces every one of them to the
cent -- $15.00/hr, mini $0.4167, cupcake $0.6667, a 12x17 pan $18.00, cakes $7.00/$10.50/$12.50 --
and the 8-inch solves to $10.50 from the parties table and independently to $10.50 as the wedding
cake, which is the check that made the rest trustworthy.

Two facts from the bakery did most of the damage. A pan cuts 48, so portions had been judged on
the cupcakes alone -- barely a third of the food. And the bride & groom cake is ceremonial, so it
feeds nobody. Against 1.25 servings a guest, five of the nine live columns promised more than they
held: all three weddings (the 200 by 37 servings) and the two smaller parties.

The fix is deliberately asymmetric. The weddings get more food so the familiar 125/200/250 can
stand, because that is the number a customer says out loud. The parties get a corrected label,
because feeding the middle one properly would have taken its cupcakes to two dozen -- the same as
the tier above -- leaving the two $2 apart in cost with only cake size between them.

Prices follow the costs. Weddings were assuming four hours of labour; six is honest, and it
reconciles with the three a Gatherings order takes. At six the old prices returned $11.92 an hour
on the 125 -- less than a Large office box -- so all three go up 20%, which still asks only
$1.48-$1.91 a serving. The entry party goes to $57, not to make it pay but to hold it at $4.00 an
hour, the same deliberate floor as the Small office box. The middle party stays at $76: at $88 it
was a column nobody could rationally buy, since the $98 beside it feeds fourteen more people.
Office prices do not move at all -- at 1.25 a head those boxes are already conservative.

New Gatherings table, because the page went from "30-40 people" to "125" with nothing between, and
the bakery confirmed those enquiries come in. Sized from the pan rather than guessed, labels
rounded down.

Delivery and a wedding deposit were never stated anywhere; both are now.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-27 12:58:20 -05:00
austinandClaude Opus 5 7449744682 A test for the class that gets thrown away
build-and-publish / build (pull_request) Successful in 1m57s
The bug this guards has now been written seven times, and the reason is that
nothing tells you: Thymeleaf drops the class, renders the page, and the result
looks nearly right. A reviewer has to know the rule and then spot it.

So the rule is a test. It walks the templates, finds any tag carrying both a
class and a th:replace, and fails with the file, the line and the tag. No Spring
context and no database — it reads files, and costs five milliseconds.

th:insert is deliberately not checked: that one keeps the host tag, so a class
on it is fine.

The second test guards the first. A file-walking assertion that quietly stopped
finding files would pass forever, so it asserts the templates are actually there
to be read.

Verified by putting the catering divider's dropped mt-10 back and watching it
fail with that exact line, then removing it again.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 22:17:06 -05:00
austinandClaude Opus 5 68f1ec5961 The classes that were being written and thrown away
A sweep for the bug behind the homepage titles found five more of it, because
the mistake is easy and silent: put a class on the tag you write a th:replace
on, and Thymeleaf hands you back the fragment with its own class attribute and
drops yours. Nothing warns, and the page looks nearly right.

  catering  the divider's mt-10, so the branch mark sat on the paragraph above it
  contact   mt-4 under both "Our hours" and "Find us" — the same flush headings
            the homepage had
  footer    mt-4 between the address and the hours under it
  home      text-bakery-800 on the address, which is why "Find us" rendered a
            shade darker (bakery-900, inherited from body) than "Our hours" in
            the panel beside it

Fixed where the fragments already said it should be: "both fragments take the
classes that vary by where they sit". They now take two — the element's own
class as well as the row's or the link's — so the caller has somewhere to put
this that survives. `divider` takes its spacing the same way.

A class on a th:replace tag now has no reason to exist anywhere in the
templates, and there are none left.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 22:13:05 -05:00
austinandClaude Opus 5 5aa2e4589e The site stops flashing white on every click
build-and-publish / build (pull_request) Successful in 2m15s
Clicking a nav link or a filter chip made the whole site look like it reloaded,
because it did: body carried `opacity: 0` and a half-second fadeIn, so every
navigation went blank, then slid 10px and faded back.

Those two lines are the SPA's. There they ran once, when React booted, and every
navigation afterwards happened inside a document that had already faded in.
Server-rendered, every link is a new document, so a once-per-session animation
became a once-per-click one — and the thing it was decorating was already fast.

Replaced with the cross-document view transition it was reaching for: the
browser holds the old page up until the new one is ready and cross-fades, so
nothing is ever blank. Where it isn't supported the navigation is simply
immediate, which is the right fallback and still better than a blank page.
Reduced motion keeps the navigation and drops the cross-fade; the print rule no
longer has to undo an opacity that isn't set any more.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 22:07:52 -05:00
austinandClaude Opus 5 b344d45978 Room under the homepage titles, which had none
"What people come in for" and "Visit us" were written with an mb-12 and rendered
with nothing: th:replace swaps the whole host element out for the fragment, so
the class attribute on that tag never reaches the page. Both titles sat flush on
the grid below them. Confirmed against the running site — mb-12 appears nowhere
in the served HTML.

The gap moves to the element below, which is where catering already keeps it
(mt-8 on its price grids) and which is the only place th:replace cannot eat it.
A little wider than the twelve that was intended, since the point is the breath:
14, and 18 from md.

The card captions get the same treatment — py-8 rather than py-6, and px-4 so a
longer name than "Cakes" doesn't run to the edges — and the two panel headings
in Visit us go mb-6 to mb-8.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 22:07:42 -05:00
austinandClaude Opus 5 0300e5a54f Sections and cards: three grids, two quiet panels and five headings become three names
build-and-publish / build (pull_request) Successful in 2m4s
Counted first, as with the last sweep. A grid of cards was written three times with three different sets
of breakpoints and gaps, so cards on different pages lined up differently for no reason anyone chose. A
tinted aside card existed twice, hand-rolled, with different borders. A section heading existed five ways:
centred with one margin, centred with another, left-aligned with a blurb, left without.

Now `card-grid`, `panel-quiet` and a `section-head` fragment (with a cream-on-sage twin for the dark
bands). The catering small print and the contact page's "ordering for a crowd?" card are the same object
now, which is what they always were.

TWO THINGS THE SCREENSHOTS DECIDED, not taste:

Three prices go three-up from md, while the shared card grid waits for lg. A grid of photos is fine
two-wide; three prices are a comparison, and 2 + 1 on a laptop strands the third size on its own row.

And the per-table column count is entirely in one classappend rather than overriding `card-grid`, because
Tailwind emits grid-cols-1, -2, -3 in that order — so a `grid-cols-3` from the shared class beats a
`grid-cols-2` written beside it in the markup, whatever the markup says. Two classes for one property,
winner decided by the stylesheet: the same trap that stopped `hidden` from hiding the open-now line, and I
had already written it before catching it. A one-size and a two-size table were both wrong; proved the fix
by building a real two-size table through the admin and looking at it, then deleting it again.

61 tests.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 21:15:37 -05:00
austinandClaude Opus 5 057deec47c Three bugs and a lighter page: 367 KB of fonts became 57
THE BUGS.

Tapping any field zoomed the page in on iOS, because Safari does that to anything under 16px and `field`
was 14px. Every enquiry typed on a phone began by throwing the layout sideways. 16px on phones, the denser
size back at sm where no browser does it.

The focus ring vanished on the sage bands: one colour for the whole site, and sage-500 on sage-800 is
about 2.5:1 — under the 3:1 a focus indicator needs. It went missing exactly where the page's main calls
to action live. Cream on those sections now.

THE FONTS. AdBhashitha shipped 1605 glyphs to set 187 codepoints, as .woff, unpreloaded: 306 KB for the
face every heading uses, arriving after first paint so each page opened in a fallback serif and then
reflowed. Subset to Latin and the punctuation it actually has, every layout feature kept, compressed to
woff2: **306 KB → 8 KB**, and a pixel diff of the catering page before and after is exactly zero. The
script wordmark is only recompressed, never subset — its swashes come from alternates and the logo is the
one thing that must not change shape. All three faces are preloaded now and together weigh less than the
display face did alone.

THE REST OF THE LIST. The products filter wrapped to three rows on a phone with "PASTRIES" stranded on the
last; it is a scrolling snap row below sm, wrapping and centred above it, and the negative margin lets a
half-chip show at the edge so it is obviously scrollable. Chips are 44px tall and the gallery arrows 44px
wide — both were under the thumb-sized minimum. The contact form has a honeypot: a real field with a
plausible name, off-page rather than display:none, skipped by the tab key. A filled one gets the same
thank-you a person gets, because telling a bot it failed only teaches it to try again — and nothing is
recorded or sent, which the test proves against the enquiry table.

And the catering small print, which was loose text floating between two rows of cards, is a soft panel
belonging to the table above it.

61 tests. Verified at 390 and 900.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 20:53:06 -05:00
austinandClaude Opus 5 f917e60284 The wordmark fits a phone instead of breaking on one
On every phone width the hero lockup was rendering "The" over "Vine", with the branch marks centred
against the two-line block — which is what you were seeing as stacking.

It was not a wrapping bug so much as a sizing one. The lockup is three things that have to stay in
proportion: two fixed-width marks and a tagline that cannot wrap. The hero used one size for phones and
desktops — two 80px marks, a 36px script, and COFFEEHOUSE + BAKERY letterspaced at 0.2em — which comes to
more than a 390px screen has. The only part that could give was the name, so it wrapped.

Both lockups step now. On a phone the hero is 56px marks and a 30px script, full size from sm up; the
bar's is a shade smaller with tighter tracking, because a 320px phone has to fit the lockup and the menu
button. And the name carries whitespace-nowrap: a wordmark does not wrap, whatever else has to give.

Checked at 320, 360 and 390 — one line, marks either side, on all three.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 20:34:40 -05:00
austinandClaude Opus 5 d9df211694 Photographs hold still, and every page opens the same way
HOVER IS OFF THE IMAGES. The product cards lifted, the homepage's three cards lifted and zoomed their
photo 5% on the way. None of those cards is a link, so the lift was promising a click that isn't there,
and a photograph of a cake does not benefit from moving. `panel-lift` is still defined for the day
something IS clickable; nothing on the public site uses it.

EVERY PAGE NOW OPENS THE SAME: a small-caps line, the title in the display face, one sentence saying what
this is. Two dresses for it — `photographed` for the pages that sell or tell (catering, the story), where a
blurred photo under the sage wash does what the homepage hero does; `plain` for the pages you arrive at
already knowing what you want, where a photograph would be in front of the thing you came for. It was four
pieces of markup that happened to look similar; it is one fragment with two variants. Products and Contact
gained the eyebrow they were missing ("From the counter", "Say hello").

THE HOMEPAGE'S ORDER was hero → what people come in for → story → catering → visit: two cream sections
butted together at the end, with the one block that sells something buried between them. Now hero →
what people come in for → catering (on the deeper cream, so two light sections still read as two) → story
→ visit. Dark, light, light-but-different, dark, light.

TWO BUGS THE SCREENSHOTS FOUND, both invisible at the width I had been checking:
- Between 1024 and about 1200 the wordmark, the nav gaps AND the open-now line all stepped up at once, so
  the script overflowed its box and painted over the branch mark. They step up at xl now, separately.
- The open-now fragment hardcoded `inline-flex` while the header passed `hidden lg:…`, which is two rules
  for one property — the winner decided by Tailwind's output order rather than by the markup, which is why
  "hidden" wasn't hiding it. The caller owns display now.

Checked at 390, 800, 900, 1000, 1024, 1100, 1200, 1280 and 1440. 60 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 20:00:59 -05:00
austinandClaude Opus 5 398233dba4 Hero: 56svh, so the homepage looks like it has more than one thing on it
At 78svh a laptop's first screen was the lockup and nothing else. It is a floor rather than a height —
content still wins on a short window — and at 56 the fold now carries the lockup, the line under it, both
buttons, and the top of "What people come in for", which is what invites the scroll.

Tightened the two gaps inside it to match (mb-10 to mb-8), or the shorter band would just have had the
same air distributed worse.

Checked at 1366×768, the size of the laptop most people have.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 19:39:24 -05:00
austinandClaude Opus 5 e2b9af56f1 Say where you are, let the keyboard past, and give the story pictures
THE NAV NEVER SAID WHICH PAGE YOU WERE ON. A four-page site gets away with that right up until somebody
clicks Catering, lands on a page of prices, and cannot tell whether the click worked. The current page now
carries aria-current for a screen reader and a sage rule under the word for everyone else — a rule rather
than a darker colour, because the nav is already sage on cream and darkening it reads as a hover. The list
is generated from one array now instead of four hand-written anchors, which is what makes the marking
possible at all.

THE KEYBOARD HAD NO WAY PAST THE NAV. Every page begins with a skip link — off-screen until it has focus,
then a real button in the corner, which is the half of that pattern everyone forgets.

THE STORY WAS FOUR BLOCKS OF TEXT. It has photographs between the sections now, captioned in small caps,
and an opening paragraph set with a drop cap in the display face. Once, on that page: a drop cap on every
page would be a costume rather than a voice.

60 tests. Two new ones, both on things that fail silently rather than loudly: the active link (a wrong
model attribute would mark nothing, which looks exactly like a page with no active link) and the skip
link's target existing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 19:33:06 -05:00
austinandClaude Opus 5 a657600078 The shop knows when it is open, and says so — to visitors and to Google
Three things a bakery's site should do that this one couldn't, all from the same fact.

THE HOURS WERE MARKUP, so the only thing the site could do with them was print them. A visitor at half
past two on a Sunday had to work out for themselves that the shop was shut. They are data now (Shop.WEEK),
and `Hours` reads them two ways: as the list the footer and the contact page print — consecutive days with
the same times collapsed into one line, the way a sign does it, instead of three hand-written rows — and
as an answer. The header now says "Open until 2:00pm", or "Closed · opens Tuesday at 7:00am", worked out
on the server in the shop's own timezone. Right now, on a Sunday evening, it says the latter.

THE SHOP IS NOW DESCRIBED TO A SEARCH ENGINE: schema.org Bakery in the head, with the address, the phone
number and those same opening times. For a shop whose customers find it by searching its town, that is the
difference between a blue link and a listing that shows "Open ⋅ closes 2pm" — and it cannot drift from
what the page says, because it is built from the same record. Serialised with Jackson rather than
string-built, so the day somebody interpolates a name into it, it is not an injection. (Jackson 3 —
`tools.jackson` — which is what Boot 4 ships; the fasterxml import does not compile.)

A SHARED LINK NOW HAS A PICTURE. og:image, og:image:alt and a large summary card: it was a grey box with a
title, which is what every link to this site has looked like in a message.

AND IT PRINTS. The catering page is a price list and a bakery prints price lists — Ctrl-P now gives the
prices on paper rather than a screenshot of a website: no chrome, no sage bands eating a cartridge, cards
that don't split across a page break, and the buttons that only exist to be clicked marked `no-print`.

Five new tests, all of them free of Spring, because this is a calculation over a constant: the week
collapses as a sign would write it, the minute of opening counts as open and the minute of closing does
not, and a Saturday afternoon is told when Tuesday starts.

Also swept the last one-off styles the earlier pass missed — max-w-3xl/4xl, an inline letter-spacing the
.h2 class already carries, and the mobile menu's link string.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 19:27:41 -05:00
austinandClaude Opus 5 9e80590687 Everything on the shared names — the copy-paste is gone
The ported markup was written a page at a time, so the same thing was said several ways. Counted before
touching anything: nine variants of the page gutter, seven max-widths, six link styles, two definitions of
an input, and one stray shadow.

Now: `container` (which already centres and pads), `measure`/`measure-wide`, `link`/`link-plain`/
`link-on-dark`, one `field`, `photo`, `page-head`. Nothing in a template writes `bg-white rounded-…
shadow-…` or `underline underline-offset-4` any more — 119 uses of `panel`, 82 of `label`, 52 of
`panel-lift`, 17 of `link`.

TWO REAL FIXES FELL OUT OF IT, not just tidying:

- Every section said `container mx-auto px-4`: `mx-auto` twice, and a px-4 that beat the responsive
  padding inside `container`. The gutter was 16px at every width, including on a 27-inch screen. Removing
  the copy-paste restores sm:px-6 lg:px-8, which is most of why the pages now breathe.
- The public form and the admin had grown two different inputs — cream vs white, ring-2 vs ring-1, py-2.5
  vs py-2, and different text sizes. There is one `field` now, and the same focus ring as everything else.

The admin uses the same names where they are the same thing (`link`, `field`, `h4`, `--radius-*`) and
keeps its own denser buttons, because it is a tool and not a shop front.

53 tests green. Every class used across all nine pages resolves in the compiled stylesheet — that check is
what makes a sweep this size safe to do with a script.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 18:43:40 -05:00
austinandClaude Opus 5 c4cd19e175 Corners: 12px, and named once
24px (Tailwind's rounded-3xl) was the roundest thing on the page and read as a phone app. A printed card
has a corner you can see.

Two tokens rather than a find-and-replace, so this is answered once and not per template:
`--radius-panel` (12px) for anything with an edge — cards, photos mounted on them, the admin's boxes — and
`--radius-field` (8px) for inputs, which were three different radii across the public form and the admin.
Buttons stay fully round: the pill is the brand's button shape and always was.

53 tests green, every class on all seven checked pages still resolves.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 18:32:29 -05:00
austinandClaude Opus 5 5448cbded1 Make a card look like it came from the shop, not from a component library
The cards were white boxes with a grey drop shadow and no edge — the default card of every web app,
sitting on a page that is otherwise cream, sage and a hand-drawn vine. Four changes, all of them things
the brand already owns:

- A WARM HAIRLINE. A card now has an edge, so it reads as stock lying on a counter rather than as a panel
  floating over a screen.
- A SHADOW IN SAGE, not black — rgba(35,43,30,…), the darkest colour in the palette. Grey shadows are what
  made them feel borrowed.
- A CREAM HEAD (`panel-head`). On a catering card the size, price and "About 6–8 people" sit on the tint
  with the contents below, the way a printed menu separates what it costs from what you get. Product cards
  use the same band for their caption, so the photo reads as mounted on the card instead of being it.
- THE BRANCH, at watermark weight, in the corner of every price card. It is the one mark that is
  unmistakably this shop, and it costs nothing: the mask was already there for the wordmark.

The lift is a lift now, two pixels, rather than the shadow just darkening.

TWO THINGS TAILWIND v4 MADE ME FIX ALONG THE WAY. `.mark`, `.mark-l`, `.mark-r` and `.no-scrollbar` were
plain classes in @layer components, and only a registered utility can be @applied by another rule — the
watermark builds on the branch mark, so they became @utility like everything else here. And rewriting that
file dropped the `summary { list-style: none }` reset, which would have put a disclosure triangle beside
the hamburger on every phone; caught it by grepping the compiled CSS rather than by luck.

53 tests green, every class on every page resolves, and the price cards are in the screenshot.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 18:29:05 -05:00
austinandClaude Opus 5 6b6ddcea42 A design pass: the type ladder from the logo, and one name per thing
TYPOGRAPHY, taken from the lockup and walked down to something you can read a paragraph in. "The Vine" in
LeJour Script over COFFEEHOUSE + BAKERY in AdBhashitha is the whole brand voice, so: the script stays in
the wordmark and nowhere else (it is unreadable at length); AdBhashitha carries h1–h4 and the price, and
keeps the wordmark's 0.01em letter-spacing so a heading sits on the same rhythm as the logo above it;
`eyebrow` and `label` are the hinge — sans, but letterspaced like the tagline — and then plain Raleway.
Sizes are declared once in type.css instead of being re-chosen per template, and none of the type classes
set a colour, because the same heading appears in sage on cream and in cream on sage.

BUTTONS AND SURFACES have names now. `panel` is the white box that was "bg-white rounded-3xl shadow-xs"
written out in nine places (which is how two of them end up with different corners); `panel-lift` is one
that reacts. `pill` is one shape in four colourways, which exist because this site puts buttons on cream
AND on sage and they cannot be the same colour on both. `chip`/`chip-on` are the products filter.

RHYTHM: `section` (py-16 md:py-24) and `band` (py-14 md:py-20, tighter because a coloured strip is already
doing the separating) replace nine hand-picked paddings.

FOCUS: one visible ring for everything reachable by keyboard, as an outline rather than a shadow so it
works on cream, on white and on the sage bands, at zero specificity so a utility can still win.

Z-INDEX: written down in base.css, all two of it — 40 the sticky header, 30 the mobile panel, which is a
child of the header and so can only ever sit under the bar. Everything else stacks in document order. The
point of the comment is that a third layer doesn't get invented at 9999.

DROPDOWNS: the admin's category selects were drawing the platform's own arrow in the platform's grey; they
get the site's chevron and room for it.

53 tests green. Every class used across all nine pages — five public, four admin — resolves in the
compiled stylesheet, so nothing here is styled by a name that doesn't exist.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 18:10:47 -05:00
austinandClaude Opus 5 8d54db7c30 Give the other pages somewhere to go, and move the stylesheets under src/main
MARKETING, not decoration — each of these was a page that stopped:

- The HOMEPAGE never mentioned catering. A customer found the page by reading the nav. It now has a
  section with a photo, what the three tables cover, and a button.
- PRODUCTS was a title and a grid. It gets a line saying what it is ("cakes and decorated cookies are
  made to order, so most of what follows started as somebody describing what they wanted") and a closing
  band with somewhere to go — an order, or the catering page.
- CONTACT was a form and nothing else. The things somebody on that page actually wants — when we're open,
  where we are, the phone number — were on the homepage and in the footer but not there. Now beside the
  form, with a card pointing at catering.
- OUR STORY got the shop's own front as a band and an ending, so the story leads somewhere.
- The FOOTER gained the opening hours, which is what people come to a bakery's site for and is now on
  every page.

CLEANED UP ON THE WAY. The hours and the address were about to exist in three places, so they are one
fragment (fragments/visit.html) that takes the classes that vary by where it sits — cream in the footer,
sage on white in a card. A shop that changes its Saturday hours should change them once.

STYLES MOVED to src/main/styles, with the rest of the app's source, and split by what each file is for:
theme.css (the fonts and the palette — names, nothing drawn), base.css (bare elements), components.css
(the site's own classes), admin.css (the admin's controls), and site.css as the entry that imports them in
cascade order and declares what Tailwind scans. It was one grab-bag file called tokens.css in a folder at
the repo root. node_modules has to stay beside them — Tailwind resolves `@import "tailwindcss"` by walking
up from the CSS file — so the whole npm project moved together, and the pom, .gitignore and README moved
with it.

53 tests green, `mvn package` builds the stylesheet from its new home, and every class used on all five
pages resolves in the compiled CSS (185/159/175/148/140, none missing).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 17:56:00 -05:00
austinandClaude Opus 5 5ab6febb4c An office box is a total, not an assortment — and every size says who it feeds
I had built in an assumption nobody stated, and a ticked list made it a claim: that a $24 box contains
mini muffins AND mini scones AND mini cinnamon rolls. It doesn't. It is twelve items, mixed in sixes,
from those three. The page was describing the offer wrongly — worse than the spreadsheet, which at least
didn't say either way.

The fix keeps one rule for the whole page rather than a mode for this table: a line is something you get,
and a choice within a line is written into its name. The parties and wedding tables already read that way
("Cupcakes or sugar cookies"), so the office table becomes one line — "Any mix of mini muffins, mini
scones and mini cinnamon rolls" — with the size's item count as its cell. Three ticks became one, and the
"6+6" detail moved to the note where the bakery's own minimum already lives: "Baked in sixes, so a dozen
can be six muffins and six scones."

Confirmed, not guessed, for the other two: parties are a cake AND a dozen to hand round; a wedding
package is everything listed. Their ticked lists were already right.

SERVES. "Small" answers nothing for someone ordering for an office, which is the whole question. Columns
gain an optional `serves` — schema, view, a box in the table editor, a line under the price. The office
numbers are estimates from the item counts and are meant to be corrected by the people who pack the
boxes. The party and wedding columns are already named by head count, so theirs stay empty rather than
saying it twice.

53 tests: the office table is one mixed line, its columns say who they feed, and the head-count tables
don't repeat themselves. The blank-cell test builds its own table now, since no seeded line is blank
across any more.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 17:37:20 -05:00
austinandClaude Opus 5 0d534a0e80 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]>
2026-07-26 17:24:59 -05:00
austinandClaude Opus 5 04cd1d5bea Read the wedding table the way the spreadsheet meant it
Its lines were carried over literally in V4, and the source is offset: the prices sit on its "cupcakes"
line and "4 dz cc or sc" on its "12x17 bars" line. Taken at face value that produced five lines, two of
which had no quantity in any column and rendered as a row of dashes.

Read as a baker would — a 12x17 pan IS the sheet pan, and "cc or sc" is what the dozens are counting —
it is three lines that each say something:

    Bride & groom cake (8 in)    B&G cake   B&G cake   B&G cake
    Sheet cakes or 12x17 bars    2 pans     3 pans     4 pans
    Cupcakes or sugar cookies    4 dz       5 dz       6 dz

A NEW migration rather than an edit to V4, because V4 has already been applied and Flyway validates
checksums. And every statement in it is guarded on the exact text V4 wrote, so if the bakery has already
reworded a line in the admin, this finds nothing to change and leaves their wording alone. Verified that
way and not just in the abstract: renamed one line through the admin, then let V5 run over that database
— the renamed line and its cells survived untouched while the untouched line was corrected and the two
empty ones removed.

This is still an interpretation of somebody else's shorthand. It is three boxes in
/admin/catering/tables/3 to change if Morissa reads it differently.

The office table's "Mini cinnamon rolls" line is the same shape — named, never quantified, and the note
under the table ("minimum of 6 items per baked good") is all the source says about it. Left alone: it
would need a number nobody has given us.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 17:10:26 -05:00
austinandClaude Opus 5 8ab302dd19 Make the catering page look like it belongs to the shop
build-and-publish / build (pull_request) Successful in 2m7s
It was correct and plain: a heading, three white cards, a bare table in each. Now it reads like the rest
of the site, using only what the site already has.

- A short photographic band, same treatment as the homepage hero — blurred cake photo, sage wash, an
  eyebrow line above the heading. Short: the content is three lines, and 34svh of dark left the title
  floating in a void.
- Prices are the loudest thing in each table, set in the display serif above a small-caps size label,
  over a ruled header band. Zebra rows below, the line names in the left column.
- The branch from the wordmark divides the intro from the tables — it is a mask over currentColor, so
  it takes the colour of the text around it and costs one span.
- Small print and the enquiry link share one tinted card footer. As separate full-width bands they made
  every card read as four stacked stripes. The link is quiet: three filled buttons down a page shout,
  and the real call to action belongs once, in a closing sage band with the terms and the phone number.
- The blank-cell dash was so faint that a line with no quantities looked like a rendering fault.

Checked by rendering it, at 1280 and at 390: hero, tables, the phone's one-card-per-size stack, the
closing band. All 197 classes the page uses resolve in the compiled stylesheet, so nothing is styled by
accident. Two notes for anyone doing the same: headless Chromium screenshots this site mid-fade unless
you pass --virtual-time-budget (the body fades in), and a very tall window makes min-h-screen stretch
the flex column, which looks like a gap above the footer and is not one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 17:03:06 -05:00
austinandClaude Opus 5 4ac984b33d One form per row in the admin, and buttons that don't trip its validation
build-and-publish / build (pull_request) Successful in 2m3s
The catalogue screen had a form per control: 467 forms, 464 CSRF tokens, 317 KB of HTML for a page that
gets opened on a phone in a bakery. It is now one form per row with several submit buttons — the same
`name="do"` pattern the catering table editor already used — which is 89 forms and 206 KB, and less
markup to read.

Only `save` looks at the text boxes, so moving a row cannot save a half-typed name. The buttons that
ignore them carry `formnovalidate`, because the name box is `required` and a browser would otherwise
refuse to submit "move down" while that box was empty — a validation error about something the button
has nothing to do with.

Verified on the running container: 89 forms, move and photo-remove still land, and the seeded catalogue
comes back the same after a database reset.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 16:51:31 -05:00
austinandClaude Opus 5 54710019d2 The admin is Thymeleaf too: no JavaScript framework left in the repo
build-and-publish / build (pull_request) Successful in 2m0s
The last React went with this. /admin and /admin/catering are pages of forms; every write is a POST and
a redirect back, so the back button and reload do what they look like they do, a double-tap cannot
repeat an upload, and there is no client-side state to lose — a reload is always the truth. The
/api/admin/** endpoints went too: they existed for the React screen, and their logic now lives in
Catalogue (extracted from the two deleted JSON controllers) and CateringMenu, which the pages call.

THE TABLE EDITOR IS THE INTERESTING PART, because a catering table cannot be edited a field at a time
— a column heading, its price and the entries beneath it only mean anything together. One form holds
the whole table and every button submits it; `name="do"` says which was pressed and its value carries
the position (`remove-column:2`). "Add a column" therefore arrives with every cell the editor has
typed, adds the column to what arrived plus an empty entry on every line, and re-renders. Nothing
typed is lost, and only Save writes — so a half-built table with a blank heading never reaches the
live page. A failed save comes back the same way, with the work still in the form and the reason above
it; a redirect would throw the work away and leave them guessing which cell the message was about.
Spring binds `lines[2].values[1]` into the right cell, which flat repeated parameters could not
promise.

Reordering moved to the server, where it always belonged: the browser used to compute the new order
and send the whole list back, and now "move this up" arrives as an action. Same for arranging photos —
one endpoint takes the key and -1/1/0 (earlier, later, remove), because those three buttons are the
same edit.

frontend/ became styles/: node, Tailwind and nothing else. It exists because Tailwind needs a
compiler and the alternative is a hand-written stylesheet; there is no bundler and no framework. The
admin's controls are @utility classes (v4 will only let you @apply a registered utility, and only a
utility can take the `file:` variant the photo pickers use) — the same buttons the React screen had,
from the same class strings it composed. Also fixed .gitignore, which still named frontend/: with
styles/ unlisted, `git add -A` staged 1,626 files of node_modules.

Verified against a running container, not only in tests: pressing "+ Column" returns the draft with an
unsaved cell intact, a new column and a matching new entry on the line, "Not saved yet" — and the live
page unchanged; Save then writes both columns with the price parsed from "48". Renaming and moving an
item land on the products page. Deleting a category that is in use is refused with the sentence naming
it. Removing the only photo of an item is refused, and that button is already disabled in the page.

51 tests (10 new): the form binding, the flash on success and on refusal, a structural button writing
nothing, and the whole admin surface closed to anonymous visitors. PlatformContractTest's routing
assertion now says what is true — an unknown path 404s, and so does /admin when no identity provider
is configured, because AdminController only exists under OIDC.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 16:47:10 -05:00
austinandClaude Opus 5 61f3eb90ff The site renders itself: Thymeleaf pages, and a catering page among them
build-and-publish / build (pull_request) Successful in 2m8s
The public site was a React SPA. It is now server-rendered Thymeleaf, and the goodie box and catering
tables added in the previous commit have a page of their own. The look is unchanged: the templates
carry the same Tailwind classes the components did, and every one of the 241 classes the five pages
use resolves in the compiled stylesheet.

WHAT WENT AWAY. PageMetaController — 148 lines whose only job was to splice per-page <title> and OG
tags into one shell with regular expressions, with a test that read the real index.html so that
reformatting it failed the build instead of silently breaking the rewriting. A page rendered on the
server writes its own head. Also react-router (no client-side routes left), motion, vite-plugin-svgr,
and the SPA fallback (platform.web.spa.enabled=false): with the site server-rendered, forwarding a
mistyped URL to /index.html would answer with a blank admin shell and a 200 instead of the site's own
404 page.

WHAT GOT BETTER ON THE WAY, none of it visible. The category filter is a ?category= link, so every
filtered view is a URL you can send someone and a crawler can reach all forty items instead of the
twelve the default filter showed. The contact form is a form post: the enquiry is recorded before
delivery is attempted, and a refused relay re-renders the page with what the visitor typed still in
the boxes. The mobile menu is a <details> — the React version needed four effects to close on
navigation, close on Escape, stop the page behind it scrolling, and unmount (a panel parked off-screen
still extends the scrollable area, which is how you used to be able to scroll sideways and find the
menu); a new document cannot inherit an open menu.

THE PUBLIC SITE SHIPS 5 KB OF JAVASCRIPT, and works without it. The product cards are scroll-snap
strips, so the photos swipe on a phone and scroll with a trackpad unaided; gallery.js adds the arrows
and the dots, and creates them itself rather than having the template render controls that would sit
there dead.

Tailwind still needs its compiler, so npm remains a BUILD tool: the CLI compiles the templates into
static/css/site.css at process-classes (so `spring-boot:run` gets it too), and frontend/ now builds
only that stylesheet and the admin. The brand tokens are one file both stylesheets import — the
alternative was the shop front and the screen that edits it drifting a shade apart. The stylesheet URL
carries ?v=<sha>, because one hand-written CSS file has no content hash and a deploy has to be able to
tell a browser that what it cached is stale.

The admin is still React and is untouched, apart from losing the router it no longer needs. It is an
editor, not content.

PlatformContractTest stopped inheriting platform-starter-test's contract and restates it. The shared
version asserts that an unknown path forwards to the SPA shell, which is no longer true here, and its
test methods are package-private so it cannot be overridden. The platform should decide that assertion
from platform.web.spa.enabled — noted in the file.

9 new tests (46 total): every page's real title and og:url, the catalogue and the catering tables in
the HTML rather than fetched afterwards, server-side filtering, the 404, and that a crafted ?about=
link cannot put words of its own choosing in front of a customer.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 16:07:01 -05:00
austinandClaude Opus 5 eb5f75bc9d Notes move both ways, and prove a deleted table takes its cells with it
build-and-publish / build (pull_request) Successful in 2m2s
Two small things found reading it back: a note could be moved up but not down, and nothing exercised
`remove` against the real foreign keys — a delete that strands its children fails in Postgres rather
than in memory, and it's a button on the screen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 15:16:09 -05:00
austinandClaude Opus 5 748b1cd59b Catering tables: the spreadsheet becomes data the bakery can edit
build-and-publish / build (pull_request) Successful in 1m58s
The goodie box and catering prices arrived as a spreadsheet — Office, Parties and Weddings, each a
few columns of sizes and prices with lines of baked goods underneath. This puts it behind
/api/catering and makes every part of it editable at /admin, because the prices move and the
spreadsheet's own last line says the tables are "mostly just an idea for people".

A package is one table, its tiers are the columns, its rows are the lines, and a line holds one
value per column. That alignment is why this is an aggregate rather than three tables edited
separately: drop the middle column on its own and every remaining entry shifts one place left, so
the Large box advertises the Medium box's contents at the Large price and nothing looks broken.
CateringPackage#arrange takes a whole table, renumbers positions from the order it arrived in, and
refuses an arrangement whose lines and columns disagree.

Money owns prices — what "24", "$24" or "24.50" means and how it prints — so the browser never
formats money and never multiplies it by 100 in floating point. Cents in the column, "$24" in the
response. An empty price is "ask us", not zero.

Seeded from the bakery's own wording. Shorthand is expanded ("4 dz cc or sc") and typos fixed, since
customers read these lines; in the wedding table the labels and the values are offset in the source
spreadsheet, so they are carried over literally and can be renamed in the admin. The lines that are
named but never quantified keep their blank cells: dropping the blanks would shorten the line and
shift everything after it.

The public response leaves out a table with no columns or no lines — adding a table and filling it
in are two separate acts, and the gap between them shouldn't put a bare heading on the live page.
No public page renders any of this yet; this is the backend and the editor for it.

Admin endpoints are @ConditionalOnProperty on SECURITY_MODE=OIDC like the rest, so a deployment with
no identity provider has no price writes. 18 new tests: the seeded spreadsheet, the alignment
invariant, money in both directions, and the HTTP surface the screen actually calls (including that
/packages/order isn't read as a table id, and that a refusal arrives as a ProblemDetail sentence).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 15:13:39 -05:00
austinandClaude Opus 4.8 651ff1e30c Reconcile: your admin wins, keeping main's non-admin work
You built a self-service catalogue admin on feature/admin-and-ui-wins while I built a
competing one that had already merged and deployed. Both forked from c8cc8fe. Per your
call, your implementation is the one that stays.

Kept from main (files your branch didn't touch, so no conflict):
- the CI test gate (tests now run and block the image)
- motion 12.42.2
- the platform contract test

Took from your branch:
- split AdminProductController / AdminCategoryController + ProductPhotoService (server-side
  webp via cwebp)
- a real category table (Category, V3__categories.sql) behind the product filters
- pages/Admin.tsx, with server-side /admin protection that redirects a browser to Authentik
  and returns it to /admin afterward — cleaner than my client-side gate, and it avoids the
  post-login-to-home issue my version had

Deleted my competing admin (AdminController, MeController, pages/admin/*, auth.tsx, and my
admin tests).

Grafted onto your gallery: swipe + arrow keys, which the deployed version had and yours
didn't. Added an AdminSecurityTest for your endpoints (admin closed, shop public, contact
CSRF) — the admin was otherwise untested, and CI now gates on tests.

Verified against a running container: /admin redirects a browser to Authentik (a bare 401
only for */* fetches, which is correct). 25 tests green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
2026-07-23 13:10:46 -05:00
austinandClaude Opus 4.8 f9f54b2a62 Remove the temporary gate probe
build-and-publish / build (push) Successful in 1m35s
Confirmed: the run failed at the Test step and the registry digest was unchanged, so a
failing test now stops the image being published.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
2026-07-23 12:20:14 -05:00
austinandClaude Opus 4.8 63a79eeb95 TEMPORARY: prove a failing test blocks the image
build-and-publish / build (push) Failing after 43s
Will be reverted in the next commit. Verifying the gate rather than assuming it — the last
assumption of this kind was wrong for months.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
2026-07-23 12:18:54 -05:00
austinandClaude Opus 4.8 3b80584e22 Admin for the menu and enquiries, plus gallery fixes
Admin
- /api/admin: products CRUD, the enquiry inbox, and presigned photo upload straight to the
  bucket so images never pass through the app. Gated by platform.security.authenticated-paths
  = /api/admin/**, so any signed-in Authentik user is staff — the alternative is a role model
  a two-person bakery would never maintain.
- /api/me is deliberately PUBLIC. The SPA asks on every page load, and requiring a login
  would bounce every anonymous visitor to Authentik just to read the menu.
- /admin screens: product list with edit and remove, an editor with drag-free photo
  reordering and upload, and an enquiry inbox that flags anything the relay refused.

Gallery
- swipe on touch devices, which the react-awesome-slider it replaced had and this did not,
  plus arrow keys and position dots — with swipe there is otherwise nothing to say a card
  holds more than one photo. Vertical drags are ignored so page scrolling still works.
- @BatchSize on the photo collection: the products page loaded the whole catalogue and
  Hibernate issued a query per product for its images, forty-odd round trips for a page
  that needs two.

Three things the tests caught, none of which are obvious:
- Adding the storage starter broke every existing test. It activates on a default endpoint,
  so an S3 client is built even in tests and dies on blank keys.
- MockMvc's webAppContextSetup leaves the security filter chain OUT, so the first version of
  the security test passed 200s and proved the opposite of what it claimed. It needs
  .apply(springSecurity()).
- Turning on the security starter turns on CSRF — for the PUBLIC contact form too, which
  then 403s. The SPA now reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN, and there is a
  test asserting the form is rejected without it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
2026-07-23 11:58:21 -05:00
austinandClaude Opus 4.8 c8cc8fe02d Inherit the platform contract tests, and extend the shared Renovate preset
build-and-publish / build (push) Successful in 1m16s
PlatformWebContract asserts what this app must do because it is on the platform: an /api
path matching no controller 404s rather than returning the SPA, health is UP, the liveness
and readiness probes exist, and a client-side route forwards to the shell. That first one
shipped broken to six live sites and was found by typing a URL by hand; now it fails a
build instead.

renovate.json is three lines pointing at austin/renovate-config. The rules were copied per
repo, so they matched only by luck and a change was six edits.

Platform 0.1.9.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
2026-07-23 09:46:53 -05:00
austinandClaude Opus 4.8 11ae5f378d Rewrite on the Bennett platform: Spring Boot + Vite/React SPA
build-and-publish / build (push) Successful in 1m9s
Replaces the Next.js app. Same site, same look; the parts that were decisions rather
than markup now live in Java.

- catalogue, curated order, category filter and image URLs move from a TypeScript array
  into Postgres behind /api/products and /api/categories
- contact form uses the shared platform-starter-contact: validate, RECORD, send, then
  fan out to n8n. Recording first means a relay outage costs a notification, not an enquiry
- PageMetaController rewrites title/description/OG per route, replacing what Next's SSR
  gave crawlers and link-preview scrapers
- 50MB of photos leave the repo for the MinIO bucket, re-encoded to webp (14MB) with EXIF
  (including phone GPS) stripped
- fixes a catalogue typo: 'Strawberry Pie' was category 'Pies', which no filter matched, so
  it was unreachable unless browsing All

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
2026-07-22 22:09:51 -05:00
austinandClaude Opus 4.8 69e67182a1 contact: fan out enquiries to n8n hub (fire-and-forget)
build-and-publish / build (push) Successful in 8s
Keeps the direct SMTP email as the reliable delivery path; when CONTACT_HUB_URL
is set, also POSTs the enquiry to the n8n webhook so the hub sends the customer
auto-reply and (later) creates CRM/task records. Best-effort with a 4s timeout,
so a slow or down hub never blocks or fails the form.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 18:12:45 -05:00
austinandClaude Opus 4.8 c88bd3bc98 contact route: optional SMTP auth + trust local self-signed relay/bridge
build-and-publish / build (push) Successful in 2s
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-22 15:17:06 -05:00
Austin 26d0e92509 Rebrand to Sage & Cream identity, working contact form, drop Firebase
- New sage/cream palette and stacked logo lockup (The Vine over Coffeehouse
  + Bakery) driven by currentColor; logo SVGs now colorable
- Reuse the logo component for the hero and section marks
- Rewrite site copy: real founding (Morissa Bennett, 2024), real story from
  the product range, remove invented claims and AI phrasing
- Contact form now sends over SMTP via /api/contact (nodemailer) with real
  send/error states, server-side validation, and reply-to the customer;
  delivers to CONTACT_TO. Add .env.example and a test-email script
- Fix mobile: unmount the off-screen menu (killed sideways scroll), cap logo
  width, responsive heroes and type
- Remove Firebase (config, tracked build output, placeholder pages, nix
  firebase-tools) now that hosting has moved
- Delete dead code: LoyaltyCardForm, ProductModal, LoadingSpinner, card-flip
  CSS, unused Playfair font
- Serve dev/start on port 2024
2026-07-14 17:48:00 -05:00
Austin d952371950 updated hours 2026-03-14 11:29:08 -05:00
austin 12c7f2ee49 added a dynamic favicon 2025-02-25 16:54:58 -08:00
Austin Bennett de4e7f4ed6 fixed hours & location 2025-02-21 18:27:37 -06:00
Austin Bennett e08127dcca fixed categories 2025-02-21 15:10:13 -06:00
Austin Bennett c0790f8a2a disabled contact page & fixed images 2025-02-21 15:07:16 -06:00
Austin Bennett 9b58d88977 fixed eslint issues 2025-02-21 14:51:41 -06:00
austin 84693e10b0 removed history page 2025-02-11 10:45:19 -06:00
austin 9d03bcf8ee 2.0 2025-02-08 14:24:51 -06:00
Paul Fresnel 51988cfb3d redirect fix 2024-11-09 17:40:08 +01:00
Paul Fresnel 8847904516 products modal added 2024-11-09 17:39:17 +01:00
Paul Fresnel f877a74467 products images added 2024-11-09 17:22:56 +01:00
Paul Fresnel b6a5e5455d card form added 2024-11-09 16:46:49 +01:00
Paul Fresnel 79a9187dea form added 2024-11-09 15:15:50 +01:00
Paul Fresnel a4a5c3bd80 fixed images path 2024-11-07 14:52:32 +01:00