Palette — fresh-steam morning, jade on mist
Every colour is argued from the job: a Morley loungeroom at 9am, windows open while the carpet dries.
The spec's ink/line rule applied twice: steam jade fails 4.5:1 as small text, so buttons and jade copy use a derived #12805F; citrus fails as link text, so links use a derived #8A5112. The bright tokens are reserved for lanes, marks and chip borders — exactly the duties where contrast rules don't bite.
Type
- Sora — display. A clean, techy rounded grotesque: the masthead, headings and buttons. Round terminals read "fresh and modern machine", which is the truck-mount's whole pitch.
- Karla — body and UI. Warm workhorse, comfortable at 16–17px on a phone held one-handed.
- DM Mono — rooms, prices, the price card and every receipt-flavoured moment. The card is set entirely in mono because the page's core promise is arithmetic you can check.
The signature — Room Planner
A procedural SVG top-down house plan that cleans itself live. Bedrooms and living areas tile into the plan as the steppers climb; each room is striped with alternating wand lanes. The flagship living room rests mid-pass: grey scruffy lanes on the uncleaned side, bright jade on the cleaned side, and the wand itself is the divider. The implementation is a full-lane grey layer with a jade lane layer above it clipped by a <clipPath> rect — one rAF loop eases the clip width and the wand's transform attribute between 35% and 72% of the room on a 14-second ping-pong, so the cleaned fraction never drops below the 0.35 visibility floor and the thumbnail reads mid-demonstration at any capture instant. Reduced motion renders the wand settled at 55% with the steam stilled. Position lives on outer <g transform> attributes and entrance animation on inner groups' CSS only, so attribute state never fights a stylesheet rule (the W16/W17 lesson). Redraws are dual-speed: the first render staggers rooms in at 60 ms/element (capped 1.4 s), every later stepper tap redraws at 12 ms/element under a ~520 ms cap.
Second-read details: the truck-mount hose threads in through the front door and re-routes to the wand every frame, and three steam wisps rise off the wand head on a 2.4-second cycle. Pet-treated rooms take a citrus dot; stairs dock as an annex on the plan's right edge; rugs park on the flagship floor with fringed ends.
The pricing model — shape and measured figures
Truck-mounted hot water extraction, pre-spray and deodorise included. The model is deliberately flat where the spec pins it flat: one honest room rate — living areas meter at the same $45 as bedrooms ("a room is a room — the size band is the meter, not the label"), stated on-card rather than inventing a gradient. A room is a room up to ~14 m² (the $/room anchor's fixed dimension, declared on-card); rugs are $45 up to ~2×3 m, larger quoted at the door.
| Anchor | Rate | Note |
|---|---|---|
| Carpeted room (bed or living) | $45 / room | up to ~14 m²; open-plan counts as two |
| Minimum job | $99 | 1–2 rooms clamp up; crossing clears at 3 rooms |
| Hallway | +$25 | threads the plan when chipped |
| Stairs | $75 / flight | hand-wand work |
| Rugs | $45 each | up to ~2×3 m |
| Pet urine treatment | +$30 / room affected | enzyme; affected count clamped to rooms selected |
| Upholstery | $50 / seat · $150 min | own service card, outside the planner |
Measured from the running page (harness asserts against an independently reimplemented oracle, per LEARNINGS W14):
| Scenario | Arithmetic | Displayed |
|---|---|---|
| Rest — 3 bed + 1 living + hallway | 4 × $45 + $25 | $205 ✓ |
| Rest + pet treatment on 2 rooms | $205 + 2 × $30 | $265 ✓ |
| Cheapest — 1 room, no add-ons | $45 → min clamp | $99 ✓ (equals services floor) |
| Three-room crossing | 3 × $45 | $135 ✓ (clamp released) |
| 3 rooms + 2 flights + 1 rug | $135 + $150 + $45 | $330 ✓ |
No spec corrections were needed — every worked example was pure anchor arithmetic and the implemented model landed exactly on it. The clamp prints its reason on the card ("the truck-mount heats up and the hoses run in whether we clean one room or four"), and the planner blocks the dishonest zero-room state: the last room's minus button disables rather than letting the card price an empty house.
Conversion decisions
- Planner in the hero. The signature shares the 1200×750 thumbnail with the h1 (W19 rule) — the first screen answers "who, where, how much" in one glance. At 375px the value prop, call CTA and trust line stack above the fold and the planner follows.
- Two CTAs everywhere, one intent. "Book a clean" (form) and the phone number, header and hero and planner card and sticky bar — because carpet cleaning is a phone-first trade but renters book after hours.
- Planner → form handoff. The configured scenario pre-fills a live job summary above the form, so the enquiry lands warm ("3 bedrooms + 1 living area + hallway — estimate $205").
- Form fields: name, phone, suburb (the trade's dispatch triangle), property-type select (end-of-lease is its own workflow), optional preferred day (type=date, min today) and a notes field seeded with the questions techs actually ask (stains, pets, parking).
- Local confirmation, honestly local. Submit validates in-page and renders the "Thanks — request received, we'd call you back within two business hours" card with the scenario summary. No fake network round-trip; the card says plainly that nothing left the browser.
- Bond band as the trust centrepiece. The seven-day re-clean receipt is the trade's defining honesty moment, so it gets the dark band and a rendered receipt, not a bullet point.
Local SEO we'd implement for the real business
This demo page ships only WebPage schema naming it a demonstration — fictional structured data in a real index would be dishonest markup. For a real client, we'd ship LocalBusiness + Review schema like this:
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": "Steamline Carpet Care",
"description": "Truck-mounted carpet steam cleaning across Morley and the Perth metro.",
"telephone": "+61 8 9000 0272",
"address": {
"@type": "PostalAddress",
"addressLocality": "Morley",
"addressRegion": "WA",
"postalCode": "6062",
"addressCountry": "AU"
},
"areaServed": ["Morley", "Dianella", "Bayswater", "Noranda", "Perth"],
"openingHours": ["Mo-Fr 07:00-17:00", "Sa 08:00-15:00"],
"priceRange": "$99-$500",
"review": {
"@type": "Review",
"reviewRating": { "@type": "Rating", "ratingValue": "5" },
"author": { "@type": "Person", "name": "— real customer, via review feed" },
"reviewBody": "— pulled live from Google reviews, never hand-typed"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.9",
"reviewCount": "— live count from the review platform"
}
}
Plus suburb-level service pages, Google Business Profile sync, and review markup fed from the live platform rather than pasted.
What would change for the real client
- ServiceM8 — run scheduling, job cards, and on-the-day "we're 20 minutes out" SMS from the van.
- Before/after photo records per job, attached to the invoice — the bond-clean evidence trail.
- Square tap-to-pay at the door, receipt on the spot.
- Automated bond-clean receipts — the seven-day guarantee letter generated from the job card and emailed to tenant and property manager together.
- Xero — invoicing and reconciliation fed from ServiceM8.
- Podium (or similar) — the review ask fired at job close, feeding the real review schema above.
- Their photos, their real reviews feed, their actual licence and insurance numbers — and the Room Planner recalibrated to their price book.
Iteration log
Pass 1 Craft
Drove the whole conversion surface headless (25 asserts): rest card $205 = oracle, pet scenario $265, one-room clamp $99 with the reason row visible, three-room crossing $135 with the clamp released, stairs+rugs scenario $330; empty submit on a fresh load raised 3 validation signals, filled submit rendered the confirmation with the scenario sum; FAQ ×6 toggled; wand drift confirmed via the state probe (wandT 0.58→0.69) with the 0.35 floor held. The screenshot review caught what the asserts couldn't: room labels sat illegibly on the lane stripes (gave every label a white pill), and the "hose in through the front door" caption collided with the hose path at bottom-left (moved to bottom-right, end-anchored). Also fixed the mobile call button to a true 44px target with a "Call" short label.
Pass 2 Depth
Enriched the signature: three steam wisps now rise off the wand head (sine-drift, 2.4 s cycle, opacity fading with height — kept inside the wand group so attribute state never fights CSS), and the price total pops 7% on a spring bezier when the figure changes. Verified the dual-speed redraw honestly: a stepper tap after first render settles every room inside 620 ms. Screenshot review of the pet scenario: label pills legible, citrus dots on the two affected bedrooms, card itemising 4 × $45 + $25 + 2 × $30 = $265 — matches the oracle.
Pass 3 Hardening
375px masthead first (the W18/W21 offender): wordmark right edge 232px vs call button left edge 267px — clear by 35px, button 44px tall, no overflow (scrollWidth 375). H1, call CTA and trust line all above the 812px fold; sticky bar fixed at the bottom with the tel link, and its quote button scrolls to the form. Keyboard path drove the hallway chip with Space. Reduced-motion run: wand settled at 0.55, hero visible, zero console errors. Reserved-phone positive sweep: every (08) number on the page uses the 9000 exchange. One screenshot appeared to show an empty plan — chased it and found the snappy redraw caught ~150 ms mid-entrance, not a defect; a settled stage screenshot shows all rooms at opacity 1. Chanel rule: removed the second (citrus) hero radial gradient — the jade one carries the corner alone.
Techniques
- Procedural SVG throughout — house plan, Perth service-area map, receipt, icons. No raster images anywhere.
- One rAF loop for wand + clip + hose + steam, paused on document.hidden, never started under reduced motion.
- Entrance choreography gated on document.fonts.ready with a 900 ms fallback; content never stranded at opacity 0.
- State probe window.__carpetcleaning carries planner state, computed price, wand phase and confirmation state — QA gates on state, never wall-clock.
- Custom JS form validation (no pattern attributes — the v-flag regex trap) with aria-invalid + inline error copy.