Case study // 06
Marketing site for a local tour guide, with WhatsApp enquiry routing.
// CONTEXT
Gimhani Tours is one SLTDA-certified guide and one Nissan Clipper mini van, running personalised trips around Sri Lanka. The customers are foreign travellers who find the business by word of mouth and then talk to it on WhatsApp. A site already existed; this was a rebuild of it, page by page, around a harder question than layout: how a static export with no server, no database and no CMS behind it can still turn a stranger reading in Japanese or German into a message the guide can quote from a phone.
// CONSTRAINTS
// DECISIONS
A static export cannot hold a date, take a deposit or know what is already sold, and a one-van operator selling bespoke itineraries has no fixed price to publish anyway. What the five steps — trip, dates, group, contact, review — are actually for is the quality of the message that arrives: one free-text box produces “hi how much?”, while a dated, sized, routed enquiry can be quoted without a round trip. Nothing is transmitted from the page; the visitor presses send in WhatsApp themselves, which is exactly what the privacy policy describes.
A contact form posting to a third-party form service — another account to maintain, another inbox to forget, and replies landing somewhere the business does not actually work.
A static export rules out Next’s locale routing, and duplicating fourteen routes into eight copies is a maintenance trap. A single 4,129-line dictionary, looked up through a path that degrades from the active locale to English to the key itself, means a missing translation reads as English rather than as a blank. Rendering English until mounted keeps the server output and the first client render identical, so the language picker does not cost a hydration mismatch on every string — and the document’s lang attribute follows the choice, with zh emitted as zh-Hans, so a screen reader does not read Japanese copy under English pronunciation rules.
Routed locale segments, which a static export cannot produce without exporting every page eight times, or a permanently English lang attribute that misreports the page to assistive tech and translation tools.
The obvious instruction — convert everything to WebP — is wrong where it matters most: a 3.40 MB 4000×6000 JPEG re-encodes to 5.82 MB as WebP at q92, because WebP only beats a well-packed JPEG at display sizes. The real waste sat in two different places. The guest photographs were 2.85 MB PNGs, which is the wrong container for a photograph and converts at unchanged pixel dimensions for roughly an 87% saving; the destination photographs were simply four times larger than any screen can show them, so they are capped at 2560px on the long edge. That pass took the deploy from 211 MB to 122 MB. Originals move to _source-images/ at the same relative path, so they stay in the repository as masters and can be re-processed at any setting — they just stop being copied into the static export.
A blanket convert-and-resize across the whole folder, which would have made the largest files bigger while destroying the only copy of the originals.
The old desktop hero was HEVC Main 10: Firefox cannot decode it at all and Chrome only where the hardware obliges, so it was not a heavy hero, it was a silently broken one — a large share of desktop visitors saw the poster or black. One H.264 file with yuv420p, faststart and a keyframe every two seconds plays everywhere, and the muted element’s audio track went with it as dead weight. The readyState check fixes the same class of bug on the client: the markup is server-rendered with preload set to auto, so on a warm cache the element reaches canplay before hydration attaches the handler, the event fires into nothing, and a fully loaded video sits at zero opacity indefinitely.
Keeping a 4K file for desktop and a smaller one for mobile, which doubles the encoding work, chooses by breakpoint rather than by capability, and still leaves Firefox with nothing to play.
The gallery puts hundreds of elements on a single page. whileInView keeps a JavaScript animation alive per element and re-observes on every re-render; a one-shot observer runs once and then costs nothing, and one observer instance covers the page instead of one per element. The off switch lives in CSS: every variant resolves to visible-and-still under prefers-reduced-motion, so nothing has to be cancelled mid-flight in JavaScript.
A motion-library call at every reveal site, which is shorter to write and turns the gallery into the slowest page on the site.
Guessing at breakpoints is what produced the defects the audit found: uppercase eyebrow and stat labels set between 9.6px and 11.5px, below the point where letterspaced capitals stay readable and with nothing smaller left to shrink to on a phone, and ten controls under the 44×44 target WCAG 2.5.5 asks for — mostly icon-only buttons that lose their label on mobile. Loading the corrections after the twelve feature stylesheets means a measured fix wins without a specificity war, and the tap-target padding is scoped to coarse pointers so a mouse user does not inherit thumb-sized buttons.
Editing the values in place across twelve stylesheets, where each fix competes with the rule it corrects and the reason for the number is lost.
// OUTCOME
// REFLECTION
The hero video is still 48.5 MB — 35 seconds of 1080p at CRF 20, about 10.9 Mbps, for footage that plays muted behind a dark scrim and a headline. I set that quality target to protect a cinematic brief that was really about the footage, not about a background loop nobody inspects frame by frame. CRF 26, or a ten-second cut, would have taken most of the weight out with no visible difference, on a site whose own audience is largely on hotel wifi; it is the one place where the measure-first discipline that shaped the image script never reached the video. The second thing I would change is structural: fourteen routes of copy in eight languages with no CMS means every wording change is a code change, and the English fallback that keeps a missing key readable is exactly what would let one locale drift out of date without anyone noticing.