Archived
Three bugs and a lighter page: 367 KB of fonts became 57
THE BUGS. Tapping any field zoomed the page in on iOS, because Safari does that to anything under 16px and `field` was 14px. Every enquiry typed on a phone began by throwing the layout sideways. 16px on phones, the denser size back at sm where no browser does it. The focus ring vanished on the sage bands: one colour for the whole site, and sage-500 on sage-800 is about 2.5:1 — under the 3:1 a focus indicator needs. It went missing exactly where the page's main calls to action live. Cream on those sections now. THE FONTS. AdBhashitha shipped 1605 glyphs to set 187 codepoints, as .woff, unpreloaded: 306 KB for the face every heading uses, arriving after first paint so each page opened in a fallback serif and then reflowed. Subset to Latin and the punctuation it actually has, every layout feature kept, compressed to woff2: **306 KB → 8 KB**, and a pixel diff of the catering page before and after is exactly zero. The script wordmark is only recompressed, never subset — its swashes come from alternates and the logo is the one thing that must not change shape. All three faces are preloaded now and together weigh less than the display face did alone. THE REST OF THE LIST. The products filter wrapped to three rows on a phone with "PASTRIES" stranded on the last; it is a scrolling snap row below sm, wrapping and centred above it, and the negative margin lets a half-chip show at the edge so it is obviously scrollable. Chips are 44px tall and the gallery arrows 44px wide — both were under the thumb-sized minimum. The contact form has a honeypot: a real field with a plausible name, off-page rather than display:none, skipped by the tab key. A filled one gets the same thank-you a person gets, because telling a bot it failed only teaches it to try again — and nothing is recorded or sent, which the test proves against the enquiry table. And the catering small print, which was loose text floating between two rows of cards, is a soft panel belonging to the table above it. 61 tests. Verified at 390 and 900.
This commit is contained in:
@@ -109,13 +109,24 @@ public class SiteController {
|
|||||||
* <p>It renders rather than redirects on both outcomes, deliberately. A failed send has to come
|
* <p>It renders rather than redirects on both outcomes, deliberately. A failed send has to come
|
||||||
* back with what the visitor typed still in the boxes: they wrote it once, and the failure is ours
|
* back with what the visitor typed still in the boxes: they wrote it once, and the failure is ours
|
||||||
* (a refused relay), not theirs. On success the fields are cleared and the message replaces them.
|
* (a refused relay), not theirs. On success the fields are cleared and the message replaces them.
|
||||||
|
*
|
||||||
|
* @param website the trap. It is a real field with a plausible name, hidden from anyone reading the
|
||||||
|
* page and skipped by the tab key — a person cannot fill it in, and the crawlers that
|
||||||
|
* post to every form they find fill in everything. A filled one is answered with the
|
||||||
|
* same thank-you a person gets: telling a bot it failed only teaches it to try again,
|
||||||
|
* and the enquiry is simply never recorded or sent.
|
||||||
*/
|
*/
|
||||||
@PostMapping("/contact")
|
@PostMapping("/contact")
|
||||||
public String submit(@RequestParam String name,
|
public String submit(@RequestParam String name,
|
||||||
@RequestParam String email,
|
@RequestParam String email,
|
||||||
@RequestParam String message,
|
@RequestParam String message,
|
||||||
|
@RequestParam(required = false) String website,
|
||||||
Model model) {
|
Model model) {
|
||||||
contactMeta(model);
|
contactMeta(model);
|
||||||
|
if (website != null && !website.isBlank()) {
|
||||||
|
model.addAttribute("sent", true);
|
||||||
|
return "contact";
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
enquiries.receive(name, email, message);
|
enquiries.receive(name, email, message);
|
||||||
model.addAttribute("sent", true);
|
model.addAttribute("sent", true);
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,7 +14,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var ARROW =
|
var ARROW =
|
||||||
'absolute top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 ' +
|
'absolute top-1/2 -translate-y-1/2 grid place-items-center h-11 w-11 rounded-full bg-bakery-900/40 ' +
|
||||||
'text-white backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 ' +
|
'text-white backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 ' +
|
||||||
'focus-visible:ring-white';
|
'focus-visible:ring-white';
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
three cards led to the same place, since an enquiry is about the table rather than the size,
|
three cards led to the same place, since an enquiry is about the table rather than the size,
|
||||||
and the label read "Ask about the 15–20 people" on the tables whose sizes are headcounts.
|
and the label read "Ask about the 15–20 people" on the tables whose sizes are headcounts.
|
||||||
*/-->
|
*/-->
|
||||||
<div class="mt-8 measure text-center">
|
<div class="mt-8 measure rounded-panel border border-bakery-200/70 bg-bakery-50/60 px-6 py-6 text-center">
|
||||||
<div th:if="${!#lists.isEmpty(table.notes)}">
|
<div th:if="${!#lists.isEmpty(table.notes)}">
|
||||||
<h3 class="label text-bakery-500">Good to know</h3>
|
<h3 class="label text-bakery-500">Good to know</h3>
|
||||||
<ul class="mt-3 space-y-2 text-sm text-bakery-700">
|
<ul class="mt-3 space-y-2 text-sm text-bakery-700">
|
||||||
|
|||||||
@@ -43,6 +43,16 @@
|
|||||||
<input type="email" id="email" name="email" th:value="${email}" required
|
<input type="email" id="email" name="email" th:value="${email}" required
|
||||||
class="field">
|
class="field">
|
||||||
</div>
|
</div>
|
||||||
|
<!--/*
|
||||||
|
The spam trap. Not display:none — the crawlers that matter check for that — but positioned off
|
||||||
|
the page, skipped by the tab key and told not to autofill. A person never sees it; a bot fills in
|
||||||
|
every field it finds. See SiteController#submit for what happens then.
|
||||||
|
*/-->
|
||||||
|
<div class="absolute left-[-9999px]" aria-hidden="true">
|
||||||
|
<label for="website">Website</label>
|
||||||
|
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-bakery-800 mb-2" for="message">Message</label>
|
<label class="block text-sm font-medium text-bakery-800 mb-2" for="message">Message</label>
|
||||||
<textarea id="message" name="message" rows="5" required th:text="${message}"
|
<textarea id="message" name="message" rows="5" required th:text="${message}"
|
||||||
|
|||||||
@@ -18,8 +18,12 @@
|
|||||||
|
|
||||||
<!--/* Open the connection to the photo bucket while the head is still parsing. */-->
|
<!--/* Open the connection to the photo bucket while the head is still parsing. */-->
|
||||||
<link rel="preconnect" th:href="${assetOrigin}" crossorigin>
|
<link rel="preconnect" th:href="${assetOrigin}" crossorigin>
|
||||||
<!--/* The wordmark is set in Raleway; without preloading it arrives late and the logo visibly reflows. */-->
|
<!--/* All three are above the fold — the wordmark, every heading, and the body — and all three together
|
||||||
|
are now smaller than the display face alone used to be. Without preloading, each page opens in
|
||||||
|
the fallback and then reflows into the brand. */-->
|
||||||
<link rel="preload" as="font" type="font/woff2" href="/fonts/raleway-latin.woff2" crossorigin>
|
<link rel="preload" as="font" type="font/woff2" href="/fonts/raleway-latin.woff2" crossorigin>
|
||||||
|
<link rel="preload" as="font" type="font/woff2" href="/fonts/LeJour-Script.woff2" crossorigin>
|
||||||
|
<link rel="preload" as="font" type="font/woff2" href="/fonts/AdBhashitha.woff2" crossorigin>
|
||||||
<link th:if="${preload}" rel="preload" as="image" th:href="${preload}" fetchpriority="high">
|
<link th:if="${preload}" rel="preload" as="image" th:href="${preload}" fetchpriority="high">
|
||||||
|
|
||||||
<title th:text="${title}">The Vine Coffeehouse + Bakery</title>
|
<title th:text="${title}">The Vine Coffeehouse + Bakery</title>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
forty items instead of whichever twelve the default filter showed. The filtering itself has always
|
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.
|
been the server's job — this just stopped pretending otherwise.
|
||||||
*/-->
|
*/-->
|
||||||
<div class="flex flex-wrap justify-center gap-4 mb-12">
|
<div class="filter-row mb-12">
|
||||||
<a th:each="category : ${categories}"
|
<a th:each="category : ${categories}"
|
||||||
th:href="${category == 'All' ? '/products' : '/products?category=' + #uris.escapeQueryParam(category)}"
|
th:href="${category == 'All' ? '/products' : '/products?category=' + #uris.escapeQueryParam(category)}"
|
||||||
th:text="${category}"
|
th:text="${category}"
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ summary::-webkit-details-marker {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* On the sage bands that same sage is about 2.5:1 against the background — under the 3:1 a focus
|
||||||
|
* indicator needs, so the ring effectively disappeared exactly where the page's main calls to action
|
||||||
|
* live. Cream on those sections.
|
||||||
|
*/
|
||||||
|
:where(.bg-bakery-800, .bg-bakery-900) :where(a, button, summary, input, select, textarea, [tabindex]):focus-visible {
|
||||||
|
outline-color: var(--color-bakery-100);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* THE Z-INDEX SCALE, in full. Two layers is all this site has, and writing them down is what stops a
|
* THE Z-INDEX SCALE, in full. Two layers is all this site has, and writing them down is what stops a
|
||||||
* third being invented at 9999:
|
* third being invented at 9999:
|
||||||
|
|||||||
@@ -55,8 +55,13 @@
|
|||||||
* FIELDS. One input, everywhere. The public form had its own (cream, ring-2, py-2.5) and the admin had
|
* FIELDS. One input, everywhere. The public form had its own (cream, ring-2, py-2.5) and the admin had
|
||||||
* another (white, ring-1, py-2, text-sm), which is what happens when a form gets written twice.
|
* another (white, ring-1, py-2, text-sm), which is what happens when a form gets written twice.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* text-base on a phone, not text-sm: Safari zooms the whole page in when you focus an input smaller than
|
||||||
|
* 16px, which on the contact form means every enquiry typed on a phone starts by throwing the layout
|
||||||
|
* sideways. The denser size comes back at sm, where no browser does that.
|
||||||
|
*/
|
||||||
@utility field {
|
@utility field {
|
||||||
@apply w-full rounded-field border border-bakery-300 bg-white px-3.5 py-2.5 text-sm
|
@apply w-full rounded-field border border-bakery-300 bg-white px-3.5 py-2.5 text-base sm:text-sm
|
||||||
focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500;
|
focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,10 +183,13 @@
|
|||||||
@apply pill border border-bakery-200/60 text-bakery-50 hover:bg-bakery-50/10;
|
@apply pill border border-bakery-200/60 text-bakery-50 hover:bg-bakery-50/10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smaller, for a filter row rather than a call to action. */
|
/*
|
||||||
|
* Smaller than a pill, for a filter row rather than a call to action — but still 44px tall, which is the
|
||||||
|
* smallest thing a thumb should be asked to hit.
|
||||||
|
*/
|
||||||
@utility chip {
|
@utility chip {
|
||||||
@apply inline-flex items-center rounded-full border px-6 py-2 label
|
@apply inline-flex items-center justify-center rounded-full border px-5 sm:px-6 min-h-11 label
|
||||||
bg-white border-bakery-300 text-bakery-700 transition-colors hover:bg-bakery-100;
|
whitespace-nowrap bg-white border-bakery-300 text-bakery-700 transition-colors hover:bg-bakery-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility chip-on {
|
@utility chip-on {
|
||||||
@@ -201,6 +209,19 @@
|
|||||||
@apply py-14 md:py-20;
|
@apply py-14 md:py-20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A row of filters that scrolls sideways on a phone instead of wrapping onto three lines with one item
|
||||||
|
* stranded on the last. It wraps and centres from sm, where there is room. The negative margin lets the
|
||||||
|
* strip bleed to the screen edge so a half-visible chip shows there is more.
|
||||||
|
*/
|
||||||
|
@utility filter-row {
|
||||||
|
@apply flex gap-3 overflow-x-auto no-scrollbar snap-x -mx-4 px-4
|
||||||
|
sm:flex-wrap sm:justify-center sm:gap-4 sm:overflow-visible sm:mx-0 sm:px-0;
|
||||||
|
& > * {
|
||||||
|
@apply snap-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* THE PAGE YOU ARE ON, in the nav. A rule under the word rather than a colour change: the nav is already
|
* THE PAGE YOU ARE ON, in the nav. A rule under the word rather than a colour change: the nav is already
|
||||||
* sage on cream, and darkening it further reads as a hover, not as "you are here".
|
* sage on cream, and darkening it further reads as a hover, not as "you are here".
|
||||||
|
|||||||
@@ -13,14 +13,23 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* 306 KB became 8 KB. The file carried 1605 glyphs for 187 codepoints — alternates and scripts this site
|
||||||
|
* never sets — so it is subset to Latin and the punctuation it actually has, with every layout feature
|
||||||
|
* kept, and compressed as woff2. The headings are pixel-identical; that was checked, not assumed.
|
||||||
|
*/
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'AdBhashitha';
|
font-family: 'AdBhashitha';
|
||||||
src: url('/fonts/AdBhashitha.woff') format('woff');
|
src: url('/fonts/AdBhashitha.woff2') format('woff2');
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* Recompressed, never subset: the wordmark's swashes come out of this font's alternates, and the logo is
|
||||||
|
* the one thing on the site that must not change shape.
|
||||||
|
*/
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'LeJour Script';
|
font-family: 'LeJour Script';
|
||||||
src: url('/fonts/LeJour-Script.woff') format('woff');
|
src: url('/fonts/LeJour-Script.woff2') format('woff2');
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.itsthevine.web;
|
|||||||
|
|
||||||
import static org.hamcrest.Matchers.containsString;
|
import static org.hamcrest.Matchers.containsString;
|
||||||
import static org.hamcrest.Matchers.not;
|
import static org.hamcrest.Matchers.not;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -48,6 +50,9 @@ class SiteControllerTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
WebApplicationContext context;
|
WebApplicationContext context;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
com.itsthevine.web.domain.ContactEnquiryRepository enquiries;
|
||||||
|
|
||||||
MockMvc mvc;
|
MockMvc mvc;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -158,6 +163,18 @@ class SiteControllerTest {
|
|||||||
.andExpect(content().string(not(containsString("like to ask about"))));
|
.andExpect(content().string(not(containsString("like to ask about"))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aFilledInTrapIsThankedAndThrownAway() throws Exception {
|
||||||
|
// The bot is told the same thing a person is told — anything else is a training signal — and
|
||||||
|
// nothing is recorded or sent. The enquiry table is what proves the second half.
|
||||||
|
mvc.perform(post("/contact")
|
||||||
|
.param("name", "Bot").param("email", "[email protected]")
|
||||||
|
.param("message", "Cheap watches").param("website", "http://example.com"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("Your message is on its way")));
|
||||||
|
assertThat(enquiries.count()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void anUnknownPageIsNotFound() throws Exception {
|
void anUnknownPageIsNotFound() throws Exception {
|
||||||
// Status only: MockMvc does not run the servlet container's error dispatch, so the body of the
|
// Status only: MockMvc does not run the servlet container's error dispatch, so the body of the
|
||||||
|
|||||||
Reference in New Issue
Block a user