This repository has been archived on 2026-09-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
itsthevine/src/main/resources/static/js/gallery.js
T
austinandClaude Opus 5 057deec47c 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.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 20:53:06 -05:00

142 lines
5.2 KiB
JavaScript

/*
* Arrows and dots for the product cards that have more than one photo.
*
* This is the only JavaScript the public site loads, and the site works without it: each gallery is a
* scroll-snap strip, so the photos are already swipeable on a phone and scrollable with a trackpad. What
* this adds is the two chevrons and the row of dots the site has always had — controls that need script
* to do anything, which is why they are created here rather than rendered in the template and left dead
* for anyone whose JavaScript did not load.
*
* Tailwind finds the class names below because src/main/tailwind/site.css lists this directory as a
* @source. Write them out in full — a class assembled from pieces at runtime would not be in the CSS.
*/
(function () {
'use strict';
var ARROW =
'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 ' +
'focus-visible:ring-white';
function chevron(direction) {
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'h-5 w-5');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.setAttribute('aria-hidden', 'true');
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', direction === 'left' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6');
svg.appendChild(path);
return svg;
}
function enhance(strip) {
var photos = strip.querySelectorAll('img');
if (photos.length < 2) return;
var frame = strip.parentElement;
var name = photos[0].getAttribute('alt') || 'this item';
// Which photo is showing: whichever one's left edge is nearest the strip's scroll position. Read
// from the scroll position rather than tracked in a variable, so a swipe and an arrow press can
// never disagree about where we are.
function current() {
return Math.round(strip.scrollLeft / strip.clientWidth);
}
function show(index) {
var target = Math.max(0, Math.min(photos.length - 1, index));
strip.scrollTo({ left: target * strip.clientWidth, behavior: 'smooth' });
}
function arrow(direction, label, position) {
var button = document.createElement('button');
button.type = 'button';
button.className = ARROW + ' ' + position;
button.setAttribute('aria-label', label);
button.appendChild(chevron(direction));
button.addEventListener('click', function () {
// Wrapping, as the old carousel did: past the last photo you land back on the first.
var next = current() + (direction === 'left' ? -1 : 1);
if (next < 0) next = photos.length - 1;
if (next > photos.length - 1) next = 0;
show(next);
});
return button;
}
frame.appendChild(arrow('left', 'Previous photo of ' + name, 'left-2'));
frame.appendChild(arrow('right', 'Next photo of ' + name, 'right-2'));
var dots = document.createElement('div');
dots.className = 'absolute inset-x-0 bottom-3 flex justify-center gap-1.5';
var buttons = [];
photos.forEach(function (_photo, index) {
var dot = document.createElement('button');
dot.type = 'button';
dot.setAttribute(
'aria-label',
'Show photo ' + (index + 1) + ' of ' + photos.length + ' of ' + name,
);
dot.addEventListener('click', function () {
show(index);
});
buttons.push(dot);
dots.appendChild(dot);
});
frame.appendChild(dots);
function paint() {
var active = current();
buttons.forEach(function (dot, index) {
dot.className =
'h-1.5 rounded-full shadow-xs transition-all motion-reduce:transition-none focus:outline-none ' +
'focus-visible:ring-2 focus-visible:ring-white ' +
(index === active ? 'w-4 bg-white' : 'w-1.5 bg-white/60 hover:bg-white/80');
if (index === active) {
dot.setAttribute('aria-current', 'true');
} else {
dot.removeAttribute('aria-current');
}
});
}
// Keyboard: the strip is focusable, so left/right work once it has focus.
strip.tabIndex = 0;
strip.setAttribute('role', 'group');
strip.addEventListener('keydown', function (event) {
if (event.key === 'ArrowLeft') {
event.preventDefault();
show(current() - 1);
}
if (event.key === 'ArrowRight') {
event.preventDefault();
show(current() + 1);
}
});
var scheduled = false;
strip.addEventListener(
'scroll',
function () {
// A smooth scroll fires this dozens of times; repaint once per frame.
if (scheduled) return;
scheduled = true;
requestAnimationFrame(function () {
scheduled = false;
paint();
});
},
{ passive: true },
);
paint();
}
document.querySelectorAll('.gallery').forEach(enhance);
})();