Catering, and a pure Java/Spring site: Thymeleaf front to back #10

Merged
austin merged 22 commits from feature/catering-tables into main 2026-07-26 21:26:57 -05:00
6 changed files with 112 additions and 65 deletions
Showing only changes of commit 5448cbded1 - Show all commits
+15 -10
View File
@@ -67,19 +67,24 @@
: (${#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 panel-lift p-6 md:p-8">
<p class="label text-bakery-600" th:text="${tier.label}">Small</p>
<p th:if="${tier.price}" class="price mt-2 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="price mt-2 text-2xl text-bakery-700">Ask us</p>
<!--/* "Small" doesn't tell anyone how many it feeds. A column whose heading is already a
head count leaves this blank rather than saying it twice. */-->
<p th:if="${tier.serves}" class="mt-1 text-sm text-bakery-600" th:text="${tier.serves}">About 68 people</p>
class="relative flex flex-col overflow-hidden panel-lift">
<!--/* The wordmark's branch, at watermark weight. */-->
<span class="panel-mark -top-3 -right-4 w-24 h-24 rotate-12" aria-hidden="true"></span>
<ul class="mt-6 space-y-3 text-bakery-800">
<div class="panel-head relative px-6 py-6 md:px-8">
<p class="label text-bakery-600" th:text="${tier.label}">Small</p>
<p th:if="${tier.price}" class="price mt-2 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="price mt-2 text-2xl text-bakery-700">Ask us</p>
<!--/* "Small" doesn't tell anyone how many it feeds. A column whose heading is already a
head count leaves this blank rather than saying it twice. */-->
<p th:if="${tier.serves}" class="mt-1 text-sm text-bakery-600" th:text="${tier.serves}">About 68 people</p>
</div>
<ul class="px-6 py-6 md:px-8 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"
<svg class="mt-1 h-4 w-4 shrink-0 text-bakery-500" 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" />
+1 -1
View File
@@ -47,7 +47,7 @@
<img th:src="${photos.of('gallery/' + item + '.webp')}" th:alt="${item}" width="600" height="400" loading="lazy"
class="w-full h-56 object-cover transition-transform duration-500 group-hover:scale-105">
</div>
<h3 class="h4 md:text-2xl text-bakery-800 py-6" th:text="${item}">Cinnamon Rolls</h3>
<h3 class="panel-head border-b-0 border-t h4 md:text-2xl text-bakery-800 py-6" th:text="${item}">Cinnamon Rolls</h3>
</div>
</div>
</div>
+4 -2
View File
@@ -35,8 +35,10 @@
<div class="relative w-full overflow-hidden">
<div th:replace="~{:: gallery(${product})}"></div>
</div>
<div class="p-6 text-center">
<h3 class="h4 text-bakery-900 mb-2" th:text="${product.name}">Name</h3>
<!--/* 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>
+9
View File
@@ -39,6 +39,15 @@ body {
color: var(--color-bakery-900);
}
/* The mobile menu is a <details>; its default disclosure triangle would sit beside the hamburger. Lost
once already in a refactor, which is exactly the kind of thing nobody notices until a phone. */
summary {
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
/*
* FOCUS. One visible ring for everything you can reach with a keyboard, as an outline rather than a
* shadow so it shows up on cream, on white and on the sage bands without needing three variants.
+75 -52
View File
@@ -1,75 +1,98 @@
/*
* The public site's own classes — the ones that would be unreadable as a string of utilities in every
* template that needs them. The admin's controls are in admin.css.
* template that needs them. The admin's controls are in admin.css; the type ladder is in type.css.
*
* Almost everything here is @utility rather than a plain class in a layer, for two reasons Tailwind v4
* enforces: only a registered utility can be @applied by another rule (panel-mark builds on mark), and
* only a registered utility can take a variant (`hover:`, `md:`, `file:`).
*/
/* In @layer components, matching the old site. That ordering matters: a utility like `px-4` — which
the markup applies alongside `container` on nearly every section has to win over this, or every
gutter on the site silently widens. */
/* The exception. In @layer components, matching the old site — and that ordering matters: a utility like
`px-4`, which the markup applies alongside `container` on nearly every section, has to win over this,
or every gutter on the site silently widens. */
@layer components {
.container {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}
}
/*
* The two branch marks of the wordmark, as masks rather than inline SVG.
*
* They have to take their colour from the surrounding text — sage in the header, cream on the hero
* and in the footer — which an <img> cannot do. React solved that by inlining them through svgr,
* but these files are 31 KB each and the lockup appears up to three times on a page: inlining them
* in server-rendered HTML would add ~190 KB to every response. A mask over `currentColor` keeps the
* tinting, keeps the files cacheable, and costs two requests once.
*/
.mark {
background-color: currentColor;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: contain;
mask-size: contain;
}
.mark-r {
-webkit-mask-image: url('/images/logo_R.svg');
mask-image: url('/images/logo_R.svg');
}
.mark-l {
-webkit-mask-image: url('/images/logo_L.svg');
mask-image: url('/images/logo_L.svg');
}
/*
* THE BRANCH MARKS of the wordmark, as masks rather than inline SVG.
*
* They have to take their colour from the surrounding text — sage in the header, cream on the hero and in
* the footer — which an <img> cannot do. React solved that by inlining them through svgr, but these files
* are 31 KB each and the lockup appears up to three times on a page: inlining them in server-rendered
* HTML would add ~190 KB to every response. A mask over `currentColor` keeps the tinting, keeps the files
* cacheable, and costs two requests once.
*/
@utility mark {
background-color: currentColor;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: contain;
mask-size: contain;
}
/*
* The photo strip on a product card scrolls, but a scrollbar across the bottom of a photo is not part
* of the design. Hiding it is safe here because the strip is not the only way through the photos: it
* snaps, it takes arrow keys, and the arrows and dots are on top of it.
*/
.no-scrollbar {
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
@utility mark-r {
-webkit-mask-image: url('/images/logo_R.svg');
mask-image: url('/images/logo_R.svg');
}
@utility mark-l {
-webkit-mask-image: url('/images/logo_L.svg');
mask-image: url('/images/logo_L.svg');
}
/*
* The photo strip on a product card scrolls, but a scrollbar across the bottom of a photo is not part of
* the design. Hiding it is safe here because the strip is not the only way through the photos: it snaps,
* it takes arrow keys, and the arrows and dots are on top of it.
*/
@utility no-scrollbar {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
/* The mobile menu is a <details>; its default disclosure triangle would sit next to the hamburger. */
summary {
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
/*
* SURFACES. Every white box on the site was "bg-white rounded-3xl shadow-xs" written out again, which is
* how two of them end up with different corners. `panel` is that box; `panel-lift` is one you can click
* or hover, and it lifts.
* SURFACES.
*
* These were plain white boxes with a grey drop shadow — the default card of every web app, on a page
* that is otherwise cream, sage and a hand-drawn vine. Three things make them the shop's:
*
* a warm hairline, so a card has an EDGE and reads as stock rather than as a floating panel;
* a shadow tinted with the darkest sage instead of black (see theme.css);
* a head that can be tinted cream, the way a printed menu separates the price from what you get.
*
* `panel-lift` is one you can click, and it rises rather than just darkening.
*/
@utility panel {
@apply bg-white rounded-3xl shadow-xs;
@apply bg-white rounded-3xl border border-bakery-200/70;
box-shadow: var(--shadow-card);
}
@utility panel-lift {
@apply panel transition duration-300 hover:shadow-lg;
@apply panel transition duration-300;
&:hover {
box-shadow: var(--shadow-card-lift);
transform: translateY(-2px);
}
}
/* The cream band on a card: a size and its price, or a photo's caption. */
@utility panel-head {
@apply bg-bakery-50/70 border-b border-bakery-200/70;
}
/*
* The branch, laid into a corner at the weight of a watermark. It is the one mark that is unmistakably
* this shop, and at this size and colour it is texture rather than decoration.
*/
@utility panel-mark {
@apply mark mark-l absolute pointer-events-none text-bakery-200/70;
}
/*
+8
View File
@@ -38,6 +38,14 @@
--color-bakery-800: #37412f;
--color-bakery-900: #232b1e;
/*
* Shadows tinted with the darkest sage rather than black. A neutral grey shadow on a cream page reads
* as a UI panel floating over a screen; a warm one reads as card stock lying on a counter, which is
* what these are meant to be.
*/
--shadow-card: 0 1px 2px rgba(35, 43, 30, 0.04), 0 10px 30px -18px rgba(35, 43, 30, 0.28);
--shadow-card-lift: 0 2px 6px rgba(35, 43, 30, 0.06), 0 20px 44px -22px rgba(35, 43, 30, 0.4);
--font-sans: 'Raleway', ui-sans-serif, system-ui, sans-serif;
--font-adbhashitha: 'AdBhashitha', ui-serif, Georgia, serif;
--font-lejour: 'LeJour Script', ui-serif, Georgia, cursive;