/* NDA Detailing — HOME journey layer (loaded by index.html only).
   Ported from the hub prototype 2026-08-07. Contracts: design-direction.md
   "Hub-era rulings" (curtain law, scoped liquid glass, scrub-video stack).
   NOTE ON UNITS: stage-space coordinates (1376x768) are content geometry
   tied to the film's pixels, not design tokens — px is correct here. The
   glass alphas/radii are spec'd in design-direction.md. */

/* ---- the pin: hero holds while scroll drives the teardown; Services
   then rises OVER the still-pinned hero (curtain law) ---- */
/* 520vh (was 360): the client rule needs the FULL -100vh curtain rise
   over the still-pinned hero, and Fahad wants the complete break-apart
   enjoyed first — so the runway grows instead of the curtain shrinking.
   Timeline: tear p=0.05→0.74 · settle · curtain rise p=0.762→1.0 (covers
   the hero exactly as it unpins — the hero never scrolls away). */
.scrub-wrap{height:520vh}
.scrub-wrap .hero-film{position:sticky;top:0;height:100vh;overflow:hidden}
/* THE TWO STATIC PATHS — reduced motion and no-JS — AND THE BUG THEY SHARED
   (fixed 2026-08-22, found 2026-08-22 while testing the button swap).
   Both collapse .scrub-wrap from 520vh to one viewport, because with no scrub
   there is nothing for a runway to drive. `.beat-services{margin-top:-100vh}`
   then pulled Services up by EXACTLY the height that was left — landing it at
   document top, opaque black, z-index 6, directly over the hero. Measured in
   both paths: wrap 795px, Services rect.top 0, and elementFromPoint over the
   h1, the sub AND the CTA all returned Services-beat nodes. A reduced-motion
   or no-JS visitor got no headline and no primary CTA on the home page.
   Nothing errored and nothing looked broken — the page just showed the wrong
   section. The -100vh only ever existed to let Services rise over a PINNED
   hero (curtain law); with no pin there is nothing to rise over, so the
   overlap is not reduced here, it is removed. `view-mode` already did exactly
   this for the same reason.
   THE CLAIM GOES ABSOLUTE OR IT NEVER LEAVES. It is position:fixed and, in
   these paths, nothing ever removes it: home.js takes the `reduced` branch and
   never adds `.gone`, and `.claim.gone` is neutralised below anyway. Fixed and
   permanent, it would float the headline and the CTA over every beat all the
   way down the page. Absolute inside .hero-film, it scrolls away with the hero
   — which is why the claim had to move INTO .hero-film in generate.py.
   THE SCROLL CUE IS HIDDEN BECAUSE IT BECOMES UNTRUE, not because it is
   motion: "SCROLL · THE CAR COMES APART" promises a teardown that the reduced
   branch (setProgress(0), no scroll driver) never plays. It is fixed as well,
   so it would also have ridden the whole page. */
@media (prefers-reduced-motion:reduce){
  .scrub-wrap{height:auto}
  .scrub-wrap .hero-film{position:relative}
  .beat-services{margin-top:0}
  .claim{position:absolute}
  .scroll-hint{display:none}
}
html:not(.js) .scrub-wrap{height:auto}
html:not(.js) .beat-services{margin-top:0}
html:not(.js) .claim{position:absolute}
/* QA hook: ?view=<section id> composes that beat at top pre-paint */
html.view-mode .scrub-wrap{display:none}
html.view-mode .beat-services{margin-top:0}
html.view-mode .claim,html.view-mode .scroll-hint{display:none}

/* ---- the stage: 1376x768 film space, cover-scaled to the viewport ---- */
.stage{position:absolute;left:50%;top:50%;width:1376px;height:768px;
  transform:translate(calc(-50% + var(--ox,0px)),-50%) scale(var(--s,1))}
/* A 16:9 film in a portrait window can only show ~25% of its width, and a
   dead-centre crop lands on the middle of the car. Bias the window left so
   the crop holds the front three-quarter — grille, headlight, front wheel. */
@media (max-width:720px){.stage{--ox:96px}}
/* Frame 0 of the film, so the rest state and the video's first frame are the
   same picture — and so a failed/blocked fetch still shows the hero. */
.stage .bg{position:absolute;inset:0;
  background:url('assets/rs7-scrub-poster.jpg') 50% 50%/cover no-repeat}
/* cover, not fill: the stage box is 1376x768 (1.792) and the film is 16:9
   (1.778), so 'fill' was stretching every frame horizontally by ~0.8%.
   Cover crops that sliver instead of distorting the car.

   NO STILL-TO-FILM HANDOVER (Fahad 2026-08-08: "I dont want the transition
   Photo > Video while scrolling, I want it to be one motion and consistent").
   The film used to fade in once scrubbing started, so the crisper poster could
   carry the rest state — but that put a visible change of picture right at the
   top of the scroll. The film is now solid the moment it is decodable, and the
   poster underneath is ONLY the pre-decode / no-JS / failed-fetch fallback.
   Because the poster is frame 0 of this same film, the swap underneath is
   invisible: same picture, one continuous motion from the first pixel. */
.bgvid{position:absolute;inset:0;width:1376px;height:768px;object-fit:cover;
  display:none;opacity:1}
html.video-mode .bgvid{display:block}

/* ---- STILL HERO (Fahad 2026-08-22: "Replace the hero video as well with an
   image of the garage and it has to be the clearest picture") -----------------
   `.still` is written into the MARKUP by generate.py's HERO_MODE, not added by
   JS, so there is no frame in which a 520vh pinned hero paints before the class
   lands. Flipping HERO_MODE back to "film" removes the class and every rule
   below stops matching; nothing here had to be deleted to make the still work.

   THESE ARE THE STATIC-PATH RULES THAT ALREADY EXISTED, NOT NEW GEOMETRY. The
   reduced-motion and no-JS paths have collapsed this hero correctly since
   2026-08-22, and that configuration was debugged the hard way (the claim is
   position:fixed and nothing removed it, so the headline floated over every
   beat to the page floor; .scrub-wrap collapsing while margin-top:-100vh still
   pulled Services up by a viewport buried the hero entirely). A still hero is
   the same situation, so it takes the same proven answer rather than a third
   variant of it. */
.scrub-wrap.still{height:auto}
.scrub-wrap.still .hero-film{position:relative}
.scrub-wrap.still .claim{position:absolute}
/* `-100vh` exists only so Services can rise over a STILL-PINNED hero (curtain
   law). Nothing is pinned here, so the pull has to go or Services lands on top
   of the picture. :has() rather than a class on <html> — the mode lives in the
   markup and this keeps it there, with no inline script to set a class before
   paint. */
html:has(.scrub-wrap.still) .beat-services{margin-top:0}
/* The cue is position:fixed and, with no pin, it would ride the whole page.
   It also has nothing left to promise: the car does not come apart. */
.scrub-wrap.still .scroll-hint{display:none}
/* The garage frame replaces frame 0 of the film as the rest state. Provenance
   and the measurement that chose it are in generate.py's HERO_MODE block. */
.scrub-wrap.still .stage .bg{
  background-image:url('assets/hero-garage-2560.jpg')}


/* ---- EXPAND HERO (Fahad 2026-09-03: "I want to put the aura detail hero
   banner into the NDA") ------------------------------------------------------
   The Aura scroll-expand set piece: a small panel centred on the site's own
   graffiti ground, growing to full bleed as you scroll, with the claim's two
   halves sliding apart around it. Rationale and the port decisions are in
   generate.py's EXPAND MODE block; this is the geometry.

   ONE INPUT: `--p`, 0..1, written on <html> by home.js off REAL SCROLL
   POSITION. Nothing here reads layout and nothing here runs on a clock, so the
   whole hero is a pure function of where the page is — scrub it backwards, jump
   it with the keyboard, land on it from an anchor, it composes correctly.

   THE DEFAULT STATE IS THE START STATE, NOT THE FINISHED ONE, and that is why
   `--p` falls back to 0 rather than a class being added by JS. `.still` learned
   this the hard way: a mode class that lands after paint gives you one frame of
   the wrong hero. Here the markup class `.expand` (written by HERO_MODE) is
   enough, because `var(--p,0)` already IS the first frame. The static paths at
   the bottom of this block then override to the finished state, which is the
   still hero exactly. */
.scrub-wrap.expand{height:400vh}
/* .hero-film keeps the BASE sticky rule — the hero pins while the panel opens,
   which is also what lets Services keep its -100vh curtain rise over it. That
   rise is cancelled for `.still` (nothing is pinned there) and must NOT be
   cancelled here.
   400vh, AND THE HEIGHT IS SET BY THE CURTAIN, NOT BY TASTE. Services is pulled
   up -100vh, so it starts rising at p = (H - 2vh) / (H - vh) — the same formula
   that yields the film's documented 0.762 at 520vh. This shipped at 300vh first,
   where that lands on p = 0.500: Services began covering the hero when the panel
   was HALF OPEN, and the whole reveal would have played out behind a rising
   curtain. Nothing in a ?step screenshot shows this, because ?step sets progress
   without scrolling, so the curtain is never exercised — it was found by working
   the arithmetic against the shipped film timeline and then confirmed with real
   pinned scroll (?y=).
   At 400vh the curtain starts at 0.667 and the panel finishes at EXPAND_SPAN
   0.40 (home.js), which leaves the timeline:
     0    -> 0.40   the panel opens        (120vh of scroll, ~Aura's gesture)
     0.40 -> 0.52   the garage stands, full bleed and undimmed (36vh)
     0.52 -> 0.86   the dim comes in, leading the curtain as it does in film mode
     0.667-> 1.0    Services rises over the still-pinned hero (curtain law)
   The two numbers are coupled: MOVING THIS HEIGHT WITHOUT MOVING EXPAND_SPAN
   PUTS THE REVEAL BACK BEHIND THE CURTAIN. */

/* The film layers go, the stage stays. The DIMMER lives inside .stage (it is
   what darkens the hero into the Services curtain) so hiding .stage would take
   the dim with it. Instead the stage is re-boxed to the viewport — the dimmer
   is inset:0 inside it — and only .drift, which carries the film's .bg and the
   video, is dropped. home.js keeps every element reference it holds. */
.scrub-wrap.expand .stage{left:0;top:0;width:100%;height:100%;transform:none}
.scrub-wrap.expand .drift{display:none}
/* Nothing paints a ground here on purpose: the site-wide graffiti wall is
   already behind this section at body level and it is the ground the panel
   opens over (rule 68). Aura fades out a stock photo to reveal its panel; the
   equivalent here is the ground the whole site already stands on. */

/* THE PANEL. Aura: 300x400 -> 1550x800 capped at 95vw/85vh. Here it ends FULL
   BLEED, because the picture it lands on is the approved still hero and the
   hero it hands over to Services is a full-bleed frame. Percentages resolve
   against .hero-film (sticky, so it is the containing block), which keeps the
   end state exact and costs no layout read.
   RADIUS 0 AND NO SHADOW, against the reference's rounded-xl and its 50px
   drop shadow: design-direction.md locks matte surfaces and radius 0. The
   reference supplies the MOVE; NDA supplies the surface. */
/* THE CLOSED PANEL IS SIZED TO THE HEADLINE, AND THAT IS THE ONE NUMBER THE
   REFERENCE COULD NOT BE COPIED ON. Aura's panel is 300x400 under a ~60px
   title, so the words sit INSIDE the window and the frame reads as an object.
   NDA's claim is Anton at clamp(44px,7.6vw,112px) — 109px at 1440, deliberately
   +70% since 2026-08-22 — and it measures ~620px wide. Dropped behind a 300px
   panel it does not straddle the window, it erases it: measured at 1440x900 the
   panel was a 300px rectangle under 620px of type and simply could not be seen.
   Two ways out, and only one of them is free: shrink the claim in this mode
   (changes an approved type scale for one hero) or open the panel to the
   headline (changes nothing anyone approved). The panel moved.
   It still opens 2.2x in width and ~2x in height, and the halves still travel
   +-56vw, so they clear the frame and then the screen exactly as they do in the
   reference. */
.xpanel{position:absolute;left:50%;top:50%;z-index:3;overflow:hidden;
  --xw0:min(680px,84vw);   /* the closed panel: a shade wider than the claim */
  --xh0:min(470px,58vh);
  width:calc(var(--xw0) + (100% - var(--xw0)) * var(--p,0));
  height:calc(var(--xh0) + (100% - var(--xh0)) * var(--p,0));
  transform:translate(-50%,-50%)}
/* cover, not contain: the box is re-cropped the whole way down as it opens, so
   the picture must survive every aspect between the closed panel and full bleed.
   `50% 52%` suits the placeholder (a 3:2 rolling shot whose subject sits centre,
   road below) — it was `52% 58%` when the panel held the 16:9 garage frame and
   needed biasing off the middle of the bay. RE-CHECK THIS WHENEVER THE PICTURE
   CHANGES: the right value is a property of the photograph, not of the hero. */
.xshot{width:100%;height:100%;object-fit:cover;object-position:50% 52%;display:block}

/* THE OPTIONAL BACKGROUND LAYER — written, and OFF unless generate.py's HERO_BG
   names a file (see the note there for why Aura's own background is not used).
   Aura fades a background photo out as the panel opens; the same fade is here,
   so turning it on is one constant rather than a round of new CSS. With HERO_BG
   empty the element is never emitted and the graffiti wall is the ground. */
.xbg{position:absolute;inset:0;z-index:1;background-position:50% 50%;
  background-size:cover;background-repeat:no-repeat;opacity:calc(1 - var(--p,0))}
/* Aura darkens its background with a flat 40% black so white type survives on
   it; NDA's claim sits on the PANEL, not the ground, so this only has to keep a
   bright photograph from fighting the wine accent. */
.xbg::after{content:"";position:absolute;inset:0;background:var(--nda-black);
  opacity:.46}
/* THE STATIC PATHS DROP IT ENTIRELY, and that is a fetch saved rather than a
   cosmetic rule. With no scroll driver `--p` stays 0, so this layer sits at full
   opacity — but the panel above it is full bleed in those paths (z-index 3 over
   this z-index 1), so it is COMPLETELY COVERED and cannot be seen. A covered
   background-image is still downloaded: 925KB fetched by a no-JS or
   reduced-motion visitor for pixels no one will ever see. Same defect the film's
   `poster` had on a display:none video, and the same fix. */
@media (prefers-reduced-motion:reduce){ .xbg{display:none} }
html:not(.js) .xbg{display:none}
/* The reveal is the picture getting brighter as well as bigger. THE NUMBERS ARE
   NOT THE REFERENCE'S, AND INVERTING THEM WAS FORCED BY THE GROUND. Aura holds
   its panel at 0.7 black and lifts to 0.4, because its panel sits on a BRIGHT
   photographic background and has to be darkened for white type to survive on
   it. NDA's ground is the graffiti wall at 7% over black — the darkest surface
   on the site — so a 62% veil made the window darker than the wall behind it
   and the panel disappeared entirely (measured at 1440x900: a 300x400 box that
   could not be picked out of the ground at all). Here the panel is the BRIGHT
   object and the veil only takes the edge off the hex lights so the claim can
   sit on it: .22 closed, .04 open. */
/* .38 -> .04, RAISED FROM .22 -> .04 ON A CONTRAST MEASUREMENT (2026-09-03).
   The placeholder's top third is pale sky, and the claim sits directly on it in
   the FIRST FRAME EVERY VISITOR SEES. Measured over the "CARS" ink box at
   1440x900: 2.16:1 median, 1.84:1 over the brightest tenth — under the 3:1 WCAG
   floor for large text. The garage frame this replaced measured 16.6:1 in the
   same region, which is why .22 was never tested against a bright picture.
   Cropping cannot fix it: at the closed 680x470 the cover fit is HEIGHT-driven,
   so there is no vertical overflow and object-position has no vertical effect at
   p=0. The veil is the only lever that works while the panel is small.
   The curve is the point: darkest exactly while the headline is on the picture,
   lifting to .04 by the time the halves have travelled off. Nothing is dimmer at
   the end than it was before this change. */
.xveil{position:absolute;inset:0;background:var(--nda-black);
  opacity:calc(.38 - var(--p,0) * .34);pointer-events:none}
/* A hairline so the closed panel reads as a FRAME and not as a lighter patch of
   wall — the site's own line token, which is how every other bordered surface
   here is drawn (no radius, no shadow: design-direction.md). It retires as the
   panel opens, because a border on a full-bleed hero is just a line on the
   screen edge. */
.xpanel::after{content:"";position:absolute;inset:0;pointer-events:none;
  border:1px solid var(--nda-line);opacity:calc(1 - var(--p,0) * 1.6)}

/* THE CLAIM ARRIVES AFTER THE PICTURE DOES. Fahad, 2026-09-03: "Initially
   there will be no text on the page. Once the image zooms out and becomes full
   the text appears."

   WHAT THIS REPLACED, so the deletion is legible: the claim used to open
   centred and slide its two halves off screen (+-78vw) as the panel grew --
   the split WAS its exit, which is why it was centred at all. The brief
   inverts that. There is no split any more, the +-78vw transforms are gone
   with it, and the centring is now doing a different job: the name landing on
   a finished full-bleed frame is a title card, and a title card belongs in the
   middle. The bottom-left corner is still where the static paths put it,
   because those resolve to the still hero, which never had this gesture.

   IT IS DRIVEN BY --q, NOT --p. See home.js: --p saturates at 1 the moment the
   panel is full, which is precisely when this is supposed to START. */
.scrub-wrap.expand .claim{position:absolute;left:0;right:0;
  top:50%;bottom:auto;transform:translateY(-50%);text-align:center;z-index:6}
.scrub-wrap.expand .claim h1 .cw{display:block}

/* THE ENTRANCE, AND THE THREE PARTS LAND ONE AFTER THE OTHER.
   Reveal law in production-rules: a group that appears in one block reads as a
   page load; the same parts arriving on their own beats read as composition.
   Each part gets its own slice of --q -- headline first, sub at .22, button at
   .45 -- and each slice is .55 long, so they overlap rather than queue and the
   whole thing still finishes with --q.

   `--r` is that part's own 0..1 progress, declared once per part and then used
   for BOTH the fade and the rise, so the two can never drift apart.

   `will-change` is deliberately absent. It was on .cw for the old split, where
   a transform ran for 160vh of scroll; this one runs for 28vh and then never
   moves again, so pinning a compositor layer for the rest of the page would
   cost memory for nothing. */
.scrub-wrap.expand .claim h1        { --r:clamp(0, calc( var(--q,0)        / .55), 1) }
.scrub-wrap.expand .claim .claim-sub{ --r:clamp(0, calc((var(--q,0) - .22) / .55), 1) }
.scrub-wrap.expand .claim .claim-cta{ --r:clamp(0, calc((var(--q,0) - .45) / .55), 1) }
.scrub-wrap.expand .claim h1,
.scrub-wrap.expand .claim .claim-sub,
.scrub-wrap.expand .claim .claim-cta{
  opacity:var(--r,1);
  transform:translateY(calc((1 - var(--r,1)) * 34px));
  transition:none}

/* THE SUBHEAD. New element -- the still and film heroes are headline + button
   only, so there was no existing rule to inherit. Gelasio (the body face), not
   Anton: setting it in the display face would make it compete with the name
   directly above it, and it is a qualifier, not a second claim. */
.scrub-wrap.expand .claim .claim-sub{
  margin:18px 0 0;font-family:Gelasio,Georgia,serif;
  font-size:clamp(17px,1.9vw,26px);line-height:1.35;
  color:var(--nda-fg);text-shadow:0 1px 24px rgba(0,0,0,.55)}
.scrub-wrap.expand .claim-cta{margin-top:30px}

/* INVISIBLE IS NOT ABSENT, AND THIS HERO ALREADY SHIPPED THAT BUG ONCE -- in
   the opposite direction. The old CTA faded OUT to opacity 0 while staying
   hit-testable, so for ~950px of scroll an invisible link to #book sat in the
   middle of a full-bleed photograph, clickable and tabbable. The inversion has
   exactly the same trap at the other end: this claim is now invisible for the
   whole 160vh of the panel opening, and without this it would be three
   invisible focusable things over the picture from the first frame.
   Same fix, same technique: a paused animation seeked by a negative delay is
   how a DISCRETE property (visibility) follows a continuous scroll value.
   Gated on the same --q as the fade, so the two can never disagree. */
.scrub-wrap.expand .claim{
  animation:xclaim-arrive 1s linear both;
  animation-delay:calc(var(--q,0) * -1s);
  animation-play-state:paused}
@keyframes xclaim-arrive{
  0%,  1%  { visibility:hidden }
  2%, 100% { visibility:visible }
}
/* `.gone` is the still/film retire and must not fire here -- home.js does not
   add it in this mode; this is the belt. */
.scrub-wrap.expand .claim.gone{opacity:1;transform:translateY(-50%)}

/* THE TYPE WASH IS ITS OWN LAYER, AND THAT IS THE WHOLE POINT OF THIS BLOCK.

   THE BUG THIS REPLACES, because it is worth not repeating: the first version
   of the reveal put the wash on `.hero-scrim` and drove its opacity from --q.
   `.hero-scrim` was previously opacity 1 for the WHOLE hero, so that quietly
   turned it OFF for the entire 160vh of the panel opening -- and the scrim was
   carrying most of the dark-to-bright ramp that makes the panel read as
   opening rather than merely growing. The picture went from opening dark and
   lifting, to being fully lit from the first frame with a hard edge against
   its background. Fahad: "the animation of the picture opening up is no longer
   as smooth as before". It was never a frame-rate problem -- profiled at a
   solid 60fps with zero dropped frames either way -- it was the tonal ramp
   being deleted by a rule that looked like it only concerned text.

   So `.hero-scrim` is left completely alone now, exactly as it was, and the
   wash the centred claim needs lives on the claim itself. Two layers, two
   jobs, neither able to affect the other.

   ::before rather than a new element: it inherits the claim's own centring, so
   the wash cannot drift from the type it exists for, and it is hidden by the
   same visibility keyframe at --q 0. z-index -1 keeps it behind the words but
   still inside .claim's stacking context, so it stays above the panel. */
.scrub-wrap.expand .claim::before{
  content:"";position:absolute;left:50%;top:50%;
  width:160vw;height:150vh;transform:translate(-50%,-50%);
  pointer-events:none;z-index:-1;opacity:var(--q,0);
  background:radial-gradient(ellipse 32% 26% at 50% 50%,
    rgba(6,8,8,.72) 0%, rgba(6,8,8,.55) 42%, rgba(6,8,8,.14) 74%, rgba(6,8,8,0) 100%)}
/* Portrait: the claim is nearly full width, so the ellipse widens to match. */
@media (max-width:720px){
  .scrub-wrap.expand .claim::before{
    background:radial-gradient(ellipse 46% 24% at 50% 50%,
      rgba(6,8,8,.78) 0%, rgba(6,8,8,.60) 46%, rgba(6,8,8,.16) 78%, rgba(6,8,8,0) 100%)}
}

/* ---- HERO_OPEN: THE PANEL OPENS ALREADY OPEN (Shah's review, 2026-09-14) ----
   Relayed by Fahad: "He doesnt not want scrolling animation on the home page ·
   He only wants the black car". Ruled with Fahad: the page opens on the Porsche
   alone, full bleed, no words; the claim arrives once the visitor scrolls; the
   change is the hero banner only. generate.py's HERO_OPEN note has the account.

   WHAT GOES: the panel growing from 680x470 (its --p calc() above is overridden
   to full bleed from the first frame), the Ferrari under it (not emitted at all),
   the veil ramp and the frame hairline (pinned to their open values).

   WHAT CHANGES CLOCK: the claim. Above, the reveal is SCRUBBED -- each part's
   opacity is a calc() off --q, welded to scroll position, which is precisely a
   scrolling animation. Here it is a TIMED ENTRANCE on a one-shot class,
   `html.hero-in`, added by home.js on the first scroll (or the first Tab, so a
   keyboard visitor is never left facing a hidden button). Headline, then sub at
   .22s, then button at .44s: one at a time, per the reveal law, and never
   scrolled back out.

   WHAT STAYS: the pin, the centred claim and its wash, the dim, and Services
   rising over the pinned hero -- Fahad: "just for the hero banner specifically".

   THE RUNWAY SHRINKS BECAUSE THERE IS NOTHING LEFT TO SCROLL THROUGH. At 400vh
   the first 120vh were the panel opening; kept, they would be a stretch of
   scrolling where nothing moves -- the stuck feeling this change exists to
   remove. Services is pulled up -100vh, so it starts rising at
   p = (H - 200vh) / (H - 100vh). At 250vh that is p = 1/3: the visitor gets 50vh
   of the finished screen -- Shah's screenshot -- before the curtain begins, and
   the curtain then takes the remaining 100vh. The dim curve in home.js is timed
   to that number; move one and re-derive the other.

   SCOPED TO JS + NO-PREFERENCE, AND THAT IS LOAD-BEARING. `.expand.open` outranks
   the static paths' `.scrub-wrap.expand` selectors, so unscoped these rules would
   pin a 250vh runway and hide the claim for reduced-motion and no-JS visitors,
   who have no scroll driver to ever reveal it. Scoped, both keep the static
   hero below exactly as it was. */
@media (prefers-reduced-motion:no-preference){
  html.js .scrub-wrap.expand.open{height:250vh}
  html.js .scrub-wrap.expand.open .xpanel{width:100%;height:100%}
  html.js .scrub-wrap.expand.open .xveil{opacity:.04}
  html.js .scrub-wrap.expand.open .xpanel::after{opacity:0}
  html.js .scrub-wrap.expand.open .claim{animation:none;visibility:hidden}
  html.js .scrub-wrap.expand.open .claim::before{opacity:0;transition:opacity .9s ease-out}
  html.js .scrub-wrap.expand.open .claim h1,
  html.js .scrub-wrap.expand.open .claim .claim-sub,
  html.js .scrub-wrap.expand.open .claim .claim-cta{
    opacity:0;transform:translateY(34px);
    transition:opacity .7s ease-out,transform .9s cubic-bezier(.22,1,.36,1)}
  html.js.hero-in .scrub-wrap.expand.open .claim{visibility:visible}
  html.js.hero-in .scrub-wrap.expand.open .claim::before{opacity:1}
  html.js.hero-in .scrub-wrap.expand.open .claim h1,
  html.js.hero-in .scrub-wrap.expand.open .claim .claim-sub,
  html.js.hero-in .scrub-wrap.expand.open .claim .claim-cta{opacity:1;transform:none}
  html.js.hero-in .scrub-wrap.expand.open .claim .claim-sub{transition-delay:.22s}
  html.js.hero-in .scrub-wrap.expand.open .claim .claim-cta{transition-delay:.44s}
}

/* THE STATIC PATHS MUST NOT INHERIT THE ENTRANCE. --q is written by home.js
   and ONLY in expand mode, so with no JS (or under reduced motion, where the
   driver never runs) every part above would compute --r 0 -- an invisible
   claim, held invisible by the visibility keyframe as well. The reveal is a
   scroll gesture; where there is no scroll gesture the finished state is what
   is correct, and that is this.
   BOTH SELECTORS ARE NEEDED and they are not redundant: `html:not(.js)` covers
   no-JS, while main.js still adds `.js` under reduced motion -- so the media
   query is what covers a visitor who has JS and has asked for stillness. */
@media (prefers-reduced-motion:reduce){
  .scrub-wrap.expand .claim{animation:none;visibility:visible}
  .scrub-wrap.expand .claim::before{opacity:1}
  .scrub-wrap.expand .claim h1,
  .scrub-wrap.expand .claim .claim-sub,
  .scrub-wrap.expand .claim .claim-cta{opacity:1;transform:none}
}
html:not(.js) .scrub-wrap.expand .claim{animation:none;visibility:visible}
html:not(.js) .scrub-wrap.expand .claim::before{opacity:1}
html:not(.js) .scrub-wrap.expand .claim h1,
html:not(.js) .scrub-wrap.expand .claim .claim-sub,
html:not(.js) .scrub-wrap.expand .claim .claim-cta{opacity:1;transform:none}

/* THE STATIC PATHS RESOLVE TO THE STILL HERO — the finished state of this one.
   No runway (there is nothing to drive it), no pin, panel full bleed, claim
   unsplit and back in its bottom-left corner. A visitor who cannot have the
   motion gets the hero Fahad approved on 2026-08-22, not a 300px window.
   The base reduced-motion / no-JS block above already collapses .scrub-wrap and
   re-anchors .claim; these add what is specific to the panel. */
@media (prefers-reduced-motion:reduce){
  .scrub-wrap.expand{height:auto}
  .scrub-wrap.expand .hero-film{position:relative}
  .xpanel{width:100%;height:100%;left:0;top:0;transform:none}
  .xveil{opacity:.28}
  .scrub-wrap.expand .claim{position:absolute;left:40px;right:auto;top:auto;
    bottom:clamp(160px,38vh,420px);transform:none;text-align:left}
  .scrub-wrap.expand .claim h1 .cw.a,
  .scrub-wrap.expand .claim h1 .cw.b{transform:none}
  .scrub-wrap.expand .claim-cta,
  .scrub-wrap.expand .claim .cta{opacity:1}
}
html:not(.js) .scrub-wrap.expand{height:auto}
html:not(.js) .scrub-wrap.expand .hero-film{position:relative}
html:not(.js) .xpanel{width:100%;height:100%;left:0;top:0;transform:none}
html:not(.js) .xveil{opacity:.28}
html:not(.js) .scrub-wrap.expand .claim{position:absolute;left:40px;right:auto;
  top:auto;bottom:clamp(160px,38vh,420px);transform:none;text-align:left}
html:not(.js) .scrub-wrap.expand .claim h1 .cw.a,
html:not(.js) .scrub-wrap.expand .claim h1 .cw.b{transform:none}
/* Services rises over a PINNED hero. With the pin gone in both static paths the
   -100vh would drag it over the picture — the exact bug `.still` was debugged
   out of on 2026-08-22, so it takes the same answer rather than a new one. */
@media (prefers-reduced-motion:reduce){ .scrub-wrap.expand ~ .beat-services,
  html:has(.scrub-wrap.expand) .beat-services{margin-top:0} }
html:not(.js):has(.scrub-wrap.expand) .beat-services{margin-top:0}
@media (max-width:720px){
  /* :not() SCOPES THIS TO THE ANIMATED PATH, and without it the static paths
     broke. This block sits LATER in the file than the reduced-motion and no-JS
     blocks at equal specificity, so `left:0;right:0` was overriding their
     `left:40px` while their `text-align:left` survived — pinning left-aligned
     type and the BOOK A SLOT chip flush against x=0 with no gutter at all on a
     phone. Measured under CDP with prefers-reduced-motion:reduce at 390x844:
     computed left 0px, ink left edge 0.0 for both halves.
     The animated path genuinely wants the full width (the halves travel off both
     edges), so the fix is to scope rather than to weaken it. */
  html.js .scrub-wrap.expand .claim{left:0;right:0}
  /* THE MOBILE SPLIT IS GONE WITH THE DESKTOP ONE, and leaving it behind was a
     real bug for one build: the desktop +-78vw rules were removed with the
     gesture, this +-92vw pair was not, and `--p` still reaches 1 -- so on a
     phone the headline was translated 92vw off the left edge and the hero
     landed showing the sub-line and the button with no name above them.
     Caught under CDP at 390x844, not in the pane. */
  /* The static paths keep a gutter at phone width. 24px rather than the desktop
     40px: the same edge distance the rest of the site uses on a phone. */
  html:not(.js) .scrub-wrap.expand .claim{left:24px;right:24px}
}
/* `html.js` AND NOT A BARE SELECTOR, because reduced motion is a JS-ENABLED
   path. The first version of this fix scoped the full-width mobile rule above to
   `html.js` and then tried to win it back here with a plain
   `.scrub-wrap.expand .claim` — but main.js still runs under reduced motion, so
   `html.js ...` (0,3,1) kept beating (0,2,1) and the gutter was still 0.
   Measured: left 0px, ink edge 0.0, CTA left 0.0 at 390x844.
   Matching the specificity and relying on source order is the fix — this block
   is below that one, so equal weight resolves here. A media feature cannot be
   negated in a selector, so ordering is the mechanism CSS gives us. */
@media (prefers-reduced-motion:reduce) and (max-width:720px){
  html.js .scrub-wrap.expand .claim{left:24px;right:24px;text-align:left}
}

/* slow camera drift — the "alive" cue */
@media (prefers-reduced-motion:no-preference){
  .drift{animation:drift 18s ease-in-out infinite alternate}
  @keyframes drift{from{transform:scale(1)}to{transform:scale(1.028)}}
}
.drift{position:absolute;inset:0;transform-origin:50% 60%}


/* the dim into Services */
.dimmer{position:absolute;inset:0;background:var(--nda-black);opacity:0;
  pointer-events:none;z-index:4}

/* Legibility scrim. The RS7 plate has a bright reflective floor exactly
   where the claim and the scroll cue sit, so white type washes out against
   it. Two gradients: one from the left for the headline, one from the
   bottom for the cue. Retires with the headline so the film plays clean. */
.hero-scrim{position:absolute;inset:0;pointer-events:none;z-index:3;
  background:
    linear-gradient(100deg,rgba(6,8,8,.92) 0%,rgba(6,8,8,.62) 26%,rgba(6,8,8,0) 58%),
    linear-gradient(to top,rgba(6,8,8,.80) 0%,rgba(6,8,8,.30) 14%,rgba(6,8,8,0) 30%);
  transition:opacity .45s ease-out}
html.claim-gone .hero-scrim{opacity:0}
/* Portrait: the claim spans nearly the full width, so a left-biased wash
   doesn't cover it — the sub line was running over the bright front wheel.
   One strong bottom band instead. */
@media (max-width:720px){
  .hero-scrim{background:linear-gradient(to top,
    rgba(6,8,8,.90) 0%,rgba(6,8,8,.66) 28%,rgba(6,8,8,.20) 46%,rgba(6,8,8,0) 62%)}
}
@media (prefers-reduced-motion:reduce){
  .hero-scrim{transition:none}
  html.claim-gone .hero-scrim{opacity:1}
}

.scroll-hint{position:fixed;left:50%;bottom:calc(var(--bookbar-height) + 14px);
  transform:translateX(-50%);z-index:5;font-family:var(--font-fact);
  font-size:12px;letter-spacing:.16em;color:var(--nda-mist);
  transition:opacity .3s ease-out}
html:not(.js) .scroll-hint{display:none}
/* On phones the bottom belt is already the claim CTA + the fixed bookbar;
   the cue wrapped onto two lines and collided with BOOK A SLOT. Nobody
   needs telling to scroll on a phone. */
@media (max-width:720px){.scroll-hint{display:none}}

/* claim, viewport space, bottom-left (clear of the bookbar) */
/* The headline clears out once the teardown starts so type never fights the
   floating panels. anime.js drove this opacity until 2026-08-08; the film
   driver now just toggles .gone and CSS owns the motion (transform+opacity
   only, so it stays off the main thread). */
/* HIGHER UP THE HERO (Fahad 2026-08-22, by screenshot). It sat at
   bookbar + 24 = 76px off the floor with 561px of empty film above it at
   1280x900 — the claim read as a caption on the bottom edge rather than as the
   thing the picture is about.
   VIEWPORT-RELATIVE, NOT A BIGGER CONSTANT: the block is anchored to the
   BOTTOM, so a fixed px offset lands it in a different part of the composition
   on every screen height, and on a short laptop a value tuned at 900 would
   push it into the nav. 28vh keeps its position in the frame proportional, and
   the clamp holds it off the floor on very short windows and stops it climbing
   into the middle on very tall ones.
   RAISED AGAIN the same day (Fahad: "higher") — 28vh -> 38vh. The floor of the
   clamp went 120 -> 160 with it, because the binding constraint at the top of
   the range is the NAV, not the floor: at 600px tall this now leaves the claim
   109px down, and the nav ends at 64. */
.claim{position:fixed;left:40px;bottom:clamp(160px,38vh,420px);z-index:5;
  transition:opacity .45s ease-out,transform .45s cubic-bezier(.22,.61,.36,1)}
.claim.gone{opacity:0;transform:translateY(-16px);pointer-events:none}
@media (prefers-reduced-motion:reduce){
  .claim{transition:none}
  .claim.gone{opacity:1;transform:none}
}
/* BIGGER (Fahad 2026-08-22), and the removed sub line is what pays for it.
   36..4.8vw..66 -> 44..7.6vw..112: +70% at the cap and +58% at 1280, which is
   a change you can see rather than a tweak. The claim is a fixed block anchored
   to the bottom-left, so its height is spent upward from the bookbar — dropping
   the "DETAILING IN MISSISSAUGA" line freed 22px of that budget and the type
   took it back with room to spare. Line-height tightens 1.05 -> 1.0 because
   leading that reads as generous at 61px reads as a gap at 97px. */
.claim h1{font-family:var(--font-display);font-weight:400;text-transform:uppercase;
  font-size:clamp(44px,7.6vw,112px);line-height:1;letter-spacing:.01em}
.claim h1 .kbar{background:var(--nda-wine);box-shadow:0 0 0 1px var(--nda-line);
  padding:0 .14em;display:inline-block}
/* The claim's own CTA rule, SUPERSEDED 2026-08-22 — first by `.btn-sweep`,
   then within the day by the `.btn` system that replaced it. Kept unreferenced
   the way this repo parks superseded work. `.claim-cta` carries ONLY the
   placement that is specific to the hero (the top margin), so whichever button
   component is in the slot keeps its single definition and the hero never forks
   it. That is why swapping the component was a one-line change. */
.claim-cta{margin-top:16px}
.claim .cta{display:inline-block;margin-top:16px;background:var(--nda-wine);
  border:1px solid var(--nda-line);color:var(--nda-paint-white);
  font-family:var(--font-fact);font-size:12px;letter-spacing:.16em;
  padding:14px 26px;text-decoration:none}
.claim .cta:focus-visible{outline:2px solid var(--nda-wine-lift);outline-offset:3px}
@media (max-width:720px){
  .claim{left:20px}
}

/* ---- curtain chain (site-wide law, re-affirmed by Fahad 2026-08-07:
   "all pages need to scroll up"): every beat pins once fully read and
   the next rises over it. Tops are JS-set (min(0, vh − height)) and kept
   in sync by a ResizeObserver per beat — the one-shot measurement that
   froze a void on screen earlier today cannot recur. ---- */
.curtain{position:sticky}

/* ---- THE RETIRE (2026-08-21, ported from ZEF — Fahad's ruling there was
   "the following page COMPLETELY takes over", and the same journey should
   read the same way here).

   The curtain chain was already NDA's. What NDA never had is what happens to
   the beat being covered: it sat at full brightness under the rising page, so
   a takeover looked like one page simply occluding another. It now falls into
   shadow and its content dissolves as the next beat rises.

   IT IS TWO EFFECTS AND BOTH ARE NEEDED. `.beat-dim` lays a shadow over the
   beat; `--retire` fades the beat's CONTENT. ZEF shipped the shadow alone
   first and Fahad reported "nothing is fading" three times — on a dark
   ground especially, a shadow over an already-black page is nearly invisible,
   so here the CONTENT fade is the load-bearing half and the shadow supports
   it. If either is ever adjusted, adjust the RAMP in home.js, not just the
   ceiling: a stronger effect that arrives too late is still nothing.

   IT IS THE CHILDREN, NEVER THE SECTION. Fading the section itself makes it
   translucent and shows the beat underneath through it — a different effect
   and a worse one. The children fade; the section's own black ground stays
   put, so the page dissolves against itself.

   TWO THINGS ARE EXCLUDED, FOR OPPOSITE REASONS. `.beat-dim` is excluded or it
   fades itself away and cancels half the effect. `.graf` is excluded because
   the graffiti wall is GROUND, not content (2026-08-21): fade it and the
   retiring beat's half of the screen shows the same picture at a different
   brightness from the rising beat's half, putting a seam exactly where the
   wall is meant to read as continuous. `.beat-dim` still passes over it.

   ONE CLASS DRIVES BOTH HALVES. `html.retire` is added by home.js at the
   moment it actually seeds the dim layers, so the shadow and the content fade
   can never disagree about whether the effect is on. Gating the fade on `.js`
   instead would split them: `.js` comes from main.js, so a world where
   main.js is blocked but home.js runs gets shadows with no fade — half an
   effect and nothing in the console to say so.

   DEFAULT 1 (production-rules #31). Without JS nothing sets --retire, the
   fallback applies, no dim is seeded, no class is added, and every beat is
   simply at full opacity. It survives reduced motion because this is depth,
   not travel (the design-direction depth doctrine) — home.js writes the value
   straight through with no easing there. ---- */
.retire .curtain > *:not(.beat-dim):not(.graf){opacity:var(--retire,1)}
/* REGISTERED NON-INHERITED (2026-09-11). home.js writes it on each direct
   child every easing frame; inherited, every write restyled that child's whole
   subtree. initial-value 1 is the same "no retire" the fallback above states. */
@property --retire{syntax:'<number>';inherits:false;initial-value:1}
.beat-dim{position:absolute;inset:0;z-index:60;pointer-events:none;
  background:var(--nda-black);opacity:0;
  /* FEATHERED BOTTOM EDGE. This layer covers its whole beat in a flat
     rectangle and stopping it dead at the beat's bottom draws a hard line
     across full width where the rising page's top edge crosses it. The mask
     tapers the darkness out across the lower third so there is no edge left
     to read as a rule. The top two thirds keep the full effect. */
  -webkit-mask-image:linear-gradient(to bottom,#000 0%,#000 62%,transparent 100%);
          mask-image:linear-gradient(to bottom,#000 0%,#000 62%,transparent 100%)}

/* ---- Services: liquid glass (SCOPED deviation, design-direction.md) ---- */
/* -100vh RESTORED (the client curtain rule): Services' entire rise
   happens while the hero is STILL PINNED, covering it exactly at unpin.
   The sequencing fix lives in the wrap height (520vh) + tear window
   (done at p=0.74, rise starts p=0.762), not in shrinking this overlap. */
.beat-services{z-index:6;margin-top:-100vh;
  background:var(--nda-black);border-top:1px solid var(--color-border);
  padding:88px 40px 88px;overflow:hidden}
.beat-services::before{content:"";position:absolute;left:-10%;top:-5%;width:55%;height:60%;
  background:radial-gradient(closest-side,rgba(73,31,39,.55),transparent 70%);
  filter:blur(60px);pointer-events:none}
.beat-services::after{content:"";position:absolute;right:-12%;bottom:-10%;width:50%;height:55%;
  background:radial-gradient(closest-side,rgba(255,214,170,.14),transparent 70%);
  filter:blur(70px);pointer-events:none}
.beat-services .inner{position:relative;max-width:1180px;margin:0 auto}
/* --text-page, shared with every page's own name in styles.css (Fahad,
   2026-08-21: "implement this on the hero/main page to services, portfolio,
   about us, etc as well"). Line-height 1 comes with the scale: at 54px the
   default leading never showed, and at 96px a wrapped beat name would set
   with a visible gutter between its lines. */
.beat-services h2{font-family:var(--font-display);font-weight:400;text-transform:uppercase;
  font-size:var(--text-page);line-height:1;text-align:center}
.gtiles{margin-top:48px;display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));
  gap:32px}
.gtile{position:relative;min-height:540px;border-radius:18px;overflow:hidden;
  background:rgba(238,255,255,.055);
  backdrop-filter:blur(22px) saturate(1.25);-webkit-backdrop-filter:blur(22px) saturate(1.25);
  border:1px solid rgba(238,255,255,.16);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.16),0 14px 44px rgba(0,0,0,.5);
  padding:0 0 26px;display:flex;flex-direction:column;
  text-decoration:none;color:var(--nda-paint-white);
  transition:transform .25s ease-out,background .25s,border-color .25s}
/* THE LIFT IS COMPENSATED BY A SCALE, AND THE NUMBER IS ARITHMETIC, NOT TASTE
   (2026-09-12). A hover style that moves its element upward pulls the lower edge
   out from under a resting cursor: hover drops, the tile falls back, hover
   fires again — a loop that runs at the transition rate for as long as the
   cursor rests there. It is the same defect as the booking buttons (see
   `.beat-book .channels a`, which carries the full account) and it was found in
   the same pass: with the page completely at rest, three `.gtile` transitions
   were still reported RUNNING by `document.getAnimations()`.

   THE BUTTONS ARE FIXED WITH A HIT-AREA GUARD; THIS TILE CANNOT USE ONE.
   `.gtile` is `overflow:hidden` for its radius, and overflow clips a
   pseudo-element out of hit-testing as well as out of paint, so a guard here
   would be silently inert — the worst kind of fix.

   So the lower edge is held down by scale instead. Scale is safe where
   translate is not: growing an element can never move it away from the cursor.
   The edge moves down by (H/2)x(s-1) and must cover the 5px lift, so
   s >= 1 + 10/H.

   H IS MEASURED, NOT READ OFF `min-height`. The rule says `min-height:540px`
   but the rendered tile is 518.4px — identical at 1440, 1280, 1024, 1680, 900
   and 760 wide, so the tile is not min-height-bound and quoting 540 would
   overstate the margin. At 518.4 the requirement is s >= 1.0193. 1.025 is used
   rather than the 1.02 that first passed: 1.02 clears it by 0.35px, which is
   not margin, it is luck. 1.025 holds for any tile down to 400px tall.

   Change the lift, or let the tile get shorter, and this number must be
   RE-DERIVED from a fresh measurement — not nudged, and not trusted from this
   comment. */
.gtile:hover{background:rgba(238,255,255,.09);border-color:rgba(238,255,255,.28);
  transform:translateY(-5px) scale(1.025)}

/* ---- TILES ARRIVE ONE BY ONE, AND EACH ROW WAITS ITS TURN (Fahad,
   2026-08-21: "Tile should appear one by one. Second row of tiles should
   appear once you scroll down and they actually start to become visible").

   NOT ON THE SITE-WIDE `.rv` SYSTEM, deliberately. That system's failsafe
   adds `html.revealed` 1.5 SECONDS AFTER LOAD and reveals every `.rv` on the
   page regardless of scroll position — which is right for a safety net but
   would pop all six tiles a moment after arrival and delete the second half
   of this ruling. The tiles need their own gate.

   `html.tilereveal` IS THAT GATE, and home.js adds it only once it has
   actually built the observer. No JS, no IntersectionObserver, or reduced
   motion means no class, which means no hidden state at all — the tiles are
   simply visible (production-rules #31, satisfied by construction rather
   than by a timer that would fight the effect).

   THE STAGGER IS NOT WRITTEN HERE, and that is the point. A per-tile
   `transition-delay` off a DOM index would give the second row delays of
   3x, 4x and 5x the step — so a row that arrives thirty seconds later would
   still be paying for the row above it. home.js staggers each ARRIVING BATCH
   from zero instead, so every row counts one-two-three from its own start.

   Transform is the hover's own property, so the resting state has to be
   `none` rather than a competing translate: the two rules meet the moment a
   pointer lands on a tile mid-arrival. ---- */
/* SLOWED 2026-08-22 (Fahad: "Slow it down") — was .5s / .62s against a 110ms
   step. Roughly doubled rather than nudged, because a tile landing is a
   large object moving and small timing changes on one read as noise.
   ZEF's process cards went the same way for the same reason and settled at
   1.15s / 1.3s, which is the neighbourhood these now sit in.
   The travel grew with the time: 34px covered in 1.15s reads as hesitation
   rather than weight, so the tile now comes from further out and the speed
   stays deliberate instead of slow. */
/* SLOWED AGAIN 2026-08-22, second pass (Fahad: "even slower so its like
   page 1.. page 2.. page 3"). The travel only went 1s/1.15s -> 1.1s/1.25s,
   because the DURATION was never what blurred the three together — the gap
   was, and that lives in home.js.

   THE EASE CHANGED AND THAT IS THE REAL WORK HERE. --nav-ease is
   cubic-bezier(.65,.01,.05,.99): slow in, fast through the middle, slow out.
   It is right for the menu, where a panel should feel weighted, but it has
   no clear moment of ARRIVAL — the last 20% of the distance is spent
   crawling, so a tile reads as still-settling right up until the next one
   starts and the row smears. This curve front-loads the travel and settles
   early, so each tile visibly LANDS and then the next begins. That is the
   difference between "one two three" and "one.. two.. three". */
/* THE BLUR IS THE INGREDIENT THAT WAS MISSING (Fahad, 2026-08-22: "it
   should be subtle like slowly appear and in animated faded type motion
   similiar to how the process steps showed up in zef").

   ZEF's `.procard` entrance is three properties easing at once, not one:
   opacity, a 70px rise WITH a scale(.96), and `filter: blur(6px)`. The blur
   is what makes it read as materialising rather than sliding — a tile that
   only fades and moves is still a solid object travelling, and it arrives
   whatever its speed. Blurred, it has no edges to track until it is nearly
   home, which is the "faded type motion" being described. Timings are ZEF's
   own, including the split easing: opacity and blur on ease-out, transform
   on a longer cubic-bezier(.22,1,.36,1) so the movement outlasts the fade
   and the tile settles into place rather than stopping dead.

   BLUR ON THIS TILE IS NOT FREE, and it is worth knowing why it is
   acceptable. `.gtile` already carries `backdrop-filter: blur(22px)` for the
   liquid glass, so an arriving tile is compositing two blurs at once. Only
   three are ever in flight (the stagger is per row) and `filter` is dropped
   entirely at rest by `.in`, so nothing blurs once the row has landed.

   `filter:none` on `.in` is required, not tidiness: a lingering blur filter
   holds a compositor layer and keeps the finished tile off the fast path. */
.tilereveal .gtile{opacity:0;transform:translateY(70px) scale(.96);
  filter:blur(6px);
  transition:opacity 1.15s ease-out, filter 1.15s ease-out,
             transform 1.3s cubic-bezier(.22,1,.36,1)}
.tilereveal .gtile.in{opacity:1;transform:none;filter:none}
/* Hover must still win once the tile has landed, or a pointer on a revealed
   tile fights the .in rule and the lift never happens. */
/* scale(1.02): see `.gtile:hover` above — it is what stops the 5px lift pulling
   the tile's lower edge out from under a resting cursor. Both hover rules carry
   it or the one without it reintroduces the loop on its own. */
.tilereveal .gtile.in:hover{transform:translateY(-5px) scale(1.025)}
.gtile .shot{flex:1 1 auto;min-height:300px;background:rgba(10,12,12,.5);
  border-bottom:1px solid rgba(238,255,255,.12);
  display:flex;align-items:center;justify-content:center;
  font-family:var(--font-fact);font-size:11px;letter-spacing:.14em;color:#5a6260}
.gtile h3{font-family:var(--font-display);font-weight:400;text-transform:uppercase;
  font-size:20px;padding:14px 20px 0}
.gtile .detail{padding:6px 20px 0;color:var(--nda-mist);font-size:13.5px;line-height:1.5}
/* THE TILE PRICE IS GONE (Fahad 2026-08-22: "remove the pricing on Home page >
   Services"). `.gtile .fact` went with its markup rather than being left
   unreferenced — it was the only user of that class.
   `.more` KEEPS ITS OWN TOP MARGIN, so the row below the description does not
   collapse onto it: the price line was contributing 8px of padding above
   itself, and without this note the next person reads 12px as arbitrary.
   Prices themselves are untouched everywhere they are actually committed to —
   the prices page, each service page's "What it costs", and the FAQ. What the
   home tiles now promise is the service, not the number. */
.gtile .more{margin:12px 20px 0;display:inline-flex;align-items:center;gap:8px;
  font-family:var(--font-fact);font-size:11.5px;letter-spacing:.14em;
  color:var(--nda-paint-white);border-bottom:1px solid var(--nda-wine-lift);
  padding-bottom:3px;width:max-content;transition:color .15s}
.gtile:hover .more{color:var(--nda-wine-lift)}
.beat-services .note{margin-top:48px;font-family:var(--font-fact);font-size:12px;
  letter-spacing:.08em;color:var(--nda-caption);line-height:2}

/* ---- Recent work: graffiti field + hover-expand accordion ---- */
.beat-portfolio{z-index:7;background:var(--nda-black);
  padding:88px 40px 96px;overflow:hidden}
.graffiti-bg,.graffiti-lit{position:absolute;inset:0;pointer-events:none;
  background:url('assets/graffiti-bg.jpg') top center/auto 100% repeat-x;
  transform:scale(1.04) translateY(var(--gd,0px))}
.graffiti-bg{opacity:.10}
.graffiti-lit{opacity:0;transition:opacity .3s ease-out;
  filter:brightness(1.18) contrast(1.05);
  -webkit-mask:radial-gradient(circle 300px at var(--gx,-600px) var(--gy,-600px),#000 20%,transparent 72%);
  mask:radial-gradient(circle 300px at var(--gx,-600px) var(--gy,-600px),#000 20%,transparent 72%)}
.beat-portfolio:hover .graffiti-lit{opacity:.42}
.beat-portfolio .inner{position:relative;max-width:1180px;margin:0 auto}
.beat-portfolio h2{font-family:var(--font-display);font-weight:400;text-transform:uppercase;
  font-size:var(--text-page);line-height:1;text-align:center}
.hx-row{margin-top:48px;display:flex;align-items:center;justify-content:center;gap:6px}
.hx{position:relative;width:5rem;height:24rem;border-radius:24px;overflow:hidden;
  cursor:pointer;flex:0 1 auto;
  transition:width .3s cubic-bezier(.4,0,.2,1)}
.hx img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.hx .shade{position:absolute;inset:0;
  background:linear-gradient(to top,rgba(0,0,0,.45),transparent 55%);
  opacity:0;transition:opacity .25s}
.hx .code{position:absolute;left:16px;bottom:12px;font-family:var(--font-fact);
  font-size:11px;letter-spacing:.12em;color:rgba(238,255,255,.65);
  opacity:0;transition:opacity .25s}
.hx.active{width:24rem}
.hx.active .shade,.hx.active .code{opacity:1}
.beat-portfolio .pf-cta{margin-top:44px;display:inline-block;
  background:var(--nda-wine);border:1px solid var(--nda-line);
  color:var(--nda-paint-white);font-family:var(--font-fact);font-size:12px;
  letter-spacing:.16em;padding:14px 26px;text-decoration:none;
  transition:background .2s}
.beat-portfolio .pf-cta:hover{background:var(--nda-wine-anchor)}
@media (max-width:900px){
  .hx{height:16rem}
  .hx.active{width:14rem}
}

/* ---- SHOWCASE RING (Fahad 2026-09-03: "add this animation to the show case
   page on the homepage") ------------------------------------------------------
   Vanilla port of a React 3D-coverflow reference. Behaviour: showcase.js.
   Markup: sc_html() in generate.py. It REPLACES the .hx-row accordion that held
   the same ten cars; that block's rules are further down and now unused.

   THE REFERENCE SUPPLIES THE MOVE, NDA SUPPLIES THE SURFACE — the same split the
   pricing and products rounds took. Kept from it: the five-slot ring, the
   translate/scale/rotateY ladder (285px/510px, .84/.68, 24deg/38deg), the
   1400px perspective and the 800ms cubic-bezier(.25,1,.5,1). Dropped, because
   design-direction.md locks them out:
     - `border-radius: 18px` on the cards and `9999px` on the CTA  -> RADIUS 0
     - three box-shadows incl. a 35px gold glow                    -> MATTE, none
     - the gold #c5a880 accent, twice                              -> WINE W03
     - `font-family: system-ui`                                    -> Anton + the
       site's own body face
   Dropped for a different reason: the reference's full-bleed blurred copy of the
   current photo as an "ambience" background. This beat is a CURTAIN — opaque
   black, carrying its own graffiti wall — and an ambience layer would cover that
   wall. The ground is a site-level decision (rule 68); the wall IS this
   component's ambience.

   ONE ATTRIBUTE DRIVES EVERYTHING. showcase.js writes `data-pos` (0, +-1, +-2,
   far) and every rule below hangs off it, so the geometry is legible in one
   place and a card can be inspected at rest in devtools. The reference wrote
   inline transforms per card per render, which is fine in React and wrong here. */
.sc{position:relative;width:100%;margin:0 auto}
.sc-stage{position:relative;height:560px;display:flex;align-items:center;
  justify-content:center;perspective:1400px}
/* 330x520 rather than the reference's 330x500: these photographs are 900x1600
   (9:16) and a shorter box crops more off the car. */
.sc-card{position:absolute;width:330px;height:520px;overflow:hidden;
  background:var(--nda-black);border:1px solid var(--nda-line);
  transform-origin:50% 50%;will-change:transform,opacity;
  transition:transform .8s cubic-bezier(.25,1,.5,1),
             opacity .8s cubic-bezier(.25,1,.5,1),
             filter .8s cubic-bezier(.25,1,.5,1)}
.sc-card[data-pos="0"]  {transform:translateX(0)      scale(1)   rotateY(0);
  opacity:1;   z-index:30; filter:none;                    cursor:default}
.sc-card[data-pos="1"]  {transform:translateX(285px)  scale(.84) rotateY(-24deg);
  opacity:.65; z-index:20; filter:brightness(.75);          cursor:pointer}
.sc-card[data-pos="-1"] {transform:translateX(-285px) scale(.84) rotateY(24deg);
  opacity:.65; z-index:20; filter:brightness(.75);          cursor:pointer}
.sc-card[data-pos="2"]  {transform:translateX(510px)  scale(.68) rotateY(-38deg);
  opacity:.38; z-index:10; filter:brightness(.55) blur(1px);cursor:pointer}
.sc-card[data-pos="-2"] {transform:translateX(-510px) scale(.68) rotateY(38deg);
  opacity:.38; z-index:10; filter:brightness(.55) blur(1px);cursor:pointer}
/* Parked, not removed: a card taken out of the flow would re-enter from the
   wrong side of the ring on the next step. */
.sc-card[data-pos="far"]{transform:translateX(0) scale(.4) rotateY(0);
  opacity:0;   z-index:0;  pointer-events:none}

.sc-shot{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
  display:block}
/* Bottom-weighted, because all the type sits in the lower half. Flat alpha
   stops rather than a gradient ramp would band on a dark photograph. */
.sc-veil{position:absolute;inset:0;pointer-events:none;z-index:1;
  background:linear-gradient(180deg,
    rgba(0,0,0,.42) 0%, rgba(0,0,0,.10) 26%,
    rgba(0,0,0,.70) 62%, rgba(0,0,0,.95) 100%)}

.sc-copy{position:relative;z-index:2;height:100%;padding:18px 18px 22px;
  display:flex;flex-direction:column;justify-content:space-between;
  text-align:center;
  opacity:0;transform:translateY(14px);
  transition:opacity .5s ease,transform .5s ease;pointer-events:none}
/* Only the centre card shows its copy — a shoulder card is 68-84% scale and
   rotated up to 38deg, and type at that angle is noise rather than information. */
.sc-card[data-pos="0"] .sc-copy{opacity:1;transform:none;pointer-events:auto}

.sc-code{font-family:var(--font-fact);font-size:11px;letter-spacing:.18em;
  color:var(--nda-paint-white);text-align:right;margin:0;opacity:.92}
.sc-body{margin-top:auto;display:flex;flex-direction:column;align-items:center}
.sc-make{font-family:var(--font-display);font-weight:400;text-transform:uppercase;
  font-size:30px;line-height:1;letter-spacing:.02em;
  color:var(--nda-paint-white);margin:0}
.sc-model{font-size:15px;line-height:1.25;margin:4px 0 0;
  color:var(--nda-paint-white)}
/* The reference's gold hairline, in wine. Its 8px glow is dropped: matte. */
.sc-rule{display:block;width:34px;height:2px;background:var(--nda-wine);
  margin:10px auto 8px}
/* --nda-wine-lift, not --nda-wine: the wine is a FILL colour and fails contrast
   as small text. design-direction.md says lift #947c80 for anything at this
   size, and this is 12px. */
.sc-meta{font-family:var(--font-fact);font-size:12px;letter-spacing:.1em;
  text-transform:uppercase;color:var(--nda-wine-lift);margin:0 0 14px}
.sc-cta{display:inline-block;background:var(--nda-wine);
  border:1px solid var(--nda-line);color:var(--nda-paint-white);
  font-family:var(--font-fact);font-size:11px;letter-spacing:.14em;
  text-transform:uppercase;padding:11px 20px;text-decoration:none}
.sc-cta:hover{background:var(--nda-wine-anchor)}
.sc-cta:focus-visible{outline:2px solid var(--nda-wine-lift);outline-offset:3px}

/* Arrows. Square, not the reference's 50% circles, and no backdrop-filter:
   backdrop-filter makes an element the containing block for fixed descendants,
   which is the bug that cost this project a debugging session on the nav. */
.sc-arrow{position:absolute;top:50%;transform:translateY(-50%);z-index:40;
  width:46px;height:46px;display:flex;align-items:center;justify-content:center;
  background:rgba(0,0,0,.62);border:1px solid var(--nda-line);
  color:var(--nda-paint-white);cursor:pointer;padding:0}
.sc-arrow:hover{background:var(--nda-wine)}
.sc-arrow:focus-visible{outline:2px solid var(--nda-wine-lift);outline-offset:2px}
.sc-prev{left:8px} .sc-next{right:8px}
.sc-arrow svg{width:20px;height:20px;fill:none;stroke:currentColor;
  stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}

.sc-dots{display:flex;align-items:center;justify-content:center;gap:8px;
  margin-top:26px}
.sc-dot{height:8px;width:8px;padding:0;border:none;cursor:pointer;
  background:rgba(255,255,255,.25);transition:width .3s ease,background .3s ease}
.sc-dot.on{width:28px;background:var(--nda-wine-lift)}
.sc-dot:focus-visible{outline:2px solid var(--nda-wine-lift);outline-offset:3px}
/* A 8x8 dot is a 8x8 tap target. The pseudo-element pads the HIT area to 44px
   without changing what is drawn (qa-acceptance-checklist.md). */
.sc-dot{position:relative}
.sc-dot::after{content:"";position:absolute;left:50%;top:50%;
  transform:translate(-50%,-50%);width:44px;height:44px}

.sc-live{position:absolute;width:1px;height:1px;overflow:hidden;
  clip-path:inset(50%);white-space:nowrap}

/* NO JS: the ring never initialises, so every card must stay readable. They
   become a plain responsive column — the same content, no stack, no overlap.
   `html.sclive` is added ONLY by showcase.js, so this state cannot outlive the
   thing that undoes it (production-rules #31). */
html:not(.sclive) .sc-stage{height:auto;display:grid;
  grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:18px;
  perspective:none}
html:not(.sclive) .sc-card{position:relative;width:100%;height:420px;
  transform:none;opacity:1;filter:none}
html:not(.sclive) .sc-copy{opacity:1;transform:none;pointer-events:auto}
html:not(.sclive) .sc-arrow,html:not(.sclive) .sc-dots{display:none}

/* Reduced motion: the ring still works, it just stops sliding. Autoplay is off
   in showcase.js on the same query. */
@media (prefers-reduced-motion:reduce){
  .sc-card,.sc-copy,.sc-dot{transition:none}
}

/* Below the second shoulder's reach, the ring drops to three cards; below that,
   one. The transforms are unchanged — only how far out they sit. */
@media (max-width:1180px){
  .sc-card[data-pos="2"],.sc-card[data-pos="-2"]{opacity:0;pointer-events:none}
  .sc-card[data-pos="1"] {transform:translateX(232px) scale(.82) rotateY(-24deg)}
  .sc-card[data-pos="-1"]{transform:translateX(-232px) scale(.82) rotateY(24deg)}
}
@media (max-width:720px){
  .sc-stage{height:520px}
  .sc-card{width:262px;height:440px}
  .sc-card[data-pos="1"],.sc-card[data-pos="-1"]{opacity:0;pointer-events:none}
  .sc-make{font-size:25px}
  .sc-arrow{width:44px;height:44px}
  .sc-prev{left:2px} .sc-next{right:2px}
}

/* ---- About us ---- */
.beat-about{z-index:8;background:var(--nda-black);border-top:1px solid var(--color-border);
  padding:88px 40px 88px}
.beat-about .inner{max-width:1180px;margin:0 auto;display:grid;
  grid-template-columns:minmax(300px,42%) 1fr;gap:56px;align-items:center}
/* center (was start): the 4:5 portrait is ~4x taller than the story
   column — top-aligned text left a black field under the CTA (flagged) */
.beat-about h2{grid-column:1/-1;text-align:center;font-family:var(--font-display);font-weight:400;
  text-transform:uppercase;font-size:var(--text-page);line-height:1}
/* The slot now holds a real picture (2026-08-22), so the placeholder's
   centred-caption styling goes with it: no padding, no type, nothing to
   centre. `aspect-ratio:4/5` STAYS and is doing real work -- it reserves the
   box before the lazy image decodes, so the beat does not reflow as you
   scroll into it, and the source happens to be exactly 4:5 (1122x1402) so
   `cover` crops nothing.
   Source is UNCONFIRMED and shared with the About page's copy; see the
   PROVENANCE launch gate in 00-Brief.md. */
.beat-about .portrait{aspect-ratio:4/5;margin:0;overflow:hidden;
  background:var(--color-surface-raised);border:1px solid var(--color-border)}
.beat-about .portrait img{width:100%;height:100%;object-fit:cover;display:block}
/* PARAGRAPH SPACING, ADDED WITH CR-02 (2026-09-03) AND IT IS A REAL FIX, NOT
   POLISH. styles.css:91 is a global `*{margin:0}` reset and this rule never
   declared a margin -- which was invisible for as long as the beat held ONE
   paragraph. Fahad's new copy is FOUR, and with no margin they butted directly
   against each other and rendered as a single undifferentiated wall of prose:
   the reader could not see where one thought ended and the next began.
   `--space-5` (24px) rather than the `--space-4` that `.prose p` uses, because
   this type sits at 17px/1.75 -- a 29.75px line box -- so a 16px gap is SMALLER
   than the leading inside a paragraph and the break would not read as a break.
   24px clears the line height and separates the four thoughts.
   `:last-of-type` keeps the CTA's own 28px top margin as the only spacing under
   the prose, instead of stacking 24 + 28 into an odd 52px gap. */
.beat-about .story p{color:var(--nda-mist);font-size:17px;line-height:1.75;
  max-width:34em;margin-bottom:var(--space-5)}
.beat-about .story p:last-of-type{margin-bottom:0}
/* SUPERSEDED by CR-02 and PARKED, not deleted, the way this repo keeps replaced
   work: Fahad's copy is plain prose, so there is no <b> left in this beat for
   this to style. It costs nothing and it is the rule to restore if emphasis
   ever comes back to the story. */
.beat-about .story p b{color:var(--nda-paint-white);font-weight:500}
.beat-about .story .cta{display:inline-block;margin-top:28px;background:var(--nda-wine);
  border:1px solid var(--nda-line);color:var(--nda-paint-white);
  font-family:var(--font-fact);font-size:12px;letter-spacing:.16em;
  padding:14px 26px;text-decoration:none}
.beat-about .story .cta:hover{background:var(--nda-wine-anchor)}
@media (max-width:860px){.beat-about .inner{grid-template-columns:1fr}}

/* ---- Process: section deleted 2026-08-07. Its declaration bodies were left
   behind WITHOUT selectors (5 stray closing braces), which put the CSS parser
   into error recovery and silently killed the `.beat-book` rule below -- the
   booking beat had no background as a result. Fragments removed 2026-08-08. ---- */

/* ---- Book (final beat) ---- */
.beat-book{z-index:10;background:var(--nda-black);border-top:1px solid var(--color-border);
  min-height:92vh;display:flex;align-items:center;padding:88px 40px}
.beat-book .inner{max-width:1180px;margin:0 auto;text-align:center;width:100%}
.beat-book h2{font-family:var(--font-display);font-weight:400;text-transform:uppercase;
  font-size:clamp(44px,8vw,110px);line-height:1}
.beat-book h2 .kbar{background:var(--nda-wine);box-shadow:0 0 0 1px var(--nda-line);
  padding:0 .14em}
.beat-book .line{margin-top:22px;color:var(--nda-mist);font-size:18px}
.beat-book .channels{margin-top:44px;display:flex;gap:16px;justify-content:center;flex-wrap:wrap}
.beat-book .channels a{display:inline-block;font-family:var(--font-fact);font-size:13px;
  letter-spacing:.16em;padding:16px 30px;text-decoration:none;
  color:var(--nda-paint-white)}
.beat-book .channels .primary{background:var(--nda-wine);border:1px solid var(--nda-line)}
.beat-book .channels .primary:hover{background:var(--nda-wine-anchor)}
.beat-book .channels .ghost{border:1px solid var(--nda-wine-lift)}
.beat-book .channels .ghost:hover{background:rgba(148,124,128,.12)}
.beat-book .num{margin-top:26px;font-family:var(--font-fact);font-size:13px;
  letter-spacing:.14em;color:var(--nda-wine-lift)}

/* ---- Services tile stills (video replaced with photography 2026-08-07) ---- */
.gtile .shot{padding:0;overflow:hidden;position:relative}
.gtile .shot .tileshot{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
  display:block;transform:scale(1.02);
  transition:transform .7s cubic-bezier(.22,.61,.36,1),filter .7s ease-out;
  filter:brightness(.92) saturate(.96)}
.gtile:hover .tileshot{transform:scale(1.09);filter:brightness(1) saturate(1)}
@media(prefers-reduced-motion:reduce){
  .gtile .shot .tileshot,.gtile:hover .tileshot{transition:filter .2s;transform:none}}

/* wine bars on journey section headings (Fahad 2026-08-07, matches hero KEEP.) */
.beat-services h2 .kbar,.beat-portfolio h2 .kbar,.beat-about h2 .kbar{
  background:var(--nda-wine);box-shadow:0 0 0 1px var(--nda-line);
  padding:0 .14em;display:inline-block}

/* ---- Homepage beat headings: hidden 2026-08-08, RESTORED 2026-08-12
   (Fahad: "add the page headings again like Services, About us, Showcase").
   The h2s and their wine kbars above were never deleted, so removing the
   visually-hidden clip block is the whole restore. ---- */

/* ---- One flat black across every beat 2026-08-08 (Fahad: "make all screens
   black with the same background instead of the hero page"). The hero film is
   deliberately untouched -- this is only the beats below it. All four beats
   were already var(--nda-black); what broke the uniformity was three overlays:
     - .beat-services::before  wine radial glow, 60px blur
     - .beat-services::after   amber radial glow, 70px blur
     - .graffiti-bg/.graffiti-lit  the portfolio graffiti field (+ hover lift)
   Neutralised rather than deleted so the originals survive above this block.
   Side effect: home.js still writes --gd for the graffiti parallax; it is now
   inert, not broken. ---- */
.beat-services::before,.beat-services::after{display:none}
.graffiti-bg,.graffiti-lit{display:none}

/* ---- THE GRAFFITI WALL ON THE HOME JOURNEY (2026-08-21) ------------------
   The site-wide `.graf` (styles.css) is a fixed layer behind the page, which
   is all an ordinary page needs. This page is not ordinary: its four beats are
   OPAQUE BLACK BY CURTAIN LAW — a beat rising over another has to hide it — so
   a single layer behind them all would be buried the instant Services rises.
   Each beat therefore carries its own copy, and the three rules below are what
   make four copies read as ONE wall the beats slide over.

   1. ABSOLUTE, NOT FIXED, AND RE-REGISTERED PER FRAME. `--graf-y` is the beat's
      own top negated (home.js), so the layer lands exactly on the viewport while
      still belonging to the beat. Fixed would register for free but is not
      clipped by ancestor overflow, so a half-risen Services would paint its wall
      over the hero film above it.
      THE REGISTRATION IS A TRANSFORM, NOT `top`. Both land the layer in the same
      place, but `top` is a layout property and this value is rewritten every
      frame the page moves — four relayouts per frame, on layers that also carry
      a filter, on the page already scrubbing a video. translateY is compositor
      work and lets the filtered layer stay cached. The mask is unaffected: it
      resolves against the layer's box wherever the transform puts it, and the
      transform's whole job is to put it on the viewport.
   2. THE BEAT CLIPS IT. `overflow:hidden` is what confines each viewport-sized
      layer to the beat that owns it. Without it rule 1 buys nothing.
   3. THE WALL DOES NOT RETIRE. `--retire` fades a beat's CONTENT as the next
      one covers it; the wall is ground, not content. Letting it fade would mean
      the top half of the screen (retiring beat) and the bottom half (rising
      beat) showed the same picture at different brightnesses — a seam exactly
      where the effect is supposed to read as continuous. `.beat-dim` still
      passes over it, so a retiring beat's wall dims with everything else
      instead of staying conspicuously lit. ---- */
.curtain{overflow:hidden}
.curtain > .graf{position:absolute;inset:auto;left:0;right:0;top:0;
  height:var(--graf-vh,100vh);transform:translateY(var(--graf-y,0px))}

/* The retired portfolio-only field. Its markup is no longer emitted and its
   `--gd` parallax driver is deleted, so these rules and the 2026-08-08
   display:none that neutralised them are both dead. Kept, unreferenced, the
   way this repo parks superseded work. */

/* Same pop as the site-wide book band (Fahad, 2026-09-03). The home beat and
   the band are the same control in two presentations, so a pop on one and not
   the other would read as one of them being unfinished -- the same argument
   that put a rail on all six service pages. */
@media (prefers-reduced-motion: no-preference) {
  .beat-book .channels a{transition:transform .18s cubic-bezier(.34,1.56,.64,1),
                                    background-color .18s ease}
  .beat-book .channels a:hover,
  .beat-book .channels a:focus-visible{transform:translateY(-3px) scale(1.05)}
  .beat-book .channels a:active{transform:translateY(-1px) scale(1.02)}

  /* ---- THE LIFT MUST NOT MOVE THE BUTTON OUT FROM UNDER THE CURSOR
     (2026-09-12). THIS IS THE FLICKER Fahad reported three times: "a glitch,
     light flickering thing happening when I went to book a slot", then "I saw
     it on the contact us page", then "Im still seeing it on the book a slot
     page".

     A hover style that MOVES its own element is self-cancelling. Rest the
     cursor in the bottom couple of pixels of one of these buttons and: hover
     lifts it 3px, the cursor is now below the button, hover drops, the button
     falls back under the cursor, hover fires again -- forever, at the
     transition rate. MEASURED with a COMPLETELY MOTIONLESS cursor: 49 hover
     state changes on this beat and 56 on the site-wide band, in about a second.
     That is the button strobing at roughly 45Hz with the wine fill flashing on
     and off, which is exactly "light flickering".

     WHY EVERY EARLIER PROBE MISSED IT, which is the part worth keeping. The bug
     needs the pointer to be STILL, inside a band about two pixels tall. Sweeping
     probes cross that band in a single frame and never trigger it, and scoring a
     whole frame's average brightness dilutes one button to nothing. It was found
     by holding a motionless cursor at a series of heights across the lower edge
     and counting `:hover` state changes -- a test with no pixels in it at all.

     THE SCALE IS NOT THE PROBLEM AND IS NOT TOUCHED. Growing an element can
     never move it out from under the cursor; only shrinking or translating can.
     `scale(1.05)` actually offsets part of the lift here -- it pushes the bottom
     edge back down by ~1.4px at this size -- so the live band is ~1.6px rather
     than the full 3px, which is a large part of why this survived so long.

     THE FIX KEEPS THE MOTION EXACTLY AS DESIGNED. An invisible guard pinned to
     the button extends its hit area 6px past the bottom edge, so the box the
     cursor is tested against never rises above where the button rests. The
     button still lifts and still pops; losing 3px of box no longer loses the
     hover. Sized 6px against a measured ~1.6px band so it holds at any font
     size or zoom, not just at this one.

     NOT FIXED BY DELETING THE LIFT: Fahad asked for this pop on 2026-09-03 and
     tied the beat and the band together deliberately. The defect is the moving
     hit target, not the movement. ---- */
  .beat-book .channels a{position:relative}
  .beat-book .channels a::after{content:"";position:absolute;
    left:0;right:0;top:0;bottom:-6px}
}
