Archived
Make a card look like it came from the shop, not from a component library
The cards were white boxes with a grey drop shadow and no edge — the default card of every web app,
sitting on a page that is otherwise cream, sage and a hand-drawn vine. Four changes, all of them things
the brand already owns:
- A WARM HAIRLINE. A card now has an edge, so it reads as stock lying on a counter rather than as a panel
floating over a screen.
- A SHADOW IN SAGE, not black — rgba(35,43,30,…), the darkest colour in the palette. Grey shadows are what
made them feel borrowed.
- A CREAM HEAD (`panel-head`). On a catering card the size, price and "About 6–8 people" sit on the tint
with the contents below, the way a printed menu separates what it costs from what you get. Product cards
use the same band for their caption, so the photo reads as mounted on the card instead of being it.
- THE BRANCH, at watermark weight, in the corner of every price card. It is the one mark that is
unmistakably this shop, and it costs nothing: the mask was already there for the wordmark.
The lift is a lift now, two pixels, rather than the shadow just darkening.
TWO THINGS TAILWIND v4 MADE ME FIX ALONG THE WAY. `.mark`, `.mark-l`, `.mark-r` and `.no-scrollbar` were
plain classes in @layer components, and only a registered utility can be @applied by another rule — the
watermark builds on the branch mark, so they became @utility like everything else here. And rewriting that
file dropped the `summary { list-style: none }` reset, which would have put a disclosure triangle beside
the hamburger on every phone; caught it by grepping the compiled CSS rather than by luck.
53 tests green, every class on every page resolves, and the price cards are in the screenshot.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -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 6–8 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 6–8 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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user