The shop knows when it is open, and says so — to visitors and to Google

Three things a bakery's site should do that this one couldn't, all from the same fact.

THE HOURS WERE MARKUP, so the only thing the site could do with them was print them. A visitor at half
past two on a Sunday had to work out for themselves that the shop was shut. They are data now (Shop.WEEK),
and `Hours` reads them two ways: as the list the footer and the contact page print — consecutive days with
the same times collapsed into one line, the way a sign does it, instead of three hand-written rows — and
as an answer. The header now says "Open until 2:00pm", or "Closed · opens Tuesday at 7:00am", worked out
on the server in the shop's own timezone. Right now, on a Sunday evening, it says the latter.

THE SHOP IS NOW DESCRIBED TO A SEARCH ENGINE: schema.org Bakery in the head, with the address, the phone
number and those same opening times. For a shop whose customers find it by searching its town, that is the
difference between a blue link and a listing that shows "Open ⋅ closes 2pm" — and it cannot drift from
what the page says, because it is built from the same record. Serialised with Jackson rather than
string-built, so the day somebody interpolates a name into it, it is not an injection. (Jackson 3 —
`tools.jackson` — which is what Boot 4 ships; the fasterxml import does not compile.)

A SHARED LINK NOW HAS A PICTURE. og:image, og:image:alt and a large summary card: it was a grey box with a
title, which is what every link to this site has looked like in a message.

AND IT PRINTS. The catering page is a price list and a bakery prints price lists — Ctrl-P now gives the
prices on paper rather than a screenshot of a website: no chrome, no sage bands eating a cartridge, cards
that don't split across a page break, and the buttons that only exist to be clicked marked `no-print`.

Five new tests, all of them free of Spring, because this is a calculation over a constant: the week
collapses as a sign would write it, the minute of opening counts as open and the minute of closing does
not, and a Saturday afternoon is told when Tuesday starts.

Also swept the last one-off styles the earlier pass missed — max-w-3xl/4xl, an inline letter-spacing the
.h2 class already carries, and the mobile menu's link string.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-07-26 19:27:41 -05:00
co-authored by Claude Opus 5
parent 9e80590687
commit a657600078
13 changed files with 474 additions and 41 deletions
+101
View File
@@ -0,0 +1,101 @@
package com.itsthevine.web;
import java.time.DayOfWeek;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import java.time.format.TextStyle;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.stereotype.Service;
/**
* The opening times, read two ways: as the list on the page, and as the answer to "are they open now?"
*
* <p>Both used to be impossible. The hours were three lines of markup, so the only thing the site could do
* with them was print them — a visitor at half past two on a Sunday had to work out for themselves that
* the shop was shut. They are data now ({@link Shop#WEEK}), and this turns them into both.
*/
@Service
public class Hours {
/** One line of the printed list: "Tuesday Friday", "7:00am 2:00pm". Closed days say so. */
public record Span(String days, String hours, boolean closed) {}
/** "Open until 2:00pm", or "Closed · opens Tuesday at 7:00am". */
public record Status(boolean open, String summary) {}
/**
* The week, with consecutive days that keep the same times collapsed into one line — which is what a
* shop sign does, and what the three hand-written lines used to do by hand.
*/
public List<Span> week() {
List<Span> spans = new ArrayList<>();
int i = 0;
while (i < Shop.READING_ORDER.size()) {
DayOfWeek first = Shop.READING_ORDER.get(i);
Shop.Opening opening = Shop.WEEK.get(first);
int j = i;
while (j + 1 < Shop.READING_ORDER.size()
&& sameHours(opening, Shop.WEEK.get(Shop.READING_ORDER.get(j + 1)))) {
j++;
}
DayOfWeek last = Shop.READING_ORDER.get(j);
String days = first == last ? name(first) : name(first) + " " + name(last);
spans.add(opening == null
? new Span(days, "Closed", true)
: new Span(days, time(opening.opens()) + " " + time(opening.closes()), false));
i = j + 1;
}
return spans;
}
public Status now() {
return at(ZonedDateTime.now(Shop.ZONE));
}
/**
* @param when the moment to answer for; taken as an argument so a test can ask about a Sunday
* afternoon without waiting for one
*/
public Status at(ZonedDateTime when) {
LocalTime time = when.toLocalTime();
Shop.Opening today = Shop.WEEK.get(when.getDayOfWeek());
if (today != null && !time.isBefore(today.opens()) && time.isBefore(today.closes())) {
return new Status(true, "Open until " + time(today.closes()));
}
if (today != null && time.isBefore(today.opens())) {
return new Status(false, "Opens at " + time(today.opens()));
}
// Closed for the day, so the next answer is on a later day. Seven steps at most, and the shop is
// open five of them — the loop cannot run out.
for (int ahead = 1; ahead <= 7; ahead++) {
DayOfWeek day = when.getDayOfWeek().plus(ahead);
Shop.Opening next = Shop.WEEK.get(day);
if (next != null) {
String when_ = ahead == 1 ? "tomorrow" : name(day);
return new Status(false, "Closed · opens " + when_ + " at " + time(next.opens()));
}
}
return new Status(false, "Closed");
}
private static boolean sameHours(Shop.Opening a, Shop.Opening b) {
return a == null ? b == null : a.equals(b);
}
private static String name(DayOfWeek day) {
return day.getDisplayName(TextStyle.FULL, Locale.US);
}
/** "7:00am", "12:00pm" — the way the shop's own sign writes it, not "07:00". */
private static String time(LocalTime at) {
int hour = at.getHour() % 12 == 0 ? 12 : at.getHour() % 12;
return "%d:%02d%s".formatted(hour, at.getMinute(), at.getHour() < 12 ? "am" : "pm");
}
}
@@ -0,0 +1,60 @@
package com.itsthevine.web;
import java.time.DayOfWeek;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
/**
* The shop itself: where it is, how to reach it, and when it is open.
*
* <p>These facts were prose in three templates. They are now stated once, in Java, because they are read
* by more than the eye: the footer and the contact page print them, the header works out from them
* whether the shop is open right now, and {@code StructuredData} hands the same opening times to a search
* engine — which is how a bakery ends up with its hours on the results page.
*
* <p>Hardcoded rather than a table with an admin screen, deliberately: opening times change about once a
* year, and a table would need a screen, a migration and a way to say "closed Christmas Eve" to be worth
* anything. When that day comes this is the one place to lift.
*/
public final class Shop {
/** The shop is in Princeville, Illinois; the container runs on UTC, which turns over first. */
public static final ZoneId ZONE = ZoneId.of("America/Chicago");
public static final String NAME = "The Vine Coffeehouse + Bakery";
public static final String STREET = "215 E Main Street";
public static final String CITY = "Princeville";
public static final String STATE = "IL";
public static final String POSTCODE = "61559";
/** For a tel: link. */
public static final String PHONE = "+13097010660";
/** For a human. */
public static final String PHONE_SPOKEN = "(309) 701-0660";
public static final String EMAIL = "[email protected]";
/**
* When the doors are open. Days that aren't here are closed days, which is what makes Sunday and
* Monday collapse into one line by themselves.
*/
public static final Map<DayOfWeek, Opening> WEEK = Map.of(
DayOfWeek.TUESDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
DayOfWeek.WEDNESDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
DayOfWeek.THURSDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
DayOfWeek.FRIDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
DayOfWeek.SATURDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(12, 0)));
/**
* The week as it is read, starting on the first open day rather than on Monday — the list on the page
* has always begun "Tuesday Friday", because that is when the shop's week begins.
*/
public static final List<DayOfWeek> READING_ORDER = List.of(
DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY,
DayOfWeek.SATURDAY, DayOfWeek.SUNDAY, DayOfWeek.MONDAY);
public record Opening(LocalTime opens, LocalTime closes) {}
private Shop() {
}
}
@@ -1,6 +1,5 @@
package com.itsthevine.web;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import org.springframework.beans.factory.annotation.Value;
@@ -17,21 +16,74 @@ import org.springframework.web.bind.annotation.ModelAttribute;
@ControllerAdvice
public class SiteModel {
/** The shop is in Princeville, Illinois; the container runs on UTC, which turns over first. */
private static final ZoneId SHOP_TIME = ZoneId.of("America/Chicago");
private final SitePhotos photos;
private final Hours hours;
private final StructuredData structuredData;
private final String baseUrl;
private final String build;
public SiteModel(SitePhotos photos,
public SiteModel(SitePhotos photos, Hours hours, StructuredData structuredData,
@Value("${site.base-url:https://itsthevine.com}") String baseUrl,
@Value("${site.build:dev}") String build) {
this.photos = photos;
this.hours = hours;
this.structuredData = structuredData;
this.baseUrl = baseUrl.replaceAll("/+$", "");
this.build = build;
}
/** The opening times as the page prints them — see Hours#week. */
@ModelAttribute("hours")
public java.util.List<Hours.Span> hours() {
return hours.week();
}
/** Whether the shop is open at this moment, for the line in the header. */
@ModelAttribute("openNow")
public Hours.Status openNow() {
return hours.now();
}
// The shop's own facts, so no template states an address or a phone number itself. Four attributes
// rather than one object because a template reading `${shopStreet}` needs no explaining.
@ModelAttribute("shopStreet")
public String shopStreet() {
return Shop.STREET;
}
@ModelAttribute("shopTown")
public String shopTown() {
return Shop.CITY + ", " + Shop.STATE + " " + Shop.POSTCODE;
}
@ModelAttribute("shopPhone")
public String shopPhone() {
return Shop.PHONE;
}
@ModelAttribute("shopPhoneSpoken")
public String shopPhoneSpoken() {
return Shop.PHONE_SPOKEN;
}
@ModelAttribute("shopEmail")
public String shopEmail() {
return Shop.EMAIL;
}
/** schema.org JSON-LD for the head. Written by Jackson; see StructuredData. */
@ModelAttribute("structuredData")
public String structuredData() {
return structuredData.bakery();
}
/** What a shared link should show. */
@ModelAttribute("shareImage")
public String shareImage() {
return photos.of("gallery/Outside.webp");
}
/** Lets a template ask for a photo by key: {@code ${photos.of('gallery/Outside.webp')}}. */
@ModelAttribute("photos")
public SitePhotos photos() {
@@ -64,6 +116,6 @@ public class SiteModel {
/** The footer's copyright year. */
@ModelAttribute("year")
public int year() {
return ZonedDateTime.now(SHOP_TIME).getYear();
return ZonedDateTime.now(Shop.ZONE).getYear();
}
}
@@ -0,0 +1,92 @@
package com.itsthevine.web;
import java.time.DayOfWeek;
import java.time.format.TextStyle;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import tools.jackson.databind.ObjectMapper;
/**
* The shop, described for machines: a schema.org Bakery in the page head.
*
* <p>This is how a search engine learns that itsthevine.com is a bakery in Princeville with a phone number
* and Saturday hours — the difference between a blue link and a listing that shows "Open ⋅ closes 2pm".
* For a shop whose customers find it by searching its town, that is worth more than anything else on this
* page.
*
* <p>Built from {@link Shop} and rendered once in the head, so the hours a crawler reads cannot drift from
* the ones a visitor reads. Serialised with Jackson rather than string-built: every value here is a
* constant of ours today, but the one day somebody interpolates a name into it, hand-built JSON in a
* {@code <script>} is an injection.
*/
@Service
public class StructuredData {
private final ObjectMapper json;
private final SitePhotos photos;
private final String baseUrl;
public StructuredData(ObjectMapper json, SitePhotos photos,
@Value("${site.base-url:https://itsthevine.com}") String baseUrl) {
this.json = json;
this.photos = photos;
this.baseUrl = baseUrl.replaceAll("/+$", "");
}
public String bakery() {
Map<String, Object> shop = new LinkedHashMap<>();
shop.put("@context", "https://schema.org");
shop.put("@type", "Bakery");
shop.put("name", Shop.NAME);
shop.put("url", baseUrl);
shop.put("image", photos.of("gallery/Outside.webp"));
shop.put("telephone", Shop.PHONE);
shop.put("email", Shop.EMAIL);
// "$" rather than a number: schema.org wants a band, and ours is one coffee to a wedding cake.
shop.put("priceRange", "$");
shop.put("servesCuisine", List.of("Bakery", "Coffee", "Sandwiches"));
shop.put("address", Map.of(
"@type", "PostalAddress",
"streetAddress", Shop.STREET,
"addressLocality", Shop.CITY,
"addressRegion", Shop.STATE,
"postalCode", Shop.POSTCODE,
"addressCountry", "US"));
shop.put("openingHoursSpecification", openingHours());
// Jackson 3 (tools.jackson, what Boot 4 ships) throws unchecked, and there is nothing to catch
// anyway: the input is a map of our own constants.
return json.writeValueAsString(shop);
}
/**
* Days that keep the same times are given as one specification with several days on it, which is both
* shorter and what the vocabulary is for.
*/
private List<Map<String, Object>> openingHours() {
Map<Shop.Opening, List<String>> byHours = new TreeMap<>(
(a, b) -> a.opens().equals(b.opens()) ? a.closes().compareTo(b.closes()) : a.opens().compareTo(b.opens()));
for (DayOfWeek day : Shop.READING_ORDER) {
Shop.Opening opening = Shop.WEEK.get(day);
if (opening != null) {
byHours.computeIfAbsent(opening, o -> new java.util.ArrayList<>())
.add(day.getDisplayName(TextStyle.FULL, Locale.US));
}
}
return byHours.entrySet().stream()
.map(entry -> Map.<String, Object>of(
"@type", "OpeningHoursSpecification",
"dayOfWeek", entry.getValue(),
"opens", entry.getKey().opens().toString(),
"closes", entry.getKey().closes().toString()))
.toList();
}
}
+3 -3
View File
@@ -15,7 +15,7 @@
<div class="absolute inset-0 bg-gradient-to-t from-bakery-900 via-bakery-800/60 to-bakery-900/80"></div>
<div class="relative container w-full min-w-0">
<div class="max-w-3xl mx-auto text-center">
<div class="measure text-center">
<p class="eyebrow text-bakery-200">Order ahead</p>
<h1 class="h1 mt-3 text-bakery-50">Goodie boxes &amp; catering</h1>
<p class="lede mt-5 text-bakery-100">
@@ -117,7 +117,7 @@
</ul>
</div>
<a th:href="|/contact?about=${#uris.escapeQueryParam(table.name)}|"
class="pill-outline mt-6"
class="pill-outline mt-6 no-print"
th:text="|Ask about ${#strings.toLowerCase(table.name)}|">Ask about office boxes</a>
</div>
</section>
@@ -134,7 +134,7 @@
<!--/* The terms that apply whichever table you were reading, and the one real call to action. On sage,
like the homepage's story band, so the page ends rather than trailing off. */-->
<section class="section bg-bakery-800 text-white">
<section class="section bg-bakery-800 text-white no-print">
<div class="container">
<div class="measure text-center">
<h2 class="h3">Before you order</h2>
@@ -27,6 +27,10 @@
<meta property="og:title" th:content="${title}">
<meta property="og:description" th:content="${description}">
<meta property="og:url" th:content="${baseUrl + (path == '/' ? '' : path)}">
<!--/* Without an image a shared link is a grey box with a title. This is the storefront. */-->
<meta property="og:image" th:content="${shareImage}">
<meta property="og:image:alt" content="The Vine Coffeehouse + Bakery on Main Street, Princeville">
<meta name="twitter:card" content="summary_large_image">
<meta property="og:type" content="website">
<meta property="og:locale" content="en_US">
<meta name="keywords" content="bakery, coffeehouse, pastries, custom cakes, cinnamon rolls, paninis, Princeville IL">
@@ -34,6 +38,11 @@
<!--/* ?v= is the commit sha in a deployment. One hand-written stylesheet has no content hash in its
name, so without it a returning visitor keeps the CSS they cached before the deploy. */-->
<link rel="stylesheet" th:href="|/css/site.css?v=${build}|">
<!--/* The shop as a search engine reads it: a Bakery, its address, its phone number and its opening
times, from the same record the page prints. It is how a local shop gets "Open ⋅ closes 2pm"
next to its listing. Unescaped because it is JSON, written by Jackson — see StructuredData. */-->
<script type="application/ld+json" th:utext="${structuredData}"></script>
</head>
<body></body>
</html>
@@ -21,9 +21,15 @@
<div class="flex items-center justify-between gap-2 h-20 md:h-24">
<div th:replace="~{fragments/lockup :: small('text-bakery-700')}"></div>
<nav class="hidden md:flex items-center gap-8">
<th:block th:replace="~{:: links('label text-bakery-700 hover:text-bakery-900 transition-colors')}"></th:block>
</nav>
<div class="hidden md:flex items-center gap-8">
<nav class="flex items-center gap-8">
<th:block th:replace="~{:: links('label text-bakery-700 hover:text-bakery-900 transition-colors')}"></th:block>
</nav>
<!--/* Worked out on the server from the same opening times the footer prints. */-->
<p th:replace="~{fragments/visit :: open-now(
${openNow.open} ? 'bg-bakery-500' : 'bg-bakery-300',
'text-sm text-bakery-600 whitespace-nowrap')}"></p>
</div>
<details class="group md:hidden shrink-0">
<summary class="p-2 cursor-pointer list-none" aria-label="Menu">
@@ -38,7 +44,10 @@
</summary>
<div class="absolute inset-x-0 top-full z-30 h-[calc(100dvh-5rem)] bg-bakery-50">
<nav class="container flex flex-col">
<th:block th:replace="~{:: links('text-bakery-800 hover:text-bakery-600 transition py-4 text-lg border-b border-bakery-100')}"></th:block>
<th:block th:replace="~{:: links('text-bakery-800 hover:text-bakery-600 transition-colors py-4 text-lg border-b border-bakery-100')}"></th:block>
<p th:replace="~{fragments/visit :: open-now(
${openNow.open} ? 'bg-bakery-500' : 'bg-bakery-300',
'text-sm text-bakery-600 py-4')}"></p>
</nav>
</div>
</details>
@@ -2,40 +2,40 @@
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!--/*
The opening hours and the address, in one place.
The opening times and the address, printed from the shop's own record rather than from markup.
They were on the homepage and in the footer already, and the moment the contact page wanted them too
that became three copies of the one fact a customer most often comes here for. A shop that changes its
Saturday hours should change them once.
They were three hand-written lines repeated in three templates. Now `Hours` collapses the week into the
lines a sign would show — consecutive days that keep the same times become one row — and the same data
answers "are they open right now?" in the header and describes the shop to a search engine. A shop that
changes its Saturday hours changes them in Shop.java, once.
Both fragments take the classes that vary by where they sit — cream text in the footer, sage on white
in a card — so the content is shared without the styling being averaged into something that suits
neither.
Both fragments take the classes that vary by where they sit — cream in the footer, sage on white in a
card — so the content is shared without the styling being averaged into something that suits neither.
*/-->
<ul th:fragment="hours(rowClass)" class="space-y-3">
<li th:class="'flex justify-between gap-4 ' + ${rowClass}">
<span>Tuesday &ndash; Friday</span>
<span class="font-medium whitespace-nowrap">7:00am &ndash; 2:00pm</span>
</li>
<li th:class="'flex justify-between gap-4 ' + ${rowClass}">
<span>Saturday</span>
<span class="font-medium whitespace-nowrap">7:00am &ndash; 12:00pm</span>
</li>
<li th:class="'flex justify-between gap-4 ' + ${rowClass}">
<span>Sunday &ndash; Monday</span>
<span class="font-medium">Closed</span>
<li th:each="span : ${hours}" th:class="'flex justify-between gap-4 ' + ${rowClass}">
<span th:text="${span.days}">Tuesday Friday</span>
<span class="font-medium whitespace-nowrap" th:text="${span.hours}">7:00am 2:00pm</span>
</li>
</ul>
<address th:fragment="address(linkClass)" class="not-italic space-y-3">
<p>215 E Main Street<br>Princeville, IL 61559</p>
<p th:text="${shopStreet}">215 E Main Street</p>
<p th:text="${shopTown}">Princeville, IL 61559</p>
<p>
<a href="tel:+13097010660" th:class="${linkClass}">(309) 701-0660</a>
<a th:href="|tel:${shopPhone}|" th:class="${linkClass}" th:text="${shopPhoneSpoken}">(309) 701-0660</a>
</p>
<p class="break-words">
<a href="mailto:[email protected]" th:class="${linkClass}">[email protected]</a>
<a th:href="|mailto:${shopEmail}|" th:class="${linkClass}" th:text="${shopEmail}">[email protected]</a>
</p>
</address>
<!--/* "Open until 2:00pm", or when it opens next. The one thing a visitor at 2:30 on a Sunday wants. */-->
<span th:fragment="open-now(dotClass, textClass)"
th:class="'inline-flex items-center gap-2 ' + ${textClass}">
<span th:class="'h-2 w-2 rounded-full shrink-0 ' + ${dotClass}" aria-hidden="true"></span>
<span th:text="${openNow.summary}">Open until 2:00pm</span>
</span>
</body>
</html>
+1 -1
View File
@@ -10,7 +10,7 @@
<div class="absolute inset-0 bg-bakery-900/80"></div>
<div class="absolute inset-0 bg-gradient-to-t from-bakery-900 via-bakery-800/60 to-bakery-900/80"></div>
<div class="relative container w-full min-w-0">
<div class="max-w-3xl mx-auto text-center">
<div class="measure text-center">
<p class="eyebrow text-bakery-200">Since 2024</p>
<h1 class="h1 mt-3 text-bakery-50">Our story</h1>
<p class="lede mt-5 text-bakery-100">
+5 -5
View File
@@ -16,7 +16,7 @@
<!--/* w-full/min-w-0 keep this flex item from sizing to its max-content width and blowing out the
page on narrow screens. */-->
<div class="relative container w-full min-w-0">
<div class="max-w-3xl mx-auto text-center">
<div class="measure text-center">
<div th:replace="~{fragments/lockup :: large('text-bakery-50 mb-10')}"></div>
<p class="lede sm:text-xl text-bakery-100 mb-10">
A coffeehouse and bakery in downtown Princeville, Illinois.
@@ -56,9 +56,9 @@
<!--/* Our story */-->
<section class="section bg-bakery-800 text-white">
<div class="container">
<div class="max-w-3xl mx-auto text-center">
<h2 class="h2 mb-8" style="letter-spacing: 0.01em">Our story</h2>
<p class="text-base md:text-lg text-bakery-100 mb-8 leading-relaxed">
<div class="measure text-center">
<h2 class="h2 mb-8">Our story</h2>
<p class="lede text-bakery-100 mb-8">
Morissa Bennett opened The Vine in 2024. We bake in our own kitchen on Main Street:
cinnamon rolls, cookies, custom cakes, sandwiches, paninis, and coffee.
</p>
@@ -99,7 +99,7 @@
<section id="visit" class="section bg-bakery-50 scroll-mt-24">
<div class="container">
<h2 class="h2 text-center text-bakery-900 mb-12">Visit us</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 max-w-4xl mx-auto">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 measure-wide">
<div class="panel p-6 md:p-8">
<h2 class="h3 text-bakery-900 mb-6">Our hours</h2>
<div th:replace="~{fragments/visit :: hours('text-bakery-800')}"></div>
+1 -1
View File
@@ -51,7 +51,7 @@
</div>
<!--/* A catalogue with no way to order from it is a gallery. */-->
<section class="section bg-bakery-800 text-white">
<section class="section bg-bakery-800 text-white no-print">
<div class="container">
<div class="measure text-center">
<h2 class="h3">Want one of these, or something else?</h2>
+39
View File
@@ -83,3 +83,42 @@ select.field {
background-size: 1.1rem;
padding-right: 2.4rem;
}
/*
* PRINT. The catering page is a price list, and a bakery prints price lists — so what comes out of a
* printer should be the prices, not a screenshot of a website. The chrome goes, the sage bands go (they
* would eat a cartridge), the cards keep their edges so the columns stay readable, and links stop being
* blue-and-underlined because paper has no links.
*/
@media print {
header,
footer,
.no-print {
display: none !important;
}
body {
background: #fff;
color: #000;
opacity: 1;
animation: none;
}
/* Sage-on-white blocks become plain text: the words matter, the ink does not. */
[class*="bg-bakery-8"],
[class*="bg-bakery-9"] {
background: #fff !important;
color: #000 !important;
}
a {
text-decoration: none;
color: inherit;
}
/* A card should not be split across a page break mid-price. */
article,
section {
break-inside: avoid;
}
}