This repository has been archived on 2026-09-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
itsthevine/src/main/resources/templates/products.html
T
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

98 lines
4.5 KiB
HTML

<!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="page-head">
<h1 class="h1 text-bakery-900">Our products</h1>
<p class="mt-4 text-bakery-800 measure leading-relaxed">
What comes out of the kitchen on Main Street. Cakes and decorated cookies are made to order, so
most of what follows started as somebody describing what they wanted.
</p>
</header>
<div class="container 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="${category == selected} ? 'chip-on' : 'chip'">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 panel-lift overflow-hidden">
<div class="relative w-full overflow-hidden">
<div th:replace="~{:: gallery(${product})}"></div>
</div>
<!--/* The caption sits on the cream, so the photo reads as mounted on the card rather than as
the card itself. */-->
<div class="panel-head border-b-0 border-t px-6 py-5 text-center">
<h3 class="h4 text-bakery-900" th:text="${product.name}">Name</h3>
<span class="label 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="link-plain">See everything</a>.
</p>
</div>
<!--/* A catalogue with no way to order from it is a gallery. */-->
<section class="section bg-bakery-800 text-white">
<div class="container">
<div class="measure text-center">
<h2 class="h3">Want one of these, or something else?</h2>
<p class="mt-4 text-bakery-100 leading-relaxed">
Cakes and decorated cookies are made to order. Tell us the date and roughly what you have in
mind — a tractor, a retirement, the class of 1964 — and we will work from there.
</p>
<div class="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
<a href="/contact"
class="pill-cream">
Ask about an order
</a>
<a href="/catering"
class="pill-ghost">
Ordering for a crowd
</a>
</div>
</div>
</div>
</section>
</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>