From 4b40acf6b83e14d7e8c42d216e0171bb5ca5f921 Mon Sep 17 00:00:00 2001 From: austin Date: Sun, 26 Jul 2026 22:07:42 -0500 Subject: [PATCH 1/4] Room under the homepage titles, which had none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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. --- src/main/resources/templates/home.html | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index aa7b568..2718bf9 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -45,14 +45,18 @@
-
-
+ +
+
-

Cinnamon Rolls

+

Cinnamon Rolls

@@ -103,14 +107,15 @@
-
-
+ +
+
-

Our hours

+

Our hours

-

Find us

+

Find us

From 79fa690f7b0a560fc8619da79da9d3a3e26853a0 Mon Sep 17 00:00:00 2001 From: austin Date: Sun, 26 Jul 2026 22:07:52 -0500 Subject: [PATCH 2/4] The site stops flashing white on every click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/main/styles/base.css | 41 +++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/main/styles/base.css b/src/main/styles/base.css index dc3ddbb..0d6493b 100644 --- a/src/main/styles/base.css +++ b/src/main/styles/base.css @@ -1,6 +1,6 @@ /* - * Element defaults: the page background, the body type, the fade-in, and the two small resets the site - * needs. Anything with a class name belongs in components.css instead. + * Element defaults: the page background, the body type, how one page gives way to the next, and the two + * small resets the site needs. Anything with a class name belongs in components.css instead. */ html { @@ -14,23 +14,32 @@ body { color: var(--color-bakery-900); font-family: var(--font-sans); overflow-x: hidden; - opacity: 0; - animation: fadeIn 0.5s ease-in forwards; +} + +/* + * BETWEEN PAGES. + * + * The body used to open at `opacity: 0` and fade in over half a second on every page. That belonged to + * the SPA, where it ran ONCE, when React booted — the rest of the site's navigation happened inside a + * document that had already faded in. Server-rendered, every nav link and every filter chip is a new + * document, so the same two lines fired on every click: the page went blank, then slid 10px and faded + * back. A navigation that was already fast read as the whole site reloading. + * + * A cross-document view transition is what that fade was reaching for. The browser holds the old page + * up until the new one is ready and cross-fades between the two, so nothing is ever blank — and where + * it isn't supported you get a plain, immediate page load, which is the correct fallback and is still + * better than what this replaced. + */ +@view-transition { + navigation: auto; } @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } - body { animation: none; opacity: 1; } -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); + /* Cut the cross-fade, keep the navigation. */ + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none; } } @@ -109,8 +118,6 @@ select.field { body { background: #fff; color: #000; - opacity: 1; - animation: none; } /* Sage-on-white blocks become plain text: the words matter, the ink does not. */ From 314577e6a9241682faa346d658cfaaf3e875e841 Mon Sep 17 00:00:00 2001 From: austin Date: Sun, 26 Jul 2026 22:13:05 -0500 Subject: [PATCH 3/4] The classes that were being written and thrown away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/main/resources/templates/catering.html | 2 +- src/main/resources/templates/contact.html | 6 ++---- src/main/resources/templates/fragments/footer.html | 5 ++--- src/main/resources/templates/fragments/opening.html | 5 +++-- src/main/resources/templates/fragments/visit.html | 9 +++++++-- src/main/resources/templates/home.html | 5 ++--- 6 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/main/resources/templates/catering.html b/src/main/resources/templates/catering.html index 9589318..a1b370a 100644 --- a/src/main/resources/templates/catering.html +++ b/src/main/resources/templates/catering.html @@ -17,7 +17,7 @@ mind, and we will bake to it.

-
+
diff --git a/src/main/resources/templates/contact.html b/src/main/resources/templates/contact.html index bb79a3d..4fbf811 100644 --- a/src/main/resources/templates/contact.html +++ b/src/main/resources/templates/contact.html @@ -81,13 +81,11 @@
- - From cfc874c4e9f4b5e035a413cdc176a6dfe59735ce Mon Sep 17 00:00:00 2001 From: austin Date: Sun, 26 Jul 2026 22:17:06 -0500 Subject: [PATCH 4/4] A test for the class that gets thrown away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../itsthevine/web/TemplateHygieneTest.java | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/test/java/com/itsthevine/web/TemplateHygieneTest.java diff --git a/src/test/java/com/itsthevine/web/TemplateHygieneTest.java b/src/test/java/com/itsthevine/web/TemplateHygieneTest.java new file mode 100644 index 0000000..774c9d9 --- /dev/null +++ b/src/test/java/com/itsthevine/web/TemplateHygieneTest.java @@ -0,0 +1,94 @@ +package com.itsthevine.web; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * A class on a tag that carries {@code th:replace} is a class that never reaches the page. + * + *

{@code th:replace} substitutes the whole host element for the fragment — the fragment's own + * {@code class} attribute is what gets rendered, and anything written on the calling tag is dropped. + * Silently: Thymeleaf does not warn, the page still renders, and it looks nearly right. That is what + * makes it worth a test rather than a code review. + * + *

It had happened seven times before anyone went looking. Two homepage section titles were written + * with an {@code mb-12} and rendered flush against the grid below them; the contact page's two headings + * had the same; the catering divider's {@code mt-10} and the footer's went the same way; and the + * address rendered a shade darker than the hours beside it because its {@code text-bakery-800} was + * dropped too. Every one of those is invisible in the source and obvious on the page. + * + *

The fix in each case is to give the fragment a parameter for the class, which is why + * {@code visit :: hours} and {@code visit :: address} take the element's own class as well as the + * row's or the link's. This test is the thing that stops the next one being written. + * + *

No Spring context and no database: it reads the templates off disk, so it costs nothing. + * {@code th:insert} is deliberately not checked — that one keeps the host tag, so a class on it is + * fine. + */ +class TemplateHygieneTest { + + private static final Path TEMPLATES = Path.of("src/main/resources/templates"); + + /** Any tag, including one written across several lines. */ + private static final Pattern TAG = Pattern.compile("<[a-zA-Z][^>]*?>", Pattern.DOTALL); + + /** A plain {@code class=}, not {@code th:class=} and not {@code th:classappend=}. */ + private static final Pattern PLAIN_CLASS = Pattern.compile("(? offenders = new ArrayList<>(); + + try (Stream files = Files.walk(TEMPLATES)) { + files.filter(p -> p.toString().endsWith(".html")).sorted().forEach(file -> { + String source = read(file); + Matcher tag = TAG.matcher(source); + while (tag.find()) { + String element = tag.group(); + if (element.contains("th:replace") && PLAIN_CLASS.matcher(element).find()) { + long line = source.substring(0, tag.start()).chars().filter(c -> c == '\n').count() + 1; + offenders.add("%s:%d — %s".formatted(file, line, element.replaceAll("\\s+", " "))); + } + } + }); + } + + assertThat(offenders) + .describedAs(""" + These tags carry both a class and a th:replace. th:replace swaps the tag out for \ + the fragment, so the class is dropped and the page renders without it. Pass the \ + class to the fragment as a parameter instead — see fragments/visit.html.""") + .isEmpty(); + } + + /** Guards the guard: a test that reads no files would pass forever. */ + @Test + @DisplayName("the templates are actually being read") + void theTemplatesAreThere() throws IOException { + try (Stream files = Files.walk(TEMPLATES)) { + assertThat(files.filter(p -> p.toString().endsWith(".html")).count()) + .describedAs("templates directory at %s", TEMPLATES.toAbsolutePath()) + .isGreaterThanOrEqualTo(10); + } + } + + private static String read(Path file) { + try { + return Files.readString(file); + } catch (IOException e) { + throw new IllegalStateException("could not read " + file, e); + } + } +}