/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5
 * Hallmark · genre: editorial · macrostructure: Photographic · theme: custom
 * (vibe: "work-truck signage over real photography" · paper oklch(97.2% 0.005 85)
 *  · accent oklch(67.9% 0.173 51.4) warm · Barlow Condensed + Newsreader)
 * axes — paper band: light · display style: display-condensed · accent hue: warm
 * nav: N9 edge-aligned · footer: Ft2 inline · enrichment: none — photography is the design
 *
 * Studied: barrettmade.com · housebrand.ca · wealdconstruction.com — all three are
 * photography-led with NO bordered cards, NO shadows, NO radius. This build follows
 * that: the image edge is the divider, type annotates, orange appears on the CTA
 * and the call bar only.
 */

/* ── base ─────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }

html {
  overflow-x: clip;
  scroll-behavior: smooth;
  scroll-padding-top: var(--nav-h);
  -webkit-text-size-adjust: 100%;
}

body {
  --nav-h: 62px;
  --callbar-h: 56px;
  margin: 0;
  padding-bottom: var(--callbar-h);
  overflow-x: clip;
  background: var(--color-paper);
  color: var(--color-ink);
  font-family: var(--font-body);
  font-size: var(--text-base);
  font-optical-sizing: auto;
  line-height: 1.55;
}
@media (min-width: 900px) { body { --nav-h: 74px; --callbar-h: 0px; } }

img { max-width: 100%; height: auto; display: block; }
ul, ol { margin: 0; padding: 0; list-style: none; }
a { color: inherit; }
button { font: inherit; color: inherit; }
h1, h2, h3 { margin: 0; font-weight: 500; }
/* the two that stay in caps */
.mark__text, .index__n { text-transform: uppercase; letter-spacing: 0.015em; }
p { margin: 0 0 1em; }
p:last-child { margin-bottom: 0; }
figure { margin: 0; }

/* Sentence case, weight 500, looser tracking, more leading. Two typefaces
   were rejected before the diagnosis landed: it was never the face, it was
   setting every heading in tight near-black caps at the largest size the
   column allowed. The small-caps LABEL device is untouched — that is what
   carries the drawing-sheet language, and it only works while the headings
   around it are not also shouting. */
h1, h2, h3, .mark__text, .index__n, .cta, .contact__tel, .nav__tel, .callbar a {
  font-family: var(--font-display);
  letter-spacing: var(--track-display);
  line-height: 1.06;
  /* break-word, NOT anywhere: `anywhere` breaks wherever it helps the line fit,
     which shattered the hero into BUIL / T / RIGH / T. It only ever kicks in
     for a single word genuinely wider than its container. */
  overflow-wrap: break-word;
  min-width: 0;
}

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
.skip-link {
  position: absolute; top: -100px; left: var(--gutter); z-index: 300;
  background: var(--color-accent); color: var(--color-accent-ink);
  padding: var(--space-xs) var(--space-md);
  font-family: var(--font-ui); font-weight: 600; text-decoration: none;
}
.skip-link:focus { top: var(--space-xs); }

:focus-visible { outline: 3px solid var(--color-focus); outline-offset: 2px; }

.icon-phone {
  display: inline-block; width: 0.92em; height: 0.92em; flex: none;
  background: currentColor;
  -webkit-mask: var(--phone) center / contain no-repeat;
          mask: var(--phone) center / contain no-repeat;
}

/* small caps label — the one repeated typographic device on the page */
.label {
  font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase;
}

/* ── nav ──────────────────────────────────────────────────────────── */
.nav {
  position: fixed; top: 0; left: 0; right: 0; z-index: 100;
  display: flex; align-items: center; gap: var(--space-md);
  height: var(--nav-h);
  padding-inline: var(--gutter);
  color: var(--color-ink-light);
  transition: background-color var(--dur-mid) var(--ease-out),
              color var(--dur-mid) var(--ease-out);
}
.nav.is-solid {
  background: var(--color-paper);
  color: var(--color-ink);
  border-bottom: var(--rule-hair) solid var(--color-rule);
}

.mark { display: inline-flex; align-items: center; gap: var(--space-2xs); text-decoration: none; margin-right: auto; }
.mark__cut {
  width: 26px; height: 5px; flex: none; background: currentColor;
  clip-path: polygon(5px 0, 100% 0, calc(100% - 5px) 100%, 0 100%);
}
.mark__text { font-size: clamp(1.0625rem, 3.6vw, 1.3125rem); font-weight: 700; letter-spacing: 0.01em; }
/* The name became "Oster Construction Specialties" on 2026-08-11 — it is the
   name on the LLC filing, the Yelp listing and the Facebook page, and it is
   what Google matches against. "Oster Construction" on its own also belongs to
   an unrelated and better-established contractor at osterconstruction.com, so
   the short form was competing with someone else's brand.

   The <i> carries no styling of its own. It exists so the two halves can be
   split when the bar runs out of room, and is normalised here. */
.mark__text i { font-style: normal; }

/* Two lines BY CHOICE on a phone. The name will not set on one line inside a
   62px bar at a readable size, and a soft wrap moves with the viewport — at
   410px it broke after "Construction", at 380px it would break after "Oster".
   Forced, so the lockup is the same on every phone. Still clears the bar:
   two lines at 16px/1.1 is 35px inside 62px. */
@media (max-width: 599px) {
  .mark__text { font-size: 1rem; line-height: 1.1; }
  .mark__text i { display: block; }
}

.nav__links { display: none; }
.nav__links a {
  font-family: var(--font-ui); font-size: var(--text-sm); font-weight: 500;
  letter-spacing: 0.04em; text-transform: uppercase; text-decoration: none;
  white-space: nowrap; opacity: 0.82;
  transition: opacity var(--dur-short) var(--ease-out);
}
.nav__links a:hover { opacity: 1; }

.nav__end { display: flex; align-items: center; gap: var(--space-sm); }
.nav__tel { display: none; font-size: var(--text-md); font-weight: 700; text-decoration: none; white-space: nowrap; }
.nav__tel:hover { color: var(--color-accent-hot); }

.burger {
  width: 40px; height: 40px; display: grid; place-items: center;
  background: none; border: 0; cursor: pointer;
}
.burger__box { display: block; width: 22px; }
.burger__box i {
  display: block; height: 2px; background: currentColor; margin: 5px 0;
  transition: transform var(--dur-short) var(--ease-out);
}
.burger[aria-expanded="true"] i:nth-child(1) { transform: translateY(3.5px) rotate(45deg); }
.burger[aria-expanded="true"] i:nth-child(2) { transform: translateY(-3.5px) rotate(-45deg); }

@media (max-width: 899px) {
  .nav__links.is-open {
    display: flex; flex-direction: column; gap: var(--space-xs);
    position: absolute; top: var(--nav-h); left: 0; right: 0;
    background: var(--color-paper); color: var(--color-ink);
    border-bottom: var(--rule-hair) solid var(--color-rule);
    padding: var(--space-md) var(--gutter) var(--space-lg);
  }
  .nav__links.is-open a { font-size: var(--text-md); opacity: 1; }
}
@media (min-width: 900px) {
  .nav__links { display: flex; align-items: center; gap: clamp(1.25rem, 2.4vw, 2.25rem); }
  .nav__tel { display: inline; }
  .burger { display: none; }
}

/* ── FOLD 1 · the film ────────────────────────────────────────────── */
.film {
  position: relative;
  min-height: 100svh;
  display: flex; align-items: center; justify-content: center;
  padding: var(--space-4xl) var(--gutter) var(--space-2xl);
  background: var(--color-navy-deep);
  overflow: hidden;
  isolation: isolate;
}
.film__media {
  position: absolute; inset: 0; z-index: -2;
  width: 100%; height: 100%; object-fit: cover;
  /* The composition lives in the FILE, not here. object-position was the
     obvious lever and it is a dead end: at 3:2 in a 16:9 viewport `cover`
     leaves only ~60px of vertical overflow to pan through, nowhere near enough
     to clear the drill out of the headline, and `transform` is already spoken
     for by the parallax below. So hero-v2.jpg is cropped to 16:9 with the tool
     placed low and left of centre. img/hero-uncropped.jpg used to sit beside
     it as the frame it was cut from; it was deleted 2026-08-11 as an unused
     working file. To re-crop, go back to Joe's originals in _from-joe/ and the
     crop script in img/README.md.

     Dead centre, restored 2026-08-11. This was 40% for the phone, on the
     grounds that a portrait viewport crops so hard from both sides that at
     50% the drill fell out of the window. That was true of the PREVIOUS hero
     file and stopped being true when hero-v2.jpg was re-cropped with the tool
     placed deliberately — at 40% the drill now runs off the RIGHT edge of a
     390px screen instead, which is the same fault in the other direction.

     The two viewports were also never getting the same treatment. `cover`
     leaves ~160px of horizontal overflow at 1440 and ~1110px at 390, so one
     percentage buys a 16px nudge on desktop and a 111px shove on the phone:
     the number looked shared and the framing was not. Desktop was effectively
     centred the whole time and the phone was the only thing 40% ever moved.
     50% gives both the same centre. Measured at 390, 430 and 1440. */
  object-position: 50% 50%;
  /* The film is decoration behind the type — it must never take a click from
     the phone-number CTA, and if the file is missing or errors, any native
     media chrome the browser draws stays non-interactive. */
  pointer-events: none;
}
/* Centre-weighted scrim: dark where the type sits, clear where the film plays.
   The top band is NOT decorative — the nav is white text sitting directly on
   the video, and a bright frame would otherwise wipe it out. It has to hold
   regardless of what Joe shot. */
/* FLAT AND EVEN, ON PURPOSE — do not put a vignette back.
   This was a centre-weighted ellipse and it was rejected on sight: a dark blob
   behind the type with bright corners around it reads as a cheap stock-photo
   treatment, because the light in it does not come from anywhere in the
   photograph. One flat value across the whole frame instead.
   The contrast it used to buy is now bought in the FILE: img/hero-v2.jpg is graded
   to 52% brightness before it ships (see the crop script in img/README.md), so
   the photograph is genuinely a dark, moody one rather than a bright one hiding
   under a screen. That is what lets this layer stay as light as it is. Grade the
   photograph, not the overlay — if a replacement hero comes in bright, re-grade
   it rather than reaching for opacity here. */
.film__scrim {
  position: absolute; inset: 0; z-index: -1;
  background: oklch(18% 0.04 262 / 0.30);
}
/* Sized in rem, not ch: `ch` here resolves against the 17px body font, so 20ch
   was ~200px — nowhere near enough for 120px display type.
   Centred, so the measure is a touch wider than the old left-aligned column —
   a centred block reads as a deliberate plate, where a narrow one reads as a
   left column that lost its alignment. */
.film__type {
  position: relative;
  max-width: min(100%, 64rem);   /* the wordmark is one 8.3:1 line — it needs the room */
  margin-inline: auto;
  text-align: center;
}
/* The headline is artwork, so nothing here is type. It scales on width alone and
   the intrinsic width/height on the <img> reserves the right box before it
   loads, so it cannot shift the fold. */
.film__wordmark { margin: 0 0 var(--space-md); }
.film__wordmark img {
  display: block; width: 100%; height: auto;
  max-width: 58rem; margin-inline: auto;
}
/* Centring a block only centres the text inside it — the box itself still has
   to be pulled to the middle, or a 34ch measure sits hard left inside a 52rem
   parent and the whole thing looks broken rather than centred. */
.film__line {
  font-size: var(--text-md); color: var(--color-ink-light);
  opacity: 0.9; max-width: 34ch; margin-inline: auto;
  margin-bottom: var(--space-lg);
}
.film__caption {
  position: absolute; right: var(--gutter); bottom: var(--space-2xl);
  margin: 0; display: none;
  font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 500;
  letter-spacing: var(--track-label); text-transform: uppercase;
  color: var(--color-ink-light); opacity: 0.72;
}
/* "Licensed & insured" is a trust signal and the city is a local-search signal;
   both were desktop-only. On the phone the film becomes a column so the caption
   can sit under the CTA in flow instead of being absolutely positioned into the
   fixed call bar. Bottom padding grows by the call bar's height to keep it clear. */
@media (max-width: 899px) {
  .film { flex-direction: column; align-items: center; justify-content: center;
          padding-bottom: calc(var(--space-2xl) + var(--callbar-h)); }
  /* Still flat, just a little heavier. A portrait viewport crops this 16:9
     photo hard from both sides, so the visible slice is a much smaller and
     more brightly lit piece of the frame than the desktop ever shows — the
     grade in the file is doing less work here. Measured at 390px, not guessed.
     Even, though: no vignette on the phone either. */
  .film__scrim { background: oklch(18% 0.04 262 / 0.42); }
  .film__caption {
    display: block; position: static;
    margin-top: var(--space-sm);
    letter-spacing: 0.1em;   /* 0.16em overruns 390px by a few pixels */
    opacity: 0.8;
    text-align: center;
  }
  .film__caption span { white-space: nowrap; }
}

@media (min-width: 900px) {
  .film__caption { display: block; }
  /* Joe's photo overhangs up into the bottom-left of the film. Reserve the same
     height here so the h1 and the phone CTA clear it. */
  .film { padding-bottom: calc(var(--space-2xl) + var(--meet-break)); }
}

/* the single button on the page */
.cta {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2xs);
  padding: var(--space-sm) var(--space-lg);
  /* The LIT blue, not the deep one. This button only ever appears on the film,
     and the deep blue against a dark video is a button you have to hunt for —
     the shape disappeared into the scrim and only the white type survived.
     It is the primary conversion element on the page; it has to be the
     brightest thing in the frame after the headline. */
  background: var(--color-accent-hot); color: var(--color-navy-deep);
  border: 0; border-radius: var(--radius);
  font-size: var(--text-md); font-weight: 700; letter-spacing: 0.04em;
  text-decoration: none; white-space: nowrap; cursor: pointer;
  transition: background-color var(--dur-short) var(--ease-out),
              color var(--dur-short) var(--ease-out);
}
.cta:hover { background: var(--color-accent); color: var(--color-ink-light); }
.cta:active { transform: translateY(1px); }
.cta--block { width: 100%; }
.cta[disabled] { opacity: 0.5; pointer-events: none; }

/* Ink variant — navy field, accent arrow. Used once, on the drawing sheet.
   The orange fill is the PHONE's colour everywhere else on this page (hero
   CTA, call bar, wordmark cut); giving the form the same fill made the two
   read as equal offers when the phone is the one Joe actually wants rung. */
.cta--ink {
  background: var(--color-navy-deep); color: var(--color-ink-light);
  justify-content: space-between; gap: var(--space-md);
  padding-inline: var(--space-md);
}
.cta--ink:hover { background: var(--color-navy); }
.cta__arrow {
  color: var(--color-accent-hot);
  transition: transform var(--dur-short) var(--ease-out);
}
.cta--ink:hover .cta__arrow { transform: translateX(4px); }

/* ── MEET JOE — the introduction ──────────────────────────────────────
   Replaces a text-only "statement" band that had no face, no colour and no
   anchor in it. A photographic hero followed by a wall of type reads as a
   drop in energy; a person reads as warmth. */
.meet {
  position: relative;
  /* Lifts the whole block over the trades panel below it. Both are positioned
     with z-index auto, so without this the later section in the document wins
     the paint order and swallows the bottom of Joe's overhang. */
  z-index: 1;
  display: grid;
  background: var(--color-navy);
  color: var(--color-ink-light-2);
}
/* Flat navy was the one surface on the page with nothing in it. This is a
   photographic oak grain (ambientCG Wood049, CC0 — see img/README.md) recoloured
   so its average is exactly --color-navy: the panel keeps its brand colour and
   only gains figure. It is a material, not an effect, which is the whole point —
   a drawn grid reads as CSS, sawn timber reads as the trade.
   The source tiles seamlessly, so this repeats rather than stretching; measured
   edge-to-edge delta is ~0.6%, below the noise floor of the grain itself. */
.meet::before {
  content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none;
  /* Native size of the file. Scaling up would soften the grain to mush, and
     scaling down would shrink it to noise; at 1:1 it reads as one wide board. */
  background: url("/img/navy-grain.5685bb53.jpg") repeat center / 1400px auto;
}
@media (max-width: 899px) {
  /* A 1400px board across a 390px screen is just a gradient. Half-scale puts a
     believable amount of figure back in the frame. */
  .meet::before { background-size: 760px auto; }
}
@media (min-width: 900px) {
  /* Narrowed from 0.9fr, which is a reversal. 0.9fr let the landscape frame
     arrive nearly intact — and intact is the problem: Joe stands in the left
     third of his own photograph and the other two thirds are trusses, so an
     uncropped frame makes the man a detail in a picture of a roof. A narrower
     column forces `cover` to take the crop off the RIGHT (see object-position
     below), which spends the roof to buy Joe. The earlier note here warned a
     narrow column slices his head off; that was the vertical anchor's fault,
     and it is fixed at the img rather than by widening the column back. */
  .meet { grid-template-columns: minmax(0, 0.62fr) minmax(0, 1.38fr); align-items: stretch; }
}
.meet__photo { position: relative; z-index: 2; overflow: hidden; }
/* Joe's entrance used to be declared here as a plain slide from the left, and
   it never ran: script.js adds `rv` to this element too, and `.js .rv` sits
   ~1000 lines further down the file at the same specificity, so the generic
   18px rise won on source order and the slide was dead code. The whole
   section's motion now lives in one place — see "4b · MEET JOE" in the motion
   block — where it is after `.js .rv` and wins on purpose rather than by
   accident. Nothing about the entrance belongs up here in the layout rules. */
/* The picture is TALLER than the band it stands on and breaks out of it at
   both ends — up into the film, down into the trades panel. That is what keeps
   the grain reading as a stripe behind Joe rather than a field around him, and
   it makes him the tallest thing in the block without the section growing to
   match. The band's own height is set by the text column alone. */
@media (min-width: 900px) {
  .meet__photo { margin-block: calc(-1 * var(--meet-break)); }
}
.meet__photo img {
  width: 100%; height: 100%; object-fit: cover;
  /* The crop now lives in the FILE, not here. img/joe.jpg was cut down from
     799x599 to 460x599 — the camera original has Joe's far shoulder outside
     the frame, so no amount of repositioning could ever show all of him, and
     the only thing that makes that edge read as deliberate is framing him as a
     portrait. img/joe-uncropped.jpg held that original and was deleted
     2026-08-11; the camera file it came from is _from-joe/_joe.jpg.

     What is left here is a safety net: the frame is usually WIDER than the
     file's 0.77, so cover crops vertically and the X value does nothing — but
     it stays at 0 for the narrow-frame case, where it would matter. 8% keeps
     his crown clear of the top edge. */
  object-position: 0% 8%;
  /* 4:5 on phones — close to the file's own shape, so almost nothing is cut
     and the portrait arrives whole on the screen where Joe matters most. */
  aspect-ratio: 4 / 5; background: var(--color-navy-deep);
}
/* Back to filling its own box, which is now the band plus both overhangs — a
   fixed aspect-ratio here would fight the negative margins for the height. */
@media (min-width: 900px) { .meet__photo img { aspect-ratio: auto; min-height: 100%; } }

.meet__text {
  position: relative; z-index: 1;
  display: flex; flex-direction: column; justify-content: center;
  padding: var(--space-xl) var(--gutter);
}
/* 2.5rem, not --space-2xl. The grain band has no height of its own — it is
   exactly as tall as this column — so taking height out of the section means
   taking it out of this padding and the gaps below. Type sizes are untouched;
   only the air between the lines is. */
@media (min-width: 900px) { .meet__text { padding: 2.5rem clamp(2rem, 5vw, 4rem); } }
.meet__text h2 {
  font-family: var(--font-lettering); font-weight: 400;
  text-transform: uppercase;
  /* Up from clamp(2rem, 1.2rem + 2.6vw, 3.25rem) on 2026-08-10. This heading
     sits one fold under the hero artwork and was being read against it — at
     52px it was the same VOICE at half the volume, which reads as timid
     rather than as hierarchy. The hero is deliberately louder than anything
     else on the page; this is the second step down, not the fourth. */
  /* Leading was 0.95 until 2026-08-11 — Anton's own cap height with nothing
     added, which is a poster setting. It works on the two-line headings
     elsewhere and it does not work here: this is the only THREE-line heading
     on the page, so the tight leading stacked three heavy condensed lines into
     one dense mass with the descender of one line almost touching the caps of
     the next. 1.06 opens the lines without loosening the block enough to stop
     reading as one unit. The same value is now on all three Anton headings so
     the display voice stays consistent.

     The tracking goes up with it. Condensed caps at 0.02em are set as tightly
     as the leading was, and opening one while leaving the other just moves the
     crowding sideways. */
  font-size: clamp(2.5rem, 1.4rem + 3.4vw, 4.25rem); line-height: 1.06;
  letter-spacing: 0.035em;
  filter: url(#f-head);
  color: var(--color-ink-light);
  /* The heading also needed air under it — at --space-xs the lead paragraph
     was crowding the last line of a heading that is now taller. */
  margin-bottom: var(--space-sm); max-width: 20ch;
}
.meet__text p { max-width: 52ch; }
/* One paragraph now — the supporting `.meet__body` line was cut 2026-08-09, and
   its rule went with it rather than sitting here unused. The lead is the whole
   voice of this panel, so it keeps the step up in size. */
/* Written as `.meet__text .meet__lead`, not `.meet__lead`, on purpose: the
   `.meet__text p` rule above is a class+type selector and outranked the bare
   class, so the 26ch this used to declare never applied and the lead has been
   running at 52ch. Narrowing the photo column widens this one, so the measure
   now has to be held deliberately — 36ch keeps Joe's line reading as speech
   rather than as a paragraph stretched across the panel. */
.meet__text .meet__lead {
  font-size: var(--text-lg); line-height: 1.34;
  color: var(--color-ink-light); max-width: 36ch;
  margin-bottom: var(--space-xs);
}
.meet__sign {
  margin-top: var(--space-2xs); margin-bottom: 0;
  font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase;
  color: var(--color-ink-light);
}

/* Facts as one spec line. Four rows in a 2x2 with accent numerals gave four
   unremarkable claims the visual weight of the headline; this states them once
   and gets out of the way, which is all they were ever worth. */
.meet__spec {
  display: flex; flex-wrap: wrap; align-items: baseline;
  gap: var(--space-2xs) var(--space-sm);
  margin-top: var(--space-md); padding-top: var(--space-xs);
  border-top: var(--rule-hair) solid var(--color-rule-light);
  font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase;
  color: var(--color-ink-light-2);
}
.meet__spec li { display: flex; align-items: baseline; gap: var(--space-sm); }
/* The tick trails its item rather than leading the next one. The row wraps at
   middling widths, and a divider stranded at the START of a line reads as a
   mistake — at the end of one it reads as "continues". This is also the only
   job the orange has left in this section now the numerals are gone. */
.meet__spec li:not(:last-child)::after {
  content: ""; width: var(--rule-hair); height: 0.7em;
  background: var(--color-accent-hot); opacity: 0.9;
}
/* One column: the facts stack and the ticks come off entirely. */
@media (max-width: 560px) {
  .meet__spec { display: block; }
  .meet__spec li + li { margin-top: var(--space-3xs); }
  .meet__spec li::after { display: none; }
}

/* ── TRADES — one typographic block ───────────────────────────────────
   Third attempt. Two columns of small rows read as a spec table; eight big
   rows read as a wall. Eight items presented flatly is the problem, not the
   styling — so they stop being a list and become one graphic: the trades set
   as a single field of display type, with the description revealed on
   approach rather than printed eight times.

   The premium version of this section is a list paired with an image that
   swaps per trade. That needs eight photos and Joe has four, so this does the
   same job with type alone. */
.index { padding: var(--space-2xl) var(--gutter) var(--space-2xl); }
.index__h {
  font-size: var(--text-xs); font-weight: 600; letter-spacing: var(--track-label);
  font-family: var(--font-ui); color: var(--color-ink-2);
  padding-bottom: var(--space-sm);
  border-bottom: var(--rule-hair) solid var(--color-rule);
}

.wall { position: relative; padding-top: var(--space-lg); }

/* ── the shifting field ────────────────────────────────────────────────
   Each trade carries a photo of that work. It fades in behind the type on
   hover, and cycles on its own every 5s so the section is alive before you
   touch it. Desktop only — the images are injected by JS, so a phone never
   downloads 570KB of decoration. */
/* ── TRADES — words on the photograph ─────────────────────────────────
   The words sit ON the picture and highlight as it changes.

   The reason an earlier pass buried the photo: a FLAT 80% scrim across the
   whole panel. Contrast is only needed where the type is, so the scrim is now
   weighted — heavy across the top third behind the words and the caption, then
   clearing to 16% so the lower half of the photograph is genuinely visible.
   The section is also taller, so there is real picture to see below the type. */
.index { position: relative; padding: var(--space-2xl) var(--gutter); }
/* Joe's photo overhangs DOWN into the top-left of this panel. Same reserve as
   the film uses above it, so the trades heading never sits under him. */
@media (min-width: 900px) {
  .index { padding-top: calc(var(--space-2xl) + var(--meet-break)); }
}

.picker__bg { display: none; }
.wall { position: relative; }
.wall__list { display: block; }
.wall__list li { display: inline; }
.wall__list a { text-decoration: none; }
.picker__desc-src { display: none; }

.wall__name {
  font-family: var(--font-lettering); font-weight: 400; text-transform: uppercase;
  font-size: clamp(1.375rem, 0.3rem + 2vw, 2.25rem);
  /* Anton sets very tight by design and nine of these stacked read as one
     clotted block, so the tracking is opened up hard — this went from
     -0.005em on Libre Franklin to +0.075em here. */
  letter-spacing: 0.075em;
  filter: url(#f-thin);
  line-height: 1.2;
  color: var(--color-ink);
  white-space: nowrap;          /* never orphan a separator onto a new line */
  transition: color var(--dur-short) var(--ease-out), opacity var(--dur-short) var(--ease-out);
}
/* The orange dashes that used to sit between names are gone. In a grid they
   land at the END of a row with nothing on the other side, which stops reading
   as punctuation and starts reading as damage. The tick added at 900+ replaces
   them and does a job the dashes never did: it says which trade is showing. */

.picker__desc {
  margin: var(--space-lg) 0 0; max-width: 46ch;
  font-size: var(--text-md); line-height: 1.45; color: var(--color-ink-2);
  min-height: 3em;              /* a fixed slot — the block must not resize as it cycles */
}
.wall__prog { display: none; }

/* ── the right-hand rail ──────────────────────────────────────────────
   NOT a card. A filled, bordered box floating on a photograph is the single
   most template-looking object you can put on a page — it reads as a widget
   someone dropped on top rather than part of the design. So this has no
   background, no border box and no padding: a hairline above it (the same
   hairline the titleblock uses) and then type sitting directly on the picture,
   carried by the same text-shadow as the trade names. */
.wall__aside {
  border-top: var(--rule-hair) solid var(--color-rule);
  padding-top: var(--space-sm);
  /* Stacked (under 1120) it lands below the countdown bar, and two hairlines
     40px apart read as one indecisive divider. Push it clear. */
  margin-top: var(--space-2xl);
  max-width: 30rem;
}
.wall__aside__label {
  margin: 0; font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase; color: var(--color-ink-2);
}
.wall__aside__tel {
  display: inline-block; margin-top: var(--space-3xs);
  font-family: var(--font-lettering); font-weight: 400;
  font-size: clamp(1.5rem, 1rem + 1.15vw, 2rem); line-height: 1;
  letter-spacing: 0.05em; white-space: nowrap;
  filter: url(#f-thin);
  color: var(--color-ink); text-decoration: none;
  transition: color var(--dur-short) var(--ease-out);
}
.wall__aside__tel:hover { color: var(--color-accent-hot); }
.wall__aside__p {
  margin: var(--space-2xs) 0 0; max-width: 30ch;
  font-family: var(--font-body); font-size: var(--text-sm); line-height: 1.5;
  color: var(--color-ink-2);
}

/* ── ONE alignment rule, two columns ──────────────────────────────────
   Fourth attempt, and the one that fixes the actual defect. The previous pass
   ran four columns and forced the last one flush right (`text-align: right` on
   :nth-child(4n)) to tidy the ragged edge. That traded a ragged edge for a
   broken one: FLOORING and GENERAL REPAIRS jammed into the gutter and read as
   words cut off by the window, while the gaps between the short names blew
   open. A field of display type gets ONE alignment rule. If the right edge is
   the problem, constrain the measure or change the column count — never flip
   the last column.

   So: two columns, both left-aligned, both inside a measure that stops well
   short of the gutter. The longest name (PRESSURE WASHING, ~295px at the 2.85rem
   cap) clears a 373px column at 900 and a 394px column at 1440, so the grid
   costs no type size. Nothing can touch an edge because nothing reaches one. */
@media (min-width: 900px) {
  .wall__list {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: var(--space-2xl);
    row-gap: 0.3em;
  }
  .wall__list li { display: block; }

  /* Which trade is showing has to be legible at a glance. The old tell was 72%
     white going to 100% white on a busy photograph, which is not a state
     change — it made a working feature look like a rendering glitch. */
  .wall__list a { position: relative; display: block; padding-left: 0.86em; }
  .wall__list a::before {
    content: ""; position: absolute; left: 0; top: 0.5em;
    width: 0.44em; height: 0.11em; background: var(--color-accent-hot);
    transform: scaleX(0); transform-origin: left;
    transition: transform var(--dur-mid) var(--ease-out);
  }
  .wall__list .is-active a::before,
  .wall__list a:hover::before,
  .wall__list a:focus-visible::before { transform: scaleX(1); }
}

/* 1120+: the card comes up beside the list. It waits until here because below
   it the type column is too narrow for PRESSURE WASHING at two columns, and
   shrinking the names to fit a card is the wrong trade. */
@media (min-width: 1120px) {
  .wall {
    display: grid;
    grid-template-columns: minmax(0, 1.5fr) minmax(16rem, 0.5fr);
    column-gap: var(--space-2xl);
    align-items: start;
  }
  .wall__aside { margin-top: 0; }
}

/* Desktop shows each trade as a full-bleed photograph; the phone showed nine
   lines of text and nothing else, so the section's only PROOF was invisible to
   most of the traffic. These thumbnails put the work back without importing the
   desktop mechanic: no hover, no rotation, no type sitting on a picture at
   390px. Separate files, not the backgrounds — 328KB against ~2.5MB, so the
   "a phone never downloads the decoration" rule above still holds. */
.wall__thumb { display: none; }

/* ── the phone gets the photographs, not a list with stamps ───────────
   Three passes, each one because the pictures were too small:

     · until 2026-08-11 — the desktop row, name and description on the left, a
       4.75rem thumbnail parked on the right. At 76px a photograph proves
       nothing: it is narrower than the word beside it.
     · 2026-08-11 — a two-up grid, 4:3, ~171 CSS px a cell. Five times the area
       and still read as thumbnails rather than as work.
     · 2026-08-12, this — one per row, 3:2, bled past the section's gutter to
       the full width of the phone. 390 CSS px against 171 is 2.3x the width
       and 5x the area again, and at that size it stops being a cell with a
       picture in it and becomes a photograph.

   The full bleed is the negative margin below and is what makes the difference
   feel like more than a size bump — a photograph that touches both edges reads
   as the subject of the section rather than as an illustration inside it. The
   NAME keeps the gutter, so the type still lines up with the heading above it
   and only the pictures break out.

   What this costs is scroll: nine full-width photographs are about 2,600px of
   phone, against ~950 for the grid. Accepted deliberately. This section is the
   only proof on the page that Joe does nine different things, and a picture
   too small to read is not proof at any scroll length.

   What it also costs is the descriptions — nine words of copy will not set
   under a picture without competing with the trade name — so they leave the
   layout but stay in the DOM, which is where a crawler reads them and where
   the desktop rotation gets its text. */
@media (max-width: 899px) {
  .wall__list {
    display: block;
    /* out past .index's gutter to the viewport edge. .wall and .wall__col add
       no horizontal padding of their own, so this one negative margin is the
       whole bleed — but it also means anything that gains padding between here
       and .index will silently inset the photographs again. */
    margin-inline: calc(var(--gutter) * -1);
  }
  .wall__list li { display: block; }
  .wall__list li + li { margin-top: var(--space-lg); }
  .wall__list a { display: block; }

  .wall__thumb {
    display: block;
    width: 100%; height: auto; aspect-ratio: 3 / 2;
    object-fit: cover;
    background: var(--color-navy-deep);   /* holds the row open before it loads */
  }

  .wall__name {
    display: block;
    padding-inline: var(--gutter);        /* the bleed is the picture's, not the type's */
    /* the name leads its picture, so the space goes UNDERNEATH it — the gap to
       the row above is the li margin and does not need help. */
    margin-bottom: var(--space-3xs);
    /* the shared clamp tops out at 2.25rem, which is desktop's size sitting on
       a photograph. Full width can carry more than the 171px cell could, so
       this is up from the grid's 1.125rem — but it stays under the heading
       above it, which is the thing the eye should reach first. */
    font-size: clamp(1.125rem, 0.7rem + 1.9vw, 1.5rem);
    letter-spacing: 0.06em;
    white-space: normal;
  }

  .picker__desc { display: none; }
}

@media (min-width: 900px) {
  .index.has-bg {
    background: var(--color-navy-deep);
    /* The photograph IS the content here — each trade is shown, not just named.
       So the panel stays tall on purpose: the area below the type is picture,
       not padding, and shortening it to "close a gap" throws away the only
       proof the section offers. If the lower half ever reads as empty, the
       scrim is too heavy — fix the scrim, not the height. */
    /* Raised from clamp(32rem, 80svh, 52rem) on 2026-08-10 — the panel read as
       cut off, with too little photograph below the type.

       The panel aspect matters more than the number. At 52rem it was ~1.69:1,
       and `object-fit: cover` scales a source until it covers BOTH axes — so a
       1.69 panel takes a 4:3 phone photo and throws away a third of its height.
       Joe shoots on a phone: 4:3 (1.33) and 3:2 (1.50). At 60rem the panel is
       ~1.46:1, which sits between those two, so his photos now lose under 10%
       instead of being gutted.

       The cost lands on the three letterboxed 1600x675 stock files (carpentry,
       flooring, handyman): they go from losing 29% of their width to 38%. That
       is accepted deliberately — all three are Unsplash placeholders due for
       replacement, so tuning the panel around them would be tuning around
       pictures that are leaving. See img/README.md. */
    min-height: clamp(38rem, 92svh, 60rem);
    padding-bottom: var(--space-3xl);
    display: flex; flex-direction: column;
    overflow: hidden;
    isolation: isolate;
  }
  .picker__bg { display: block; position: absolute; inset: 0; z-index: -2; }

  /* ── grain over the photographs ────────────────────────────────────
     Joe's photos are the resolution they are — 1131–1600px against a panel
     that is 2880 DEVICE pixels wide on a 2x display, so every one of them is
     stretched 1.8–2.5x and reads soft. There is no larger source; this is what
     came off his phone.

     So the softness is dressed rather than denied. A fine grain laid over the
     photograph gives the eye high-frequency detail to hold onto, which is the
     thing an upscaled image lacks, and it makes the result read as a graded
     photographic treatment instead of a low-resolution file. It also earns its
     place: the hero headline is distressed lettering and the display type is
     eroded and grained, so the pictures now speak the same language.

     Cheap: one 180px SVG tile, no request, composited once. soft-light rather
     than overlay — overlay crushes the blacks in the night shots. */
  .picker__bg::after {
    content: ""; position: absolute; inset: 0; pointer-events: none;
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='g'><feTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='4' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='180' height='180' filter='url(%23g)' opacity='0.42'/></svg>");
    background-size: 180px 180px;
    mix-blend-mode: soft-light;
    opacity: 0.55;
  }

  /* A touch of contrast and saturation back into the photographs. Upscaling
     softens local contrast as well as edges; this returns some of the bite
     without touching the files. Kept small — past ~1.1 the night shots
     posterise in the sky. */
  .picker__bg img { filter: contrast(1.06) saturate(1.05); }
  /* Crossfade only. This used to run a 7s scale(1.06) -> scale(1) push-in
     behind the trade list, which is the same zoom tic pulled everywhere else
     and the worst place for it: the type sits ON the photograph, so a moving
     background drags the words with it. */
  .picker__bg img {
    position: absolute; inset: 0; width: 100%; height: 100%;
    object-fit: cover; opacity: 0;
    /* The outgoing photograph. It does NOT fade — it holds at full opacity for
       the length of the crossfade and then cuts to 0, which is a 0s transition
       on a delay, not an animation.

       This is the fix for "the pictures change abruptly" (2026-08-11). The
       obvious cross-dissolve — fade the old one out while the new one fades in
       — is what was here, and it dips: at the midpoint both sit near 0.5, they
       are stacked over a dark panel, and the sum is visibly darker than either
       photograph. The eye reads that dip as a flicker, and shortening the fade
       only made it snappier.

       Holding the outgoing frame underneath means the incoming one is always
       revealed over a complete picture, so the luminance never drops and the
       cut underneath is invisible — it happens after the new photograph has
       fully covered it. */
    transition: opacity 0s linear var(--pick-fade);
  }
  /* The incoming photograph: on top, and the only one that actually animates. */
  .picker__bg img.on {
    opacity: 1;
    z-index: 1;
    transition: opacity var(--pick-fade) var(--ease-out);
  }

  /* weighted, not flat: dark only where the type actually sits */
  .index.has-bg::after {
    content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
    background: linear-gradient(
      oklch(18% 0.04 262 / 0.82) 0%,
      oklch(18% 0.04 262 / 0.74) 40%,
      oklch(18% 0.04 262 / 0.54) 56%,
      oklch(18% 0.04 262 / 0.16) 72%,
      oklch(18% 0.04 262 / 0.04) 100%);
  }

  /* This section is deliberately SHADOWLESS. It used to carry a two-layer
     text-shadow here — "contrast that travels with the type" — which is what
     bought the lighter gradient above. Two things killed it:

     1. The trade names are now eroded (see the filters in index.html), and
        erode takes the minimum in its kernel: it fattens dark pixels while
        thinning light ones. The shadow bloomed into a visible halo.
     2. Marcus asked for it gone outright.

     The contrast it was buying is paid for instead by --color-on-photo, which
     went 0.72 -> 0.85. That clears WCAG AA on all nine backgrounds without
     touching the scrim. Do NOT reintroduce a text-shadow on eroded type; put
     it in the filter chain after the erode, or not at all. */

  .index.has-bg .titleblock__label,
  .index.has-bg .titleblock__no { color: var(--color-ink-light); }
  .index.has-bg .titleblock { border-bottom-color: var(--color-rule-light); }
  .index.has-bg .titleblock::before { background: var(--color-rule-light); }
  .index.has-bg .wall__name { color: var(--color-on-photo); }   /* inactive */
  .index.has-bg .is-active .wall__name,
  .index.has-bg a:hover .wall__name,
  .index.has-bg a:focus-visible .wall__name { color: var(--color-ink-light); }
  .index.has-bg .picker__desc { color: var(--color-ink-light); }
  .index.has-bg .wall__aside { border-top-color: var(--color-rule-light); }
  .index.has-bg .wall__aside__tel { color: var(--color-ink-light); }
  .index.has-bg .wall__aside__tel:hover { color: var(--color-accent-hot); }
  .index.has-bg .wall__aside__label,
  .index.has-bg .wall__aside__p { color: var(--color-on-photo); }

  /* countdown, hairline across the foot of the type block */
  .index.has-bg .wall__prog {
    display: block; margin-top: var(--space-md);
    width: 180px;            /* an indicator, not a divider across the page */
    height: 2px; background: var(--color-rule-light);
  }
  .wall__prog i { display: block; height: 100%; background: var(--color-accent-hot);
    transform: scaleX(0); transform-origin: left; }
  /* Must equal the setInterval in script.js — 2s since 2026-08-11. If the two
     drift apart the bar either fills early and sits there, or is still
     travelling when the photo swaps, and both read as a broken timer. */
  .wall.is-running .wall__prog i { animation: fillbar 2s linear forwards; }
  .wall.is-held .wall__prog i { animation-play-state: paused; }
  @keyframes fillbar { from { transform: scaleX(0); } to { transform: scaleX(1); } }
}

@media (prefers-reduced-motion: reduce) {
  .picker__bg img { transition: opacity 1ms linear; }
  .wall__prog { display: none !important; }
}

/* ── MORE RECENT WORK · the thumbnail strip ───────────────────────────
   Deliberately subordinate to the two featured jobs above it: smaller plates,
   inset instead of full-bleed, quieter captions. Full-bleed here competed with
   the sliders and the eye couldn't tell which set of photos mattered.
   Caption is two lines — what it was, then the town. The town is the line a
   homeowner in Melbourne actually scans for. */
/* no bottom padding — .ba already closes the section, and stacking both left
   a 144px hole above the contact form */
/* Sub-head inside the same section, not a new one — 4xl read as a section
   break and left a band of empty paper between the two jobs and the strip.
   A flat 3rem rather than 2xl: 2xl is larger than 4xl resolves to on a phone,
   so it would have made the strip further away on the screen that can least
   afford it. */
.more { margin-top: var(--space-lg); }

/* A contact sheet, not four cards. Gaps, gutters and captions-underneath were
   what made this strip read as a stock template: four identical boxes on
   paper, each one small, each one framed by more empty page than photograph.
   Edge to edge with hairline splits, the four photos become one band of work
   and the paper stops competing with them. */
.plates {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  margin-top: var(--space-md);
}
@media (min-width: 700px) { .plates { grid-template-columns: repeat(4, minmax(0, 1fr)); } }

/* The split is the paper colour, so the leftmost cell's border simply
   disappears against the page — no :first-child exception needed. */
.plate { position: relative; border-left: 2px solid var(--color-paper); }
.plate__btn {
  display: block; width: 100%; padding: 0; border: 0; cursor: pointer;
  background: var(--color-navy-deep); overflow: hidden;
}
/* Tall crops. A 4:3 thumbnail of a room is mostly floor and ceiling; a
   portrait crop at this width holds one subject and stacks into a band with
   real vertical presence. */
.plate__btn img { display: block; width: 100%; height: 100%; object-fit: cover; aspect-ratio: 4 / 5; }
@media (min-width: 700px) { .plate__btn img { aspect-ratio: 4 / 5; } }

/* Printed on the photograph, over a gradient — always visible, because a
   phone has no hover to reveal it. */
.plate figcaption {
  position: absolute; left: 0; right: 0; bottom: 0; pointer-events: none;
  padding: var(--space-xl) var(--space-sm) var(--space-sm);
  background: linear-gradient(to top, oklch(12% 0.03 262 / 0.88), oklch(12% 0.03 262 / 0));
  font-family: var(--font-ui); line-height: 1.4;
}
.plate figcaption b {
  display: block; font-size: var(--text-sm); font-weight: 600;
  color: var(--color-ink-light);
  transition: color var(--dur-short) var(--ease-out);
}
.plate figcaption span {
  display: block; font-size: var(--text-xs);
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-on-photo);
}
/* accent-hot, not accent: plain orange on a dark scrim is the wrong end of
   the ramp and goes muddy against a shadowed photograph. */
.plate:hover figcaption b, .plate:focus-within figcaption b { color: var(--color-accent-hot); }

.more__cta {
  margin-top: var(--space-lg); padding-inline: var(--gutter); text-align: center;
}

/* ── FOLD 6 · contact — THE DRAWING SHEET ─────────────────────────────
   The ornament family that annotates the rest of the page (title blocks,
   plate numbers, crop marks, small-caps labels) becomes the structure here.
   The fold is one sheet off a drawing set: ruled border, crop marks at the
   four corners, a registration cross outside the trim, the site's coordinates
   set up the right edge, and a ghosted elevation behind the type.

   Everything is drawn in CSS or inline SVG. No images, no box-shadows, no
   radius — a bevelled "blueprint card" would undo the whole idea. */
.contact {
  padding: var(--space-2xl) var(--gutter) var(--space-xl);
}

/* The trim line, and the crop marks sitting on its corners. Eight gradients
   rather than eight elements: two arms per corner, drawn on the border box so
   they land exactly on the trim rather than near it. */
.sheet {
  --crop: 30px;
  --crop-w: 2px;
  position: relative;
  display: grid; gap: var(--space-2xl);
  padding: clamp(1.5rem, 3vw, 2.5rem) clamp(1.25rem, 3.5vw, 3rem);
  border: var(--rule-hair) solid var(--color-rule);
  background-origin: border-box;
  background-repeat: no-repeat;
  background-image:
    linear-gradient(var(--color-accent) 0 0), linear-gradient(var(--color-accent) 0 0),
    linear-gradient(var(--color-accent) 0 0), linear-gradient(var(--color-accent) 0 0),
    linear-gradient(var(--color-accent) 0 0), linear-gradient(var(--color-accent) 0 0),
    linear-gradient(var(--color-accent) 0 0), linear-gradient(var(--color-accent) 0 0);
  background-size:
    var(--crop) var(--crop-w), var(--crop-w) var(--crop),
    var(--crop) var(--crop-w), var(--crop-w) var(--crop),
    var(--crop) var(--crop-w), var(--crop-w) var(--crop),
    var(--crop) var(--crop-w), var(--crop-w) var(--crop);
  background-position:
    left top, left top,
    right top, right top,
    left bottom, left bottom,
    right bottom, right bottom;
}
@media (min-width: 900px) {
  .sheet {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: clamp(2.5rem, 5vw, 5rem);
    align-items: start;
  }
}

/* Registration cross, outside the trim on the top right — the mark a printer
   lines the plates up on. Hidden on phones, where there is no room outside
   the sheet for anything to sit. */
.sheet__reg { display: none; }
@media (min-width: 1100px) {
  .sheet__reg {
    display: block; position: absolute; top: -9px; right: -34px;
    width: 18px; height: 18px; pointer-events: none;
  }
  .sheet__reg::before, .sheet__reg::after {
    content: ""; position: absolute; background: var(--color-accent);
  }
  .sheet__reg::before { top: 8px; left: 0; width: 18px; height: 2px; }
  .sheet__reg::after  { left: 8px; top: 0; width: 2px; height: 18px; }
}

/* Coordinates up the right edge. writing-mode rather than a rotate, so the
   glyphs stay upright to the baseline and the box reserves its own width. */
.sheet__coord { display: none; }
@media (min-width: 1100px) {
  .sheet__coord {
    display: block; position: absolute; top: clamp(3rem, 8vw, 7rem); right: -2.35rem;
    writing-mode: vertical-rl;
    font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 500;
    letter-spacing: var(--track-label); text-transform: uppercase;
    color: var(--color-ink-2); opacity: 0.75; white-space: nowrap;
  }
}

/* The backdrop. A real drawing sheet — elevation, truss detail, dimension
   grids, contour lines — sitting behind the whole fold instead of the single
   hand-drawn house that was here before.

   It rides a pseudo-element rather than .sheet's own background because that
   slot is already taken by the eight gradients that draw the crop marks. Its
   own frame was cropped off in the file (see img/README.md): the sheet draws
   crop marks in CSS where they adapt to any height, and two frames would
   never have lined up.

   Opacity, not a lightened file — one number to tune, and it keeps working if
   the artwork is ever swapped for a different drawing. */
.sheet::before {
  content: ""; position: absolute; z-index: 0; pointer-events: none;
  /* The backdrop is confined to the LEFT of the sheet and faded out before it
     reaches the form. Lowering the opacity was tried first and was the wrong
     fix: at any opacity the drawing still crossed every input underline, and a
     field rule you have to visually separate from a background line is a field
     rule that has stopped doing its job. The elevation lives in the artwork's
     left half, so cropping to the left keeps the good part anyway.

     Belt and braces on purpose — the inset confines it even where the mask is
     not supported, and the mask softens what would otherwise be a hard edge
     down the middle of the paper. */
  inset: 0 42% 0 0;
  background: url("/img/sheet-blueprint.b5b7ce6c.jpg") left bottom / cover no-repeat;
  opacity: 0.5;
  -webkit-mask-image: linear-gradient(to right, #000 0 58%, transparent 96%);
          mask-image: linear-gradient(to right, #000 0 58%, transparent 96%);
}
/* Below the two-column breakpoint the sheet is a tall narrow box — measured,
   350 x 1292 CSS px at a 390 viewport, a ratio of 0.271 — and `cover` on a 16:9
   drawing in a box that shape scales it 6.5x. That is not a drawing, it is a
   handful of stray lines crossing the form, which is why this fold had no
   backdrop at all on a phone until 2026-08-12.

   The fix is not a different `background-size`, it is different artwork: a
   piece cut from the source and pinned where the fold has room for it. See
   img/build-sheet-blueprint.py for how it is cut.

   It is alpha, not paper, so there is no cream rectangle to match against the
   fold's own cream, and it is faded IN THE FILE at the end that points inward.
   That is why this rule is one background line and no mask — the desktop mask
   fades left-to-right, which is the wrong axis here.

   THE BOTTOM HALF IS GONE ON PURPOSE. The first version also pinned the house
   elevation along the floor of the sheet, which is where the source drawing
   puts it. It landed straight behind the AREA and HOURS block and Marcus called
   it: the roofline ran through the address, and dropping the opacity to 0.4 made
   it fainter without making it stop competing. There is nowhere else in this
   fold to put it — the only clean gaps are ~60px tall — so the elevation was
   removed rather than shrunk into something pointless. The rafter detail keeps
   the corner it already had, over paper that has nothing else in it.

   If it is ever wanted back, `sheet-elevation.webp` is one uncommented line in
   img/build-sheet-blueprint.py and one more `url()` here. Do not bring it back
   without also moving what sits under it. */
@media (max-width: 899px) {
  .sheet::before {
    inset: 0;
    background: url("/img/sheet-truss.3a515437.webp") right 1% top 2% / 46% auto no-repeat;
    /* LOWER than desktop's 0.5. Desktop's drawing is confined to the left half
       where it never meets type; this one sits in the open corner of a single
       column, at the size type is read. */
    opacity: 0.4;
    -webkit-mask-image: none;
            mask-image: none;
  }
}

.sheet > .contact__intro, .sheet > .sheet__side { position: relative; z-index: 1; }
.sheet__side { display: grid; align-content: start; gap: var(--space-lg); }
/* Plate number. The one place on the page a section numbers itself. */
.sheet__idx {
  display: inline-block; margin: 0 0 var(--space-md);
  padding-bottom: var(--space-2xs);
  border-bottom: 2px solid var(--color-accent);
  font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase; color: var(--color-ink);
}

.contact__intro h2 {
  font-family: var(--font-lettering); font-weight: 400;
  text-transform: uppercase;
  font-size: clamp(2.75rem, 1.4rem + 4.2vw, 4.75rem); line-height: 1.06;
  letter-spacing: 0.02em;
  filter: url(#f-head);
  margin-bottom: var(--space-md); max-width: 20ch;
}
.contact__lede { color: var(--color-ink-2); max-width: 34ch; margin-bottom: 0; }

/* Short accent rule between the pitch and the phone number — the drawing-set
   equivalent of a paragraph break. */
.sheet__tick {
  display: block; width: 42px; height: 2px;
  margin: var(--space-md) 0;
  background: var(--color-accent);
}

.contact__callto {
  margin: 0 0 var(--space-3xs);
  font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase; color: var(--color-ink-2);
}
.contact__tel {
  display: inline-block; margin: 0;
  font-size: var(--text-xl); text-decoration: none;
  transition: color var(--dur-short) var(--ease-out);
}
.contact__tel:hover { color: var(--color-accent-hot); }

/* The trust row under the trim — the title block along the bottom of a
   drawing sheet. Centred, not left-aligned: it is the last thing on the page
   before the footer and it summarises rather than continues, so it reads as a
   stamp across the sheet instead of one more left-ranged column.

   Icons rather than dots between items. The dots were doing separation only;
   a mark per claim does that AND tells you what kind of claim it is at a
   glance, which is the whole job of a trust row. */
.trust {
  display: flex; flex-wrap: wrap; justify-content: center;
  gap: var(--space-sm) clamp(1.25rem, 4vw, 3.25rem);
  margin-top: var(--space-md); padding-top: var(--space-md);
  border-top: var(--rule-hair) solid var(--color-rule);
  font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase;
  color: var(--color-ink);
  text-align: center;
}
.trust li { display: flex; align-items: center; gap: 0.55em; }
/* Stroked, never filled, at the weight of the hairlines around them —
   a filled glyph here would read as an app icon dropped on a drawing. */
.trust svg {
  width: 1.55em; height: 1.55em; flex: none;
  fill: none; stroke: var(--color-accent-hot); stroke-width: 1.6;
  stroke-linecap: round; stroke-linejoin: round;
}

.facts { margin: 0; display: grid; gap: var(--space-sm);
  padding-top: var(--space-sm); border-top: var(--rule-hair) solid var(--color-rule); }
/* Three across only once there is room for it. A two-column tier was tried at
   380px and made the block TALLER, not shorter — the hours and the service-area
   strings both wrap to five lines in a half column, which costs more than
   stacking them ever did. */
@media (min-width: 560px) { .facts { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-md); } }
.facts dt {
  font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase;
  color: var(--color-ink-2); margin-bottom: var(--space-3xs);
}
.facts dd { margin: 0; font-size: var(--text-sm); }
.facts dd a { text-underline-offset: 3px; }
.facts dd a:hover { color: var(--color-accent-hot); }

/* Inputs are ruled lines, not boxes — the same reason there are no cards. */
.field { margin-bottom: var(--space-sm); }
.field-row { display: grid; gap: var(--space-sm); }
@media (min-width: 540px) { .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.field label {
  display: block; margin-bottom: var(--space-3xs);
  font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase; color: var(--color-ink);
}
.req { color: var(--color-accent-hot); }
.field input, .field select, .field textarea {
  width: 100%; padding: var(--space-2xs) 0;
  font-family: var(--font-body); font-size: 1rem; font-weight: 500;
  color: var(--color-ink);
  background: transparent;
  /* 2px in ink at 40%, not a 1px hairline at 16%. The faint rule was the
     single biggest reason the form read as an afterthought next to the
     headline — the line you are asked to write on has to look like a line. */
  border: 0; border-bottom: 2px solid oklch(28.9% 0.062 263.9 / 0.4);
  border-radius: 0;
  transition: border-color var(--dur-short) var(--ease-out);
}
.field select { padding-inline-end: var(--space-md); }
.field textarea { resize: vertical; min-height: 76px; }
.field input:hover, .field select:hover, .field textarea:hover { border-bottom-color: var(--color-ink-2); }
.field input:focus, .field select:focus, .field textarea:focus {
  outline: 0; border-bottom-color: var(--color-accent);
}
.field input:focus-visible, .field select:focus-visible, .field textarea:focus-visible {
  outline: 3px solid var(--color-focus); outline-offset: 3px;
}
.field input[disabled], .field select[disabled], .field textarea[disabled] { opacity: 0.5; }
.field.is-invalid input, .field.is-invalid select { border-bottom-color: var(--color-accent-hot); }
.err { margin: var(--space-3xs) 0 0; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--color-ink); }
.err::before { content: "▸ "; color: var(--color-accent); }

.quote .cta { margin-top: var(--space-sm); }

/* Honeypot. Moved off-screen rather than display:none or hidden — a bot that
   checks whether a field is rendered will skip a hidden one, and the entire
   point is that it fills this in. Nothing here is reachable by keyboard or
   announced by a screen reader; see the markup for how. */
.hp {
  position: absolute; left: -9999px; top: auto;
  width: 1px; height: 1px; overflow: hidden;
}

.form-note {
  margin: var(--space-md) 0 0; padding: var(--space-sm);
  background: var(--color-navy); color: var(--color-ink-light); font-size: var(--text-sm);
}
.form-note a { color: var(--color-accent-hot); }

/* Replaces the whole form on success. Sized like the intro copy rather than
   like a toast — this is the last thing a customer reads on the page, and a
   thin grey line of confirmation text reads as "maybe that worked". */
.form-sent {
  margin: 0; padding: var(--space-lg);
  background: var(--color-navy); color: var(--color-ink-light);
  font-size: var(--text-md); line-height: 1.5;
}
.form-sent b { color: #fff; }
.form-sent a { color: var(--color-accent-hot); }
/* Focused programmatically to announce it, never reached by Tab — so the ring
   would only ever appear for someone who did not press a key. */
.form-sent:focus { outline: none; }
.form-fine { margin: var(--space-2xs) 0 0; font-size: var(--text-xs); color: var(--color-ink-2); }

/* ── footer ───────────────────────────────────────────────────────── */
.foot {
  background: var(--color-navy-deep); color: var(--color-ink-light-2);
  padding: var(--space-2xl) var(--gutter) var(--space-lg);
  font-size: var(--text-sm);
}
.foot__row { display: grid; gap: var(--space-lg); align-items: start; }
@media (min-width: 860px) { .foot__row { grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-2xl); } }
.foot__trades { margin: 0; line-height: 1.9; max-width: 52ch; }
.foot__reach { margin: 0; }
.foot a { color: var(--color-ink-light); text-underline-offset: 3px; }
.foot a:hover { color: var(--color-accent-hot); }
.foot__base {
  display: flex; flex-wrap: wrap; gap: var(--space-3xs) var(--space-md);
  justify-content: space-between;
  margin-top: var(--space-2xl); padding-top: var(--space-sm);
  border-top: var(--rule-hair) solid var(--color-rule-light);
  font-size: var(--text-xs);
}
.foot__base p { margin: 0; }
/* --color-steel would be 3.01:1 on navy-deep and fail AA at this size. */
.credit a {
  display: inline-flex; align-items: center; gap: 0.45em;
  color: var(--color-ink-light-2); text-decoration-color: transparent;
}
.credit a:hover { color: var(--color-accent-hot); }
/* Deliberately small — this is a signature, not a second logo on Joe's site.
   alt="" because the sentence beside it already names Hatwebs; announcing the
   mark as well would read the credit twice. */
.credit__mark {
  width: 18px; height: auto; flex: none;
  opacity: 0.85;
  transition: opacity var(--dur-short) var(--ease-out);
}
.credit a:hover .credit__mark { opacity: 1; }

/* ── mobile call bar ──────────────────────────────────────────────── */
.callbar {
  position: fixed; inset: auto 0 0 0; z-index: 95;
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  height: var(--callbar-h);
}
.callbar a {
  display: flex; align-items: center; justify-content: center; gap: var(--space-3xs);
  font-size: var(--text-md); font-weight: 700; letter-spacing: 0.04em;
  text-decoration: none; white-space: nowrap;
}
.callbar__call  { background: var(--color-navy-deep); color: var(--color-ink-light); }
.callbar__quote { background: var(--color-paper); color: var(--color-ink);
                  box-shadow: inset 1px 0 0 var(--color-rule); }
@media (min-width: 900px) { .callbar { display: none; } }

/* ── lightbox ─────────────────────────────────────────────────────── */
.lightbox {
  position: fixed; inset: 0; z-index: 200;
  display: flex; align-items: center; justify-content: center; gap: var(--space-2xs);
  padding: var(--gutter);
  background: oklch(18% 0.04 262 / 0.97);
}
.lightbox[hidden] { display: none; }
.lightbox__fig { max-width: min(1200px, 100%); }
.lightbox__fig img { max-height: 78svh; width: auto; margin-inline: auto; }
.lightbox__fig figcaption {
  margin-top: var(--space-sm); text-align: center;
  color: var(--color-ink-light-2); font-family: var(--font-ui);
  font-size: var(--text-xs); letter-spacing: var(--track-label); text-transform: uppercase;
}
.lightbox__close, .lightbox__nav {
  background: transparent; border: var(--rule-hair) solid var(--color-rule-light);
  color: var(--color-ink-light); cursor: pointer; line-height: 1; flex: none;
  transition: background-color var(--dur-short) var(--ease-out), color var(--dur-short) var(--ease-out);
}
.lightbox__close { position: absolute; top: var(--space-sm); right: var(--space-sm); width: 46px; height: 46px; font-size: 1.75rem; }
.lightbox__nav { width: 46px; height: 64px; font-size: 2rem; }
.lightbox__close:hover, .lightbox__nav:hover {
  background: var(--color-accent-hot); border-color: var(--color-accent-hot); color: var(--color-navy-deep);
}

/* ── motion ───────────────────────────────────────────────────────────
   No scroll reveals. The Photographic macrostructure is explicit: let the
   photographs do the work. The only motion is the film itself. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important; animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important; scroll-behavior: auto !important;
  }
}

@media print {
  .nav, .callbar, .lightbox, .film__media, .film__scrim, .meet::before
    { display: none !important; }
  /* an overlapping print bleeds badly on paper — unstack the pair so both
     photographs print at full size, one under the other */
  .job__stack { padding: 0; }
  .job__before { position: static; width: 100%; margin-top: 4px; box-shadow: none; padding: 0; }
  .job__before img { filter: none; }
  .job__before figcaption, .job__after figcaption { position: static; display: inline-block; }
  body { padding: 0; background: none; }
  .film { min-height: auto; padding-bottom: var(--space-2xl); }
  /* the overlap has nothing to overlap once the film collapses */
  .meet__photo { margin-block: 0; }
}

/* ── ORNAMENT · "the drawing sheet" ───────────────────────────────────
   One family, drawn from what the reference sites actually use: index
   counters (Haven Constructions numbers its projects 1/5 … 5/5), small-caps
   labels, and rules. Rendered as an architect's drawing sheet — title blocks,
   plate numbers, crop marks, a stamp — because that's the right vocabulary for
   a contractor and it keeps every ornament in one hand.

   Nothing here states a fact Joe hasn't given us. No years-in-business, no job
   counts, no awards — the reference sites carry those, we don't have them. */

/* Title block: label, rule, sheet number. Replaces the bare section label. */
/* Flex with explicit order, not grid: a grid ::before placed in column 2 is
   still the FIRST item in DOM order, so auto-placement pushed the label to
   column 3 and wrapped the sheet number onto a second row. */
.titleblock {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  padding-bottom: var(--space-sm);
  border-bottom: var(--rule-hair) solid var(--color-rule);
}
.titleblock--inset { margin-inline: var(--gutter); }
.titleblock__label {
  font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase;
  color: var(--color-ink); white-space: nowrap;
  order: 1;
}
/* the rule that runs between label and sheet number */
.titleblock::before {
  content: ""; order: 2; flex: 1 1 var(--space-md);
  height: 1px; background: var(--color-rule);
}
.titleblock__no {  /* retired — kept harmless for any future use */
  order: 3;
  font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 500;
  letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--color-ink-2); white-space: nowrap;
}
/* the sheet number is the first thing to go when space runs out */
@media (max-width: 599px) { .titleblock__no { display: none; } }

/* The drop cap was removed: at a 52px statement a two-line cap needs ~123px of
   cap height and it only had ~95px, so the "E" floated free of the baseline and
   "ight trades" read as a broken word. Drop caps belong at body-copy sizes. */
/* (the LICENSED & INSURED stamp was retired: the credentials rail now
   carries the same claim as item 02, and two of them was repetition) */

@media (prefers-reduced-motion: reduce) {
  .ticker__track { animation: none; }
}

/* ── MOTION SYSTEM ────────────────────────────────────────────────────
   Added after a static build read as "generated". The reference that scored
   at Awwwards (Haven Constructions) ships a load animation, parallax and a
   reveal on scroll; a page with none of that reads as output, not craft.

   Rules kept: transform and opacity only, the three named easings, one idea
   per element, and everything collapses under prefers-reduced-motion. */

/* 1 · Load choreography — the hero fades up rather than appearing.
      It used to zoom out from scale(1.07). Every scale-based move on this page
      has been pulled: a photograph that grows or shrinks under the reader is
      the most recognisable template tic there is, and it draws attention to
      the animation instead of to the work. Photographs now arrive by moving
      IN FROM THE SIDE — see 3 below — and nothing on this page scales. */
.film__media { animation: settle 1.4s var(--ease-out) both; }
@keyframes settle { from { opacity: 0; } to { opacity: 1; } }

/* The headline used to be two lines of type revealed by sliding each line out
   from behind its own overflow mask. It is one piece of artwork now, so there
   are no lines to mask and it takes the same lift the rest of the fold uses.
   .ln is kept because the footer and the meet panel still use it. */
.film__wordmark { animation: lift 0.9s var(--ease-out) 0.18s both; }
.ln { display: block; overflow: hidden; }
.ln i {
  display: block; font-style: normal;
  animation: rise 1.05s var(--ease-out) both;
}
.ln:nth-child(1) i { animation-delay: 0.16s; }
.ln:nth-child(2) i { animation-delay: 0.28s; }
@keyframes rise { from { transform: translateY(110%); } to { transform: translateY(0); } }

.slogan--hero { animation: lift 0.8s var(--ease-out) 0.06s both; }
.film__line { animation: lift 0.9s var(--ease-out) 0.46s both; }
.film .cta   { animation: lift 0.9s var(--ease-out) 0.58s both; }
.film__caption { animation: lift 0.9s var(--ease-out) 0.7s both; }
.nav { animation: lift 0.8s var(--ease-out) 0.1s both; }
@keyframes lift { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }

/* 2 · Parallax — REMOVED 2026-08-10.
      It was `translate3d(0, var(--par) * 14vh, 0) scale(1.14)`, engaged by JS
      1.7s after load. The scale was not decoration: translating the photo
      ±14vh at scale 1 would drag its edges into frame and expose the navy
      behind it, so the 14% zoom existed purely to give the drift somewhere to
      hide. The visible result was a hero that sat at its intended crop for
      well over a second and then silently zoomed in — Marcus flagged the
      untouched framing as the better one, and he is right: the crop is
      composed in the FILE (see .film__media above), so any scale here fights
      a decision already made at the pixel level.

      Both halves had to go together. Keeping the drift without the scale
      shows bands; keeping the scale without the drift is a zoom for nothing.
      The load fade (`settle`, 1 above) is untouched, and the JS rAF loop that
      fed --par was removed from script.js at the same time. If parallax ever
      comes back here it needs a crop with vertical overflow to spend, not a
      transform bolted onto a 16:9 file that has none. */

/* 3 · Scroll reveal — photographs travel IN FROM THE SIDE.
      One rule for every picture on the page: it starts offset horizontally and
      slides to its place over about a second. Nothing scales, on scroll or on
      hover. Slow enough to read as deliberate, short enough (56–72px) that the
      page never looks like it is assembling itself.

      --slide-in is the distance and sign; each block below sets it and the
      direction is chosen from where the picture sits, so everything moves
      outward-to-inward rather than all one way. */
.js .plate { opacity: 0; transform: translateX(72px); }
.js .plate.in {
  opacity: 1; transform: none;
  transition: opacity 1.05s var(--ease-out), transform 1.15s var(--ease-out);
  transition-delay: .14s;
}
/* left to right across the strip, so the four read as one band arriving
   rather than four separate things happening at once. Each value is the
   .14s onset plus its place in the stagger — keep them in step if the
   onset changes. */
.js .plate:nth-child(2).in { transition-delay: .23s; }
.js .plate:nth-child(3).in { transition-delay: .32s; }
.js .plate:nth-child(4).in { transition-delay: .41s; }

.js .plate figcaption { opacity: 0; }
.js .plate.in figcaption {
  opacity: 1;
  transition: opacity .7s var(--ease-out) .44s, color var(--dur-short) var(--ease-out);
}
/* Hover is a colour change only. The 1.045 scale that used to live here was
   the "lame" one — it re-cropped the photograph under the cursor. */
.js .plate.in:hover figcaption b { color: var(--color-accent-hot); }

/* 4 · Everything else rises once, quietly. */
.js .rv { opacity: 0; transform: translateY(18px); }
.js .rv.in {
  opacity: 1; transform: none;
  transition: opacity .85s var(--ease-out), transform .85s var(--ease-out);
  transition-delay: .14s;
}
.js .rv-d1.in { transition-delay: .22s; }
.js .rv-d2.in { transition-delay: .30s; }
.js .rv-d3.in { transition-delay: .38s; }

/* 4b · MEET JOE — the panel flip.
      The one place on the page that gets a 3D entrance, because it is the one
      place built out of two flat planes meeting at an edge: Joe's portrait and
      the grain panel he stands against. The photograph swings in on a hinge at
      its LEFT edge, so the seam between the two columns is the axis and the
      picture opens into the page like a door rather than sliding across it.
      Then the text stands up off the panel, line by line, hinged at its
      baseline.

      Deliberately the slowest arrival on the site — the photo alone is 1.3s
      and the last spec line does not settle until ~1.5s in. That is the point:
      it is the introduction, it comes after the delay the reveal observer
      already imposes, and it is worth waiting through once.

      Every transform carries its OWN `perspective()` rather than inheriting
      one from a perspective/preserve-3d ancestor. The h2 has `filter:
      url(#f-head)` on it, and a filter flattens 3D for its descendants, so an
      ancestor-based setup would silently collapse the headline's flip into a
      2D squash while every other line kept its depth. Per-element perspective
      has no such dependency. Keep it that way. */
.js .meet__photo {
  opacity: 0;
  transform: perspective(1200px) translateX(-40px) rotateY(-24deg);
  transform-origin: left center;
}
.js .meet__photo.in {
  opacity: 1;
  transform: perspective(1200px) translateX(0) rotateY(0deg);
  transition: opacity .9s var(--ease-out) .16s,
              transform 1.3s var(--ease-flip) .16s;
}

/* The container itself does not move. It carries `rv` from script.js, and
   letting it rise 18px while its children flip would put two motions on the
   same pixels — the flip stops reading as a hinge and starts reading as
   drift. The children carry all of it. */
.js .meet__text { opacity: 1; transform: none; }
.js .meet__text > * {
  opacity: 0;
  transform: perspective(900px) rotateX(-62deg);
  transform-origin: 50% 100%;
}
.js .meet__text.in > * {
  opacity: 1;
  transform: perspective(900px) rotateX(0deg);
  transition: opacity .62s var(--ease-out), transform .9s var(--ease-flip);
}
/* Behind the photograph's hinge, not with it — the picture should be most of
   the way open before the first line of type starts to stand up. */
.js .meet__text.in > *:nth-child(1) { transition-delay: .42s; }
.js .meet__text.in > *:nth-child(2) { transition-delay: .56s; }
.js .meet__text.in > *:nth-child(3) { transition-delay: .66s; }
.js .meet__text.in > *:nth-child(4) { transition-delay: .74s; }

/* Narrow the compositor hint to the window where it earns its keep: the
   headline is a large element under an SVG morphology filter, and promoting it
   for the duration of the flip keeps the filter from re-rasterising per frame.
   `:not(.in)` drops the hint the moment the section has arrived, so nothing is
   left holding a layer for the rest of the visit. */
.js .meet__text:not(.in) > *, .js .meet__photo:not(.in) { will-change: transform; }

/* On phones the two planes are stacked, not side by side, so there is no seam
   for the photo to hinge on and a 24deg rotate on a full-width picture just
   distorts Joe's face. The type keeps its flip — shallower, because the
   viewing angle on a held phone exaggerates it — and the picture goes back to
   a plain rise. */
@media (max-width: 899px) {
  .js .meet__photo { transform: translateY(20px); }
  .js .meet__photo.in {
    transform: none;
    transition: opacity .9s var(--ease-out) .16s, transform 1s var(--ease-out) .16s;
  }
  .js .meet__text > * { transform: perspective(700px) rotateX(-42deg); }
  .js .meet__text.in > * { transform: perspective(700px) rotateX(0deg); }
}

@media (prefers-reduced-motion: reduce) {
  .js .meet__photo, .js .meet__text, .js .meet__text > * {
    opacity: 1; transform: none;
  }
}

/* 5 · The title-block rule draws itself in. */
.js .titleblock::before { transform: scaleX(0); transform-origin: left; }
.js .titleblock.in::before { transform: scaleX(1); transition: transform 1.1s var(--ease-out) .24s; }

@media (prefers-reduced-motion: reduce) {
  .film__media, .ln i, .film__wordmark, .film__line, .film .cta, .film__caption, .nav, .slogan--hero { animation: none !important; }
  .js .plate .plate__btn { clip-path: none; }
  .js .plate .plate__btn img,
  .js .plate, .js .plate figcaption, .js .rv { opacity: 1; transform: none; }
  .js .titleblock::before { transform: scaleX(1); }
}

/* ── BEFORE / AFTER ───────────────────────────────────────────────────
   Was two photos side by side. Side by side makes the reader do the work —
   two frames, two viewpoints, spot the difference. A wipe puts both states on
   the same pixels, so the change is the only thing that moves. It is also the
   one interaction on this page worth building: it costs nothing to understand
   and it is the reason someone stays on the section.

   Beside each slider: a title and one paragraph about what was actually done.
   A Location / Scope / Work done / On site ticket used to sit here and was
   cut — the values were placeholders, and a spec table is only worth its
   space when every value in it is true. */
.ba { padding-bottom: var(--space-xl); }

.ba__head {
  display: grid; gap: var(--space-sm);
  margin-top: var(--space-md); padding-inline: var(--gutter);
}
@media (min-width: 900px) {
  /* not 50/50 — the headline leads, the lede sits in the margin beside it */
  .ba__head {
    grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
    gap: var(--space-2xl); align-items: end;
  }
}
/* Display-scale headings carry the hero's lettering voice: Anton, eroded, with
   the grain punched through. Grain is only safe above ~40px — below that the
   speckle stops reading as texture, which is why .job__h below gets the face
   but not the filter. */
.ba__h {
  font-family: var(--font-lettering); font-weight: 400;
  text-transform: uppercase;
  font-size: clamp(2.75rem, 1.4rem + 4.2vw, 4.75rem); line-height: 1.06;
  letter-spacing: 0.02em;
  filter: url(#f-head);
  color: var(--color-ink); text-wrap: balance;
}
/* the wordmark's cut, reused. Same mark that opens the hero slogan. */
.ba__h::before {
  content: ""; display: block; width: 34px; height: 6px;
  margin-bottom: var(--space-sm);
  background: var(--color-accent);
  clip-path: polygon(6px 0, 100% 0, calc(100% - 6px) 100%, 0 100%);
}
.ba__lede { color: var(--color-ink-2); max-width: 44ch; }

/* ---- the two jobs, stacked and alternating ---------------------------
   Fourth arrangement. The two-up slider layout came out for one reason: the
   drag handle. Nobody who lands here is looking for a control to operate, and
   the people Joe works for skew old enough that a discovered gesture is a
   wall, not a feature. So the comparison stops being an interaction and
   becomes a picture — see .job__stack below.

   One job per row instead of two side by side. A diptych needs the width: the
   finished room has to be big enough to sell the work, and the before print
   has to be small enough that nobody confuses the two. */
.jobs {
  display: grid; gap: var(--space-xl);
  margin-top: var(--space-lg); padding-inline: var(--gutter);
}
@media (min-width: 1000px) {
  .job {
    display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
    gap: var(--space-2xl); align-items: center;
  }
  /* the second job mirrors the first. The column WIDTHS swap too — order
     alone would drop the photograph into the narrow track and shrink it by a
     third, which is the bug that made the first pass look lopsided. */
  .job:nth-child(even) { grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr); }
  .job:nth-child(even) .job__stack { order: 2; }
  .job:nth-child(even) .job__panel { justify-self: end; }
}

/* Caption, not a panel: the job's name and one thing worth knowing about it.
   The "Featured job" eyebrow and the per-job "Ask Joe" link both came out —
   printed twice side by side they read as furniture, and the section already
   ends in that same link. */
.job__panel { max-width: 44ch; }
@media (max-width: 999px) {
  /* stacked, the panel needs the rule back — with nothing beside it the type
     would otherwise float under the photograph with no edge to sit on */
  .job__panel {
    margin-top: var(--space-md); padding-top: var(--space-sm);
    border-top: var(--rule-hair) solid var(--color-rule);
  }
}
.job__h {
  margin-bottom: var(--space-sm);
  font-family: var(--font-lettering); font-weight: 400;
  text-transform: uppercase;
  font-size: clamp(1.375rem, 1.1rem + 1.1vw, 1.75rem); line-height: 1.14;
  letter-spacing: 0.05em;
  filter: url(#f-thin);      /* thinned, never grained — sits below 40px */
  color: var(--color-ink); text-wrap: balance;
}
.job__note { font-size: var(--text-base); line-height: 1.65; color: var(--color-ink-2); }
.job__meta {
  margin-top: var(--space-md); padding-top: var(--space-sm);
  border-top: var(--rule-hair) solid var(--color-rule);
  font-family: var(--font-ui); font-size: var(--text-xs);
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-ink-2);
}
@media (max-width: 999px) { .job__meta { border-top: 0; padding-top: 0; } }

/* Navy text, accent arrow. Orange type on paper is ~3.1:1 and fails at this
   size; the arrow carries the accent instead and it is decorative. */
.more__cta a {
  display: inline-flex; align-items: baseline; gap: var(--space-2xs);
  font-family: var(--font-ui); font-size: var(--text-sm); font-weight: 600;
  color: var(--color-ink); text-decoration: none;
  border-bottom: 1px solid var(--color-rule);
  padding-bottom: 2px;
}
.more__cta a span {
  display: inline-block; color: var(--color-accent);
  transition: transform var(--dur-short) var(--ease-out);
}
.more__cta a:hover, .more__cta a:focus-visible { border-bottom-color: var(--color-ink); }
.more__cta a:hover span { transform: translateX(5px); }

/* ---- the diptych ----------------------------------------------------
   Both states are on the page at once, and neither one is hiding. The
   finished room is the plate; the room Joe walked into is a smaller print
   laid over its bottom corner, on paper, the way a builder actually shows
   you. No control, no gesture, nothing to discover — it reads the same in a
   browser, in a screenshot, on a printed flyer and in a Google listing.

   The before is deliberately weaker than the after in three ways at once —
   size, saturation and the paper mount — because a viewer has to be able to
   tell which is which in the quarter-second before they read a label. */
.job__stack {
  position: relative;
  /* Only the DOWNWARD overhang is reserved here, and only part of it — the
     rest is borrowed from the row gap below (see .job__before). The sideways
     overhang used to be reserved the same way, as padding-left, which meant
     every pixel the print hung out by was a pixel taken off the width of the
     finished room. It now hangs into the page gutter instead and the after
     photo gets the whole column. */
  padding-bottom: clamp(2rem, 4vw, 3.5rem);
}

.job__after { position: relative; background: var(--color-navy-deep); }
.job__after img {
  display: block; width: 100%; object-fit: cover; aspect-ratio: 4 / 3;
}
/* 5:3, between the source's own 3:2 and the 16:9 this was briefly set to.
   16:9 bought the most scroll back and cost the most: a bathroom is a TALL
   room, and the letterbox took out the floor and the ceiling — in an
   accessible conversion the curbless floor is half the point. 5:3 keeps the
   room and still returns ~35px a job. */
@media (min-width: 700px) { .job__after img { aspect-ratio: 5 / 3; } }
/* accent marks the payoff. 4.6:1, navy on orange. */
.job__after figcaption {
  position: absolute; top: 0; right: 0;
  padding: 0.55em 0.9em;
  background: var(--color-accent); color: var(--color-accent-ink);
  font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase;
}

/* The print hangs OFF the corner rather than sitting on it. It used to overlap
   the finished room by about 85% of its own area, which buried the thing the
   section exists to show. Now roughly a fifth of its width and a third of its
   height are outside the frame, so it clears the after photo's bottom corner
   and only its inner quadrant sits over the picture.

   The downward offset is negative on purpose: it borrows from the row gap
   below instead of adding to the row's height, so the print moves further out
   without making the section any longer to scroll. */
.job__before {
  position: absolute;
  /* Sideways offset stops just inside var(--gutter) — past that the print
     bleeds off the page edge and reads as a mistake rather than a margin. */
  left: calc(-1 * clamp(1.25rem, 3.75vw, 3rem));
  bottom: calc(-1 * clamp(1.25rem, 2.75vw, 2.5rem));
  width: clamp(30%, 27vw, 34%);
  /* the paper border is the whole trick: it makes the small photo read as a
     physical print sitting ON the big one rather than a second crop of it */
  padding: clamp(6px, 0.7vw, 10px);
  padding-bottom: clamp(1.6rem, 2.4vw, 2.2rem);
  background: var(--color-paper);
  box-shadow: 0 clamp(10px, 2vw, 26px) clamp(20px, 4vw, 50px) oklch(20% 0.04 262 / 0.22);
}
/* The mirrored job hangs its print off the OUTER corner too — anchored left on
   a right-hand photo would push the print in toward the text column. */
@media (min-width: 1000px) {
  .job:nth-child(even) .job__before {
    left: auto; right: calc(-1 * clamp(1.25rem, 3.75vw, 3rem));
  }
}
.job__before img {
  display: block; width: 100%; object-fit: cover; aspect-ratio: 4 / 3;
  filter: saturate(0.72) contrast(0.96);
}
.job__before figcaption {
  position: absolute; left: clamp(6px, 0.7vw, 10px); bottom: clamp(0.45rem, 0.8vw, 0.7rem);
  font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase;
  color: var(--color-ink-2);
}

/* reveal, matching the gallery plates: both photographs come in from the side
   the job sits on — job 01 from the left, job 02 from the right — and the
   before print follows the finished room in a beat later, travelling further
   so the two do not arrive as one flat block. */
.js .job .job__after  { opacity: 0; transform: translateX(-64px); }
.js .job .job__before { opacity: 0; transform: translateX(-96px); }
.js .job:nth-child(even) .job__after  { transform: translateX(64px); }
.js .job:nth-child(even) .job__before { transform: translateX(96px); }
.js .job.in .job__after, .js .job.in .job__before {
  opacity: 1; transform: none;
}
.js .job.in .job__after {
  transition: opacity 1s var(--ease-out) .14s, transform 1.2s var(--ease-out) .14s;
}
.js .job.in .job__before {
  transition: opacity .9s var(--ease-out) .42s, transform 1.1s var(--ease-out) .42s;
}
.js .job .job__panel { opacity: 0; transform: translateY(14px); }
.js .job.in .job__panel {
  opacity: 1; transform: none;
  transition: opacity .7s var(--ease-out) .34s, transform .7s var(--ease-out) .34s;
}
@media (prefers-reduced-motion: reduce) {
  .js .job .job__after, .js .job .job__before, .js .job .job__panel {
    opacity: 1; transform: none;
  }
}

/* ── SLOGAN ───────────────────────────────────────────────────────────
   Joe's own line. It travels with the wordmark, so it sits in the two places
   the brand signs itself: the top of the film and under the footer wordmark.
   Deliberately NOT the h1 — "Built right. Done once." is the sales message;
   this is the signature. */
.slogan {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.22em;
  line-height: 1;
}
/* A flex row ignores the parent's text-align, so the eyebrow and its accent bar
   have to be centred on their own or they stay pinned left under centred type. */
.slogan--hero {
  display: flex; align-items: center; justify-content: center; gap: var(--space-2xs);
  margin-bottom: var(--space-md);
  font-size: var(--text-sm);
  color: var(--color-ink-light);
}
.slogan--hero::before {
  content: ""; width: 30px; height: 5px; flex: none;
  background: var(--color-accent-hot);
  clip-path: polygon(5px 0, 100% 0, calc(100% - 5px) 100%, 0 100%);
}
.foot__brand { display: block; }
.slogan--foot {
  margin: var(--space-2xs) 0 var(--space-md);
  font-size: var(--text-sm);
  color: var(--color-accent-hot);   /* 6.0:1 on navy-deep */
}
