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' : (${#lists.size(table.tiers) == 2} ? 'sm:grid-cols-2 max-w-3xl mx-auto'
: 'sm:grid-cols-2 lg:grid-cols-3')"> : 'sm:grid-cols-2 lg:grid-cols-3')">
<article th:each="tier, t : ${table.tiers}" <article th:each="tier, t : ${table.tiers}"
class="flex flex-col panel-lift p-6 md:p-8"> class="relative flex flex-col overflow-hidden panel-lift">
<p class="label text-bakery-600" th:text="${tier.label}">Small</p> <!--/* The wordmark's branch, at watermark weight. */-->
<p th:if="${tier.price}" class="price mt-2 text-bakery-900" th:text="${tier.price}">$24</p> <span class="panel-mark -top-3 -right-4 w-24 h-24 rotate-12" aria-hidden="true"></span>
<!--/* 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>
<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"> <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. */--> <!--/* 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" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"> aria-hidden="true">
<path d="M20 6L9 17l-5-5" /> <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" <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"> class="w-full h-56 object-cover transition-transform duration-500 group-hover:scale-105">
</div> </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> </div>
</div> </div>
+4 -2
View File
@@ -35,8 +35,10 @@
<div class="relative w-full overflow-hidden"> <div class="relative w-full overflow-hidden">
<div th:replace="~{:: gallery(${product})}"></div> <div th:replace="~{:: gallery(${product})}"></div>
</div> </div>
<div class="p-6 text-center"> <!--/* The caption sits on the cream, so the photo reads as mounted on the card rather than as
<h3 class="h4 text-bakery-900 mb-2" th:text="${product.name}">Name</h3> 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> <span class="label text-bakery-600" th:text="${product.category}">Category</span>
</div> </div>
</div> </div>
+9
View File
@@ -39,6 +39,15 @@ body {
color: var(--color-bakery-900); 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 * 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. * 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 * 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 exception. In @layer components, matching the old site — and that ordering matters: a utility like
the markup applies alongside `container` on nearly every section has to win over this, or every `px-4`, which the markup applies alongside `container` on nearly every section, has to win over this,
gutter on the site silently widens. */ or every gutter on the site silently widens. */
@layer components { @layer components {
.container { .container {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; @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. * 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 * They have to take their colour from the surrounding text — sage in the header, cream on the hero and in
* and in the footer — which an <img> cannot do. React solved that by inlining them through svgr, * the footer — which an <img> cannot do. React solved that by inlining them through svgr, but these files
* but these files are 31 KB each and the lockup appears up to three times on a page: inlining them * are 31 KB each and the lockup appears up to three times on a page: inlining them in server-rendered
* in server-rendered HTML would add ~190 KB to every response. A mask over `currentColor` keeps the * HTML would add ~190 KB to every response. A mask over `currentColor` keeps the tinting, keeps the files
* tinting, keeps the files cacheable, and costs two requests once. * cacheable, and costs two requests once.
*/ */
.mark { @utility mark {
background-color: currentColor; background-color: currentColor;
-webkit-mask-repeat: no-repeat; -webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat; mask-repeat: no-repeat;
-webkit-mask-position: center; -webkit-mask-position: center;
mask-position: center; mask-position: center;
-webkit-mask-size: contain; -webkit-mask-size: contain;
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');
}
/* @utility mark-r {
* The photo strip on a product card scrolls, but a scrollbar across the bottom of a photo is not part -webkit-mask-image: url('/images/logo_R.svg');
* of the design. Hiding it is safe here because the strip is not the only way through the photos: it mask-image: url('/images/logo_R.svg');
* snaps, it takes arrow keys, and the arrows and dots are on top of it. }
*/
.no-scrollbar { @utility mark-l {
scrollbar-width: none; -webkit-mask-image: url('/images/logo_L.svg');
} mask-image: url('/images/logo_L.svg');
.no-scrollbar::-webkit-scrollbar { }
/*
* 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; 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 * SURFACES.
* 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. * 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 { @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 { @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-800: #37412f;
--color-bakery-900: #232b1e; --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-sans: 'Raleway', ui-sans-serif, system-ui, sans-serif;
--font-adbhashitha: 'AdBhashitha', ui-serif, Georgia, serif; --font-adbhashitha: 'AdBhashitha', ui-serif, Georgia, serif;
--font-lejour: 'LeJour Script', ui-serif, Georgia, cursive; --font-lejour: 'LeJour Script', ui-serif, Georgia, cursive;