The site renders itself: Thymeleaf pages, and a catering page among them

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.
This commit is contained in:
2026-07-26 16:07:01 -05:00
parent 5b1c837059
commit 70af2922f5
54 changed files with 2127 additions and 2398 deletions
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
<body>
<div th:fragment="content" class="min-h-screen bg-bakery-50">
<header class="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
<h1 class="font-adbhashitha text-4xl md:text-5xl text-bakery-900">Our products</h1>
</header>
<div class="container mx-auto px-4 pb-16">
<!--/*
The category filter: links, not buttons. Each filtered view is now a URL you can bookmark or send
to somebody, the browser's back button does what it looks like it does, and a crawler can see all
forty items instead of whichever twelve the default filter showed. The filtering itself has always
been the server's job — this just stopped pretending otherwise.
*/-->
<div class="flex flex-wrap justify-center gap-4 mb-12">
<a th:each="category : ${categories}"
th:href="${category == 'All' ? '/products' : '/products?category=' + #uris.escapeQueryParam(category)}"
th:text="${category}"
th:aria-current="${category == selected ? 'page' : null}"
th:class="'px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors '
+ (${category == selected}
? 'bg-bakery-600 text-white border-bakery-600'
: 'bg-white border-bakery-300 text-bakery-700 hover:bg-bakery-100')">All</a>
</div>
<!--/* Deliberately unanimated, as before: filtering used to run a layout reflow plus an enter/exit
fade on every card, which on a 40-card grid reads as the page lurching rather than
responding. The only motion left is the shadow on hover. */-->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div th:each="product : ${products}"
class="group bg-white rounded-3xl overflow-hidden shadow-xs transition-shadow duration-300 hover:shadow-lg">
<div class="relative w-full overflow-hidden">
<div th:replace="~{:: gallery(${product})}"></div>
</div>
<div class="p-6 text-center">
<h3 class="font-adbhashitha text-xl text-bakery-900 mb-2 tracking-wide" th:text="${product.name}">Name</h3>
<span class="text-xs uppercase tracking-[0.15em] text-bakery-600" th:text="${product.category}">Category</span>
</div>
</div>
</div>
<p th:if="${#lists.isEmpty(products)}" class="text-center text-bakery-800">
Nothing in that category just now.
<a href="/products" class="underline underline-offset-4">See everything</a>.
</p>
</div>
</div>
<!--/*
The photo on a card.
A scroll-snap strip rather than a stack of absolutely positioned images cross-fading: it is one square
photo at a time either way, but this version swipes on a phone and scrolls with a trackpad with no
script at all. /js/gallery.js adds the arrows and the dots on top. They are created there rather than
rendered here on purpose — a control that does nothing without JavaScript is worse than no control.
*/-->
<div th:fragment="gallery(product)" class="relative aspect-square bg-bakery-100">
<div class="gallery flex h-full w-full overflow-x-auto snap-x snap-mandatory no-scrollbar"
th:attr="aria-label=${#lists.size(product.images) > 1 ? product.name + ' — ' + #lists.size(product.images) + ' photos' : null}"
th:aria-roledescription="${#lists.size(product.images) > 1 ? 'carousel' : null}">
<img th:each="image, i : ${product.images}" th:src="${image}"
th:alt="${i.index == 0 ? product.name : ''}"
th:aria-hidden="${i.index == 0 ? null : 'true'}"
loading="lazy" decoding="async"
class="snap-center shrink-0 h-full w-full object-cover">
</div>
</div>
</body>
</html>