Car Detailing Template: Driftmark
Car detailing and ceramic coating site for a Leeds studio: prices that change with car size, a before/after paint-correction slider, a pinned process track, a filterable gallery, a postcode map and a booking builder.
- Section Templates
- gsap
- scrolltrigger
- splittext
- flip
- lenis
- svg
- expert
- medium perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 3395 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Car Detailing Template: Driftmark
**What it does (visual):** A complete, light, single-page website for a fictional car detailing and ceramic coating studio in Leeds, UK ("Driftmark Detailing": a studio in Holbeck plus a mobile van covering 15 miles). A car owner knows within two seconds that this is a detailer, can see the price for their car, and remembers the page for the before/after paint slider. Palette `studio` (default studio: cool paint-shop white `#f3f5f7` ground, white `#ffffff` cards, foam-blue `#d9e9f4` and blush `#fbe4dc` colour fields, ink `#0f1720`, muted `#4a5562`) and one brand colour `#d8321f` (default signal red `#d8321f`). Headline set `plain`, starting car size `medium`. One motion language: entrances `power3.out` (0.5–1.1 s), state changes and wipes `expo.inOut` (0.6–1.1 s), scroll-linked pieces `ease: 'none'` + scrub, Lenis smooth scroll. Every travel distance and zoom × `1`. Motif: an **inspection-light glare**, a soft white band in `mix-blend-mode: soft-light` that sweeps across photos (toggle `true`).
Sections, top to bottom (neighbouring sections never share a layout):
1. **Fixed header** (76px; 66px on phones; it gets a 94% ground background and a hairline once scrolled): a round accent logo mark (two swirl arcs and a diagonal glare line) + "Driftmark" with "Car detailing in Leeds" under it, links Packages · Paint correction · Gallery · Reviews · Area & hours, the phone `0113 496 0274` and a magnetic pill "Book a detail". Under 1180px a round ink burger opens a full-height menu (big display links, Book + Call buttons); Escape and link taps close it, and Lenis stops while it is open.
2. **Hero** (12-column grid, copy 1–6, photo 7–12): headline left-aligned in exactly three lines, `clamp(44px, 5.6vw, 96px)`, weight 700. Plain: "Car detailing & / ceramic coating / in Leeds"; result: "Paint that looks / better than new, / in Leeds"; offer: "Ceramic coating / from £649 / in Leeds". Under it one sentence ("Hand washes, full valets, paint correction and ceramic coatings. At our Holbeck studio, or at your door anywhere within 15 miles."), a magnetic "Book a detail" pill (56px) + a text link "See prices by car size", and a three-up trust row above a hairline: ★ 4.9 Google rating (sample) · 2,140 cars detailed since 2017 · Studio + mobile, van carries its own water & power (4.9 and 2,140 count up). Right: the studio photo of a red saloon (height `min(76svh, 760px)`, radius 4px) with a white chip at bottom-left: "Ceramic coating / **from £749** / medium car, 3-year protection" (price and size follow the car size).
3. **Packages and prices** (5/7 split): left, sticky: heading, one sentence, a 2×2 grid of car-size radio cards with drawn line silhouettes (Small: city cars, small hatchbacks · Medium: hatchbacks, saloons, estates · Large: SUVs, 7-seaters, big estates · Extra large: vans, pick-ups, long-wheelbase; the checked card turns ink), and a preview photo `clamp(260px, 40vh, 440px)` tall. Right: five rows under a 1px ink rule; each row is a button: display name + one line | time | big price + "mobile or studio"/"studio only" | round +. Prices (small / medium / large / XL): Wash & protect £45 / £50 / £60 / £70 (1½–2 hours) · Full valet £129 / £149 / £169 / £189 (4–5 hours) · Interior deep clean £149 / £169 / £199 / £229 (5–6 hours) · Paint correction £349 / £399 / £449 / £549 (1–2 days, studio only) · Ceramic coating £649 / £749 / £849 / £999 (2 days, studio only). Expanding shows four accent-tick lines of what is included (two-bucket wash with grit guards; vacuum and matte dressing; hot-water extraction and steam; decon + paint depth readings + one-stage 60–80% / two-stage +£250 85–95%; panel wipe, 3-year coating, IR cure overnight, 6-week check, 5-year +£200) and a "Book …" button that preselects the package in the booking form.
4. **Swirls out, gloss back** on the foam-blue field (4/8 split): sticky copy left (heading, a sentence explaining that swirls are fine scratches from brush washes and dry towels, the hint "← → Drag the light bar, or focus it and use the arrow keys."). Right: the **before/after slider** (aspect 1600 : 1066, radius 4px, 4:3 and full-bleed on phones): the corrected photo underneath, the swirled photo on top clipped by `clip-path: inset(0 calc(100% - var(--split)) 0 0)`, "Before"/"After" pills top-left/right (each fades out when the split passes 14% / 86%), and the handle: a 4px white LED bar with a white and cool-blue glow (`0 0 10px 2px rgba(255,255,255,.75), 0 0 34px 8px rgba(190,225,255,.35)`) and a 48px white round grip with ‹ › chevrons. It starts at `50`%. Under it: Gloss at 60° **58 → 89** GU · Clear coat removed **3** µm of 118 · Time on this bonnet **2.5** hours, two-stage (all count up), a magnetic "Book paint correction", and the honest note "Demo pair: the swirls were added to one photograph to show the effect. Replace both with your own before and after."
5. **How a detail runs**: heading left, sentence right, a 3px progress bar, then six steps, each a 5:4 visual + accent time + title + sentence: Inspect and measure (20 minutes; a drawn top-view car with paint-depth readings 121µm, 116, 118, 112, 94 (flagged in accent), 119µm and the note "Rear-right door reads 94µm: likely resprayed, polished lightly.") · Snow foam pre-wash (10 minutes) · Two-bucket hand wash (30 minutes) · Decontaminate (40 minutes; iron fallout remover bleeds purple, tar remover, clay bar) · Machine polish (4–10 hours; trims masked, dual-action, readings re-taken) · Coat, cure, hand over (Overnight; IR lamps, walk-round, aftercare kit).
6. **From the studio this month**: a heading and filter pills (All · Exterior · Interior · Wheels · Correction & coating; pressed pill turns ink), then a 4-column dense grid (rows `clamp(180px, 17vw, 250px)`, wide tiles span 2 columns, tall tiles 2 rows, gap 14px) of eight photos with dark caption chips that describe exactly what is in each photo.
7. **What owners say** on the blush field (7/4 split): heading, ★★★★★ 4.9 from 386 reviews, "Sample reviews, template content. Replace with your own.", one big display quote (Rob, Headingley, paint correction, paint readings before and after) and four short quotes on the right (mobile valet with own water and power; dog hair; coating still beads; an honest thin-door story). No photos of people.
8. **Studio in Holbeck, van across West Yorkshire** (6/5 split): a drawn SVG map (640×560, sticky): an 8-mile free ring and a 15-mile £15 ring around the studio pin, the River Aire, M62, M1, M621 and A1(M) drawing in, and towns placed roughly to scale (Leeds, Bradford, Wakefield, Harrogate, Wetherby, Otley, Horsforth, Morley, Garforth, Castleford, Dewsbury, Ilkley greyed as outside), a key card and "Illustrative, not to scale". Right: heading, a sentence (400 litres of filtered water and a quiet generator; correction and ceramic stay in the studio), a **postcode checker** (LS/BD/WF/HG districts with miles and area names → "Yes, we come to LS6 (Headingley). No call-out fee…", "£15 call-out…", "outside the mobile area: book a studio drop-off…", the matching town dot turns accent and the ring outlines in accent), then the address (Unit 6, Brindle Yard, Holbeck, Leeds LS11 9QP, two minutes from M621 junction 3) and an hours table with today's row in accent: Mon–Fri 8:00am – 6:00pm, Sat 8:30am – 4:00pm, Sun closed.
9. **Before you book** FAQ (white band, 4/7 split): valet vs detail · will correction remove every scratch (no: fingernail test, paint depth first) · how long a coating lasts and how to wash it (3 or 5 years, 7 days before the first wash, pH-neutral two-bucket, no brush washes, £79 yearly inspection) · correction/ceramic at home (no, studio only) · water or electricity (no) · payment and cancelling (pay on completion; £50 deposit for correction and ceramic days, refundable up to 48 hours ahead).
10. **Book a detail** (7/4 split): a builder form left and a sticky ink summary card right. Fieldsets: Car size (pills) · Package (five radio rows with live prices) · Add-ons (two-column checkboxes: Engine bay clean +£35, Headlight restoration, pair +£60, Pet hair removal +£30, Leather clean & condition +£40, Windscreen glass coating +£45, Wheels-off ceramic, set of four +£180, studio only) · Where (Studio drop-off, Holbeck / Mobile, at your address; studio-only packages disable Mobile and vice versa, with a line saying why; Mobile shows a postcode field that adds a £15 call-out for 8–15 miles and rejects outside postcodes) · Date and time (native date input, min = next open day, max +60 days, Sundays rejected; slot pills that depend on the day and where: studio 8:00am / 9:30am / 12:30pm drop-off (Saturday 8:30am / 10:00am), mobile 8:00–10:00am / 11:00am–1:00pm / 2:00–4:00pm, two-day jobs only the first slot, one sample slot struck through as booked) · Your details (name, UK mobile, email, car make/model/colour, optional notes). The summary lists each line, a big animated **Total**, size, duration, place and slot, and the deposit line, then "Request booking". Success state: an accent tick that draws itself, "Thanks, {first name}. Booking requested.", the phone it will text, a DM-xxxxx reference and a summary list, plus "Make another booking".
11. **Footer** (ink): giant "Driftmark", one line saying the business, address, phone, email, Services list, opening hours, mobile area, then "Fully insured for vehicles in our care (sample wording). Prices include VAT." and "Fictional brand — template content."
12. **Mobile sticky bar** (`true`, phones only): ink "Call" pill + accent "Book a detail from £50" (price follows the size); it slides up after the hero buttons scroll away and hides while the booking section is on screen.
**Image slots (Demo images from Unsplash are not included: replace them with your own photos. Give them one shared grade: slightly cool, soft contrast, one white point; no visible number plates, badges or logos):**
- `studio-red-saloon.webp`, 1600×1360: a freshly finished car under studio strip lights (hero, gallery wide tile).
- `paint-after.webp` / `paint-before.webp`, 1600×1066 each, **the same framing**: a dark glossy panel with point-light reflections, corrected and swirled.
- `snow-foam.webp`, 1200×1560: a car covered in snow foam (process step, gallery tall tile).
- `two-bucket-wash.webp`, 1600×1066: a gloved hand washing a panel with a wash pad (Wash & protect preview, process step).
- `wheel-clean.webp`, 1200×1500: a long brush cleaning a foamy alloy wheel.
- `machine-polish.webp`, 1400×1400: a dual-action polisher on paint with trim masked in tape.
- `polishing-bonnet.webp`, 1600×642: machine polishing a bonnet.
- `ceramic-beading.webp`, 1600×614: tight water beads on coated paint.
- `interior-seats.webp`, 1200×1500: clean light leather seats.
- `interior-detail.webp`, 1600×899: a detail brush along the dashboard seams (Full valet preview, gallery wide tile).
**Tech stack:** GSAP 3.13+ with ScrollTrigger, SplitText and Flip (`npm i gsap lenis` → `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { SplitText } from "gsap/SplitText"; import { Flip } from "gsap/Flip"; import Lenis from "lenis"; gsap.registerPlugin(ScrollTrigger, SplitText, Flip);`), `ScrollTrigger.config({ ignoreMobileResize: true })`. Lenis `{ duration: 1.15, smoothWheel: true }` driven by `gsap.ticker` (`lenis.raf(t * 1000)`, `lagSmoothing(0)`, `lenis.on('scroll', ScrollTrigger.update)`). Fonts (Google Fonts): **Hubot Sans** for display (weights 600–800, `font-stretch: 112%`, tracking −0.02 to −0.04em) and **Instrument Sans** for text (400–700). Plain HTML, CSS and one ES module, with no framework.
**HTML structure:**
```html
<div class="dms" data-palette="studio" data-size="medium">
<header class="dms__nav" data-nav>…logo, links, phone, .dms__btn[data-magnetic], .dms__burger + #dms-menu[hidden]…</header>
<main>
<section class="dms__hero"><div class="dms__hero-copy"><h1 class="dms__h1" data-headline><span class="dms__line">…</span>×3</h1>
<p class="dms__lede" data-hero-fade>…</p><div class="dms__hero-actions" data-hero-fade>…</div><ul class="dms__trust" data-hero-fade>…[data-count]…</ul></div>
<figure class="dms__hero-photo" data-hero-photo data-glare><img …><span class="dms__glare"></span><figcaption class="dms__hero-chip">…<span data-hero-price>£749</span>…</figcaption></figure></section>
<section class="dms__pk" id="dms-packages"><div class="dms__pk-side"><div class="dms__pk-side-in">…<fieldset class="dms__sizes" data-sizes>4× label>input[name=dms-size]+span</fieldset>
<figure class="dms__pk-preview" data-pk-preview><img data-pk-img><span class="dms__glare"></span></figure></div></div>
<ul class="dms__pk-list" data-pk><li class="dms__pkg" data-pkg="wash" data-img="snow-foam"><button class="dms__pkg-hit" aria-expanded="false" aria-controls="dms-pk-wash">…<b data-price="45,50,60,70">£50</b>…</button>
<div class="dms__pkg-more" id="dms-pk-wash" hidden>…<a data-book="wash">Book …</a></div></li>…×5</ul></section>
<section class="dms__fix" id="dms-correction">…<div class="dms__ba" data-ba style="--split:50%"><img class="dms__ba-img" after><div class="dms__ba-before"><img before></div>
<span class="dms__ba-tag dms__ba-tag--b">Before</span><span class="dms__ba-tag dms__ba-tag--a">After</span>
<div class="dms__ba-handle" role="slider" tabindex="0" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50"><i class="dms__ba-bar"></i><span class="dms__ba-grip">‹ ›</span></div></div>
<dl class="dms__fix-facts">…</dl></section>
<section class="dms__proc" data-proc><div class="dms__proc-head">…<div class="dms__proc-bar"><i data-proc-bar></i></div></div>
<div class="dms__proc-view"><ol class="dms__proc-track" data-proc-track><li class="dms__step"><div class="dms__step-vis">…</div><div class="dms__step-txt">…</div></li>×6</ol></div></section>
<section class="dms__gal" id="dms-gallery"><div class="dms__chips" data-filters><button data-f="all" aria-pressed="true">…</div><ul class="dms__gal-grid" data-gallery><li class="dms__tile" data-cat="exterior" data-glare>…</li>×8</ul></section>
<section class="dms__rev" id="dms-reviews">…</section>
<section class="dms__area" id="dms-area"><svg class="dms__map" data-map>…rings, roads[pathLength=1], towns[data-town], [data-pin]…</svg><form class="dms__pc" data-pc>…</form>…<table class="dms__hours">…</table></section>
<section class="dms__faq">…<button aria-expanded aria-controls>…</button><div class="dms__a" hidden>…</div>…</section>
<section class="dms__book" id="dms-book"><form class="dms__form" data-form novalidate><div class="dms__form-main">fieldsets: size, pkg, add, where (+pc), date + [data-f-slots], details</div>
<aside class="dms__sum" data-sum>…[data-sum-rows] [data-sum-total] [data-sum-meta] [data-sum-dep]…</aside></form><div class="dms__done" data-done hidden>…</div></section>
</main>
<footer class="dms__foot">…Fictional brand — template content.</footer>
<div class="dms__bar" data-bar><a class="dms__bar-call">Call</a><a class="dms__bar-book">Book a detail <span data-bar-price>from £50</span></a></div>
</div>
```
**CSS requirements:** All tokens live on `.dms[data-palette]` and redefine `--bg --surface --text --muted --line --field --field-2 --deep --on-deep --map-*` inside the template only; `html:has(.dms[data-palette="studio"]) body` paints the page ground so it never opens dark. Palettes: studio (above) · foam (`#f1f7f6` ground, aqua `#cdeee8`, apricot `#fde8cf`, ink `#0d1e1f`) · lime (`#f4f4ef`, chartreuse `#e4f28a`, `#dfe6ee`, ink `#141712`) · night (`#0e141b`, surface `#16202a`, text `#ecf1f5`, optional dark). `--knob-accent: #d8321f`; `--on-acc` is black or white by luminance (> 0.36 → `#0f1720`), and `--acc-ink` mixes the accent 45% with ink when it is too light for text. Five type steps: 14.5px, 17px, `clamp(21px,1.7vw,26px)`, `clamp(36px,4.4vw,68px)`, `clamp(44px,5.6vw,96px)`. Gutter `max(20px, 5.5vw)`, max width 1360px. Buttons and chips are fully round pills; photos and cards use 4px corners, nothing else. Resets go through `:where(.dms)` so component classes always win, plus `.dms [hidden] { display:none !important }`. Root `overflow-x: clip`. Glare: `.dms__glare::before` is a 46%-wide 100° white gradient (0 → .08 → .55 → .08 → 0), `skewX(-12deg)`, starting at `translateX(-120%)`; `.is-sweep` runs `translateX(-120%) → translateX(260%)` over 1.15 s `cubic-bezier(.87,0,.13,1)`. Slider: `touch-action: pan-y; user-select: none; cursor: ew-resize`, the handle positioned with `left: var(--split)`.
**Animation spec (exact values):**
- Hero (under 1.2 s, `power3.out`): SplitText `{ type: 'lines', mask: 'lines' }` on each `.dms__line` (masks get `padding-bottom: .14em; margin-bottom: -.14em` so descenders are not cut); lines `yPercent 110 → 0`, 0.8 s, stagger 0.08 at 0. `[data-hero-fade]` `opacity 0, y 18×1` → 0.6 s, stagger 0.07 at 0.2. Photo `clipPath inset(0 0 0 100%) → open`, 1.0 s `expo.inOut` at 0.05; photo img `scale 1 + 0.14×1 → 1`, 1.3 s. Chip `opacity 0, y 20` → 0.6 s at 0.7. Glare sweep at 0.85. Counters 0 → value, 1.1 s at 0.3 (2,140 formatted en-GB).
- Hero photo drift: `yPercent 0 → −7×1` (−4 on phones), trigger hero `top top` → `bottom top`, scrub 0.6.
- Reveals: `ScrollTrigger.batch('[data-reveal]', { start: 'top 88%', once: true })` → `opacity 0, y 26×1` → 0.8 s, stagger 0.07. Package rows: `top 92%`, y 18, 0.7 s, stagger 0.06. Gallery tiles: `clipPath inset(100% 0 0 0) → inset(0)` 1.0 s `expo.inOut`, stagger 0.08, and each tile gets the glare 0.55 s + i×0.08 later.
- Car size change: every `[data-price]`, `[data-opt-price]`, the hero chip and the summary total tween their number from old to new, 0.6 s `power3.out`, formatted `£` + en-GB.
- Package expand: `height 0 → auto` 0.6 s `expo.inOut` (close 0.5 s), inner `opacity 0, y −6` → 0.5 s delay 0.1; ScrollTrigger.refresh on complete. Preview swap (on mouse hover or focus): a cloned img is laid over with `clipPath inset(0 0 100% 0), scale 1.08 → inset(0), scale 1`, 0.8 s `expo.inOut`, plus a glare sweep.
- Slider first view (`[data-ba]` `top 70%`, once, skipped if the user already dragged): split → start + 28×max(.5,1) (0.9 s) → start − 34×… (1.1 s) → `50` (0.9 s), all `expo.inOut`, clamped 8–92.
- Process (≥ 1024 × 640 only): the section gets `.is-pin` (100svh, flex column), track `x: 0 → −(scrollWidth − innerWidth)`, `ease: 'none'`, ScrollTrigger `{ trigger: section, start: 'top top', end: '+=' + distance, pin: true, scrub: 0.8, anticipatePin: 1, invalidateOnRefresh: true }`. The progress bar `scaleX 0.08 → 1` uses the same start/end with scrub 0.8 and no pin. Each step image goes `scale 1 + 0.12×1 → 1` with `containerAnimation: <the track tween>`, `start: 'left right', end: 'right left'`, scrub. Elsewhere steps are a stacked list revealed with the batch rule.
- Gallery filter: `const state = Flip.getState(tiles)`, toggle `hidden`, then `Flip.from(state, { duration: 0.7, ease: 'expo.inOut', scale: true, absolute: true, onEnter: fade+scale 0.9 → 1 (0.6 s), onLeave: → opacity 0, scale 0.9 (0.4 s) })`.
- Map (`top 75%`, once): outer ring `scale .2, opacity 0 → 1` 1.0 s `expo.inOut` at 0, inner ring 0.9 s at 0.15, roads `strokeDashoffset 1 → 0` 0.9 s stagger 0.1 at 0.3, towns fade in with stagger 0.04 at 0.7, road labels at 0.9, pin `y −36 → 0` 0.7 s at 1.1.
- Success: children `opacity 0, y 14` → 0.6 s stagger 0.06; tick `strokeDashoffset 1 → 0` 0.6 s delay 0.2.
**Interaction:** Size cards, booking size pills and the knob all drive one state and re-price the whole page. Every "Book …" link and the slider's CTA preselect the package (studio-only packages switch Where to studio) and glide to the booking form with Lenis (header offset), focusing the name field. The slider follows the pointer on the stage (`setPointerCapture`; a tap jumps the bar), keyboard ←/→/↑/↓ ±2 (Shift ±10), PageUp/Down ±10, Home 0, End 100; `aria-valuetext` reads "62% before, 38% after". The postcode checker accepts districts or full postcodes (`^([A-Z]{1,2}\d[A-Z\d]?)(\d[A-Z]{2})$`). Slots are a radiogroup with roving tabindex and arrow keys. Validation: package, postcode zone for mobile, date (future, not Sunday) + slot, name, UK phone `^(\+44\d{9,10}|0\d{9,10})$`, email, car. The first error gets focus; errors live in `aria-describedby` text. On desktop with a fine pointer, primary buttons are magnetic (`quickTo` x ×0.28, y ×0.4, 0.5 s) and every `[data-glare]` photo sweeps on pointerenter.
**Responsive behavior:** `gsap.matchMedia()` with `(min-width: 1024px)`, `(hover: hover) and (pointer: fine)` and `(min-width: 1024px) and (min-height: 640px)`. Under 1024px every split becomes one column, sticky columns become static and the package preview photo hides. Under 768px the hero photo, slider and map go full-bleed, the hero chip shrinks, package rows become a 2×2 grid (name/price, time/+), the slider is 4:3 with the photo anchored `30% 60%`, facts go 2-up with a full-width CTA, the gallery becomes 2 columns with 44vw rows, filter pills scroll sideways, the form is single-column and the sticky Call / Book bar shows. No pin, magnetic pull or hover glare on touch.
**Accessibility:** Reduced motion means no Lenis, SplitText, pin, scrub, glare, slider sweep or Flip. Counters show final values, the map is drawn, prices swap instantly and accordions open without tweening. A skip link, `aria-expanded`/`aria-controls` on package rows, FAQ and burger, `role="slider"` with value text, `aria-pressed` filter pills, `role="radio"` slots, `aria-live="polite"` postcode result, labelled fields with inline errors and visible 3px accent focus rings everywhere. Decorative SVG and glare are `aria-hidden`; the map has a `<title>` describing the zones. AA contrast on every palette.
**Performance rules:** Animate only transforms, opacity and clip-path (plus the two height tweens). The slider changes one custom property per move and never triggers layout. Use one `gsap.context()` and one `gsap.matchMedia()`, both reverted and rebuilt on knob changes and on unmount, with SplitText reverted too. Lenis is the only per-frame loop. It stops on `ml:pause`, and the slider demo timeline pauses too. Photos are lazy except the hero (`fetchpriority="high"`); all are WebP ≤ 180 KB at ≤ 1600px.
**Customization knobs:** `studio` (studio / foam / lime / night) swaps the scoped tokens. `#d8321f` is the brand colour (buttons, logo, ticks, pin, progress bar), with on-colour and ink variants computed. `plain` picks the plain / result / offer line set. `medium` is the size every price opens at. `50` is the slider's resting position in %. `true` turns the inspection-light sweeps on or off. `true` turns the phone Call / Book bar on or off. `1` multiplies travel, zoom and sweep amplitude (0 = fades only).
**Acceptance criteria:**
- [ ] First screen at 1440 and 375 says "car detailing & ceramic coating in Leeds", shows the brand + descriptor and one "Book a detail" action; the page opens light
- [ ] Changing car size re-prices the packages, hero chip, booking options, summary and mobile bar, with the numbers tweening
- [ ] The before/after slider works by mouse drag, touch drag (vertical scrolling still works), tap and keyboard, and announces its value
- [ ] Process pins horizontally only on large screens and is a clean stacked list elsewhere; the gallery filters with Flip; the postcode checker answers for LS6, LS22, BD1, YO1 and nonsense input
- [ ] The booking builder blocks Sundays, enforces studio-only packages and add-ons, adds the £15 call-out, validates fields and shows a success state with a reference
- [ ] Reduced-motion users get the final states; there are no console errors, no horizontal overflow and no layout shift Source
Full source code
<div class="dms" id="dms-top" data-palette="studio" data-size="medium">
<a class="dms__skip" href="#dms-main">Skip to content</a>
<!-- ============ header ============ -->
<header class="dms__nav" data-nav>
<div class="dms__nav-in">
<a class="dms__logo" href="#dms-top" aria-label="Driftmark Detailing, home">
<svg class="dms__mark" viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="20" fill="var(--knob-accent)"/><path d="M11.5 23.5a9 9 0 0 1 14.2-9.4M14.6 25.6a5.6 5.6 0 0 1 8.6-7.1" fill="none" stroke="var(--on-acc)" stroke-width="2.6" stroke-linecap="round"/><path d="M26.5 9.5 13 31" stroke="var(--on-acc)" stroke-width="2.6" stroke-linecap="round" opacity=".55"/></svg>
<span class="dms__logo-word">Driftmark<small>Car detailing in Leeds</small></span>
</a>
<nav class="dms__links" aria-label="Main">
<a href="#dms-packages">Packages</a>
<a href="#dms-correction">Paint correction</a>
<a href="#dms-gallery">Gallery</a>
<a href="#dms-reviews">Reviews</a>
<a href="#dms-area">Area & hours</a>
</nav>
<div class="dms__nav-cta">
<a class="dms__nav-phone" href="tel:+441134960274">0113 496 0274</a>
<a class="dms__btn dms__btn--sm" href="#dms-book" data-magnetic>Book a detail</a>
<button class="dms__burger" type="button" aria-expanded="false" aria-controls="dms-menu" data-burger>
<span class="dms__sr" data-burger-label>Open menu</span><i></i><i></i>
</button>
</div>
</div>
<div class="dms__menu" id="dms-menu" hidden data-menu>
<nav aria-label="Mobile">
<a href="#dms-packages">Packages</a>
<a href="#dms-correction">Paint correction</a>
<a href="#dms-gallery">Gallery</a>
<a href="#dms-reviews">Reviews</a>
<a href="#dms-area">Area & hours</a>
</nav>
<div class="dms__menu-foot">
<a class="dms__btn" href="#dms-book">Book a detail</a>
<a class="dms__btn dms__btn--ghost" href="tel:+441134960274">Call 0113 496 0274</a>
</div>
</div>
</header>
<main id="dms-main" tabindex="-1">
<!-- ============ hero ============ -->
<section class="dms__hero" aria-labelledby="dms-h1">
<div class="dms__hero-copy">
<h1 class="dms__h1" id="dms-h1" data-headline><span class="dms__line">Car detailing &</span> <span class="dms__line">ceramic coating</span> <span class="dms__line">in Leeds</span></h1>
<p class="dms__lede" data-hero-fade data-sub>Hand washes, full valets, paint correction and ceramic coatings. At our Holbeck studio, or at your door anywhere within 15 miles.</p>
<div class="dms__hero-actions" data-hero-fade>
<a class="dms__btn dms__btn--lg" href="#dms-book" data-magnetic>Book a detail</a>
<a class="dms__textlink" href="#dms-packages">See prices by car size</a>
</div>
<ul class="dms__trust" data-hero-fade>
<li><b><span data-count="4.9" data-dec="1">4.9</span><svg class="dms__star" viewBox="0 0 20 20" aria-hidden="true"><path d="m10 1.8 2.5 5.3 5.8.7-4.3 4 1.1 5.7L10 14.7l-5.1 2.8 1.1-5.7-4.3-4 5.8-.7Z"/></svg></b><span>Google rating <em>(sample)</em></span></li>
<li><b><span data-count="2140" data-sep>2,140</span></b><span>cars detailed since 2017</span></li>
<li><b>Studio + mobile</b><span>van carries its own water & power</span></li>
</ul>
</div>
<figure class="dms__hero-photo" data-hero-photo data-glare>
<img src="/assets/driftmark-studio/studio-red-saloon.webp" width="1600" height="1360" alt="A red saloon with fresh gloss under the strip lights of a bright detailing studio" fetchpriority="high">
<span class="dms__glare" aria-hidden="true"></span>
<figcaption class="dms__hero-chip"><span>Ceramic coating</span><b>from <span data-hero-price>£749</span></b><small data-hero-size>medium car, 3-year protection</small></figcaption>
</figure>
</section>
<!-- ============ packages with prices by car size ============ -->
<section class="dms__pk" id="dms-packages" aria-labelledby="dms-pk-h">
<div class="dms__pk-side">
<div class="dms__pk-side-in">
<h2 class="dms__h2" id="dms-pk-h">Packages and prices</h2>
<p class="dms__body">Pick your car size and every price on the page updates. Not sure which size? Medium covers most family hatchbacks, saloons and estates.</p>
<fieldset class="dms__sizes" data-sizes>
<legend class="dms__sr">Car size</legend>
<label><input type="radio" name="dms-size" value="small"><span><svg viewBox="0 0 64 28" aria-hidden="true"><path d="M6 21h3m10 0h22m10 0h7V15l-6-2-9-8H22l-9 8-7 2Z" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round"/><circle cx="14" cy="21" r="4.5" fill="none" stroke="currentColor" stroke-width="2.2"/><circle cx="46" cy="21" r="4.5" fill="none" stroke="currentColor" stroke-width="2.2"/></svg><b>Small</b><small>city cars, small hatchbacks</small></span></label>
<label><input type="radio" name="dms-size" value="medium" checked><span><svg viewBox="0 0 64 28" aria-hidden="true"><path d="M3 21h4m10 0h28m10 0h6v-6l-8-2-10-7H22l-10 7-9 2Z" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round"/><circle cx="12" cy="21" r="4.5" fill="none" stroke="currentColor" stroke-width="2.2"/><circle cx="50" cy="21" r="4.5" fill="none" stroke="currentColor" stroke-width="2.2"/></svg><b>Medium</b><small>hatchbacks, saloons, estates</small></span></label>
<label><input type="radio" name="dms-size" value="large"><span><svg viewBox="0 0 64 28" aria-hidden="true"><path d="M3 21h4m11 0h26m11 0h6V12l-6-2-6-6H12L7 10l-4 1Z" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round"/><circle cx="12.5" cy="21" r="5" fill="none" stroke="currentColor" stroke-width="2.2"/><circle cx="49.5" cy="21" r="5" fill="none" stroke="currentColor" stroke-width="2.2"/></svg><b>Large</b><small>SUVs, 7-seaters, big estates</small></span></label>
<label><input type="radio" name="dms-size" value="xl"><span><svg viewBox="0 0 64 28" aria-hidden="true"><path d="M2 21h4m11 0h28m11 0h6V9l-5-1-4-5H2Z" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round"/><circle cx="11.5" cy="21" r="5" fill="none" stroke="currentColor" stroke-width="2.2"/><circle cx="50.5" cy="21" r="5" fill="none" stroke="currentColor" stroke-width="2.2"/></svg><b>Extra large</b><small>vans, pick-ups, long-wheelbase</small></span></label>
</fieldset>
<figure class="dms__pk-preview" aria-hidden="true" data-pk-preview>
<img src="/assets/driftmark-studio/two-bucket-wash.webp" width="1600" height="1066" alt="" loading="lazy" data-pk-img style="object-position:60% 50%">
<span class="dms__glare"></span>
</figure>
</div>
</div>
<ul class="dms__pk-list" data-pk>
<li class="dms__pkg" data-pkg="wash" data-img="two-bucket-wash" data-pos="60% 50%">
<button class="dms__pkg-hit" type="button" aria-expanded="false" aria-controls="dms-pk-wash">
<span class="dms__pkg-main"><span class="dms__pkg-name">Wash & protect</span><span class="dms__pkg-desc">Snow foam, two-bucket hand wash, wheels, dried and sealed.</span></span>
<span class="dms__pkg-time">1½–2 hours</span>
<span class="dms__pkg-price"><b data-price="45,50,60,70">£50</b><small>mobile or studio</small></span>
<i class="dms__plus" aria-hidden="true"></i>
</button>
<div class="dms__pkg-more" id="dms-pk-wash" hidden><div class="dms__pkg-more-in">
<ul class="dms__ticks"><li>pH-neutral snow foam, then a two-bucket hand wash with grit guards</li><li>Wheels, tyres and arches with their own brushes and buckets</li><li>Filtered-water rinse, towel and air dried, no drips from mirrors</li><li>Spray sealant on the paint, tyres dressed, glass inside and out</li></ul>
<a class="dms__btn dms__btn--sm" href="#dms-book" data-book="wash">Book Wash & protect</a>
</div></div>
</li>
<li class="dms__pkg" data-pkg="valet" data-img="interior-detail" data-pos="45% 50%">
<button class="dms__pkg-hit" type="button" aria-expanded="false" aria-controls="dms-pk-valet">
<span class="dms__pkg-main"><span class="dms__pkg-name">Full valet</span><span class="dms__pkg-desc">Everything in Wash & protect, plus a full interior clean.</span></span>
<span class="dms__pkg-time">4–5 hours</span>
<span class="dms__pkg-price"><b data-price="129,149,169,189">£149</b><small>mobile or studio</small></span>
<i class="dms__plus" aria-hidden="true"></i>
</button>
<div class="dms__pkg-more" id="dms-pk-valet" hidden><div class="dms__pkg-more-in">
<ul class="dms__ticks"><li>The full exterior wash and sealant from Wash & protect</li><li>Seats, carpets, boot and mats vacuumed, mats washed</li><li>Dash, doors and console cleaned and dressed matte, never greasy</li><li>Door shuts cleaned, interior glass and mirrors streak-free</li></ul>
<a class="dms__btn dms__btn--sm" href="#dms-book" data-book="valet">Book a full valet</a>
</div></div>
</li>
<li class="dms__pkg" data-pkg="interior" data-img="interior-seats" data-pos="40% 60%">
<button class="dms__pkg-hit" type="button" aria-expanded="false" aria-controls="dms-pk-interior">
<span class="dms__pkg-main"><span class="dms__pkg-name">Interior deep clean</span><span class="dms__pkg-desc">For spills, dog hair, smells and the family car after summer.</span></span>
<span class="dms__pkg-time">5–6 hours</span>
<span class="dms__pkg-price"><b data-price="149,169,199,229">£169</b><small>mobile or studio</small></span>
<i class="dms__plus" aria-hidden="true"></i>
</button>
<div class="dms__pkg-more" id="dms-pk-interior" hidden><div class="dms__pkg-more-in">
<ul class="dms__ticks"><li>Hot-water extraction on fabric seats, carpets and mats</li><li>Leather cleaned with a soft brush, then conditioned</li><li>Steam on vents, cup holders, seat rails and door pockets</li><li>Headlining spot-cleaned and an odour treatment; dry in 3–4 hours</li></ul>
<a class="dms__btn dms__btn--sm" href="#dms-book" data-book="interior">Book an interior clean</a>
</div></div>
</li>
<li class="dms__pkg" data-pkg="correction" data-img="machine-polish" data-pos="40% 40%">
<button class="dms__pkg-hit" type="button" aria-expanded="false" aria-controls="dms-pk-correction">
<span class="dms__pkg-main"><span class="dms__pkg-name">Paint correction</span><span class="dms__pkg-desc">Machine polishing that removes swirls, haze and light marring.</span></span>
<span class="dms__pkg-time">1–2 days</span>
<span class="dms__pkg-price"><b data-price="349,399,449,549">£399</b><small>studio only</small></span>
<i class="dms__plus" aria-hidden="true"></i>
</button>
<div class="dms__pkg-more" id="dms-pk-correction" hidden><div class="dms__pkg-more-in">
<ul class="dms__ticks"><li>Full decontamination: iron fallout remover, tar remover, clay bar</li><li>Paint depth readings on every panel before and after</li><li>One-stage polish removes around 60–80% of swirls; two-stage (+£250) takes 85–95%</li><li>Finished with a 6-month sealant; upgrade to ceramic at the same visit</li></ul>
<a class="dms__btn dms__btn--sm" href="#dms-book" data-book="correction">Book paint correction</a>
</div></div>
</li>
<li class="dms__pkg" data-pkg="ceramic" data-img="ceramic-beading" data-pos="50% 50%">
<button class="dms__pkg-hit" type="button" aria-expanded="false" aria-controls="dms-pk-ceramic">
<span class="dms__pkg-main"><span class="dms__pkg-name">Ceramic coating</span><span class="dms__pkg-desc">Our most-booked package for new and nearly-new cars.</span></span>
<span class="dms__pkg-time">2 days</span>
<span class="dms__pkg-price"><b data-price="649,749,849,999">£749</b><small>studio only</small></span>
<i class="dms__plus" aria-hidden="true"></i>
</button>
<div class="dms__pkg-more" id="dms-pk-ceramic" hidden><div class="dms__pkg-more-in">
<ul class="dms__ticks"><li>Decontamination and a one-stage polish, so the coating locks in gloss, not swirls</li><li>Panel wipe, then a 3-year coating on paint, gloss trims and wheel faces</li><li>Cured overnight under infrared lamps; collect after 4:30pm on day two</li><li>Aftercare kit and a free check-up wash at six weeks. 5-year coating: +£200</li></ul>
<a class="dms__btn dms__btn--sm" href="#dms-book" data-book="ceramic">Book ceramic coating</a>
</div></div>
</li>
</ul>
</section>
<!-- ============ signature: before / after paint correction slider ============ -->
<section class="dms__fix" id="dms-correction" aria-labelledby="dms-fix-h">
<div class="dms__fix-in">
<div class="dms__fix-copy" data-reveal>
<h2 class="dms__h2" id="dms-fix-h">Swirls out, gloss back</h2>
<p class="dms__body">Those spider-web rings you see around a street light are thousands of fine scratches from brush washes and dry towels. A machine polish levels the clear coat until the light reflects clean again.</p>
<p class="dms__fix-hint"><span aria-hidden="true">← →</span> Drag the light bar, or focus it and use the arrow keys.</p>
</div>
<div class="dms__ba" data-ba style="--split: 50%">
<img class="dms__ba-img" src="/assets/driftmark-studio/paint-after.webp" width="1600" height="1066" alt="After: deep brown metallic bonnet with crisp, clean light reflections" loading="lazy" draggable="false">
<div class="dms__ba-before" data-ba-before>
<img class="dms__ba-img" src="/assets/driftmark-studio/paint-before.webp" width="1600" height="1066" alt="Before: the same bonnet covered in circular swirl marks and haze around each reflection" loading="lazy" draggable="false">
</div>
<span class="dms__ba-tag dms__ba-tag--b" data-ba-tb>Before</span>
<span class="dms__ba-tag dms__ba-tag--a" data-ba-ta>After</span>
<div class="dms__ba-handle" data-ba-handle role="slider" tabindex="0" aria-label="Before and after comparison, share of the photo showing before" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" aria-valuetext="50% before">
<i class="dms__ba-bar" aria-hidden="true"></i>
<span class="dms__ba-grip" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M9 6 3 12l6 6M15 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
</div>
</div>
<dl class="dms__fix-facts" data-reveal>
<div><dt>Gloss at 60°</dt><dd><span data-count="58">58</span> → <span data-count="89">89</span> <small>GU</small></dd></div>
<div><dt>Clear coat removed</dt><dd><span data-count="3">3</span> <small>µm of 118</small></dd></div>
<div><dt>Time on this bonnet</dt><dd><span data-count="2.5" data-dec="1">2.5</span> <small>hours, two-stage</small></dd></div>
<div class="dms__fix-cta"><a class="dms__btn" href="#dms-book" data-book="correction" data-magnetic>Book paint correction</a></div>
</dl>
<p class="dms__fine dms__fix-note">Demo pair: the swirls were added to one photograph to show the effect. Replace both with your own before and after.</p>
</div>
</section>
<!-- ============ process (pinned horizontal track on desktop) ============ -->
<section class="dms__proc" aria-labelledby="dms-proc-h" data-proc>
<div class="dms__proc-head">
<h2 class="dms__h2" id="dms-proc-h">How a detail runs</h2>
<p class="dms__body">The order matters more than the products. Grit comes off before anything touches the paint, and nothing gets polished until it is properly clean.</p>
<div class="dms__proc-bar" aria-hidden="true"><i data-proc-bar></i></div>
</div>
<div class="dms__proc-view" data-proc-view>
<ol class="dms__proc-track" data-proc-track>
<li class="dms__step">
<div class="dms__step-vis dms__step-vis--card" aria-hidden="true">
<svg class="dms__readings" viewBox="0 0 320 300" data-readings>
<rect x="96" y="22" width="128" height="256" rx="46" fill="var(--surface)" stroke="var(--text)" stroke-width="2.5"/>
<path d="M112 92h96M108 146h104M112 200h96" stroke="var(--text)" stroke-width="1.6" opacity=".35"/>
<rect x="116" y="96" width="88" height="46" rx="10" fill="var(--field)"/>
<rect x="116" y="150" width="88" height="46" rx="10" fill="var(--field)"/>
<g class="dms__rd" font-size="15" text-anchor="middle">
<g><circle cx="160" cy="56" r="4" fill="var(--knob-accent)"/><text x="160" y="80">121µm</text></g>
<g><circle cx="60" cy="98" r="4" fill="var(--knob-accent)"/><path d="M64 98h30" stroke="var(--text)" opacity=".4"/><text x="44" y="122">116</text></g>
<g><circle cx="260" cy="98" r="4" fill="var(--knob-accent)"/><path d="M226 98h30" stroke="var(--text)" opacity=".4"/><text x="276" y="122">118</text></g>
<g><circle cx="60" cy="210" r="4" fill="var(--knob-accent)"/><path d="M64 210h30" stroke="var(--text)" opacity=".4"/><text x="44" y="234">112</text></g>
<g class="dms__rd-low"><circle cx="260" cy="210" r="4" fill="var(--knob-accent)"/><path d="M226 210h30" stroke="var(--text)" opacity=".4"/><text x="276" y="234">94</text></g>
<g><circle cx="160" cy="250" r="4" fill="var(--knob-accent)"/><text x="160" y="240">119µm</text></g>
</g>
</svg>
<p class="dms__readings-note">Rear-right door reads 94µm: likely resprayed, polished lightly.</p>
</div>
<div class="dms__step-txt"><span class="dms__step-time">20 minutes</span><h3>Inspect and measure</h3><p>A walk-round under LED inspection lights, a paint depth reading on every panel and photos of anything we find. You see the numbers before we start.</p></div>
</li>
<li class="dms__step">
<div class="dms__step-vis"><img src="/assets/driftmark-studio/snow-foam.webp" width="1200" height="1560" alt="A white hatchback covered in thick snow foam" loading="lazy" style="object-position:50% 45%"></div>
<div class="dms__step-txt"><span class="dms__step-time">10 minutes</span><h3>Snow foam pre-wash</h3><p>pH-neutral foam dwells for five minutes and carries grit off before a wash mitt touches the paint.</p></div>
</li>
<li class="dms__step">
<div class="dms__step-vis"><img src="/assets/driftmark-studio/two-bucket-wash.webp" width="1600" height="1066" alt="A gloved hand washing a door panel with a soapy wash pad" loading="lazy" style="object-position:62% 50%"></div>
<div class="dms__step-txt"><span class="dms__step-time">30 minutes</span><h3>Two-bucket hand wash</h3><p>Grit guards in both buckets, a clean mitt for each section, and separate brushes for wheels and arches.</p></div>
</li>
<li class="dms__step">
<div class="dms__step-vis"><img src="/assets/driftmark-studio/wheel-clean.webp" width="1200" height="1500" alt="A long brush cleaning foam out of a red alloy wheel" loading="lazy" style="object-position:50% 40%"></div>
<div class="dms__step-txt"><span class="dms__step-time">40 minutes</span><h3>Decontaminate</h3><p>Iron fallout remover (it bleeds purple as it works), tar remover, then a clay bar until the paint feels like glass.</p></div>
</li>
<li class="dms__step">
<div class="dms__step-vis"><img src="/assets/driftmark-studio/machine-polish.webp" width="1400" height="1400" alt="A dual-action polisher on black paint, with the trim edge masked in green tape" loading="lazy" style="object-position:50% 30%"></div>
<div class="dms__step-txt"><span class="dms__step-time">4–10 hours</span><h3>Machine polish</h3><p>Trims masked, compound then finishing polish with a dual-action machine. Readings are re-taken so no panel goes thin.</p></div>
</li>
<li class="dms__step">
<div class="dms__step-vis"><img src="/assets/driftmark-studio/ceramic-beading.webp" width="1600" height="614" alt="Tight water beads sitting on freshly coated blue paint" loading="lazy"></div>
<div class="dms__step-txt"><span class="dms__step-time">Overnight</span><h3>Coat, cure, hand over</h3><p>Coating levelled panel by panel, cured under infrared lamps, then a walk-round with you and an aftercare kit to take home.</p></div>
</li>
</ol>
</div>
</section>
<!-- ============ gallery (filterable) ============ -->
<section class="dms__gal" id="dms-gallery" aria-labelledby="dms-gal-h">
<div class="dms__gal-head" data-reveal>
<h2 class="dms__h2" id="dms-gal-h">From the studio this month</h2>
<div class="dms__chips" role="group" aria-label="Filter the gallery" data-filters>
<button type="button" aria-pressed="true" data-f="all">All</button>
<button type="button" aria-pressed="false" data-f="exterior">Exterior</button>
<button type="button" aria-pressed="false" data-f="interior">Interior</button>
<button type="button" aria-pressed="false" data-f="wheels">Wheels</button>
<button type="button" aria-pressed="false" data-f="paint">Correction & coating</button>
</div>
</div>
<ul class="dms__gal-grid" data-gallery>
<li class="dms__tile dms__tile--wide" data-cat="exterior" data-glare><img src="/assets/driftmark-studio/studio-red-saloon.webp" width="1600" height="1360" alt="" loading="lazy" style="object-position:50% 35%"><span class="dms__glare" aria-hidden="true"></span><p>Red saloon after a valet and sealant, under the studio lights</p></li>
<li class="dms__tile dms__tile--tall" data-cat="exterior" data-glare><img src="/assets/driftmark-studio/snow-foam.webp" width="1200" height="1560" alt="" loading="lazy"><span class="dms__glare" aria-hidden="true"></span><p>Snow foam dwelling on a white hatchback</p></li>
<li class="dms__tile" data-cat="paint" data-glare><img src="/assets/driftmark-studio/polishing-bonnet.webp" width="1600" height="642" alt="" loading="lazy" style="object-position:30% 50%"><span class="dms__glare" aria-hidden="true"></span><p>Polishing a bonnet, panel by panel</p></li>
<li class="dms__tile dms__tile--tall" data-cat="interior" data-glare><img src="/assets/driftmark-studio/interior-seats.webp" width="1200" height="1500" alt="" loading="lazy"><span class="dms__glare" aria-hidden="true"></span><p>Cream leather, cleaned and conditioned</p></li>
<li class="dms__tile" data-cat="wheels" data-glare><img src="/assets/driftmark-studio/wheel-clean.webp" width="1200" height="1500" alt="" loading="lazy" style="object-position:50% 45%"><span class="dms__glare" aria-hidden="true"></span><p>Wheel barrels cleaned with their own brush</p></li>
<li class="dms__tile dms__tile--wide" data-cat="interior" data-glare><img src="/assets/driftmark-studio/interior-detail.webp" width="1600" height="899" alt="" loading="lazy" style="object-position:40% 50%"><span class="dms__glare" aria-hidden="true"></span><p>Detail brush along the dashboard seams</p></li>
<li class="dms__tile" data-cat="paint" data-glare><img src="/assets/driftmark-studio/paint-after.webp" width="1600" height="1066" alt="" loading="lazy" style="object-position:30% 60%"><span class="dms__glare" aria-hidden="true"></span><p>Brown metallic after a two-stage correction</p></li>
<li class="dms__tile" data-cat="paint" data-glare><img src="/assets/driftmark-studio/ceramic-beading.webp" width="1600" height="614" alt="" loading="lazy"><span class="dms__glare" aria-hidden="true"></span><p>Water beading on a fresh ceramic coating</p></li>
</ul>
</section>
<!-- ============ reviews (sample) ============ -->
<section class="dms__rev" id="dms-reviews" aria-labelledby="dms-rev-h">
<div class="dms__rev-in">
<div class="dms__rev-lead" data-reveal>
<h2 class="dms__h2" id="dms-rev-h">What owners say</h2>
<p class="dms__rev-score"><span class="dms__stars" aria-hidden="true">★★★★★</span> <b>4.9</b> from 386 reviews</p>
<p class="dms__rev-tag">Sample reviews, template content. Replace with your own.</p>
<blockquote class="dms__rev-big">“Two-stage correction on my eleven-year-old black hatchback. The swirls I’d stared at for years are gone, and they showed me the paint readings before and after.”<cite><b>Rob, Headingley</b><span>Paint correction</span></cite></blockquote>
</div>
<ul class="dms__rev-list">
<li data-reveal><blockquote>“They came to the office car park with their own water and power. Car was done by lunch.”</blockquote><p><b>Aisha, Leeds Dock</b><span>Full valet, mobile</span></p></li>
<li data-reveal><blockquote>“Dog hair in every seam of the boot. Not any more.”</blockquote><p><b>Mark, Horsforth</b><span>Interior deep clean</span></p></li>
<li data-reveal><blockquote>“Coated last spring. A foam and rinse every fortnight and it still beads like the first week.”</blockquote><p><b>Kirsty, Wetherby</b><span>Ceramic coating</span></p></li>
<li data-reveal><blockquote>“They told me one door had been resprayed and was too thin to polish hard, so they went gently there. Honest.”</blockquote><p><b>Daniel, Morley</b><span>Paint correction</span></p></li>
</ul>
</div>
</section>
<!-- ============ service area + hours ============ -->
<section class="dms__area" id="dms-area" aria-labelledby="dms-area-h">
<div class="dms__area-in">
<div class="dms__area-map" data-reveal>
<svg class="dms__map" viewBox="0 0 640 560" role="img" aria-labelledby="dms-map-t" data-map>
<title id="dms-map-t">Illustrative map: the Holbeck studio, a free mobile zone within 8 miles and a £15 zone up to 15 miles, covering Leeds, Bradford, Wakefield, Wetherby, Otley and Harrogate</title>
<rect width="640" height="560" fill="var(--map-ground)"/>
<circle class="dms__ring dms__ring--2" cx="320" cy="300" r="225" fill="var(--map-z2)" data-ring="2"/>
<circle class="dms__ring dms__ring--1" cx="320" cy="300" r="120" fill="var(--map-z1)" data-ring="1"/>
<path class="dms__river" d="M-10 318C80 300 150 330 230 306S300 290 336 296 420 330 500 322 600 300 660 312" fill="none" stroke="var(--map-water)" stroke-width="7" stroke-linecap="round"/>
<g class="dms__roads" fill="none" stroke="var(--map-road)" stroke-linecap="round" stroke-linejoin="round">
<path d="M-10 392C120 380 220 362 282 352S420 330 520 336 620 344 660 340" stroke-width="7" pathLength="1"/>
<path d="M334 292C360 330 400 380 452 430S560 520 600 570" stroke-width="7" pathLength="1"/>
<path d="M484 -10C478 100 474 200 470 300S466 460 462 570" stroke-width="6" pathLength="1"/>
<path d="M322 302C310 320 296 338 282 352" stroke-width="5" pathLength="1"/>
</g>
<g class="dms__road-l"><text x="58" y="378">M62</text><text x="536" y="512">M1</text><text x="494" y="80">A1(M)</text></g>
<g class="dms__towns">
<g data-town="leeds"><circle cx="340" cy="282" r="5"/><text x="350" y="276">Leeds</text></g>
<g data-town="bradford"><circle cx="190" cy="296" r="5"/><text x="180" y="288" text-anchor="end">Bradford</text></g>
<g data-town="wakefield"><circle cx="350" cy="433" r="5"/><text x="360" y="449">Wakefield</text></g>
<g data-town="harrogate"><circle cx="336" cy="90" r="5"/><text x="346" y="86">Harrogate</text></g>
<g data-town="wetherby"><circle cx="461" cy="190" r="5"/><text x="451" y="180" text-anchor="end">Wetherby</text></g>
<g data-town="otley"><circle cx="233" cy="163" r="5"/><text x="243" y="158">Otley</text></g>
<g data-town="horsforth"><circle cx="269" cy="225" r="4"/><text x="259" y="220" text-anchor="end">Horsforth</text></g>
<g data-town="morley"><circle cx="282" cy="351" r="4"/><text x="272" y="370" text-anchor="end">Morley</text></g>
<g data-town="garforth"><circle cx="431" cy="307" r="4"/><text x="441" y="300">Garforth</text></g>
<g data-town="castleford"><circle cx="461" cy="390" r="4"/><text x="471" y="394">Castleford</text></g>
<g data-town="dewsbury"><circle cx="248" cy="406" r="4"/><text x="238" y="422" text-anchor="end">Dewsbury</text></g>
<g data-town="ilkley" class="is-out"><circle cx="174" cy="123" r="4"/><text x="164" y="116" text-anchor="end">Ilkley</text></g>
</g>
<g transform="translate(320 300)"><g class="dms__pin" data-pin>
<circle r="26" fill="var(--knob-accent)" opacity=".18"/>
<path d="M0 0c-8-10-14-16-14-24a14 14 0 0 1 28 0C14-16 8-10 0 0Z" fill="var(--knob-accent)"/>
<circle cy="-24" r="5" fill="var(--on-acc)"/>
</g></g>
<g class="dms__map-key">
<rect x="16" y="482" width="270" height="62" rx="8" fill="var(--surface)"/>
<circle cx="36" cy="501" r="7" fill="var(--map-z1)" stroke="var(--text)" stroke-opacity=".25"/><text x="50" y="506">Within 8 miles: no call-out fee</text>
<circle cx="36" cy="525" r="7" fill="var(--map-z2)" stroke="var(--text)" stroke-opacity=".25"/><text x="50" y="530">8 to 15 miles: £15 call-out</text>
</g>
<text class="dms__map-scale" x="624" y="548" text-anchor="end">Illustrative, not to scale</text>
</svg>
</div>
<div class="dms__area-info" data-reveal>
<h2 class="dms__h2" id="dms-area-h">Studio in Holbeck, van across West Yorkshire</h2>
<p class="dms__body">The mobile van carries 400 litres of filtered water and a quiet generator, so we need nothing from you but space to open the doors. Paint correction and ceramic coatings stay in the studio, where the lighting, dust and curing are under control.</p>
<form class="dms__pc" data-pc novalidate onsubmit="return false">
<label for="dms-pc-in">Check your postcode</label>
<div class="dms__pc-row">
<input id="dms-pc-in" name="postcode" autocomplete="postal-code" placeholder="e.g. LS6 or WF1 2AB" aria-describedby="dms-pc-out" data-pc-in>
<button class="dms__btn" type="submit">Check</button>
</div>
<p class="dms__pc-out" id="dms-pc-out" aria-live="polite" data-pc-out>Free mobile visits across most of Leeds. Try your postcode.</p>
</form>
<div class="dms__visit">
<address class="dms__addr"><b>Driftmark Detailing</b><br>Unit 6, Brindle Yard, Holbeck<br>Leeds LS11 9QP<br><span class="dms__muted">Two minutes from M621 junction 3</span></address>
<table class="dms__hours">
<caption class="dms__sr">Opening hours</caption>
<tbody data-hours>
<tr data-day="1"><th scope="row">Monday</th><td>8:00am – 6:00pm</td></tr>
<tr data-day="2"><th scope="row">Tuesday</th><td>8:00am – 6:00pm</td></tr>
<tr data-day="3"><th scope="row">Wednesday</th><td>8:00am – 6:00pm</td></tr>
<tr data-day="4"><th scope="row">Thursday</th><td>8:00am – 6:00pm</td></tr>
<tr data-day="5"><th scope="row">Friday</th><td>8:00am – 6:00pm</td></tr>
<tr data-day="6"><th scope="row">Saturday</th><td>8:30am – 4:00pm</td></tr>
<tr data-day="0"><th scope="row">Sunday</th><td>Closed</td></tr>
</tbody>
</table>
<p class="dms__small">Studio drop-off from 8:00am. Two-day jobs are ready after 4:30pm on day two.</p>
</div>
</div>
</div>
</section>
<!-- ============ FAQ ============ -->
<section class="dms__faq" aria-labelledby="dms-faq-h">
<div class="dms__faq-in">
<h2 class="dms__h2" id="dms-faq-h" data-reveal>Before you book</h2>
<div class="dms__faq-list" data-faq>
<div class="dms__qa" data-reveal><h3><button type="button" aria-expanded="false" aria-controls="dms-a1" id="dms-q1">What’s the difference between a valet and a detail?<i aria-hidden="true"></i></button></h3><div class="dms__a" id="dms-a1" role="region" aria-labelledby="dms-q1" hidden><p>A valet cleans the car inside and out. A detail goes further: decontaminating the paint, correcting it with a machine polish and protecting it with a sealant or coating. Most cars on a regular plan get a Wash & protect every two to four weeks and a full detail once a year.</p></div></div>
<div class="dms__qa" data-reveal><h3><button type="button" aria-expanded="false" aria-controls="dms-a2" id="dms-q2">Will paint correction remove every scratch?<i aria-hidden="true"></i></button></h3><div class="dms__a" id="dms-a2" role="region" aria-labelledby="dms-q2" hidden><p>No honest detailer will promise that. Swirls, holograms, haze and light marring come out. A scratch that catches your fingernail has usually gone through the clear coat and needs a paint repair. We measure paint depth first and tell you what is safe to remove.</p></div></div>
<div class="dms__qa" data-reveal><h3><button type="button" aria-expanded="false" aria-controls="dms-a3" id="dms-q3">How long does a ceramic coating last, and how do I wash it?<i aria-hidden="true"></i></button></h3><div class="dms__a" id="dms-a3" role="region" aria-labelledby="dms-q3" hidden><p>Our standard coating is rated for three years, the upgrade for five, as long as it is washed properly. Leave it seven days before the first wash, then use a pH-neutral shampoo and the two-bucket method. Avoid brush car washes. A yearly inspection (£79) keeps the guarantee valid.</p></div></div>
<div class="dms__qa" data-reveal><h3><button type="button" aria-expanded="false" aria-controls="dms-a4" id="dms-q4">Can you do paint correction or ceramic at my house?<i aria-hidden="true"></i></button></h3><div class="dms__a" id="dms-a4" role="region" aria-labelledby="dms-q4" hidden><p>No. Correction needs strong inspection lighting and a dust-free bay, and a coating has to cure overnight away from rain and pollen. Washes, valets and interior cleans can all be done at your home or workplace.</p></div></div>
<div class="dms__qa" data-reveal><h3><button type="button" aria-expanded="false" aria-controls="dms-a5" id="dms-q5">Do you need my water or electricity?<i aria-hidden="true"></i></button></h3><div class="dms__a" id="dms-a5" role="region" aria-labelledby="dms-q5" hidden><p>No. The van carries 400 litres of filtered water and a quiet generator. We just need somewhere we can park legally and open all the doors.</p></div></div>
<div class="dms__qa" data-reveal><h3><button type="button" aria-expanded="false" aria-controls="dms-a6" id="dms-q6">How do I pay, and what if I need to cancel?<i aria-hidden="true"></i></button></h3><div class="dms__a" id="dms-a6" role="region" aria-labelledby="dms-q6" hidden><p>Pay by card or bank transfer when you collect, or when we finish a mobile job. Paint correction and ceramic days take a £50 deposit, refunded in full if you cancel more than 48 hours ahead.</p></div></div>
</div>
</div>
</section>
<!-- ============ booking: package builder + date and slot ============ -->
<section class="dms__book" id="dms-book" aria-labelledby="dms-book-h">
<div class="dms__book-in">
<div class="dms__book-head" data-reveal>
<h2 class="dms__h2" id="dms-book-h">Book a detail</h2>
<p class="dms__body">Build your booking and see the price as you go. We confirm by text within two working hours. Prefer to talk? Call <a href="tel:+441134960274">0113 496 0274</a>.</p>
</div>
<form class="dms__form" data-form novalidate>
<div class="dms__form-main">
<fieldset class="dms__fs">
<legend>Car size</legend>
<div class="dms__seg dms__seg--4" data-f-size>
<label><input type="radio" name="size" value="small"><span>Small</span></label>
<label><input type="radio" name="size" value="medium" checked><span>Medium</span></label>
<label><input type="radio" name="size" value="large"><span>Large</span></label>
<label><input type="radio" name="size" value="xl"><span>Extra large</span></label>
</div>
</fieldset>
<fieldset class="dms__fs" aria-describedby="dms-e-pkg">
<legend>Package</legend>
<div class="dms__opts" data-f-pkg>
<label><input type="radio" name="pkg" value="wash"><span><b>Wash & protect</b><em data-opt-price="wash">£50</em></span></label>
<label><input type="radio" name="pkg" value="valet" checked><span><b>Full valet</b><em data-opt-price="valet">£149</em></span></label>
<label><input type="radio" name="pkg" value="interior"><span><b>Interior deep clean</b><em data-opt-price="interior">£169</em></span></label>
<label><input type="radio" name="pkg" value="correction"><span><b>Paint correction</b><em data-opt-price="correction">£399</em></span></label>
<label><input type="radio" name="pkg" value="ceramic"><span><b>Ceramic coating</b><em data-opt-price="ceramic">£749</em></span></label>
</div>
<p class="dms__err" id="dms-e-pkg" data-err></p>
</fieldset>
<fieldset class="dms__fs">
<legend>Add-ons <span class="dms__muted">(optional)</span></legend>
<div class="dms__adds" data-f-adds>
<label><input type="checkbox" name="add" value="engine" data-p="35"><span>Engine bay clean<em>+£35</em></span></label>
<label><input type="checkbox" name="add" value="headlights" data-p="60"><span>Headlight restoration, pair<em>+£60</em></span></label>
<label><input type="checkbox" name="add" value="pet" data-p="30"><span>Pet hair removal<em>+£30</em></span></label>
<label><input type="checkbox" name="add" value="leather" data-p="40"><span>Leather clean & condition<em>+£40</em></span></label>
<label><input type="checkbox" name="add" value="glass" data-p="45"><span>Windscreen glass coating<em>+£45</em></span></label>
<label><input type="checkbox" name="add" value="wheels" data-p="180" data-studio><span>Wheels-off ceramic, set of four<em>+£180</em></span></label>
</div>
</fieldset>
<fieldset class="dms__fs">
<legend>Where</legend>
<div class="dms__seg" data-f-where>
<label><input type="radio" name="where" value="studio" checked><span>Studio drop-off, Holbeck</span></label>
<label><input type="radio" name="where" value="mobile"><span>Mobile, at your address</span></label>
</div>
<p class="dms__note" data-where-note>Paint correction and ceramic coating are studio only.</p>
<div class="dms__field dms__field--pc" data-f-pcwrap hidden>
<label for="dms-f-pc">Postcode for the visit</label>
<input id="dms-f-pc" name="pc" autocomplete="postal-code" aria-describedby="dms-e-pc dms-f-pc-hint">
<p class="dms__hint" id="dms-f-pc-hint" data-f-pc-hint>Free within 8 miles, £15 call-out up to 15 miles.</p>
<p class="dms__err" id="dms-e-pc" data-err></p>
</div>
</fieldset>
<fieldset class="dms__fs" aria-describedby="dms-e-when">
<legend>Date and time</legend>
<div class="dms__when">
<div class="dms__field"><label for="dms-f-date">Date</label><input id="dms-f-date" name="date" type="date" required data-f-date></div>
<div class="dms__slots" role="radiogroup" aria-label="Time slot" data-f-slots></div>
</div>
<p class="dms__err" id="dms-e-when" data-err></p>
</fieldset>
<fieldset class="dms__fs">
<legend>Your details</legend>
<div class="dms__field-row">
<div class="dms__field"><label for="dms-f-name">Full name</label><input id="dms-f-name" name="name" autocomplete="name" required aria-describedby="dms-e-name"><p class="dms__err" id="dms-e-name" data-err></p></div>
<div class="dms__field"><label for="dms-f-phone">Mobile number</label><input id="dms-f-phone" name="phone" type="tel" inputmode="tel" autocomplete="tel" required aria-describedby="dms-e-phone"><p class="dms__err" id="dms-e-phone" data-err></p></div>
</div>
<div class="dms__field-row">
<div class="dms__field"><label for="dms-f-email">Email</label><input id="dms-f-email" name="email" type="email" autocomplete="email" required aria-describedby="dms-e-email"><p class="dms__err" id="dms-e-email" data-err></p></div>
<div class="dms__field"><label for="dms-f-car">Car make, model and colour</label><input id="dms-f-car" name="car" placeholder="e.g. grey estate, 2019" required aria-describedby="dms-e-car"><p class="dms__err" id="dms-e-car" data-err></p></div>
</div>
<div class="dms__field"><label for="dms-f-notes">Anything we should know? <span class="dms__muted">(optional)</span></label><textarea id="dms-f-notes" name="notes" rows="3" placeholder="Stains, a dent, a dog that sheds, parking at the address"></textarea></div>
</fieldset>
</div>
<aside class="dms__sum" aria-labelledby="dms-sum-h" data-sum>
<div class="dms__sum-in">
<p class="dms__sum-h" id="dms-sum-h">Your booking</p>
<ul class="dms__sum-rows" data-sum-rows></ul>
<p class="dms__sum-total"><span>Total</span><b data-sum-total>£149</b></p>
<p class="dms__sum-meta" data-sum-meta>Medium car, 4–5 hours, studio drop-off</p>
<p class="dms__sum-dep" data-sum-dep>Pay on collection. No deposit.</p>
<button class="dms__btn dms__btn--lg dms__btn--block" type="submit" data-magnetic>Request booking</button>
<p class="dms__small">This demo form sends nothing.</p>
</div>
</aside>
</form>
<div class="dms__done" data-done hidden tabindex="-1">
<svg viewBox="0 0 56 56" aria-hidden="true"><circle cx="28" cy="28" r="26" fill="var(--knob-accent)"/><path d="m17 29 7.5 7.5L39 21" fill="none" stroke="var(--on-acc)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" data-tick pathLength="1"/></svg>
<h3 data-done-h>Thanks, request received.</h3>
<p data-done-p>We’ll text you within two working hours to confirm.</p>
<dl class="dms__done-sum" data-done-sum></dl>
<button class="dms__textlink" type="button" data-again>Make another booking</button>
</div>
</div>
</section>
</main>
<!-- ============ footer ============ -->
<footer class="dms__foot">
<div class="dms__foot-in">
<div class="dms__foot-brand">
<p class="dms__foot-word">Driftmark</p>
<p>Car detailing and ceramic coating in Leeds.</p>
<p>Unit 6, Brindle Yard, Holbeck, Leeds LS11 9QP</p>
<p><a href="tel:+441134960274">0113 496 0274</a><br><a href="mailto:[email protected]">[email protected]</a></p>
</div>
<div class="dms__foot-col">
<p class="dms__foot-h">Services</p>
<ul><li><a href="#dms-packages">Wash & protect</a></li><li><a href="#dms-packages">Full valet</a></li><li><a href="#dms-packages">Interior deep clean</a></li><li><a href="#dms-correction">Paint correction</a></li><li><a href="#dms-packages">Ceramic coating</a></li></ul>
</div>
<div class="dms__foot-col">
<p class="dms__foot-h">Opening hours</p>
<p>Mon–Fri 8:00am – 6:00pm<br>Sat 8:30am – 4:00pm<br>Sun closed</p>
</div>
<div class="dms__foot-col">
<p class="dms__foot-h">Mobile area</p>
<p>Leeds, Bradford, Wakefield, Wetherby, Otley, Harrogate and everywhere in between.</p>
</div>
</div>
<div class="dms__foot-legal">
<p>Fully insured for vehicles in our care (sample wording). Prices include VAT.</p>
<p>Fictional brand — template content.</p>
</div>
</footer>
<!-- ============ mobile sticky bar ============ -->
<div class="dms__bar" data-bar>
<a class="dms__bar-call" href="tel:+441134960274"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M6.2 2.8 8 6.6 6.5 8.1a10 10 0 0 0 5.4 5.4l1.5-1.5 3.8 1.8-.6 3a1.5 1.5 0 0 1-1.6 1.2A15 15 0 0 1 1.5 4a1.5 1.5 0 0 1 1.2-1.6Z" fill="currentColor"/></svg>Call</a>
<a class="dms__bar-book" href="#dms-book">Book a detail <span data-bar-price>from £50</span></a>
</div>
</div>
@import url("/fonts/lib/hubot-sans.css");
@import url("/fonts/lib/instrument-sans.css");
/* ================================================================ Driftmark Detailing — car detailing & ceramic coating, Leeds
Palettes live on .dms[data-palette] and redefine the shared tokens inside the template only:
studio (default: cool paint-shop white + foam blue), foam (aqua + pale apricot), lime (warm grey + chartreuse), night (optional dark).
Signature motif: an inspection-light glare that sweeps across photos, and an LED light bar that drives the before/after slider. */
.dms, .dms[data-palette="studio"] {
--bg: #f3f5f7; --surface: #ffffff; --text: #0f1720; --muted: #4a5562; --line: rgba(15, 23, 32, .12);
--field: #d9e9f4; --field-2: #fbe4dc; --field-ink: #0f1720;
--deep: #0f1720; --on-deep: #f3f5f7;
--map-ground: #eef2f5; --map-z1: #cfe3f1; --map-z2: #e3edf5; --map-road: #ffffff; --map-water: #a9cfe8;
color-scheme: light;
}
.dms[data-palette="foam"] {
--bg: #f1f7f6; --surface: #ffffff; --text: #0d1e1f; --muted: #47595a; --line: rgba(13, 30, 31, .12);
--field: #cdeee8; --field-2: #fde8cf; --field-ink: #0d1e1f;
--deep: #0d1e1f; --on-deep: #f1f7f6;
--map-ground: #eaf4f2; --map-z1: #c2e8e1; --map-z2: #ddf1ed; --map-road: #ffffff; --map-water: #9fd3e0;
color-scheme: light;
}
.dms[data-palette="lime"] {
--bg: #f4f4ef; --surface: #ffffff; --text: #141712; --muted: #50554b; --line: rgba(20, 23, 18, .12);
--field: #e4f28a; --field-2: #dfe6ee; --field-ink: #141712;
--deep: #141712; --on-deep: #f4f4ef;
--map-ground: #eff0e7; --map-z1: #e1ef95; --map-z2: #ecf3c4; --map-road: #ffffff; --map-water: #b7d3e4;
color-scheme: light;
}
.dms[data-palette="night"] {
--bg: #0e141b; --surface: #16202a; --text: #ecf1f5; --muted: #a2afbb; --line: rgba(236, 241, 245, .13);
--field: #172a3a; --field-2: #2a1d1c; --field-ink: #ecf1f5;
--deep: #05090d; --on-deep: #ecf1f5;
--map-ground: #121b24; --map-z1: #1d3446; --map-z2: #172634; --map-road: #2a3a49; --map-water: #24506c;
color-scheme: dark;
}
html:has(.dms[data-palette="studio"]), html:has(.dms[data-palette="studio"]) body { background: #f3f5f7; }
html:has(.dms[data-palette="foam"]), html:has(.dms[data-palette="foam"]) body { background: #f1f7f6; }
html:has(.dms[data-palette="lime"]), html:has(.dms[data-palette="lime"]) body { background: #f4f4ef; }
html:has(.dms[data-palette="night"]), html:has(.dms[data-palette="night"]) body { background: #0e141b; }
.dms {
--knob-accent: #d8321f;
--on-acc: #ffffff;
--acc-ink: var(--knob-accent);
--f-display: "Hubot Sans", "Instrument Sans", system-ui, sans-serif;
--f-text: "Instrument Sans", system-ui, sans-serif;
--ease-out: cubic-bezier(.215, .61, .355, 1); /* power3.out */
--ease-io: cubic-bezier(.87, 0, .13, 1); /* expo.inOut */
--gutter: max(20px, 5.5vw);
--maxw: 1360px;
--nav-h: 76px;
/* five type steps */
--t-s: 14.5px;
--t-b: 17px;
--t-m: clamp(21px, 1.7vw, 26px);
--t-l: clamp(36px, 4.4vw, 68px);
--t-xl: clamp(44px, 5.6vw, 96px);
position: relative;
background: var(--bg); color: var(--text);
font-family: var(--f-text); font-size: var(--t-b); line-height: 1.55;
overflow-x: clip;
-webkit-font-smoothing: antialiased;
}
.dms *, .dms *::before, .dms *::after { box-sizing: border-box; }
:where(.dms) a { color: inherit; text-decoration: none; }
.dms svg { max-width: none; }
:where(.dms) img { display: block; max-width: 100%; }
:where(.dms) :where(p, h1, h2, h3, figure, ul, ol, dl, dd, blockquote) { margin: 0; padding: 0; }
:where(.dms) :where(ul, ol) { list-style: none; }
:where(.dms) :where(h1, h2, h3) { font-weight: inherit; }
:where(.dms) fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
:where(.dms) :where(button, input, select, textarea) { font: inherit; color: inherit; }
.dms [hidden] { display: none !important; }
.dms ::selection { background: var(--knob-accent); color: var(--on-acc); }
.dms :focus-visible { outline: 3px solid var(--knob-accent); outline-offset: 3px; }
.dms__sr { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.dms__skip { position: fixed; left: 16px; top: -64px; z-index: 90; padding: 12px 18px; border-radius: 999px; background: var(--knob-accent); color: var(--on-acc); font-weight: 600; transition: top .4s var(--ease-out); }
.dms__skip:focus-visible { top: 12px; }
.dms main:focus { outline: none; }
.dms__muted { color: var(--muted); }
.dms__h2 { font-family: var(--f-display); font-weight: 600; font-stretch: 112%; font-size: var(--t-l); line-height: .98; letter-spacing: -0.03em; text-wrap: balance; }
.dms__body { font-size: var(--t-b); color: var(--muted); max-width: 58ch; }
.dms__small { font-size: var(--t-s); color: var(--muted); }
.dms__fine { font-size: 13.5px; color: var(--muted); }
/* buttons: one shape, fully round */
.dms__btn { display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-height: 48px; padding: 0 24px; border: 0; border-radius: 999px; background: var(--knob-accent); color: var(--on-acc); font-weight: 600; font-size: 16px; line-height: 1; cursor: pointer; white-space: nowrap; transition: background-color .4s var(--ease-out), color .4s var(--ease-out); }
.dms__btn:hover { background: var(--text); color: var(--bg); }
.dms__btn--sm { min-height: 42px; padding: 0 18px; font-size: 15px; }
.dms__btn--lg { min-height: 56px; padding: 0 30px; font-size: 17px; }
.dms__btn--ghost { background: transparent; color: var(--text); box-shadow: inset 0 0 0 1.5px var(--text); }
.dms__btn--ghost:hover { background: var(--text); color: var(--bg); }
.dms__btn--block { width: 100%; }
.dms__textlink { display: inline-flex; align-items: center; gap: 8px; padding: 0; border: 0; background: none; font-weight: 600; color: var(--text); text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 5px; text-decoration-color: var(--knob-accent); cursor: pointer; }
.dms__textlink:hover { color: var(--acc-ink); }
/* inspection-light glare: a soft white band that sweeps across a photo */
.dms__glare { position: absolute; inset: 0; pointer-events: none; overflow: hidden; z-index: 2; }
.dms__glare::before { content: ""; position: absolute; top: -20%; bottom: -20%; left: 0; width: 46%; background: linear-gradient(100deg, rgba(255,255,255,0) 0%, rgba(255,255,255,.08) 30%, rgba(255,255,255,.55) 50%, rgba(255,255,255,.08) 70%, rgba(255,255,255,0) 100%); mix-blend-mode: soft-light; transform: translateX(-120%) skewX(-12deg); }
.dms[data-glare-on] .dms__glare.is-sweep::before { animation: dms-sweep 1.15s var(--ease-io) both; }
@keyframes dms-sweep { from { transform: translateX(-120%) skewX(-12deg); } to { transform: translateX(260%) skewX(-12deg); } }
/* ================================================================ nav */
.dms__nav { position: fixed; inset: 0 0 auto 0; z-index: 60; transition: background-color .4s var(--ease-out), box-shadow .4s var(--ease-out); }
.dms__nav.is-scrolled { background: color-mix(in srgb, var(--bg) 94%, transparent); box-shadow: 0 1px 0 var(--line); }
.dms__nav-in { display: flex; align-items: center; gap: 28px; height: var(--nav-h); padding: 0 var(--gutter); max-width: calc(var(--maxw) + 2 * var(--gutter)); margin: 0 auto; }
.dms__logo { display: flex; align-items: center; gap: 12px; margin-right: auto; }
.dms__mark { width: 38px; height: 38px; flex: none; }
.dms__logo-word { display: flex; flex-direction: column; font-family: var(--f-display); font-weight: 800; font-stretch: 112%; font-size: 21px; line-height: 1; letter-spacing: -0.02em; }
.dms__logo-word small { margin-top: 4px; font-family: var(--f-text); font-stretch: 100%; font-weight: 500; font-size: 12.5px; letter-spacing: 0; color: var(--muted); }
.dms__links { display: flex; gap: 4px; }
.dms__links a { padding: 10px 12px; border-radius: 999px; font-weight: 500; font-size: 15.5px; transition: background-color .3s var(--ease-out); }
.dms__links a:hover { background: color-mix(in srgb, var(--text) 7%, transparent); }
.dms__nav-cta { display: flex; align-items: center; gap: 16px; }
.dms__nav-phone { font-weight: 600; font-variant-numeric: tabular-nums; font-size: 15.5px; }
.dms__burger { display: none; position: relative; width: 46px; height: 46px; border: 0; border-radius: 999px; background: var(--text); cursor: pointer; }
.dms__burger i { position: absolute; left: 14px; right: 14px; height: 2px; background: var(--bg); transition: transform .4s var(--ease-out); }
.dms__burger i:first-of-type { top: 18px; } .dms__burger i:last-of-type { top: 26px; }
.dms__burger[aria-expanded="true"] i:first-of-type { transform: translateY(4px) rotate(45deg); }
.dms__burger[aria-expanded="true"] i:last-of-type { transform: translateY(-4px) rotate(-45deg); }
.dms__menu { position: fixed; inset: var(--nav-h) 0 0 0; z-index: 59; display: flex; flex-direction: column; justify-content: space-between; padding: 24px var(--gutter) 32px; background: var(--bg); overflow-y: auto; }
.dms__menu[hidden] { display: none; }
.dms__menu nav { display: flex; flex-direction: column; }
.dms__menu nav a { padding: 12px 0; font-family: var(--f-display); font-weight: 700; font-stretch: 112%; font-size: 30px; letter-spacing: -0.02em; border-bottom: 1px solid var(--line); }
.dms__menu-foot { display: grid; gap: 12px; margin-top: 28px; }
/* ================================================================ hero */
.dms__hero { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 24px; align-items: center; min-height: min(100svh, 940px); padding: calc(var(--nav-h) + 28px) var(--gutter) 56px; max-width: calc(var(--maxw) + 2 * var(--gutter)); margin: 0 auto; }
.dms__hero-copy { grid-column: 1 / span 6; position: relative; z-index: 2; }
.dms__h1 { font-family: var(--f-display); font-weight: 700; font-stretch: 112%; font-size: var(--t-xl); line-height: .95; letter-spacing: -0.04em; }
.dms__line { display: block; }
.dms__h1 .dms__line > * { padding-bottom: .14em; margin-bottom: -.14em; }
.dms__lede { margin-top: 28px; font-size: clamp(18px, 1.35vw, 20px); line-height: 1.5; color: var(--muted); max-width: 44ch; }
.dms__hero-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 22px 28px; margin-top: 34px; }
.dms__trust { display: flex; flex-wrap: wrap; gap: 18px 36px; margin-top: 48px; padding-top: 24px; border-top: 1px solid var(--line); }
.dms__trust li { display: flex; flex-direction: column; gap: 2px; font-size: var(--t-s); color: var(--muted); }
.dms__trust b { display: inline-flex; align-items: center; gap: 6px; font-family: var(--f-display); font-stretch: 112%; font-size: 22px; line-height: 1.1; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.dms__trust em { font-style: normal; opacity: .8; }
.dms__star { width: 18px; height: 18px; fill: var(--knob-accent); }
.dms__hero-photo { grid-column: 7 / span 6; position: relative; height: min(76svh, 760px); overflow: hidden; border-radius: 4px; background: var(--field); }
.dms__hero-photo img { width: 100%; height: 100%; object-fit: cover; object-position: 46% 50%; will-change: transform; }
.dms__hero-chip { position: absolute; left: 20px; bottom: 20px; z-index: 3; display: grid; gap: 2px; padding: 16px 20px 15px; border-radius: 4px; background: var(--surface); color: var(--text); box-shadow: 0 18px 40px -22px rgba(10, 16, 24, .45); min-width: 230px; }
.dms__hero-chip > span { font-size: var(--t-s); color: var(--muted); }
.dms__hero-chip b { font-family: var(--f-display); font-stretch: 112%; font-weight: 800; font-size: 30px; letter-spacing: -0.03em; line-height: 1.1; font-variant-numeric: tabular-nums; }
.dms__hero-chip small { font-size: 13.5px; color: var(--muted); }
.dms.is-loading [data-hero-fade], .dms.is-loading .dms__h1, .dms.is-loading .dms__hero-chip { opacity: 0; }
/* ================================================================ packages */
.dms__pk { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 24px; padding: 72px var(--gutter) 128px; max-width: calc(var(--maxw) + 2 * var(--gutter)); margin: 0 auto; scroll-margin-top: var(--nav-h); }
.dms__pk-side { grid-column: 1 / span 5; }
.dms__pk-side-in { position: sticky; top: calc(var(--nav-h) + 24px); display: grid; gap: 22px; }
.dms__sizes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 6px; }
.dms__sizes label { position: relative; cursor: pointer; }
.dms__sizes input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.dms__sizes span { display: grid; grid-template-columns: 54px 1fr; grid-template-rows: auto auto; column-gap: 12px; align-items: center; padding: 12px 14px; border-radius: 4px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); transition: background-color .35s var(--ease-out), color .35s var(--ease-out), box-shadow .35s var(--ease-out); }
.dms__sizes svg { grid-row: 1 / span 2; width: 54px; height: 24px; }
.dms__sizes b { font-weight: 600; font-size: 16px; line-height: 1.2; }
.dms__sizes small { font-size: 12.5px; line-height: 1.3; color: var(--muted); transition: color .35s; }
.dms__sizes input:checked + span { background: var(--text); color: var(--bg); box-shadow: none; }
.dms__sizes input:checked + span small { color: color-mix(in srgb, var(--bg) 75%, transparent); }
.dms__sizes input:focus-visible + span { outline: 3px solid var(--knob-accent); outline-offset: 2px; }
.dms__pk-preview { position: relative; height: clamp(260px, 40vh, 440px); overflow: hidden; border-radius: 4px; background: var(--field); }
.dms__pk-preview img { width: 100%; height: 100%; object-fit: cover; }
.dms__pk-list { grid-column: 7 / span 6; border-top: 1px solid var(--text); }
.dms__pkg { border-bottom: 1px solid var(--line); }
.dms__pkg-hit { display: grid; grid-template-columns: minmax(0, 1fr) auto auto 28px; align-items: center; gap: 8px 22px; width: 100%; padding: 26px 0; border: 0; background: none; text-align: left; cursor: pointer; }
.dms__pkg-main { display: grid; gap: 4px; }
.dms__pkg-name { font-family: var(--f-display); font-weight: 700; font-stretch: 112%; font-size: var(--t-m); line-height: 1.1; letter-spacing: -0.02em; transition: color .3s var(--ease-out); }
.dms__pkg-desc { font-size: 15.5px; color: var(--muted); }
.dms__pkg-time { font-size: var(--t-s); color: var(--muted); white-space: nowrap; }
.dms__pkg-price { display: grid; justify-items: end; min-width: 92px; }
.dms__pkg-price b { font-family: var(--f-display); font-weight: 800; font-stretch: 112%; font-size: var(--t-m); line-height: 1.1; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.dms__pkg-price small { font-size: 12.5px; color: var(--muted); white-space: nowrap; }
.dms__pkg-hit:hover .dms__pkg-name, .dms__pkg.is-on .dms__pkg-name { color: var(--acc-ink); }
.dms__plus { position: relative; width: 28px; height: 28px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); transition: background-color .35s var(--ease-out), transform .5s var(--ease-io); }
.dms__plus::before, .dms__plus::after { content: ""; position: absolute; left: 8px; right: 8px; top: 13px; height: 2px; background: currentColor; }
.dms__plus::after { transform: rotate(90deg); transition: transform .5s var(--ease-io); }
[aria-expanded="true"] > .dms__plus { background: var(--knob-accent); color: var(--on-acc); }
[aria-expanded="true"] > .dms__plus::after { transform: rotate(0deg); }
.dms__pkg-more { overflow: hidden; }
.dms__pkg-more-in { display: grid; gap: 20px; padding: 0 44px 28px 0; }
.dms__ticks { display: grid; gap: 9px; }
.dms__ticks li { position: relative; padding-left: 28px; font-size: 15.5px; }
.dms__ticks li::before { content: ""; position: absolute; left: 2px; top: .42em; width: 13px; height: 7px; border-left: 2px solid var(--knob-accent); border-bottom: 2px solid var(--knob-accent); transform: rotate(-45deg); }
.dms__pkg-more .dms__btn { justify-self: start; }
/* ================================================================ before / after */
.dms__fix { background: var(--field); color: var(--field-ink); scroll-margin-top: var(--nav-h); }
.dms__fix-in { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 24px; row-gap: 36px; padding: 120px var(--gutter) 96px; max-width: calc(var(--maxw) + 2 * var(--gutter)); margin: 0 auto; }
.dms__fix-copy { grid-column: 1 / span 4; align-self: start; position: sticky; top: calc(var(--nav-h) + 32px); display: grid; gap: 22px; }
.dms__fix .dms__body { color: color-mix(in srgb, var(--field-ink) 78%, transparent); }
.dms__fix-hint { font-size: var(--t-s); font-weight: 600; }
.dms__fix-hint span { display: inline-block; margin-right: 6px; color: var(--acc-ink); }
.dms__ba { grid-column: 5 / span 8; position: relative; aspect-ratio: 1600 / 1066; overflow: hidden; border-radius: 4px; background: #2a1d17; touch-action: pan-y; user-select: none; -webkit-user-select: none; cursor: ew-resize; }
.dms__ba-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
.dms__ba-before { position: absolute; inset: 0; clip-path: inset(0 calc(100% - var(--split)) 0 0); }
.dms__ba-tag { position: absolute; top: 18px; z-index: 3; padding: 7px 13px; border-radius: 999px; background: rgba(10, 14, 18, .62); color: #fff; font-size: 13.5px; font-weight: 600; transition: opacity .35s var(--ease-out); }
.dms__ba-tag--b { left: 18px; } .dms__ba-tag--a { right: 18px; }
.dms__ba-handle { position: absolute; top: 0; bottom: 0; left: var(--split); z-index: 4; width: 48px; margin-left: -24px; display: grid; place-items: center; cursor: ew-resize; outline: none; }
.dms__ba-bar { position: absolute; top: 0; bottom: 0; left: 50%; width: 4px; margin-left: -2px; border-radius: 4px; background: #fff; box-shadow: 0 0 10px 2px rgba(255, 255, 255, .75), 0 0 34px 8px rgba(190, 225, 255, .35); }
.dms__ba-grip { position: relative; display: grid; place-items: center; width: 48px; height: 48px; border-radius: 999px; background: #fff; color: #0f1720; box-shadow: 0 8px 22px -8px rgba(0, 0, 0, .55); transition: transform .35s var(--ease-out); }
.dms__ba-grip svg { width: 24px; height: 24px; }
.dms__ba-handle:focus-visible .dms__ba-grip { outline: 3px solid var(--knob-accent); outline-offset: 3px; }
.dms__ba.is-drag .dms__ba-grip { transform: scale(.9); }
.dms__fix-facts { grid-column: 5 / span 8; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) auto; align-items: end; gap: 20px 32px; }
.dms__fix-facts dt { font-size: var(--t-s); color: color-mix(in srgb, var(--field-ink) 72%, transparent); }
.dms__fix-facts dd { font-family: var(--f-display); font-stretch: 112%; font-weight: 800; font-size: clamp(26px, 2.4vw, 36px); line-height: 1.1; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
.dms__fix-facts dd small { white-space: nowrap; font-family: var(--f-text); font-stretch: 100%; font-weight: 500; font-size: 14px; letter-spacing: 0; }
.dms__fix-cta { justify-self: end; }
.dms__fix-note { grid-column: 5 / span 8; color: color-mix(in srgb, var(--field-ink) 70%, transparent); }
/* ================================================================ process */
.dms__proc { position: relative; padding: 120px 0 112px; overflow: hidden; }
.dms__proc-head { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 24px; row-gap: 20px; align-items: end; padding: 0 var(--gutter); max-width: calc(var(--maxw) + 2 * var(--gutter)); margin: 0 auto 48px; }
.dms__proc-head .dms__h2 { grid-column: 1 / span 6; }
.dms__proc-head .dms__body { grid-column: 8 / span 5; }
.dms__proc-bar { display: none; grid-column: 1 / -1; height: 3px; background: var(--line); border-radius: 3px; overflow: hidden; }
.dms__proc-bar i { display: block; height: 100%; background: var(--knob-accent); transform-origin: 0 50%; transform: scaleX(0); }
.dms__proc-track { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 48px 24px; padding: 0 var(--gutter); max-width: calc(var(--maxw) + 2 * var(--gutter)); margin: 0 auto; }
.dms__step { display: grid; grid-template-rows: auto 1fr; gap: 20px; }
.dms__step-vis { position: relative; aspect-ratio: 4 / 3; overflow: hidden; border-radius: 4px; background: var(--field); }
.dms__step-vis img { width: 100%; height: 100%; object-fit: cover; }
.dms__step-vis--card { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 16px 18px; background: var(--field-2); }
.dms__readings { flex: 1 1 auto; min-height: 0; width: auto; max-width: 100%; height: 100%; max-height: 300px; }
.dms__readings text { fill: var(--text); font-family: var(--f-text); font-weight: 600; font-variant-numeric: tabular-nums; }
.dms__rd-low text { fill: var(--acc-ink); }
.dms__readings-note { flex: none; max-width: 36ch; text-align: center; font-size: 13.5px; line-height: 1.35; color: var(--muted); }
.dms__step-txt { display: grid; gap: 8px; align-content: start; max-width: 46ch; }
.dms__step-time { font-size: var(--t-s); font-weight: 600; color: var(--acc-ink); }
.dms__step-txt h3 { font-family: var(--f-display); font-weight: 700; font-stretch: 112%; font-size: var(--t-m); line-height: 1.1; letter-spacing: -0.02em; }
.dms__step-txt p { color: var(--muted); font-size: 16px; }
/* pinned horizontal track (set by JS on large screens) */
.dms__proc.is-pin { height: 100svh; padding: 0; display: flex; flex-direction: column; justify-content: center; }
.dms__proc.is-pin .dms__proc-head { margin-bottom: 40px; width: 100%; }
.dms__proc.is-pin .dms__proc-bar { display: block; }
.dms__proc.is-pin .dms__proc-track { display: flex; gap: 28px; width: max-content; max-width: none; margin: 0; padding: 0 var(--gutter); will-change: transform; }
.dms__proc.is-pin .dms__step { width: clamp(300px, 27vw, 400px); flex: none; }
.dms__proc.is-pin .dms__step-vis { aspect-ratio: 5 / 4; }
/* ================================================================ gallery */
.dms__gal { padding: 0 var(--gutter) 128px; max-width: calc(var(--maxw) + 2 * var(--gutter)); margin: 0 auto; scroll-margin-top: var(--nav-h); }
.dms__gal-head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 36px; padding-top: 24px; }
.dms__gal-head .dms__h2 { max-width: 12ch; }
.dms__chips { display: flex; flex-wrap: wrap; gap: 8px; }
.dms__chips button { min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: color-mix(in srgb, var(--text) 7%, transparent); font-weight: 600; font-size: 15px; cursor: pointer; transition: background-color .35s var(--ease-out), color .35s var(--ease-out); }
.dms__chips button:hover { background: color-mix(in srgb, var(--text) 13%, transparent); }
.dms__chips button[aria-pressed="true"] { background: var(--text); color: var(--bg); }
.dms__gal-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: clamp(180px, 17vw, 250px); grid-auto-flow: dense; gap: 14px; }
.dms__tile { position: relative; overflow: hidden; border-radius: 4px; background: var(--field); }
.dms__tile[hidden] { display: none; }
.dms__tile--wide { grid-column: span 2; }
.dms__tile--tall { grid-row: span 2; }
.dms__tile img { width: 100%; height: 100%; object-fit: cover; transition: transform 1s var(--ease-out); }
.dms__tile p { position: absolute; left: 12px; right: 12px; bottom: 12px; z-index: 3; justify-self: start; width: fit-content; max-width: calc(100% - 24px); padding: 6px 11px; border-radius: 4px; background: rgba(10, 14, 18, .66); color: #fff; font-size: 13.5px; line-height: 1.35; }
@media (hover: hover) { .dms__tile:hover img { transform: scale(1.04); } }
/* ================================================================ reviews */
.dms__rev { background: var(--field-2); scroll-margin-top: var(--nav-h); }
.dms__rev-in { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 24px; row-gap: 40px; padding: 112px var(--gutter); max-width: calc(var(--maxw) + 2 * var(--gutter)); margin: 0 auto; }
.dms__rev-lead { grid-column: 1 / span 7; display: grid; gap: 14px; align-content: start; }
.dms__rev-score { font-weight: 600; }
.dms__stars { color: var(--knob-accent); letter-spacing: 2px; }
.dms__rev-tag { font-size: var(--t-s); color: var(--muted); }
.dms__rev-big { margin-top: 26px; font-family: var(--f-display); font-stretch: 106%; font-weight: 600; font-size: clamp(25px, 2.5vw, 38px); line-height: 1.2; letter-spacing: -0.02em; max-width: 24ch; text-wrap: pretty; }
.dms__rev-big cite, .dms__rev-list p { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 18px; font-family: var(--f-text); font-stretch: 100%; font-style: normal; font-size: var(--t-s); font-weight: 400; letter-spacing: 0; }
.dms__rev-big cite span, .dms__rev-list p span { color: var(--muted); }
.dms__rev-list { grid-column: 9 / span 4; display: grid; gap: 28px; align-content: start; }
.dms__rev-list li { padding-bottom: 26px; border-bottom: 1px solid var(--line); }
.dms__rev-list li:last-child { border-bottom: 0; }
.dms__rev-list blockquote { font-size: 17.5px; line-height: 1.5; }
.dms__rev-list p { margin-top: 10px; }
/* ================================================================ area + hours */
.dms__area { scroll-margin-top: var(--nav-h); }
.dms__area-in { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 24px; row-gap: 48px; align-items: start; padding: 128px var(--gutter) 112px; max-width: calc(var(--maxw) + 2 * var(--gutter)); margin: 0 auto; }
.dms__area-map { grid-column: 1 / span 6; position: sticky; top: calc(var(--nav-h) + 24px); }
.dms__map { width: 100%; height: auto; border-radius: 4px; display: block; }
.dms__map text { font-family: var(--f-text); font-size: 15px; font-weight: 600; fill: var(--text); }
.dms__towns circle { fill: var(--text); }
.dms__towns .is-out text { fill: var(--muted); font-weight: 500; }
.dms__towns .is-out circle { fill: var(--muted); }
.dms__towns g.is-hit circle { fill: var(--knob-accent); }
.dms__towns g.is-hit text { fill: var(--acc-ink); }
.dms__road-l text { font-size: 12.5px; font-weight: 700; fill: var(--muted); }
.dms__map-key text { font-size: 13.5px; font-weight: 500; }
.dms__map-scale { font-size: 12px !important; font-weight: 500 !important; fill: var(--muted) !important; }
.dms__ring { transform-box: fill-box; transform-origin: 50% 50%; transition: opacity .4s var(--ease-out); }
.dms__map.is-z1 .dms__ring--1, .dms__map.is-z2 .dms__ring--2 { stroke: var(--knob-accent); stroke-width: 2.5; }
.dms__area-info { grid-column: 8 / span 5; display: grid; gap: 22px; }
.dms__pc { display: grid; gap: 10px; margin-top: 8px; padding: 22px; border-radius: 4px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); }
.dms__pc label { font-weight: 600; }
.dms__pc-row { display: flex; gap: 10px; }
.dms__pc-row input { flex: 1; min-width: 0; }
.dms__pc-out { font-size: 15.5px; color: var(--muted); min-height: 3em; }
.dms__pc-out b { color: var(--text); }
.dms__visit { display: grid; grid-template-columns: 1fr 1fr; gap: 22px 28px; margin-top: 10px; }
.dms__addr { font-style: normal; line-height: 1.6; }
.dms__hours { border-collapse: collapse; font-size: 15px; width: 100%; }
.dms__hours th { text-align: left; font-weight: 500; padding: 5px 16px 5px 0; }
.dms__hours td { text-align: right; padding: 5px 0; font-variant-numeric: tabular-nums; white-space: nowrap; }
.dms__hours tr.is-today th, .dms__hours tr.is-today td { font-weight: 700; color: var(--acc-ink); }
.dms__visit .dms__small { grid-column: 1 / -1; }
/* ================================================================ FAQ */
.dms__faq { background: var(--surface); }
.dms__faq-in { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 24px; row-gap: 32px; padding: 112px var(--gutter); max-width: calc(var(--maxw) + 2 * var(--gutter)); margin: 0 auto; }
.dms__faq .dms__h2 { grid-column: 1 / span 4; align-self: start; }
.dms__faq-list { grid-column: 6 / span 7; border-top: 1px solid var(--text); }
.dms__qa { border-bottom: 1px solid var(--line); }
.dms__qa button { display: flex; align-items: center; justify-content: space-between; gap: 20px; width: 100%; padding: 22px 0; border: 0; background: none; text-align: left; font-weight: 600; font-size: 18px; line-height: 1.35; cursor: pointer; }
.dms__qa button i { position: relative; flex: none; width: 28px; height: 28px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); transition: background-color .35s var(--ease-out); }
.dms__qa button i::before, .dms__qa button i::after { content: ""; position: absolute; left: 8px; right: 8px; top: 13px; height: 2px; background: currentColor; transition: transform .5s var(--ease-io); }
.dms__qa button i::after { transform: rotate(90deg); }
.dms__qa button[aria-expanded="true"] i { background: var(--knob-accent); color: var(--on-acc); }
.dms__qa button[aria-expanded="true"] i::after { transform: rotate(0); }
.dms__a { overflow: hidden; }
.dms__a p { padding: 0 48px 24px 0; color: var(--muted); max-width: 64ch; }
/* ================================================================ booking */
.dms__book { scroll-margin-top: var(--nav-h); }
.dms__book-in { padding: 120px var(--gutter) 128px; max-width: calc(var(--maxw) + 2 * var(--gutter)); margin: 0 auto; }
.dms__book-head { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 24px; row-gap: 16px; align-items: end; margin-bottom: 48px; }
.dms__book-head .dms__h2 { grid-column: 1 / span 5; }
.dms__book-head .dms__body { grid-column: 7 / span 6; }
.dms__book-head a { color: var(--text); font-weight: 600; text-decoration: underline; text-decoration-color: var(--knob-accent); text-underline-offset: 4px; }
.dms__form { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 24px; align-items: start; }
.dms__form[hidden] { display: none; }
.dms__form-main { grid-column: 1 / span 7; display: grid; gap: 36px; }
.dms__fs { display: grid; gap: 14px; }
.dms__fs legend { padding: 0; margin-bottom: 14px; font-family: var(--f-display); font-stretch: 112%; font-weight: 700; font-size: 21px; letter-spacing: -0.02em; }
.dms__fs legend .dms__muted { font-family: var(--f-text); font-stretch: 100%; font-size: 15px; font-weight: 500; letter-spacing: 0; }
.dms__seg { display: flex; flex-wrap: wrap; gap: 8px; }
.dms__seg label, .dms__opts label, .dms__adds label { position: relative; cursor: pointer; }
.dms__seg input, .dms__opts input, .dms__adds input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.dms__seg span { display: inline-flex; align-items: center; min-height: 46px; padding: 0 18px; border-radius: 999px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); font-weight: 600; font-size: 15.5px; transition: background-color .3s var(--ease-out), color .3s var(--ease-out); }
.dms__seg input:checked + span { background: var(--text); color: var(--bg); box-shadow: none; }
.dms__seg input:disabled + span, .dms__opts input:disabled + span, .dms__adds input:disabled + span { opacity: .42; cursor: not-allowed; }
.dms__opts { display: grid; gap: 8px; }
.dms__opts span { display: flex; justify-content: space-between; align-items: center; gap: 16px; min-height: 56px; padding: 0 20px; border-radius: 4px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); transition: box-shadow .3s var(--ease-out), background-color .3s var(--ease-out); }
.dms__opts b { font-weight: 600; }
.dms__opts em { font-style: normal; font-weight: 700; font-variant-numeric: tabular-nums; }
.dms__opts input:checked + span { box-shadow: inset 0 0 0 2px var(--knob-accent); background: color-mix(in srgb, var(--knob-accent) 6%, var(--surface)); }
.dms__adds { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.dms__adds span { position: relative; display: flex; justify-content: space-between; align-items: center; gap: 12px; min-height: 52px; padding: 10px 16px 10px 46px; border-radius: 4px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); font-size: 15px; line-height: 1.3; transition: box-shadow .3s var(--ease-out); }
.dms__adds span::before { content: ""; position: absolute; left: 16px; top: 50%; width: 18px; height: 18px; margin-top: -9px; border-radius: 4px; box-shadow: inset 0 0 0 1.5px var(--muted); transition: background-color .25s, box-shadow .25s; }
.dms__adds span::after { content: ""; position: absolute; left: 21px; top: 50%; width: 9px; height: 5px; margin-top: -4px; border-left: 2px solid var(--on-acc); border-bottom: 2px solid var(--on-acc); transform: rotate(-45deg); opacity: 0; transition: opacity .25s; }
.dms__adds em { font-style: normal; font-weight: 700; white-space: nowrap; font-variant-numeric: tabular-nums; }
.dms__adds input:checked + span { box-shadow: inset 0 0 0 2px var(--knob-accent); }
.dms__adds input:checked + span::before { background: var(--knob-accent); box-shadow: none; }
.dms__adds input:checked + span::after { opacity: 1; }
.dms__seg input:focus-visible + span, .dms__opts input:focus-visible + span, .dms__adds input:focus-visible + span { outline: 3px solid var(--knob-accent); outline-offset: 2px; }
.dms__note, .dms__hint { font-size: var(--t-s); color: var(--muted); }
.dms__when { display: grid; grid-template-columns: minmax(180px, 220px) 1fr; gap: 16px 20px; align-items: end; }
.dms__slots { display: flex; flex-wrap: wrap; gap: 8px; }
.dms__slot { min-height: 50px; padding: 0 16px; border: 0; border-radius: 999px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); font-weight: 600; font-size: 15px; font-variant-numeric: tabular-nums; cursor: pointer; transition: background-color .3s var(--ease-out), color .3s var(--ease-out); }
.dms__slot[aria-checked="true"] { background: var(--text); color: var(--bg); box-shadow: none; }
.dms__slot:disabled { opacity: .4; cursor: not-allowed; text-decoration: line-through; }
.dms__field { display: grid; gap: 7px; }
.dms__field label { font-weight: 600; font-size: 15px; }
.dms__field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.dms input:not([type="radio"]):not([type="checkbox"]), .dms textarea { width: 100%; min-height: 50px; padding: 12px 14px; border: 0; border-radius: 4px; background: var(--surface); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 22%, transparent); font-size: 16px; transition: box-shadow .3s var(--ease-out); }
.dms textarea { resize: vertical; min-height: 96px; }
.dms input:not([type="radio"]):not([type="checkbox"]):focus, .dms textarea:focus { outline: none; box-shadow: inset 0 0 0 2px var(--knob-accent); }
.dms input::placeholder, .dms textarea::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
.dms .is-bad input, .dms input[aria-invalid="true"] { box-shadow: inset 0 0 0 2px #c0271a !important; }
.dms__err { font-size: 14px; font-weight: 600; color: #b3200f; min-height: 0; }
.dms[data-palette="night"] .dms__err { color: #ff8b7d; }
.dms__err:empty { display: none; }
.dms__sum { grid-column: 9 / span 4; position: sticky; top: calc(var(--nav-h) + 24px); }
.dms__sum-in { display: grid; gap: 14px; padding: 28px; border-radius: 4px; background: var(--deep); color: var(--on-deep); }
.dms__sum-h { font-family: var(--f-display); font-stretch: 112%; font-weight: 700; font-size: 21px; letter-spacing: -0.02em; }
.dms__sum-rows { display: grid; gap: 8px; padding-bottom: 14px; border-bottom: 1px solid color-mix(in srgb, var(--on-deep) 18%, transparent); }
.dms__sum-rows li { display: flex; justify-content: space-between; gap: 16px; font-size: 15px; }
.dms__sum-rows li b { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
.dms__sum-total { display: flex; justify-content: space-between; align-items: baseline; }
.dms__sum-total span { font-weight: 600; }
.dms__sum-total b { font-family: var(--f-display); font-stretch: 112%; font-weight: 800; font-size: 44px; line-height: 1; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
.dms__sum-meta, .dms__sum-dep { font-size: 14px; color: color-mix(in srgb, var(--on-deep) 72%, transparent); }
.dms__sum .dms__small { color: color-mix(in srgb, var(--on-deep) 60%, transparent); }
.dms__sum .dms__btn:hover { background: var(--on-deep); color: var(--deep); }
.dms__done { display: grid; justify-items: start; gap: 14px; max-width: 640px; padding: 40px; border-radius: 4px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); }
.dms__done[hidden] { display: none; }
.dms__done:focus { outline: none; }
.dms__done svg { width: 56px; height: 56px; }
.dms__done h3 { font-family: var(--f-display); font-stretch: 112%; font-weight: 800; font-size: var(--t-m); letter-spacing: -0.02em; }
.dms__done p { color: var(--muted); }
.dms__done-sum { display: grid; grid-template-columns: auto 1fr; gap: 6px 18px; margin: 6px 0 8px; font-size: 15px; }
.dms__done-sum dt { color: var(--muted); }
.dms__done-sum dd { font-weight: 600; }
/* ================================================================ footer */
.dms__foot { background: var(--deep); color: var(--on-deep); }
.dms__foot-in { display: grid; grid-template-columns: 2fr 1fr 1fr 1.3fr; gap: 36px; padding: 88px var(--gutter) 56px; max-width: calc(var(--maxw) + 2 * var(--gutter)); margin: 0 auto; font-size: 15px; line-height: 1.7; }
.dms__foot-word { font-family: var(--f-display); font-stretch: 112%; font-weight: 800; font-size: clamp(40px, 5vw, 72px); line-height: 1; letter-spacing: -0.04em; margin-bottom: 16px; }
.dms__foot-h { font-weight: 700; margin-bottom: 8px; }
.dms__foot a:hover { text-decoration: underline; text-underline-offset: 4px; }
.dms__foot-legal { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px 28px; padding: 22px var(--gutter) 30px; max-width: calc(var(--maxw) + 2 * var(--gutter)); margin: 0 auto; border-top: 1px solid color-mix(in srgb, var(--on-deep) 16%, transparent); font-size: 13.5px; color: color-mix(in srgb, var(--on-deep) 72%, transparent); }
/* ================================================================ mobile sticky bar */
.dms__bar { position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 55; display: none; grid-template-columns: auto 1fr; gap: 8px; transform: translateY(140%); transition: transform .6s var(--ease-out); }
.dms__bar.is-on { transform: none; }
.dms__bar a { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 52px; padding: 0 18px; border-radius: 999px; font-weight: 600; box-shadow: 0 14px 30px -14px rgba(0, 0, 0, .5); }
.dms__bar-call { background: var(--text); color: var(--bg); }
.dms__bar-call svg { width: 17px; height: 17px; }
.dms__bar-book { background: var(--knob-accent); color: var(--on-acc); }
.dms__bar-book span { font-weight: 500; opacity: .85; }
/* ================================================================ responsive */
@media (max-width: 1180px) {
.dms__links { display: none; }
.dms__burger { display: block; }
}
@media (max-width: 1023px) {
.dms__hero { grid-template-columns: 1fr; row-gap: 32px; min-height: 0; padding-bottom: 32px; }
.dms__hero-copy, .dms__hero-photo { grid-column: 1 / -1; }
.dms__h1 { margin-right: 0; }
.dms__hero-photo { height: auto; aspect-ratio: 4 / 3; }
.dms__pk, .dms__fix-in, .dms__rev-in, .dms__area-in, .dms__faq-in, .dms__form, .dms__book-head, .dms__proc-head { grid-template-columns: 1fr; }
.dms__pk-side, .dms__pk-list, .dms__fix-copy, .dms__ba, .dms__fix-facts, .dms__fix-note, .dms__rev-lead, .dms__rev-list, .dms__area-map, .dms__area-info,
.dms__faq .dms__h2, .dms__faq-list, .dms__form-main, .dms__sum, .dms__book-head .dms__h2, .dms__book-head .dms__body, .dms__proc-head .dms__h2, .dms__proc-head .dms__body { grid-column: 1 / -1; }
.dms__pk-side-in, .dms__fix-copy, .dms__area-map, .dms__sum { position: static; }
.dms__pk-preview { display: none; }
.dms__pk-list { margin-top: 36px; }
.dms__sum { margin-top: 36px; }
.dms__foot-in { grid-template-columns: 1fr 1fr; }
.dms__foot-brand { grid-column: 1 / -1; }
}
@media (max-width: 767px) {
.dms { --nav-h: 66px; --t-b: 16.5px; }
.dms__nav-phone, .dms__nav-cta > .dms__btn { display: none; }
.dms__logo-word { font-size: 19px; }
.dms__hero { padding-top: calc(var(--nav-h) + 20px); }
.dms__h1 { font-size: clamp(40px, 11.6vw, 60px); }
.dms__lede { margin-top: 20px; }
.dms__hero-actions { margin-top: 26px; }
.dms__trust { gap: 16px 26px; margin-top: 32px; }
.dms__trust li:last-child { flex-basis: 100%; }
.dms__hero-photo { aspect-ratio: 5 / 4; margin: 0 calc(-1 * var(--gutter)); border-radius: 0; }
.dms__hero-chip { left: var(--gutter); bottom: 14px; min-width: 0; padding: 12px 16px; }
.dms__hero-chip b { font-size: 24px; }
.dms__pk, .dms__book-in { padding-top: 88px; padding-bottom: 96px; }
.dms__sizes span { grid-template-columns: 1fr; padding: 12px; row-gap: 2px; }
.dms__sizes svg { grid-row: auto; width: 44px; height: 20px; margin-bottom: 4px; }
.dms__pkg-hit { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "main price" "time plus"; padding: 22px 0; }
.dms__pkg-main { grid-area: main; } .dms__pkg-price { grid-area: price; } .dms__pkg-time { grid-area: time; } .dms__plus { grid-area: plus; justify-self: end; }
.dms__pkg-more-in { padding-right: 0; }
.dms__fix-in { padding: 88px var(--gutter) 72px; }
.dms__ba { margin: 0 calc(-1 * var(--gutter)); border-radius: 0; aspect-ratio: 4 / 3; }
.dms__ba-img { object-position: 30% 60%; }
.dms__fix-facts { grid-template-columns: 1fr 1fr; }
.dms__fix-cta { grid-column: 1 / -1; justify-self: stretch; }
.dms__fix-cta .dms__btn { width: 100%; }
.dms__proc { padding: 88px 0 72px; }
.dms__proc-track { grid-template-columns: 1fr; gap: 40px; }
.dms__step-vis { aspect-ratio: 16 / 10; }
.dms__step-vis--card { aspect-ratio: auto; height: 340px; }
.dms__gal { padding-bottom: 96px; }
.dms__chips { flex-wrap: nowrap; overflow-x: auto; margin: 0 calc(-1 * var(--gutter)); padding: 2px var(--gutter) 6px; scrollbar-width: none; }
.dms__chips button { flex: none; }
.dms__gal-grid { grid-template-columns: 1fr 1fr; grid-auto-rows: 44vw; gap: 10px; }
.dms__tile p { left: 8px; bottom: 8px; max-width: calc(100% - 16px); font-size: 12.5px; padding: 5px 8px; }
.dms__rev-in, .dms__faq-in { padding: 88px var(--gutter); }
.dms__area-in { padding: 88px var(--gutter) 80px; }
.dms__map { margin: 0 calc(-1 * var(--gutter)); width: calc(100% + 2 * var(--gutter)); border-radius: 0; }
.dms__visit { grid-template-columns: 1fr; }
.dms__pc-row { flex-direction: column; }
.dms__adds { grid-template-columns: 1fr; }
.dms__when { grid-template-columns: 1fr; }
.dms__field-row { grid-template-columns: 1fr; }
.dms__sum-in { padding: 24px; }
.dms__done { padding: 28px 22px; }
.dms__foot-in { grid-template-columns: 1fr; gap: 28px; padding-top: 64px; padding-bottom: 120px; }
.dms[data-bar-on] .dms__bar { display: grid; }
}
@media (prefers-reduced-motion: reduce) {
.dms *, .dms *::before, .dms *::after { transition-duration: .01ms !important; animation: none !important; }
}
// Driftmark Detailing (car detailing & ceramic coating, Leeds): header + mobile menu → SplitText hero with a live
// "Ceramic from £" chip and an inspection-light glare → packages priced by car size (prices tween when the size changes,
// rows expand, a preview photo wipes in on hover) → the signature before/after paint-correction slider driven by an LED
// light bar (pointer, touch and keyboard) → a pinned horizontal process track → Flip-filtered gallery → sample reviews →
// service-area map with a postcode checker + hours → FAQ → a booking builder (size, package, add-ons, studio/mobile,
// date + slot, animated total, success state) → footer, plus a mobile Call / Book bar.
gsap.registerPlugin(ScrollTrigger, SplitText, Flip);
ScrollTrigger.config({ ignoreMobileResize: true });
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { palette: 'studio', accent: '#d8321f', headline: 'plain', carSize: 'medium', startSplit: 50, glare: true, stickyBar: true, intensity: 1 };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
// One motion language for the whole page
const ENTER = 'power3.out'; // entrances, counters, number tweens, magnetic buttons
const SHIFT = 'expo.inOut'; // state changes and wipes (accordions, preview wipe, slider demo, Flip)
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches || !!window.MotionLab?.reducedMotion;
const root = document.querySelector('.dms');
const $ = (s, el = root) => el.querySelector(s);
const $$ = (s, el = root) => [...el.querySelectorAll(s)];
const ac = new AbortController();
const on = (el, ev, fn, opt) => el && el.addEventListener(ev, fn, { signal: ac.signal, ...opt });
const I = () => Math.max(0, Math.min(1.5, +K('intensity') || 0));
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const gbp = (n) => '£' + Math.round(n).toLocaleString('en-GB');
if (!reduced) root.classList.add('is-loading');
/* ------------------------------------------------------------------ content */
const SIZES = ['small', 'medium', 'large', 'xl'];
const SIZE_NAME = { small: 'Small car', medium: 'Medium car', large: 'Large car', xl: 'Extra large vehicle' };
const PKG = {
wash: { name: 'Wash & protect', time: '1½–2 hours', price: [45, 50, 60, 70], mobile: true },
valet: { name: 'Full valet', time: '4–5 hours', price: [129, 149, 169, 189], mobile: true },
interior: { name: 'Interior deep clean', time: '5–6 hours', price: [149, 169, 199, 229], mobile: true },
correction: { name: 'Paint correction', time: '1–2 days', price: [349, 399, 449, 549], mobile: false, days: 2 },
ceramic: { name: 'Ceramic coating', time: '2 days', price: [649, 749, 849, 999], mobile: false, days: 2 },
};
const ADD_NAME = { engine: 'Engine bay clean', headlights: 'Headlight restoration', pet: 'Pet hair removal', leather: 'Leather clean & condition', glass: 'Windscreen glass coating', wheels: 'Wheels-off ceramic' };
const HEADLINES = {
plain: ['Car detailing &', 'ceramic coating', 'in Leeds'],
result: ['Paint that looks', 'better than new,', 'in Leeds'],
offer: ['Ceramic coating', 'from £649', 'in Leeds'],
};
// approximate road miles from the Holbeck studio (LS11) per postcode district, plus a friendly area name
const PC = {
LS1: [1, 'Leeds city centre'], LS2: [1.5, 'Leeds city centre'], LS3: [1.5, 'Burley'], LS4: [2.5, 'Kirkstall'], LS5: [4, 'Hawksworth'],
LS6: [3, 'Headingley'], LS7: [3.5, 'Chapel Allerton'], LS8: [4, 'Roundhay'], LS9: [2.5, 'Burmantofts'], LS10: [2, 'Hunslet'],
LS11: [0.5, 'Holbeck and Beeston'], LS12: [1.5, 'Armley'], LS13: [4, 'Bramley'], LS14: [6, 'Seacroft'], LS15: [6, 'Cross Gates'],
LS16: [6, 'Adel'], LS17: [6.5, 'Moortown'], LS18: [6.5, 'Horsforth'], LS19: [8.5, 'Yeadon'], LS20: [10, 'Guiseley'], LS21: [11, 'Otley'],
LS22: [13, 'Wetherby'], LS23: [14, 'Boston Spa'], LS24: [17, 'Tadcaster'], LS25: [8, 'Garforth'], LS26: [5, 'Rothwell'],
LS27: [4, 'Morley'], LS28: [5.5, 'Pudsey'], LS29: [16, 'Ilkley'],
BD1: [9, 'Bradford'], BD2: [8.5, 'Bradford'], BD3: [8.5, 'Bradford'], BD4: [7.5, 'Bradford'], BD5: [9.5, 'Bradford'], BD6: [10.5, 'Bradford'],
BD7: [10.5, 'Bradford'], BD8: [10, 'Bradford'], BD9: [11, 'Bradford'], BD10: [8.5, 'Idle'], BD11: [6.5, 'Drighlington'], BD12: [9.5, 'Wyke'],
BD13: [13, 'Queensbury'], BD14: [12, 'Clayton'], BD15: [12, 'Allerton'], BD16: [14, 'Bingley'], BD17: [11, 'Baildon'], BD18: [10, 'Shipley'], BD19: [8, 'Cleckheaton'],
WF1: [9.5, 'Wakefield'], WF2: [9, 'Wakefield'], WF3: [5, 'Tingley and Outwood'], WF4: [12, 'Crigglestone'], WF5: [9, 'Ossett'], WF6: [9, 'Normanton'],
WF7: [13, 'Featherstone'], WF8: [15.5, 'Pontefract'], WF10: [10.5, 'Castleford'], WF11: [14, 'Knottingley'], WF12: [9.5, 'Dewsbury'],
WF13: [10, 'Dewsbury'], WF14: [12, 'Mirfield'], WF15: [11, 'Liversedge'], WF16: [9, 'Heckmondwike'], WF17: [6.5, 'Batley'],
HG1: [14, 'Harrogate'], HG2: [14, 'Harrogate'], HG3: [16, 'Harrogate outskirts'], HG5: [16, 'Knaresborough'],
};
const TOWN_OF = (code) => {
const n = +(code.match(/\d+/) || [0])[0], a = code.replace(/\d.*/, '');
if (a === 'LS') return ({ 18: 'horsforth', 21: 'otley', 22: 'wetherby', 25: 'garforth', 27: 'morley', 29: 'ilkley' })[n] || (n <= 17 || n === 26 || n === 28 ? 'leeds' : null);
if (a === 'BD') return n <= 9 ? 'bradford' : null;
if (a === 'WF') return ({ 1: 'wakefield', 2: 'wakefield', 10: 'castleford', 12: 'dewsbury', 13: 'dewsbury' })[n] || null;
if (a === 'HG') return n <= 2 ? 'harrogate' : null;
return null;
};
function lookupPostcode(raw) {
const s = String(raw || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
let out = null;
const full = s.match(/^([A-Z]{1,2}\d[A-Z\d]?)(\d[A-Z]{2})$/);
if (full) out = full[1]; else if (/^[A-Z]{1,2}\d[A-Z\d]?$/.test(s)) out = s;
if (!out) return { ok: false };
const hit = PC[out];
if (!hit) {
const known = /^(LS|BD|WF|HG)\d/.test(out);
return { ok: true, code: out, zone: known ? 'ask' : 'out', name: out };
}
const [mi, name] = hit;
return { ok: true, code: out, miles: mi, name, zone: mi <= 8 ? 1 : mi <= 15 ? 2 : 'out', town: TOWN_OF(out) };
}
/* ------------------------------------------------------------------ helpers */
function luminance(hex) {
const n = parseInt(String(hex).replace('#', '').padEnd(6, '0').slice(0, 6), 16);
const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * lin(((n >> 16) & 255) / 255) + 0.7152 * lin(((n >> 8) & 255) / 255) + 0.0722 * lin((n & 255) / 255);
}
let lenis = null;
const navH = () => $('[data-nav]').offsetHeight;
function scrollToEl(el, done) {
if (!el) return;
const y = el.getBoundingClientRect().top + scrollY - navH() + 1;
if (lenis) lenis.scrollTo(y, { duration: 1.2, onComplete: done });
else { window.scrollTo({ top: y, behavior: reduced ? 'auto' : 'smooth' }); if (done) setTimeout(done, reduced ? 0 : 700); }
}
// tween a £ figure in place (prices re-tween when the car size changes)
const moneyTweens = new WeakMap();
function setMoney(el, to, animate = true) {
if (!el) return;
const from = +el.dataset.v || to;
el.dataset.v = to;
moneyTweens.get(el)?.kill();
if (!animate || reduced || from === to) { el.textContent = gbp(to); return; }
const p = { v: from };
moneyTweens.set(el, gsap.to(p, { v: to, duration: 0.6, ease: ENTER, onUpdate: () => { el.textContent = gbp(p.v); }, onComplete: () => { el.textContent = gbp(to); } }));
}
function glare(el) {
const g = el?.querySelector('.dms__glare');
if (!g || reduced || !root.hasAttribute('data-glare-on')) return;
g.classList.remove('is-sweep'); void g.offsetWidth; g.classList.add('is-sweep');
}
/* ------------------------------------------------------------------ theme + headline */
function applyTheme() {
const pal = ['studio', 'foam', 'lime', 'night'].includes(K('palette')) ? K('palette') : 'studio';
root.dataset.palette = pal;
const acc = /^#[0-9a-f]{6}$/i.test(K('accent')) ? K('accent') : DEFAULTS.accent;
root.style.setProperty('--knob-accent', acc);
const L = luminance(acc);
root.style.setProperty('--on-acc', L > 0.36 ? '#0f1720' : '#ffffff');
const dark = pal === 'night';
let ink = acc;
if (!dark && L > 0.2) ink = `color-mix(in srgb, ${acc} 45%, #0f1720)`;
if (dark && L < 0.12) ink = `color-mix(in srgb, ${acc} 45%, #ffffff)`;
root.style.setProperty('--acc-ink', ink);
if (K('stickyBar')) root.setAttribute('data-bar-on', ''); else root.removeAttribute('data-bar-on');
if (K('glare')) root.setAttribute('data-glare-on', ''); else root.removeAttribute('data-glare-on');
const lines = HEADLINES[K('headline')] ?? HEADLINES.plain;
$('[data-headline]').innerHTML = lines.map((l) => `<span class="dms__line">${esc(l)}</span>`).join(' ');
}
/* ------------------------------------------------------------------ car size: every price on the page follows it */
let sizeIdx = 1;
function setSize(key, { animate = true, from = null } = {}) {
const i = SIZES.indexOf(key); if (i < 0) return;
sizeIdx = i; root.dataset.size = key;
$$('[data-sizes] input').forEach((r) => { if (r !== from) r.checked = r.value === key; });
$$('[data-f-size] input').forEach((r) => { if (r !== from) r.checked = r.value === key; });
$$('[data-price]').forEach((b) => setMoney(b, +b.dataset.price.split(',')[i], animate));
Object.keys(PKG).forEach((k) => setMoney($(`[data-opt-price="${k}"]`), PKG[k].price[i], animate));
setMoney($('[data-hero-price]'), PKG.ceramic.price[i], animate);
$('[data-hero-size]').textContent = `${({ small: 'small car', medium: 'medium car', large: 'large car or SUV', xl: 'van or pick-up' })[key]}, 3-year protection`;
$('[data-bar-price]').textContent = `from ${gbp(PKG.wash.price[i])}`;
updateSummary(animate);
}
/* ------------------------------------------------------------------ expanders (package rows + FAQ) */
function toggleExpand(btn, force) {
const panel = document.getElementById(btn.getAttribute('aria-controls'));
const open = force ?? btn.getAttribute('aria-expanded') !== 'true';
btn.setAttribute('aria-expanded', String(open));
btn.closest('.dms__pkg')?.classList.toggle('is-on', open);
gsap.killTweensOf(panel);
if (open) {
panel.hidden = false;
if (reduced) { gsap.set(panel, { height: 'auto' }); return; }
gsap.fromTo(panel, { height: 0 }, { height: 'auto', duration: 0.6, ease: SHIFT, onComplete: () => ScrollTrigger.refresh() });
gsap.fromTo(panel.firstElementChild, { opacity: 0, y: -6 * I() }, { opacity: 1, y: 0, duration: 0.5, ease: ENTER, delay: 0.1 });
} else {
if (reduced) { panel.hidden = true; return; }
gsap.to(panel, { height: 0, duration: 0.5, ease: SHIFT, onComplete: () => { panel.hidden = true; gsap.set(panel, { clearProps: 'height' }); ScrollTrigger.refresh(); } });
}
}
/* ------------------------------------------------------------------ package preview photo (desktop): wipes in on hover/focus */
let previewKey = 'wash';
function showPreview(row) {
const key = row?.dataset.pkg; if (!key || key === previewKey) return;
previewKey = key;
const fig = $('[data-pk-preview]'), img = $('[data-pk-img]');
if (!fig || getComputedStyle(fig).display === 'none') { img.src = `/assets/driftmark-studio/${row.dataset.img}.webp`; return; }
const next = img.cloneNode();
next.removeAttribute('data-pk-img');
next.src = `/assets/driftmark-studio/${row.dataset.img}.webp`;
next.style.cssText = `position:absolute;inset:0;object-position:${row.dataset.pos || '50% 50%'}`;
fig.insertBefore(next, fig.querySelector('.dms__glare'));
const swap = () => { img.src = next.src; img.style.objectPosition = row.dataset.pos || ''; next.remove(); };
if (reduced) { swap(); return; }
gsap.fromTo(next, { clipPath: 'inset(0% 0% 100% 0%)', scale: 1.08 }, { clipPath: 'inset(0% 0% 0% 0%)', scale: 1, duration: 0.8, ease: SHIFT, overwrite: true,
onComplete: () => { gsap.set(next, { clearProps: 'all' }); swap(); } });
glare(fig);
}
/* ------------------------------------------------------------------ before / after slider (the signature) */
let split = 50, baDragged = false, baTween = null;
function setSplit(v) {
split = clamp(v, 0, 100);
const ba = $('[data-ba]'), h = $('[data-ba-handle]');
ba.style.setProperty('--split', split.toFixed(2) + '%');
const r = Math.round(split);
h.setAttribute('aria-valuenow', r);
h.setAttribute('aria-valuetext', r <= 2 ? 'All after' : r >= 98 ? 'All before' : `${r}% before, ${100 - r}% after`);
$('[data-ba-tb]').style.opacity = split < 14 ? 0 : 1;
$('[data-ba-ta]').style.opacity = split > 86 ? 0 : 1;
}
function setupSlider() {
const ba = $('[data-ba]'), h = $('[data-ba-handle]');
let drag = false;
const fromX = (cx) => { const r = ba.getBoundingClientRect(); return ((cx - r.left) / r.width) * 100; };
on(ba, 'pointerdown', (e) => {
if (e.button > 0) return;
drag = true; baDragged = true; baTween?.kill();
ba.setPointerCapture?.(e.pointerId); ba.classList.add('is-drag');
h.focus({ preventScroll: true });
setSplit(fromX(e.clientX));
});
on(ba, 'pointermove', (e) => { if (drag) setSplit(fromX(e.clientX)); });
const end = () => { drag = false; ba.classList.remove('is-drag'); };
on(ba, 'pointerup', end); on(ba, 'pointercancel', end); on(ba, 'lostpointercapture', end);
on(h, 'keydown', (e) => {
const step = e.shiftKey ? 10 : 2;
const map = { ArrowLeft: -step, ArrowDown: -step, ArrowRight: step, ArrowUp: step, PageDown: -10, PageUp: 10 };
let to = null;
if (e.key in map) to = split + map[e.key]; else if (e.key === 'Home') to = 0; else if (e.key === 'End') to = 100;
if (to == null) return;
e.preventDefault(); baDragged = true; baTween?.kill(); setSplit(to);
});
}
function demoSlider() {
// first view: the light bar sweeps across so people see it moves, then settles back
if (baDragged || reduced) return;
const p = { v: split }, k = I();
const lo = clamp(split - 34 * Math.max(0.5, k), 8, 92), hi = clamp(split + 28 * Math.max(0.5, k), 8, 92);
baTween = gsap.timeline({ defaults: { ease: SHIFT }, onUpdate: () => setSplit(p.v) })
.to(p, { v: hi, duration: 0.9 }).to(p, { v: lo, duration: 1.1 }).to(p, { v: +K('startSplit') || 50, duration: 0.9 });
}
/* ------------------------------------------------------------------ gallery filter (Flip) */
function setupGallery() {
const items = $$('[data-gallery] .dms__tile');
on($('[data-filters]'), 'click', (e) => {
const b = e.target.closest('button[data-f]'); if (!b || b.getAttribute('aria-pressed') === 'true') return;
$$('[data-filters] button').forEach((x) => x.setAttribute('aria-pressed', String(x === b)));
const f = b.dataset.f;
const state = reduced ? null : Flip.getState(items);
items.forEach((it) => { it.hidden = !(f === 'all' || it.dataset.cat === f); });
if (!state) { ScrollTrigger.refresh(); return; }
Flip.from(state, { duration: 0.7, ease: SHIFT, scale: true, absolute: true, nested: true,
onEnter: (els) => gsap.fromTo(els, { opacity: 0, scale: 0.9 }, { opacity: 1, scale: 1, duration: 0.6, ease: ENTER }),
onLeave: (els) => gsap.to(els, { opacity: 0, scale: 0.9, duration: 0.4, ease: ENTER }),
onComplete: () => ScrollTrigger.refresh() });
});
}
/* ------------------------------------------------------------------ postcode checker + map */
function zoneText(r) {
if (!r.ok) return 'That doesn’t look like a UK postcode. Try something like <b>LS6</b> or <b>WF1 2AB</b>.';
if (r.zone === 1) return `<b>Yes, we come to ${esc(r.code)} (${esc(r.name)}).</b> No call-out fee, about ${r.miles} miles from the studio.`;
if (r.zone === 2) return `<b>Yes, ${esc(r.code)} (${esc(r.name)}) is in our wider area.</b> £15 call-out, about ${r.miles} miles away.`;
if (r.zone === 'ask') return `<b>${esc(r.code)} is close to the edge of our area.</b> Call us and we’ll confirm the call-out.`;
return `<b>${esc(r.code)} is outside the mobile area.</b> Book a studio drop-off instead: we’re two minutes from M621 junction 3.`;
}
function setupPostcode() {
const form = $('[data-pc]'), out = $('[data-pc-out]'), map = $('[data-map]');
on(form, 'submit', (e) => {
e.preventDefault();
const r = lookupPostcode($('[data-pc-in]').value);
out.innerHTML = zoneText(r);
map.classList.toggle('is-z1', r.zone === 1); map.classList.toggle('is-z2', r.zone === 2);
$$('.dms__towns g').forEach((g) => g.classList.toggle('is-hit', !!r.town && g.dataset.town === r.town));
if (!reduced && r.ok) gsap.fromTo(out, { y: 6 * I(), opacity: 0.4 }, { y: 0, opacity: 1, duration: 0.5, ease: ENTER });
if (!reduced && r.town) gsap.fromTo(`.dms__towns [data-town="${r.town}"] circle`, { scale: 2.4, transformOrigin: '50% 50%' }, { scale: 1, duration: 0.7, ease: ENTER });
});
}
/* ------------------------------------------------------------------ booking builder */
const form = () => $('[data-form]');
const val = (n) => form().elements[n];
let total = 0;
function slotList(dateStr, where, pkg) {
if (!dateStr) return [];
const d = new Date(dateStr + 'T12:00:00');
const dow = d.getDay();
if (dow === 0) return null; // closed
const sat = dow === 6;
let list = where === 'mobile' ? ['8:00–10:00am', '11:00am–1:00pm', '2:00–4:00pm'] : sat ? ['8:30am drop-off', '10:00am drop-off'] : ['8:00am drop-off', '9:30am drop-off', '12:30pm drop-off'];
if (where === 'mobile' && sat) list = list.slice(0, 2);
if (PKG[pkg]?.days) list = [list[0]]; // two-day jobs start first thing
else if (pkg !== 'wash' && where === 'studio') list = list.slice(0, 2); // longer jobs need a morning start
const busy = d.getDate() % (list.length + 1); // sample availability: one slot booked on most days
return list.map((t, i) => ({ t, busy: list.length > 1 && i === busy }));
}
let slotPick = '';
function renderSlots() {
const box = $('[data-f-slots]');
const list = slotList(val('date').value, val('where').value, val('pkg').value);
if (list === null) { box.innerHTML = '<p class="dms__note">Closed on Sundays. Pick another day.</p>'; slotPick = ''; return; }
if (!list.length) { box.innerHTML = '<p class="dms__note">Choose a date to see times.</p>'; slotPick = ''; return; }
if (!list.some((s) => s.t === slotPick && !s.busy)) slotPick = (list.find((s) => !s.busy) || {}).t || '';
box.innerHTML = list.map((s) => `<button type="button" class="dms__slot" role="radio" aria-checked="${s.t === slotPick}" ${s.busy ? 'disabled aria-label="' + esc(s.t) + ', booked"' : ''} tabindex="${s.t === slotPick ? 0 : -1}" data-slot="${esc(s.t)}">${esc(s.t)}</button>`).join('');
}
function syncWhere() {
const pkg = val('pkg').value, studioOnly = PKG[pkg] && !PKG[pkg].mobile;
const mob = $('[data-f-where] input[value="mobile"]');
mob.disabled = studioOnly;
if (studioOnly && mob.checked) $('[data-f-where] input[value="studio"]').checked = true;
const mobile = val('where').value === 'mobile';
$$('[data-f-pkg] input').forEach((r) => { r.disabled = mobile && !PKG[r.value].mobile; });
const wh = $('[data-f-adds] input[value="wheels"]');
wh.disabled = mobile; if (mobile) wh.checked = false;
$('[data-f-pcwrap]').hidden = !mobile;
$('[data-where-note]').textContent = studioOnly ? `${PKG[pkg].name} is done in the studio, where the lighting and curing are controlled.` : mobile ? 'We bring water and power. Correction, ceramic and wheels-off work stay in the studio.' : 'Free parking at the unit. We can drop you at Leeds station, ten minutes away.';
}
function callout() {
if (val('where').value !== 'mobile') return 0;
const r = lookupPostcode(val('pc').value);
return r.ok && r.zone === 2 ? 15 : 0;
}
function updateSummary(animate = true) {
const f = form(); if (!f) return;
const pkg = val('pkg').value, P = PKG[pkg];
const adds = $$('[data-f-adds] input:checked');
const rows = [];
if (P) rows.push([`${P.name}, ${SIZE_NAME[SIZES[sizeIdx]].toLowerCase()}`, P.price[sizeIdx]]);
adds.forEach((a) => rows.push([ADD_NAME[a.value], +a.dataset.p]));
const co = callout(); if (co) rows.push(['Mobile call-out, 8–15 miles', co]);
$('[data-sum-rows]').innerHTML = rows.map(([n, v]) => `<li><span>${esc(n)}</span><b>${gbp(v)}</b></li>`).join('') || '<li><span>Choose a package</span><b>£0</b></li>';
total = rows.reduce((a, [, v]) => a + v, 0);
setMoney($('[data-sum-total]'), total, animate);
const where = val('where').value === 'mobile' ? 'mobile visit' : 'studio drop-off';
$('[data-sum-meta]').textContent = P ? `${SIZE_NAME[SIZES[sizeIdx]]}, ${P.time}, ${where}${slotPick ? ', ' + slotPick.replace(' drop-off', '') : ''}` : '';
$('[data-sum-dep]').textContent = P?.days ? `£50 deposit holds the two days. The rest is paid on collection, after 4:30pm on day two.` : 'Pay when the job is done. No deposit.';
}
const setErr = (el, msg) => {
const box = el.closest('.dms__field') || el.closest('.dms__fs');
const err = box?.querySelector('[data-err]'); if (err) err.textContent = msg || '';
box?.classList.toggle('is-bad', !!msg);
if (el.tagName === 'INPUT') el.setAttribute('aria-invalid', msg ? 'true' : 'false');
};
const CHECKS = {
name: (v) => v.trim().length < 2 ? 'Please enter your name.' : '',
phone: (v) => { const d = v.replace(/[^\d+]/g, ''); return /^(\+44\d{9,10}|0\d{9,10})$/.test(d) ? '' : 'Please enter a UK number, for example 07700 900461.'; },
email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v.trim()) ? '' : 'Please enter an email address like [email protected].',
car: (v) => v.trim().length < 3 ? 'Tell us the car, for example “grey estate”.' : '',
};
function checkWhen() {
const el = val('date'), v = el.value;
let msg = '';
if (!v) msg = 'Please choose a date.';
else {
const d = new Date(v + 'T12:00:00'), t = new Date(); t.setHours(0, 0, 0, 0);
if (d <= t) msg = 'Please choose a date from tomorrow onwards.';
else if (d.getDay() === 0) msg = 'We’re closed on Sundays. Please pick another day.';
else if (!slotPick) msg = 'Please choose a time.';
}
const err = $('#dms-e-when'); err.textContent = msg; el.setAttribute('aria-invalid', msg ? 'true' : 'false');
return !msg;
}
function checkPc() {
if (val('where').value !== 'mobile') return true;
const r = lookupPostcode(val('pc').value);
const msg = !r.ok ? 'Please enter the postcode for the visit.' : r.zone === 'out' ? 'That’s outside our mobile area. Choose studio drop-off instead.' : '';
setErr(val('pc'), msg);
$('[data-f-pc-hint]').textContent = r.ok && r.zone === 1 ? `${r.name}: no call-out fee.` : r.ok && r.zone === 2 ? `${r.name}: £15 call-out added.` : r.ok && r.zone === 'ask' ? 'Near the edge of our area. We’ll confirm by text.' : 'Free within 8 miles, £15 call-out up to 15 miles.';
return !msg;
}
function nextOpenDay(from = new Date()) {
const d = new Date(from); d.setDate(d.getDate() + 1);
while (d.getDay() === 0) d.setDate(d.getDate() + 1);
return d;
}
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
function setupForm() {
const f = form(), dateEl = val('date');
const today = new Date();
dateEl.min = iso(nextOpenDay(today)); const max = new Date(today); max.setDate(max.getDate() + 60); dateEl.max = iso(max);
dateEl.value = iso(nextOpenDay(today));
on(f, 'change', (e) => {
const t = e.target;
if (t.name === 'size') setSize(t.value, { from: t });
if (t.name === 'pkg' || t.name === 'where') { syncWhere(); renderSlots(); }
if (t.name === 'date') { renderSlots(); checkWhen(); }
if (t.name === 'pkg') setErr(t, '');
updateSummary();
});
on(val('pc'), 'input', () => { if (val('pc').value.length >= 2) checkPc(); updateSummary(); });
on($('[data-f-slots]'), 'click', (e) => {
const b = e.target.closest('[data-slot]'); if (!b || b.disabled) return;
slotPick = b.dataset.slot; renderSlots(); $(`[data-slot="${CSS.escape(slotPick)}"]`)?.focus(); checkWhen(); updateSummary();
});
on($('[data-f-slots]'), 'keydown', (e) => {
if (!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(e.key)) return;
const opts = $$('[data-slot]:not(:disabled)'); if (!opts.length) return;
e.preventDefault();
let i = opts.findIndex((o) => o.dataset.slot === slotPick);
i = (i + (e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : -1) + opts.length) % opts.length;
slotPick = opts[i].dataset.slot; renderSlots(); $(`[data-slot="${CSS.escape(slotPick)}"]`)?.focus(); updateSummary();
});
Object.keys(CHECKS).forEach((n) => {
on(val(n), 'blur', () => { if (val(n).value) setErr(val(n), CHECKS[n](val(n).value)); });
on(val(n), 'input', () => { if (val(n).closest('.dms__field').classList.contains('is-bad')) setErr(val(n), CHECKS[n](val(n).value)); });
});
on(f, 'submit', (e) => {
e.preventDefault();
let first = null;
if (!val('pkg').value) { setErr($('[data-f-pkg] input'), 'Please choose a package.'); first = $('[data-f-pkg] input'); }
if (!checkPc()) first ??= val('pc');
if (!checkWhen()) first ??= dateEl;
Object.keys(CHECKS).forEach((n) => { const m = CHECKS[n](val(n).value); setErr(val(n), m); if (m) first ??= val(n); });
if (first) { first.focus(); return; }
const P = PKG[val('pkg').value];
const d = new Date(dateEl.value + 'T12:00:00');
const when = `${d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })}, ${slotPick}`;
const ref = 'DM-' + String(10000 + ((d.getDate() * 977 + val('name').value.length * 131 + total) % 89999));
const first1 = val('name').value.trim().split(/\s+/)[0];
$('[data-done-h]').textContent = `Thanks, ${first1}. Booking requested.`;
$('[data-done-p]').textContent = `We’ll text ${val('phone').value.trim()} within two working hours to confirm. Your reference is ${ref}.`;
const where = val('where').value === 'mobile' ? `Mobile visit, ${lookupPostcode(val('pc').value).code}` : 'Studio drop-off, Holbeck LS11';
const extras = $$('[data-f-adds] input:checked').map((a) => ADD_NAME[a.value]).join(', ');
$('[data-done-sum]').innerHTML = [['Package', `${P.name} (${SIZE_NAME[SIZES[sizeIdx]].toLowerCase()})`], ['Add-ons', extras || 'None'], ['Car', val('car').value.trim()], ['When', when], ['Where', where], ['Total', gbp(total) + (P.days ? ', £50 deposit' : '')]]
.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
f.hidden = true; const done = $('[data-done]'); done.hidden = false;
scrollToEl($('#dms-book'), () => done.focus({ preventScroll: true }));
if (!reduced) {
gsap.fromTo(done.children, { opacity: 0, y: 14 * I() }, { opacity: 1, y: 0, duration: 0.6, ease: ENTER, stagger: 0.06 });
gsap.fromTo(done.querySelector('[data-tick]'), { strokeDasharray: 1, strokeDashoffset: 1 }, { strokeDashoffset: 0, duration: 0.6, ease: ENTER, delay: 0.2 });
}
ScrollTrigger.refresh();
});
on($('[data-again]'), 'click', () => {
const keepSize = SIZES[sizeIdx];
f.reset(); $$('.is-bad', f).forEach((b) => b.classList.remove('is-bad')); $$('[data-err]', f).forEach((x) => { x.textContent = ''; });
$$('[aria-invalid]', f).forEach((x) => x.setAttribute('aria-invalid', 'false'));
dateEl.value = iso(nextOpenDay()); slotPick = '';
setSize(keepSize, { animate: false }); syncWhere(); renderSlots(); updateSummary(false);
$('[data-done]').hidden = true; f.hidden = false; val('name').focus();
ScrollTrigger.refresh();
});
syncWhere(); renderSlots();
}
function pickPackage(key) {
const r = $(`[data-f-pkg] input[value="${key}"]`); if (!r) return;
if (!PKG[key].mobile) $('[data-f-where] input[value="studio"]').checked = true;
r.disabled = false; r.checked = true; setErr(r, '');
syncWhere(); renderSlots(); updateSummary();
}
/* ------------------------------------------------------------------ nav + mobile menu */
function setupNav() {
const nav = $('[data-nav]'), burger = $('[data-burger]'), menu = $('[data-menu]'), label = $('[data-burger-label]');
const setOpen = (open) => {
burger.setAttribute('aria-expanded', String(open));
label.textContent = open ? 'Close menu' : 'Open menu';
document.documentElement.style.overflow = open ? 'hidden' : '';
nav.classList.toggle('is-open', open);
if (open) { lenis?.stop(); menu.hidden = false; if (!reduced) gsap.fromTo(menu.querySelectorAll('nav a, .dms__menu-foot > *'), { opacity: 0, y: 14 * I() }, { opacity: 1, y: 0, duration: 0.5, ease: ENTER, stagger: 0.04 }); menu.querySelector('a')?.focus(); }
else { menu.hidden = true; lenis?.start(); }
};
on(burger, 'click', () => setOpen(burger.getAttribute('aria-expanded') !== 'true'));
menu.querySelectorAll('a').forEach((a) => on(a, 'click', () => setOpen(false)));
on(document, 'keydown', (e) => { if (e.key === 'Escape' && burger.getAttribute('aria-expanded') === 'true') { setOpen(false); burger.focus(); } });
on(window, 'resize', () => { if (innerWidth > 1180 && burger.getAttribute('aria-expanded') === 'true') setOpen(false); });
const onScroll = () => nav.classList.toggle('is-scrolled', scrollY > 8);
on(window, 'scroll', onScroll, { passive: true }); onScroll();
}
/* ------------------------------------------------------------------ delegated clicks */
function setupClicks() {
on(root, 'click', (e) => {
const hit = e.target.closest('.dms__pkg-hit, [data-faq] button');
if (hit) { toggleExpand(hit); if (hit.classList.contains('dms__pkg-hit')) showPreview(hit.closest('.dms__pkg')); return; }
const bk = e.target.closest('[data-book]');
if (bk) { e.preventDefault(); pickPackage(bk.dataset.book); scrollToEl($('#dms-book'), () => val('name')?.focus({ preventScroll: true })); return; }
const a = e.target.closest('a[href^="#"]');
if (a && a.getAttribute('href').length > 1) {
const t = root.querySelector(a.getAttribute('href'));
if (t) { e.preventDefault(); scrollToEl(t); }
}
});
on($('[data-sizes]'), 'change', (e) => { if (e.target.name === 'dms-size') setSize(e.target.value, { from: e.target }); });
const list = $('[data-pk]');
on(list, 'pointerover', (e) => { if (e.pointerType === 'mouse') showPreview(e.target.closest('.dms__pkg')); });
on(list, 'focusin', (e) => showPreview(e.target.closest('.dms__pkg')));
}
/* ------------------------------------------------------------------ opening hours: today's row */
let dayTimer = 0;
function markToday() { const d = new Date().getDay(); $$('[data-hours] tr').forEach((tr) => tr.classList.toggle('is-today', +tr.dataset.day === d)); }
/* ------------------------------------------------------------------ motion */
let ctx, mm, splits = [];
function motion() {
ctx?.revert(); mm?.revert();
splits.forEach((s) => s.revert()); splits = [];
const k = I();
const bar = $('[data-bar]');
ctx = gsap.context(() => {
let pastHero = false, inBook = false;
const syncBar = () => bar.classList.toggle('is-on', pastHero && !inBook);
ScrollTrigger.create({ trigger: '.dms__hero-actions', start: 'bottom top', end: '+=100000', onToggle: (s) => { pastHero = s.isActive; syncBar(); } });
ScrollTrigger.create({ trigger: '#dms-book', start: 'top 85%', end: 'bottom 30%', onToggle: (s) => { inBook = s.isActive; syncBar(); } });
const counters = $$('[data-count]');
const fmt = (c, v) => { const dec = +(c.dataset.dec || 0); return c.hasAttribute('data-sep') ? Math.round(v).toLocaleString('en-GB') : v.toFixed(dec); };
if (reduced) {
root.classList.remove('is-loading');
counters.forEach((c) => { c.textContent = fmt(c, +c.dataset.count); });
gsap.set('.dms__roads path', { strokeDasharray: 1, strokeDashoffset: 0 });
return;
}
// hero entrance (< 1 s): SplitText line masks, copy, photo wipe, chip, then the inspection-light sweep
splits = $$('.dms__h1 .dms__line').map((el) => SplitText.create(el, { type: 'lines', mask: 'lines', linesClass: 'dms__hl' }));
root.classList.remove('is-loading');
const tl = gsap.timeline({ defaults: { ease: ENTER } });
tl.from(splits.flatMap((s) => s.lines), { yPercent: 110, duration: 0.8, stagger: 0.08 }, 0)
.from('[data-hero-fade]', { opacity: 0, y: 18 * k, duration: 0.6, stagger: 0.07 }, 0.2)
.from('[data-hero-photo]', { clipPath: 'inset(0% 0% 0% 100%)', duration: 1.0, ease: SHIFT, clearProps: 'clipPath' }, 0.05)
.from('[data-hero-photo] img', { scale: 1 + 0.14 * k, duration: 1.3 }, 0.05)
.from('.dms__hero-chip', { opacity: 0, y: 20 * k, duration: 0.6 }, 0.7)
.add(() => glare($('[data-hero-photo]')), 0.85);
counters.filter((c) => c.closest('.dms__hero')).forEach((c) => {
const p = { v: 0 };
tl.to(p, { v: +c.dataset.count, duration: 1.1, ease: ENTER, onUpdate: () => { c.textContent = fmt(c, p.v); } }, 0.3);
});
counters.filter((c) => !c.closest('.dms__hero')).forEach((c) => {
const p = { v: 0 };
gsap.to(p, { v: +c.dataset.count, duration: 1.1, ease: ENTER, onUpdate: () => { c.textContent = fmt(c, p.v); }, scrollTrigger: { trigger: c, start: 'top 90%', once: true } });
});
// one reveal rule for blocks, clip wipes for package preview, steps and tiles
ScrollTrigger.batch($$('[data-reveal]'), { start: 'top 88%', once: true,
onEnter: (els) => gsap.fromTo(els, { opacity: 0, y: 26 * k }, { opacity: 1, y: 0, duration: 0.8, ease: ENTER, stagger: 0.07, overwrite: true }) });
ScrollTrigger.batch($$('.dms__pkg'), { start: 'top 92%', once: true,
onEnter: (els) => gsap.fromTo(els, { opacity: 0, y: 18 * k }, { opacity: 1, y: 0, duration: 0.7, ease: ENTER, stagger: 0.06 }) });
ScrollTrigger.batch($$('.dms__tile'), { start: 'top 90%', once: true,
onEnter: (els) => { gsap.fromTo(els, { clipPath: 'inset(100% 0% 0% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)', duration: 1.0, ease: SHIFT, stagger: 0.08, clearProps: 'clipPath' }); els.forEach((el, i) => gsap.delayedCall(0.55 + i * 0.08, () => glare(el))); } });
// before/after: a one-time sweep of the light bar the first time it is seen
ScrollTrigger.create({ trigger: '[data-ba]', start: 'top 70%', once: true, onEnter: demoSlider });
// map: rings open, roads draw, towns and the pin drop
const roads = $$('.dms__roads path');
gsap.set(roads, { strokeDasharray: 1, strokeDashoffset: 1 });
gsap.set('.dms__ring', { scale: 0.2, opacity: 0 });
gsap.set('.dms__towns g, .dms__road-l', { opacity: 0 });
gsap.set('[data-pin]', { opacity: 0, y: -36 });
gsap.timeline({ scrollTrigger: { trigger: '[data-map]', start: 'top 75%', once: true }, defaults: { ease: ENTER } })
.to('.dms__ring--2', { scale: 1, opacity: 1, duration: 1.0, ease: SHIFT }, 0)
.to('.dms__ring--1', { scale: 1, opacity: 1, duration: 0.9, ease: SHIFT }, 0.15)
.to(roads, { strokeDashoffset: 0, duration: 0.9, stagger: 0.1 }, 0.3)
.to('.dms__road-l', { opacity: 1, duration: 0.5 }, 0.9)
.to('.dms__towns g', { opacity: 1, duration: 0.4, stagger: 0.04 }, 0.7)
.to('[data-pin]', { opacity: 1, y: 0, duration: 0.7 }, 1.1);
}, root);
if (reduced) return;
mm = gsap.matchMedia();
mm.add({ desk: '(min-width: 1024px)', fine: '(hover: hover) and (pointer: fine)', pin: '(min-width: 1024px) and (min-height: 640px)' }, (c) => {
const { desk, fine, pin } = c.conditions;
const k2 = I();
const undo = [];
// hero photo drifts as the hero scrolls away
gsap.to('[data-hero-photo] img', { yPercent: (desk ? -7 : -4) * k2, ease: 'none', scrollTrigger: { trigger: '.dms__hero', start: 'top top', end: 'bottom top', scrub: 0.6 } });
// process: pinned horizontal track on large screens, a stacked list everywhere else
if (pin) {
const sec = $('[data-proc]'), track = $('[data-proc-track]');
sec.classList.add('is-pin');
const dist = () => Math.max(0, track.scrollWidth - innerWidth);
const hz = gsap.to(track, { x: () => -dist(), ease: 'none', scrollTrigger: { trigger: sec, start: 'top top', end: () => '+=' + dist(), pin: true, scrub: 0.8, anticipatePin: 1, invalidateOnRefresh: true } });
gsap.fromTo('[data-proc-bar]', { scaleX: 0.08 }, { scaleX: 1, ease: 'none', scrollTrigger: { trigger: sec, start: 'top top', end: () => '+=' + dist(), scrub: 0.8, invalidateOnRefresh: true } });
$$('.dms__step-vis img', track).forEach((img) => gsap.fromTo(img, { scale: 1 + 0.12 * k2 }, { scale: 1, ease: 'none', scrollTrigger: { trigger: img.parentElement, containerAnimation: hz, start: 'left right', end: 'right left', scrub: true } }));
undo.push(() => { sec.classList.remove('is-pin'); gsap.set(track, { clearProps: 'transform' }); });
} else {
ScrollTrigger.batch($$('.dms__step'), { start: 'top 88%', once: true,
onEnter: (els) => gsap.fromTo(els, { opacity: 0, y: 24 * k2 }, { opacity: 1, y: 0, duration: 0.8, ease: ENTER, stagger: 0.08 }) });
}
if (fine && desk) {
// magnetic primary buttons
const mags = $$('[data-magnetic]').map((el) => {
const xTo = gsap.quickTo(el, 'x', { duration: 0.5, ease: ENTER }), yTo = gsap.quickTo(el, 'y', { duration: 0.5, ease: ENTER });
const mv = (e) => { const r = el.getBoundingClientRect(); xTo((e.clientX - r.left - r.width / 2) * 0.28 * k2); yTo((e.clientY - r.top - r.height / 2) * 0.4 * k2); };
const lv = () => { xTo(0); yTo(0); };
el.addEventListener('pointermove', mv); el.addEventListener('pointerleave', lv);
return () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerleave', lv); gsap.set(el, { x: 0, y: 0 }); };
});
// inspection-light sweep on photo hover
const glareEls = $$('[data-glare]');
const hov = (e) => glare(e.currentTarget);
glareEls.forEach((el) => el.addEventListener('pointerenter', hov));
undo.push(() => { mags.forEach((f) => f()); glareEls.forEach((el) => el.removeEventListener('pointerenter', hov)); });
}
return () => undo.forEach((f) => f());
});
}
/* ------------------------------------------------------------------ smooth scroll */
let tickFn = null;
function startLenis() {
if (reduced || !window.Lenis) return;
if (window.MotionLab?.lenis) { lenis = window.MotionLab.lenis; return; }
lenis = new Lenis({ duration: 1.15, smoothWheel: true });
lenis.on('scroll', ScrollTrigger.update);
tickFn = (t) => lenis.raf(t * 1000);
gsap.ticker.add(tickFn);
gsap.ticker.lagSmoothing(0);
}
function stopLenis() {
if (tickFn) gsap.ticker.remove(tickFn); tickFn = null;
if (lenis && lenis !== window.MotionLab?.lenis) lenis.destroy();
lenis = null;
}
/* ------------------------------------------------------------------ build */
function build() {
applyTheme();
const sz = SIZES.includes(K('carSize')) ? K('carSize') : 'medium';
setSize(sz, { animate: false });
baDragged = false; baTween?.kill();
setSplit(clamp(+K('startSplit') || 50, 5, 95));
motion();
ScrollTrigger.refresh();
}
setupNav();
setupClicks();
setupSlider();
setupGallery();
setupPostcode();
setupForm();
markToday();
dayTimer = setInterval(markToday, 60000);
startLenis();
build();
(document.fonts?.ready ?? Promise.resolve()).then(() => ScrollTrigger.refresh()).catch(() => {});
on(window, 'ml:knobs', build);
on(window, 'ml:pause', () => { baTween?.pause(); lenis?.stop(); });
on(window, 'ml:resume', () => { baTween?.resume(); lenis?.start(); });
window.MotionLab?.onCleanup(() => {
ac.abort();
clearInterval(dayTimer);
baTween?.kill();
ctx?.revert(); mm?.revert();
splits.forEach((s) => s.revert());
stopLenis();
document.documentElement.style.overflow = '';
});