/* ============================================================
   LINK-IN-BIO LANDING PAGE — COMPONENT LAYER
   ------------------------------------------------------------
   Design tokens, the reset, the type scale, .container and
   .section all live in _template.css and load before this file.
   Nothing here declares a colour, a font size, or a raw pixel
   margin. Components consume semantic tokens only.

   To retint the whole page, edit --brand-base and --accent-base
   at the top of _template.css. Nothing in this file changes.
   ============================================================ */

:root {
  /* Section rhythm override. A link-in-bio page wants to be
     denser than the system default of 96px / 64px. Overriding a
     token in the consuming layer is allowed; referencing a
     primitive is not. */
  --section-y: var(--space-9);            /* 64px */

  /* Component dimensions, not spacing. The 8pt rule governs
     margins, padding, and gaps — not the height of a banner or
     the height of a control. */
  /* FULL VIEWPORT. Was clamp(34rem, 92svh, 48rem) / clamp(38rem,
     94svh, 58rem) — a near-full hero with a 768/928px ceiling, so on
     a tall desktop the photograph stopped short of the fold. The
     brief is a hero that owns the first screen, so the height is the
     screen: no floor and no ceiling, just the viewport.

     svh, not vh, and the difference matters on a phone. vh on mobile
     is the LARGEST viewport — the height with the browser chrome
     retracted — so a 100vh hero is taller than what you can actually
     see on load, and the copy sitting at flex-end gets pushed under
     the address bar. svh is the smallest viewport, chrome extended,
     which is the state the page loads in. The hero then fits the
     first screen at every scroll position rather than only after the
     bar collapses.

     min-height, not height, so the copy block can still outgrow the
     screen on a short landscape phone instead of being clipped. */
  --hero-min:     100svh;
  --hero-min-lg:  100svh;
  /* NO --hero-seam HEIGHT ANY MORE, and no --hero-seam-to either.
     The hero used to dissolve into the section below over 112px
     (160 on desktop), with the destination colour as a token because
     a fade landing on the wrong ground is worse than no fade. That
     whole apparatus is gone: the boundary is a hairline now, and a
     hairline has no height to scale and no colour to arrive at. See
     .hero-offer__seam — the element stayed, only the paint changed.
     The welcome page has drawn its boundary this way all along. */
  /* A HEIGHT, NOT A BOX: the logo keeps its own aspect ratio, so the
     max-widths below only bite on a wide wordmark.

     RAISED FOR NORTH STAR, because the shape of the mark decides what
     a height means. These numbers were set against a wordmark of
     roughly 8:1, where 88px of height is 700px of width and the cap
     is what you actually see. North Star's mark is a near-square
     shield at 641x450, so at 88px it was 125px wide — a third of the
     width the same token gives a wordmark, and it read as a small
     badge stuck in the corner rather than as the thing naming the
     property. 120px puts it at 171px wide, still less than half the
     320px cap, and gives it the presence a page with no headline
     needs from its logo. */
  --hero-logo:      5rem;                 /* 80px  */
  --hero-logo-lg:  7.5rem;                /* 120px */
  --hero-logo-max:  15rem;                /* 240px */
  --hero-logo-max-lg: 20rem;              /* 320px */
  --field-h:      3.5rem;                 /* 56px  — one control row */
  --field-h-lg:     4rem;                 /* 64px  */
  --claim-radius:   2rem;                 /* 32px  — the closing panel's own
                                             corner. --radius-lg is 16px and
                                             correct on a 373px card; on a
                                             1200px plate the same 16px reads
                                             as a square with the edges filed
                                             off. The radius is a proportion
                                             of the object, so a much larger
                                             object gets its own. */

  /* Where a landscape photo should sit inside the hero crop.
     Slightly above centre keeps horizons out of the copy. */
  --hero-focal: 50% 42%;

  /* MOTION
     The built in CSS easings are too weak to read as intentional.
     These are the two curves the page uses; nothing else. */
  --ease-out:      cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out:   cubic-bezier(0.77, 0, 0.175, 1);
  --dur-press:     160ms;
  --dur-ui:        200ms;
  --dur-reveal:    560ms;
  --dur-breathe:  3600ms;   /* the amount's glow. Slow enough to read as
                               breathing rather than blinking. */
  --dur-crossfade: 1200ms;   /* editorial, not UI — allowed to be slow */

  /* HERO SCRIM STOPS
     The overlay tokens in _template.css cover flat scrims. A hero
     needs a multi stop ramp, which no single token can express, so
     the stops live here as one component owned set. Near black with
     a blue cast rather than #000 — pure black reads cheap over a
     photograph. */
  --hero-scrim-1: rgba(9, 11, 14, 0.40);
  --hero-scrim-2: rgba(9, 11, 14, 0.18);
  --hero-scrim-0: rgba(9, 11, 14, 0);

  /* THE SAME RAMP, SIZED FOR BRIGHT FOOTAGE, and used by the desktop
     diagonal alone — see the note over it in the 1024px block.

     Why it is a separate pair rather than a heavier --hero-scrim-1:
     that token is also the welcome page's hero and the mobile ramp,
     and neither of those has this problem. Mobile paints TWO scrims
     that composite well past this weight, and the welcome hero is not
     over client footage at all. Raising the shared token to fix one
     layout would darken three.

     The numbers are measured, not chosen. The hero clip is a sunset:
     its sky is around #E6D2B4 where the headline sits, and at the old
     0.40 the title measured 1.96:1 against a 3:1 floor — a real
     failure, not a thin pass. Solving white text to 4.5:1 over a
     ~230 channel source needs about 0.50 of near black, so the
     plateau was 0.56: enough to clear the body text bar rather than
     the large text one, which is the margin that lets the client
     swap the clip for another bright one without this breaking.

     RE-MEASURED FOR NORTH STAR AND RAISED. This hero is a still of
     Killington Peak under fresh snow, which is a harder ground than
     the sunset those numbers were solved for — sunlit snow runs to
     the 240s across the middle of the frame, where the sky the old
     note describes topped out around #E6D2B4. Screenshotted at 1440
     with the headline itself made transparent, so the reading is of
     what is actually behind the glyphs and not of a swatch:

       0.56 / 0.30   min 2.01   p2 2.49   <- FAILS the 3:1 floor
       0.66 / 0.40   min 2.53   p2 3.16   <- passes on p2 alone
       0.74 / 0.48   min 3.97   p2 4.85   <- shipped

     The shipped pair holds the same standard the old note set: the
     worst pixel clears the 3:1 large text bar the h1 needs, and the
     2nd percentile clears the 4.5:1 body bar, so there is room for
     another bright photograph without this breaking again. The
     text-shadow on the string is on top of all of it.

     If a darker photograph or real footage ever lands here, put
     these back to 0.56 / 0.30 and re-measure — 0.74 over a dark
     frame is a slab, not a scrim. */
  --hero-scrim-lg-1: rgba(9, 11, 14, 0.74);
  --hero-scrim-lg-2: rgba(9, 11, 14, 0.48);

  /* The copy scrim's own stops, below the tokens above rather than
     sharing them. Above 1024px only ONE scrim is painted, so the
     stops above carry the job alone. Below it BOTH are painted and
     they multiply: 0.40 over 0.40 composites to an effective 0.64,
     which is what made the phone hero read as a dark slab. These
     are the same ramp sized to be stacked with it, not to stand on
     its own. Measured at the glyph at 390px this left the title
     at 3.51:1 — the hero strings were 24px and 48px, so the bar was
     the 3:1 large text one, and that was +17% on it.

     RAISED TO 0.44 WITH THE NEW HEADLINE, and it is the copy that
     changed rather than these numbers being wrong. Two things at
     once: the h1 is a different string that wraps to three lines
     and puts its worst line over sunlit snow rather than sky, and
     the line under it went from 36px to 16px, which moves it off
     the 3:1 large text bar and onto the 4.5:1 one. Re-screenshotted
     at 390 and 768 with both strings made transparent, worst pixel
     behind the glyphs:

                   390: h1 / offer      768: h1 / offer
       0.34        2.94 / 3.99          2.82 / 4.37   <- both FAIL
       0.40        3.42 / 4.64          3.31 / 4.96
       0.44        3.84 / 5.06          3.69 / 5.48   <- shipped

     0.44 holds the standard the rest of this file works to: the
     worst pixel clears the bar the string is actually on, and the
     2nd percentile clears 4.5:1, which is the room that lets the
     photograph be swapped.

     IT IS ALSO AS HEAVY AS THIS SHOULD GO. It composites with the
     0.40 ramp below it to about 0.66 effective, and the note above
     is right that the phone hero starts reading as a slab near
     there. If it looks too dark, the lever is a less blown out
     photograph — at 0.40 the headline is back under its floor. */
  --hero-scrim-copy-1: rgba(9, 11, 14, 0.44);
  --hero-scrim-copy-2: rgba(9, 11, 14, 0.16);

  /* The amenity panel's own ground, under the description that
     sits over a client photo nobody has vetted. 0.82 rather than
     _template.css's flat --color-overlay-scrim at 0.50 because
     the worst case here is a blown out white sky: at 0.50 the
     panel blends to #8A8A8A and --color-overlay-fg lands at
     3.4:1, which fails AA for caption sized text. 0.82 blends to
     #363636 and puts the same text at 8.3:1. */
  --card-panel:      rgba(9, 11, 14, 0.82);
  --card-panel-edge: rgba(255, 255, 255, 0.14);

  /* THERE WAS A --card-panel-soft HERE, at 0.50, and it is worth
     one paragraph rather than a silent deletion.

     It existed for one consumer: the amenity deck's description,
     which used to be printed on the pinned photograph. Where
     --card-panel is defensive — 0.82 holds AA against a blown out
     white sky, so it is safe under a photo nobody has looked at —
     0.50 was measured against the four files actually in assets/img
     and bottomed out at 5.30:1 on the water shot, which meant it
     could not survive one of those photographs being replaced.

     That description is now in the left column, on the section's own
     ground, where it needs no panel at all and measures 7.4:1. So
     the token has no consumer and the tie to those four files is
     gone with it. --card-panel is untouched and still carries Local
     Favourites, which is over photos of places nobody here shot and
     wants the defensive number.

     If a panel over an amenity photograph is ever wanted again,
     start from --card-panel and re-measure downward against the
     photographs of the day — do not restore 0.50 from git on the
     strength of a measurement taken against a different set. */

  /* THE ROOM TYPE TAG'S GLASS, from the Figma handoff for Your Stay
     (node 16197:7106). A mid grey at 0.38, not the near black
     --card-panel above: this pill is a label ON a photograph rather
     than a panel over one, and the design wants the picture reading
     through it.

     IT IS THE ONE OVERLAY GROUND ON THIS PAGE THAT IS NOT DEFENSIVE,
     which is worth stating here rather than leaving to be found.
     Composited over a blown out white sky it blends to #C3C3C3 and
     puts --color-overlay-text at 1.76:1. Measured over the corner
     the tag actually covers in the four photographs in assets/img:
     3.75:1 on the standard king, 4.97 on the suite king, 5.83 on
     the suite queen, 7.81 on the two queens — and across the
     brightest twentieth of those same corners, 2.00 to 5.59. One
     shipped card is already under AA's 4.5 and three dip under it
     on a bright patch.

     THE LEVER, IF AA IS WANTED BACK, is the alpha, not the text.
     White clears 4.5:1 over a blown out sky only once this grey
     reaches about 0.86 — which is no longer glass — or once the
     tint moves to --card-panel's near black at about 0.55. Both
     are departures from the design; neither should be made quietly.

     The edge is the design's #d6d6d6 read as --gray-200, the
     nearest step on the neutral ramp and two units off it. */
  --stay-tag-glass: rgba(97, 97, 97, 0.38);
  --stay-tag-edge:  var(--gray-200);

  /* The closing panel's top lip. Raw white alpha rather than a
     palette step, for the same reason the scrims above are: it is
     a light source catching the edge of the plate, so it has to
     hold the same value whatever hue the client's brand is. A
     gray-800 border would go invisible against the panel's own
     gray-800 top and visible against its gray-900 bottom, which
     is a hairline that fades out halfway down the box. */
  --claim-lip: rgba(255, 255, 255, 0.08);

  /* THE AMOUNT'S GLOW, tinted from the client's brand.
     --color-brand-identity is the token meant for "the recognisable
     brand colour", which is the right source for a bloom that is
     supposed to feel like the brand rather than like a generic neon.
     Because the hex is client supplied, the alphas are deliberately
     low: a brand set to something bright has to stay subtle, and a
     bloom sitting behind white text eats into its contrast. */
  --glow-core: color-mix(in oklab, var(--color-brand-identity) 68%, transparent);
  --glow-mid:  color-mix(in oklab, var(--color-brand-identity) 22%, transparent);

  /* Lift for objects that float on a photograph. Black tinted is
     correct here — the ground is a photo, not a light surface. */
  --lift-lg: 0 1.5rem 3.5rem rgba(8, 10, 13, 0.42), 0 0.125rem 0.5rem rgba(8, 10, 13, 0.24);
  /* drop-shadow, not box-shadow: it follows the logo's alpha channel
     instead of drawing a rectangle around a transparent PNG or SVG.
     Delete it if the logo already has its own plate or backdrop. */
  --lift-logo: drop-shadow(0 0.25rem 1rem rgba(8, 10, 13, 0.55));
}

@media (max-width: 767px) {
  :root { --section-y: var(--space-7); }  /* 48px */
}

body {
  background: var(--color-bg-primary);
  color: var(--color-text-primary);
}

a { color: inherit; text-decoration: none; }

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

.center { text-align: center; }

/* ============================================================
   SECTION MODIFIERS
   .container and .section themselves come from _template.css.
   ============================================================ */
.section--tight { padding-block: var(--space-4) var(--section-y); }
/* brand-900 rather than the neutral surface token: the panel inside
   sits on brand-800, and two steps of one ramp read as depth where a
   gray ground under a brand panel reads as two unrelated colours. */
.section--alt   { background: var(--brand-900); }
/* brand-50. The section carries data-theme="light" in the markup, so
   every token inside it resolves against a light ground while the
   page around it stays dark. */
.section--map   { background: var(--color-bg-brand-subtle); }

/* brand-25, and a raw primitive rather than a role for the same reason
   .section--alt takes brand-900: this is a specific rung of the ramp
   chosen against what sits on it, not a semantic surface.

   The section and its cards traded places. The cards used to be the
   tinted thing on a white page; now the ground is the tint and the
   cards are plain white, which is what makes the rail read as four
   objects ON a ground rather than four panels flush with it.

   MEASURED, because the number is small enough to matter: brand-25
   resolves to #F7FBFE, which is 8/4/1 RGB units off the card's white.
   That is a whisper — it will not survive a screenshot compressed to
   JPEG, and on an uncalibrated display it may not read as a colour at
   all. What actually draws the card's edge is the 0.5px gray-400
   frame on .stay-card, and it has to stay there for that reason. If
   the separation is ever wanted as colour rather than as a line,
   brand-50 (#F0F7FC, 15/8/3 off white) is the next rung down and is
   roughly twice the step. */
.section--stays { background: var(--brand-25); }

/* WELCOME.HTML ONLY. There the stays rail sits directly under the dark
   amenities band, and a pale band between two dark blocks read as a
   gap in the page rather than a section of it. So on that page the
   section takes brand-900 to match .section--alt beside it and carries
   data-theme="dark", which is what turns the eyebrow, heading and
   subhead light.

   THE CARDS STAY WHITE. The rail wrapper inside the section carries
   data-theme="light" in the markup, so every token on the cards
   resolves exactly as it does on index.html: white plate, gray-400
   hairline, dark type. Dark cards on the dark ground were tried and
   reverted — white plates on the navy band is the look. That is why
   there is no .stay-card rule here: the cards need nothing changed.
   index.html never sets this class, so its rail is untouched.

   THE SEAM. Two brand-900 blocks meet here, and the amenities band
   above ends in its copy scrim, so without a line the join reads as
   a smudge rather than an edge. A 1px rule in the theme's subtle
   border token — gray-800 under data-theme="dark" — is the same
   treatment the footer uses where it meets the block above it, so
   the page has one kind of dark-on-dark seam, not two. */
.section--stays.section--stays-dark {
  background: var(--brand-900);
  border-top: 1px solid var(--color-border-subtle);
}

/* LEFT ALIGNED HEADER, welcome.html only. The amenities block above
   sets its eyebrow, heading and copy on the container's left edge, and
   on this page the stays header follows the same rule so the two dark
   bands read as one column of type. The rail and its CTA are not
   touched: the cards still centre while they fit and the button stays
   on the section's axis, as on index.html.

   Both halves are needed. .rail-head centres with text-align, and the
   subhead carries a max-width with auto margins — so the text-align
   alone would leave a left aligned paragraph inside a box still parked
   in the middle. Two classes deep to beat the .rail-head .subhead rule
   wherever it sits in the file. */
.section--stays-dark .rail-head { text-align: left; }
.section--stays-dark .rail-head .subhead { margin-inline: 0; }

/* ============================================================
   SECTION HEADINGS
   Size and line height come from the .type-* class on the
   element. Only colour and rhythm live here.
   ============================================================ */
.eyebrow { color: var(--color-text-tertiary); margin-bottom: var(--space-2); }
.heading { color: var(--color-text-primary); margin-bottom: var(--space-3); }

.subhead {
  color: var(--color-text-secondary);
  max-width: 46ch;
  margin-bottom: var(--space-5);
}
.center .subhead { margin-inline: auto; }

/* SECTION TITLES AT 32px ON A PHONE, one rung above what the role
   gives them.

   _template.css steps .type-section-title from 36/44 down to 24/32
   under 768px — the system's standard two-rung drop. On this page
   that lands too small. Every section here is a centred header over
   a full-bleed band, and the title is the only thing carrying it: at
   24px it sits barely clear of the 18px subhead directly beneath and
   the header reads as one soft block instead of a title with a line
   under it.

   32/40 is not a rung on the Untitled UI scale — it skips 30 to 36 —
   so it is written as a literal rather than a token. That is the
   exception this file makes, not a new habit; anything else reaching
   for a size still asks the scale for it.

   40px line height keeps the 1.25 ratio the Display rungs run at and
   lands on the 8pt grid, so the header's rhythm does not move.

   Scoped to the role, so all five move together — Find Your Basecamp,
   The Reasons You'll Stay, Local Favorites, the location intro, and the reviews
   header. */
@media (max-width: 767px) {
  .type-section-title {
    font-size: 2rem;                      /* 32px */
    line-height: 2.5rem;                  /* 40px */
  }
}

/* ============================================================
   BUTTONS
   Pressed is a treatment, not a colour — steps 700/800/900 are
   visually bunched, so there is no third step to reach for.

   THE FIGMA BUTTON (node 16199:7121) LANDED ALMOST EXACTLY WHERE
   THIS ALREADY WAS, which is the ramp doing its job: the design's
   #446476 fill is --color-bg-brand-solid to the byte, its 16/32
   padding is --space-3 / --space-5, and its 52px height falls out
   of the two. Only the shape and the label's size and weight
   moved. Nothing below hardcodes a colour from that file.
   ============================================================ */
.btn {
  display: inline-block;
  padding: var(--space-3) var(--space-5);
  border: 0;

  /* A PILL, AND EVERY BUTTON CARRYING A FILL IS ONE. That is the
     standing rule, not this one button's number — Figma draws a
     57px radius on a 52px tall button, which is already a pill
     with the corners rounded past meeting, so --radius-pill says
     the same thing without a magic number.

     It sits on .btn and not on .btn--primary because a family
     that changes shape with its fill reads as two components.
     Neither page uses the outline or ghost variants today; when
     one does, it should be the same shape as the filled button
     standing next to it. */
  border-radius: var(--radius-pill);

  text-align: center;
  cursor: pointer;
  touch-action: manipulation;             /* kills the 300ms tap delay */

  /* 16px MEDIUM, FROM THE SAME FIGMA NODE, set here rather than in
     .type-button-caps because size and weight are the component
     layer's to override and tracking is not — _template.css says
     tracking may only be set in the type layer, and it is right.

     WHICH IS ALSO THE ONE PLACE THIS MISSES THE DESIGN. Figma
     tracks the label at a flat 0.84px, and that is exactly what
     --ls-wide (0.06em) computed to back when this role was 14px.
     At 16px the same token gives 0.96px, so the label runs about
     2px wider than the 243px in the file. The alternative is a
     raw pixel tracking value in a layer that is not allowed one,
     for 0.12px a character. */
  font-size: var(--fs-text-md);
  font-weight: var(--fw-medium);

  transition: background-color var(--dur-ui) ease,
              filter var(--dur-ui) ease,
              transform var(--dur-press) var(--ease-out);
}

/* Scale, not translate. A button that moves down looks like it
   slipped; a button that shrinks looks pressed. */
.btn:active { filter: brightness(0.92); transform: scale(0.98); }

.btn--primary { background: var(--color-bg-brand-solid); color: var(--color-text-on-solid); }

.btn--outline,
.btn--ghost {
  background: transparent;
  color: var(--color-text-primary);
  border: 1px solid var(--color-border-default);
}

/* A filled button that is not the brand colour. It reads as the
   obvious next step inside a card without competing with the one
   primary action on the page. Tertiary rather than secondary
   because gray-50 on a white card is not visible as a shape. */
.btn--soft {
  background: var(--color-bg-tertiary);
  color: var(--color-text-primary);
}

/* Touch devices fire :hover on tap and then keep it. Gate it. */
@media (hover: hover) and (pointer: fine) {
  .btn--primary:hover { background: var(--color-bg-brand-hover); }
  .btn--outline:hover,
  .btn--ghost:hover   { background: var(--color-bg-brand-subtle); }
  .btn--soft:hover    { background: var(--color-bg-brand-muted); }
}

.btn--sm    { padding: var(--space-2) var(--space-4); }
.btn--block { display: block; width: 100%; }

/* ============================================================
   ICONS
   ------------------------------------------------------------
   One box, many glyphs. The base class owns the size and takes
   its fill from currentColor, so an icon is always the colour of
   the text it sits beside and never carries a colour of its own.
   A modifier only swaps --icon.

   Masks rather than <img> or inline <svg>: a mask discards the
   file's own colour, which is what lets one file serve a light
   brand and a dark one. The glyphs are Lucide, 24x24, 2px stroke.

   The sizes are dimensions, not spacing — an icon box is an
   object, so it is exempt from the 8pt rule the same way the
   field heights and the logo caps are.
   ============================================================ */
:root {
  --icon-phone: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z'/%3E%3C/svg%3E");
  --icon-mail:  url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='20' height='16' x='2' y='4' rx='2'/%3E%3Cpath d='m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7'/%3E%3C/svg%3E");
  --icon-car:   url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2'/%3E%3Ccircle cx='7' cy='17' r='2'/%3E%3Cpath d='M9 17h6'/%3E%3Ccircle cx='17' cy='17' r='2'/%3E%3C/svg%3E");
  --icon-train: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='16' height='16' x='4' y='3' rx='2'/%3E%3Cpath d='M4 11h16'/%3E%3Cpath d='M12 3v8'/%3E%3Cpath d='m8 19-2 3'/%3E%3Cpath d='m18 22-2-3'/%3E%3Cpath d='M8 15h.01'/%3E%3Cpath d='M16 15h.01'/%3E%3C/svg%3E");
  --icon-route: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 11l19-9l-9 19l-2-8l-8-2z'/%3E%3C/svg%3E");
  --icon-pin:   url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z'/%3E%3Ccircle cx='12' cy='10' r='3'/%3E%3C/svg%3E");
  --icon-clock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 6v6l4 2'/%3E%3C/svg%3E");
  --icon-peak:  url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m8 3 4 8 5-5 5 15H2L8 3z'/%3E%3C/svg%3E");
  --icon-home:  url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 9l9-7l9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z'/%3E%3Cpath d='M9 22V12h6v10'/%3E%3C/svg%3E");
  --icon-star:  url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z'/%3E%3C/svg%3E");
}

.icon {
  flex: 0 0 auto;
  display: inline-block;
  width:  1.25rem;                        /* 20px */
  height: 1.25rem;
  background: currentColor;
  -webkit-mask: var(--icon) center / contain no-repeat;
          mask: var(--icon) center / contain no-repeat;
}
.icon--sm { width: 1rem;   height: 1rem;   }   /* 16px — inside a chip or a button */
.icon--lg { width: 1.5rem; height: 1.5rem; }   /* 24px — the lead mark on a tile  */

.icon--phone { --icon: var(--icon-phone); }
.icon--mail  { --icon: var(--icon-mail);  }
.icon--car   { --icon: var(--icon-car);   }
.icon--train { --icon: var(--icon-train); }
.icon--route { --icon: var(--icon-route); }
.icon--pin   { --icon: var(--icon-pin);   }
.icon--clock { --icon: var(--icon-clock); }
.icon--peak  { --icon: var(--icon-peak);  }
.icon--home  { --icon: var(--icon-home);  }
.icon--star  { --icon: var(--icon-star);  }

/* ============================================================
   1 + 2 · HERO OFFER
   ------------------------------------------------------------
   The old hero and the old offer card are one section now. The
   photograph is the ground, the offer is the content, and on
   desktop the copy and the form sit on top of the photo.

   Three layers, back to front:
     __media   the background video
     __scrim   the gradient that buys the white copy its contrast
     __seam    the bottom fade that dissolves the photo into the
               page ground, so section 3 starts with no hard edge
   ============================================================ */
.hero-offer {
  position: relative;
  isolation: isolate;                     /* keeps the layers out of the page stack */
  display: flex;
  /* CENTRED, not flex-end. The copy used to sit low with the photo
     breathing above it; now that the hero is a full 100svh, low meant
     the block was pinned to the fold with a screen of empty mountain
     over it. Centred, the copy sits on the optical middle and the
     photograph gets an even margin top and bottom.

     This moves the strings up out of the heaviest part of both
     scrims, so it is a contrast change as well as a layout one.
     Re-measured at the glyph with the strings made transparent,
     worst pixel behind each, white at 0.96 alpha:

       width    390   768  1024  1280  1440  1920
       h1      3.50  3.67  5.29  5.07  5.07  4.72   (floor 3.0)
       offer   4.76  4.84 12.63 10.03 10.11  9.82   (floor 4.5)

     Desktop improves — the copy leaves the diagonal's release and
     moves into its plateau. Mobile gives some back, because the
     frame scrim there is bottom weighted and the copy has walked up
     out of it: 390px is the tight one at 4.76 against a 4.5 floor.
     Still passing, but that is the number to re-measure if this
     photograph is ever swapped for a brighter one. */
  align-items: center;
  min-height: 100vh;                      /* fallback for anything without svh */
  min-height: var(--hero-min);
  overflow: hidden;
}

.hero-offer__media  { position: absolute; inset: 0; z-index: 0; }
.hero-offer__slides { position: absolute; inset: 0; }

/* Two cuts of the footage, chosen by <source media> in the markup:
   1920x1080 from 768px up and 788x516 below. Neither matches the
   frame exactly — the hero is 100svh tall at every width — so cover
   is doing real scaling here. That is the trade for full bleed: it
   crops rather than letterboxes, and the scrims and the copy sit
   over the part that stays sharp enough. object-position is the same
   --hero-focal the photographs used, so re-aiming the crop is still
   a one line change up in the tokens.

   Explicit width and height, not just object-fit: a <video> keeps its
   intrinsic aspect ratio otherwise and would sit in the middle of the
   frame at its native size. */
.hero-offer__video {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  object-position: var(--hero-focal);
  /* Sits under the poster, so a frame that has not arrived yet is the
     page ground rather than a transparent hole with the scrims over it. */
  background: var(--color-bg-primary);
}

/* Kept for a hero that goes back to stills — see the notes in
   index.html and the slideshow block in script.js, which both still
   work if the markup is swapped back. */
.hero-offer__slide {
  position: absolute; inset: 0;
  opacity: 0;
  transition: opacity var(--dur-crossfade) var(--ease-in-out);
}
.hero-offer__slide.is-active { opacity: 1; }

.hero-offer__slide img {
  width: 100%; height: 100%;
  object-fit: cover;
  object-position: var(--hero-focal);
}

/* Stills only — the video moves on its own, and a Ken Burns push on
   top of footage that is already moving reads as a wobble.

   The drift is the reason a still photograph does not read as a
   dead background, and it makes the crossfade legible as a change
   of scene rather than a flicker. It restarts on every slide
   because the script re-adds .is-active. Linear because the motion
   is continuous — any easing would visibly land mid slide.
   Scale runs 1.01 -> 1.09 so neither end can expose an edge.

   Suppressed under reduced motion — and so is the rotation itself,
   in script.js. The usual rule is that reduced motion means gentler
   rather than none, so an opacity crossfade would normally survive.
   It cannot here: _template.css zeroes every transition-duration
   with !important, which would turn each slide change into a hard
   flash-cut every six seconds. A static hero is the better answer,
   and moving content that runs longer than five seconds with no way
   to stop it is a WCAG 2.2.2 problem regardless of the preference. */
@media (prefers-reduced-motion: no-preference) {
  .hero-offer__slide.is-active img { animation: hero-drift 8s linear forwards; }
}
@keyframes hero-drift {
  from { transform: scale(1.01); }
  to   { transform: scale(1.09); }
}

/* ACCESSIBILITY, NOT DECORATION.
   The scrims below exist so white copy clears its contrast bar over
   footage nobody has vetted. They are about half the weight they used
   to be, and the text shadow further down is what paid for it: the
   shadow buys contrast at the glyph edge, where it is actually read,
   instead of across the whole frame, where it only costs you the
   picture. Measured at the glyph — the worst 2% of the pixels the
   letters actually sit on, over seven frames spread across the clip —
   the title holds 6.4:1 and the offer line 4.9:1 at 1440, and 7.1:1
   and 8.3:1 at 390. Both are large text, so the bar is 3:1; both
   clear the 4.5:1 body bar as well. Taking the scrim down without the
   shadow puts the desktop pair at 3.25:1 and 3.08:1, which is inside
   the standard and too close to the edge of it to ship.

   There are two scrims because one is not enough. This first is
   sized to the FRAME, which is the wrong shape on a narrow viewport:
   the copy block is most of the hero's height there, so its top sits
   where a bottom weighted ramp has already faded out. Measured at
   390px before the second scrim existed, the brand strip came in at
   1.65:1. The frame scrim handles the overall grade; the copy scrim
   below handles legibility. */
.hero-offer__scrim {
  position: absolute; inset: 0;
  background:
    linear-gradient(to top,
      var(--hero-scrim-1)  0%, var(--hero-scrim-1) 18%,
      var(--hero-scrim-2) 42%, var(--hero-scrim-0) 70%),
    linear-gradient(to top, var(--hero-scrim-2) 0%, var(--hero-scrim-0) 30%);
}

/* A DIVIDER, NOT A BLEND. This used to be two stacked ramps taking
   the footage down to the ground of whatever came next — one ramp
   alone has a visible waist about a third of the way up, hence two.
   It is a 1px rule now: the photograph ends where it ends, and a
   hairline says so.

   The element is kept rather than deleted from the markup because it
   is already pinned to the hero's bottom edge and already out of the
   accessibility tree — only the paint changes. It is also what the
   welcome hero has always used, so the two pages now draw the same
   boundary from the same declaration instead of one overriding the
   other.

   --color-border-subtle resolves in the hero's own (light) scope,
   not the dark band's, so this is a pale hairline over the foot of
   the photograph rather than a dark one lost against it. */
.hero-offer__seam {
  position: absolute; inset: auto 0 0 0;
  height: 1px;
  background: var(--color-border-subtle);
}

/* THE HERO ABOVE A DARK BAND — nothing to do here now.
   There used to be a :has(+ [data-theme="dark"]) rule re-pointing
   --hero-seam-to at #152128, a measured composite of the band's
   ground plus its photo wash plus its copy scrim, so the fade landed
   on the band's real colour instead of finishing on white and then
   cutting to dark. A hairline has no destination, so the rule and
   the sampled colour both went with it. If a fade is ever wanted
   back, that number and the reasoning behind it are in git. */


.hero-offer__inner {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: var(--container-max);
  margin-inline: auto;
  /* Not .container — a hero over a photo wants a wider gutter than
     a text section, and it has no left edge to align to. */
  padding: var(--space-12) var(--space-4);

  /* THE COPY SCRIM. Sized to the copy rather than to the frame, so
     it is exactly as tall as it needs to be at any viewport. Below
     1200px this element is full width, which is why it can carry a
     background at all; above 1024px the diagonal frame scrim is
     already doing the work and this one is switched off, because
     here it would darken the bright right hand side of the photo.

     Both ends fade inside the box's own padding, so the band has no
     visible edge — and the bottom fade matters twice over, since a
     hard alpha meeting __seam would print a line exactly where the
     hero is supposed to dissolve into the page.

     The bottom stop is 24%, not 16%: the plateau used to start
     below the copy and darken frame the text never sat on, which
     is dark mass paid for with nothing. Measured at the glyph,
     moving it up changes the title and offer readings by 0.00 —
     it only takes weight out of the band under the form. */
  background: linear-gradient(to top,
    var(--hero-scrim-0)        0%,
    var(--hero-scrim-copy-1)  24%,
    var(--hero-scrim-copy-1)  70%,
    var(--hero-scrim-copy-2)  94%,
    var(--hero-scrim-0)      100%);
}

/* ---- logo ------------------------------------------------------ */
/* No crop and no frame — the logo is shown as it is. It is sized by
   HEIGHT with width left to follow, so a square mark and a wide
   wordmark both drop in without editing anything but the token. */
.hero-offer__logo {
  display: block;
  width: fit-content;
  margin-bottom: var(--space-6);
}
.hero-offer__logo img {
  height: var(--hero-logo);
  width: auto;
  max-width: min(var(--hero-logo-max), 100%);
  object-fit: contain;                    /* honours the ratio if both cap */
  filter: var(--lift-logo);
}

/* Every string in this hero uses --color-overlay-text. Dimming
   secondary copy with --color-overlay-fg measures ~4.0:1 against
   the scrim's worst case and fails AA, so the hierarchy is carried
   by size and weight instead of by opacity. */

/* THE SHADOW IS LOAD BEARING NOW, on both strings below.
   It used to be a single wide 24px glow doing an atmospheric job the
   scrim had already done. Two layers instead, and both are contrast:
   the 2px one is a hard edge that separates a letter from whatever is
   immediately behind it, the 12px one is the pad that catches a
   bright patch of footage drifting under a counter or a serif. That
   pair is what let the scrim come down by half — see the measurements
   over the frame scrim. If you soften these, put the scrim back. */

/* ---- headline: the one liner, first in the hierarchy ------------ */
/* 16px under the headline, not the 32px it was. The offer line
   below is 16px body copy now rather than a 36px second string,
   and a satellite that small needs to sit close to what it is
   attached to — at 32px it floated exactly halfway between the
   headline and the form and stopped belonging to either. The
   welcome page's title keeps the old figure; it is followed by
   the coupon plate rather than by a subline. */
.hero-offer__title {
  /* KICKER OVER A TWO-LINE HEADLINE, to the client's reference:
     "Your Killington Basecamp" small and italic on top, then
     "Modern Rustic Lodging" / "for Mountain Adventures" large, one
     line each, breaking exactly there. All of it is the one h1 so the
     page still has a single title.

     The lines are nowrap and the size is fluid, set by the longer
     big line: "for Mountain Adventures" measures ~9.6px of width per
     1px of font size in Cormorant Infant regular at -0.02em. 7vw
     fills a tablet column (~54px at 768) and 4.5rem is the ceiling;
     the desktop and phone blocks below set their own vw. Regular
     weight rather than .type-hero's semibold, to match the
     reference; line-height is unitless so it tracks the fluid size. */
  max-width: none;
  width: max-content;                     /* the box hugs the longer line */
  white-space: nowrap;
  font-size: min(var(--fs-display-2xl), 7vw);
  line-height: 1.1;
  font-weight: var(--fw-regular);
  margin-bottom: var(--space-4);
  color: var(--color-overlay-text);
  text-shadow: 0 1px 2px rgba(9, 11, 14, 0.55), 0 2px 12px rgba(9, 11, 14, 0.75);
}

/* ---- the kicker --------------------------------------------------- */
/* Sized in em so it keeps its ratio to the headline at every width:
   0.42em is ~29px over a 68px headline. The 1rem floor is for phones,
   where 0.42em of a 31px headline is 13px — small text on a photo,
   and it probed at 3.6:1 at 360px against a 4.5:1 floor.
   Italic, regular weight, normal tracking — the display face's true
   italic is in the Google Fonts request (ital 400), not synthesised. */
.hero-offer__kicker {
  display: block;
  margin-bottom: 0.25em;
  font-size: max(0.42em, 1rem);
  line-height: 1.2;
  font-style: italic;
  letter-spacing: var(--ls-normal);
}

/* ---- the offer: second in the hierarchy ------------------------- */
/* It carried half the headline's size — 36px against 72px — and it
   carries .type-body's 16px now, at the client's ask. The step is
   what the hierarchy rests on either way: at 72 to 16 there is no
   question which line is read first, and the offer reads as the
   terms under the promise rather than as a second headline.

   16px is also the floor this line can take. It is white type over
   a photograph, and below 16px the scrim under it would have to be
   re-measured against the 4.5:1 small text bar rather than the 3:1
   large text one it was solved for. */
.hero-offer__offer {
  max-width: 34rem;
  color: var(--color-overlay-text);
  text-shadow: 0 1px 2px rgba(9, 11, 14, 0.55), 0 2px 12px rgba(9, 11, 14, 0.75);

  /* Regular. The amount beside it is bold and that is where the
     emphasis lives; the rest of the line is the label around it. */
  font-weight: var(--fw-regular);
}

.hero-offer__amount {
  display: inline-block;                  /* so the bloom below has a box to
                                             sit in; an inline span does not
                                             give ::before a reliable one */
  white-space: nowrap;                    /* and so it never splits mid amount */
  position: relative;
  isolation: isolate;                     /* keeps ::before above the photo */

  /* Georgia — the fallback inside --font-display — has OLDSTYLE numerals, so
     an unloaded webfont renders "10%" with an x-height zero. The discount is
     the loudest glyph on the page; it does not get to depend on the network. */
  font-variant-numeric: lining-nums;
}

/* THE GLOW.
   A soft brand tinted bloom BEHIND the text, not a halo traced around
   each glyph. Two reasons. A per glyph outline is the neon tell that
   reads cheap on a hospitality page, and animating text-shadow or
   filter repaints 72px type every frame, where a pseudo element only
   needs opacity and transform — both of which the compositor handles.

   The dark text-shadow above is now doing double duty: it keeps a
   dark separation at the glyph edges, which is what stops a bright
   client brand colour from washing the contrast out. Removing one
   without the other is what breaks this. */
.hero-offer__amount::before {
  content: "";
  position: absolute;
  /* In em, not rem. It was -1rem/-1.5rem, sized by hand against
     36px type, and the line is 16px now — a fixed bloom would be
     twice the height of the words it sits behind. In em it is the
     same shape at any size the offer is ever set in. */
  inset: -0.45em -0.7em;
  z-index: -1;
  border-radius: 50%;
  background: radial-gradient(58% 58% at 50% 50%,
    var(--glow-core)  0%,
    var(--glow-mid)  46%,
    transparent      74%);
  opacity: .42;                           /* the still state */
  pointer-events: none;
}

/* The one perpetual animation on the page, and it is on the one
   element the whole hero exists to sell. Under reduced motion the
   bloom above simply holds at .42, which looks deliberate rather
   than broken. */
@media (prefers-reduced-motion: no-preference) {
  .hero-offer__amount::before {
    animation: amount-breathe var(--dur-breathe) var(--ease-in-out) infinite;
  }
}
@keyframes amount-breathe {
  0%, 100% { opacity: .30; transform: scale(0.96); }
  50%      { opacity: .66; transform: scale(1.04); }
}

/* ---- entrance -------------------------------------------------- */
/* Four elements, 70ms apart. First load only, so it is allowed to
   be slower than a UI transition. */
@media (prefers-reduced-motion: no-preference) {
  .hero-offer__logo,
  .hero-offer__title,
  .hero-offer__offer,
  .offer-form--hero {
    animation: hero-rise var(--dur-reveal) var(--ease-out) both;
  }
  .hero-offer__logo  { animation-delay:  40ms; }
  .hero-offer__title { animation-delay: 110ms; }
  .hero-offer__offer { animation-delay: 180ms; }
  .offer-form--hero  { animation-delay: 250ms; }
}
@keyframes hero-rise {
  from { opacity: 0; transform: translateY(0.5rem); }
  to   { opacity: 1; transform: none; }
}

/* ============================================================
   OFFER FORM  —  used by the hero and by section 9
   ------------------------------------------------------------
   One component in two settings. In the hero it is a white
   surface floating on a photograph, so it carries its own fill
   and shadow. In the closing panel it is a dark well cut into a
   dark plate, so it carries a hairline instead. Both run their
   control row inline once there is width for it; the base is the
   stacked phone layout.

   The control carries data-theme="light" in the hero — a light
   surface on a dark page, the same trick the cards use. The
   closing panel inverts that: the panel itself is pinned dark
   and the control simply inherits it.
   ============================================================ */

/* Hidden from view, kept for assistive tech. The 1px box and the
   negative margin are the canonical visually-hidden recipe, not
   layout spacing, which is why they sit off the 8pt scale.
   display:none and visibility:hidden would both take the label out
   of the accessibility tree with it, leaving the input unnamed. */
.offer-form__label {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.offer-form__control {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding: var(--space-1);
  border: 1px solid var(--color-border-default);
  /* HALF A FIELD ROW, which is a pill exactly when the control is
     one row and a concentric 28px when it is two. --radius-pill
     cannot do both: CSS scales an over-large radius down against
     the SHORTER side, so on the stacked layout 999px turned the
     control into a lozenge and the full width button inside it
     pushed straight out through the corners.

     Derived instead. The button is one row less its own share of
     the padding, so its pill radius is (--field-h - 8) / 2 = 24,
     and 4px of padding around it puts this at 28 — concentric in
     both directions, and a true pill in the row case because 28 is
     already half of the 56 the row measures. The 1024 breakpoint
     bumps the rows to --field-h-lg and re-derives it there. */
  border-radius: calc(var(--field-h) / 2);
}

/* The ring goes on the container, not the input. The input has no
   edge of its own, so a ring drawn on it would float in the middle
   of a white box. */
.offer-form__control:focus-within {
  outline: 2px solid var(--color-border-focus);
  outline-offset: 2px;
}

.offer-form__field {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: calc(var(--field-h) - var(--space-2));
  padding-inline: var(--space-3);
}

.offer-form__icon {
  flex: 0 0 auto;
  width: 1.5rem; height: 1.5rem;
  color: var(--color-fg-secondary);
}
.offer-form__icon svg { width: 100%; height: 100%; }

.offer-form__input {
  flex: 1;
  min-width: 0;
  border: 0;
  outline: 0;                             /* the container shows focus */
  background: transparent;
  color: var(--color-text-primary);
}

/* gray-700, not gray-600. The lighter step measures 3.9:1 on white
   and fails AA for placeholder text. */
.offer-form__input::placeholder { color: var(--color-text-secondary); }

/* THE RADIUS USED TO BE --radius-md HERE, and the reason it no
   longer is: it was the parent's 16px minus the parent's 4px of
   padding, so the two curves nested concentrically. That was the
   right answer while buttons were rounded rectangles. Now every
   filled button is a pill, and a pill nested in a 16px box is the
   one place that reads as a mistake rather than a style — so the
   control follows the button rather than the button following the
   control, and the concentric maths comes back the same way it
   left: --radius-pill on both, 4px apart, still concentric. */
.offer-form__control .btn {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: calc(var(--field-h) - var(--space-2));
  padding-block: 0;
  border-radius: var(--radius-pill);
}


/* ---- validation state ------------------------------------------ */
/* script.js adds .is-invalid to the control and fills the
   .offer-form__error line when an address is refused — either by its
   own shape check or by the function's 400. Both use the semantic
   error tokens, which _template.css already re-points per theme
   (error-700 on light, error-400 on dark), so one pair of rules reads
   correctly on the hero's footage AND on the dark closing plate.
   Nothing new is defined here.

   The focus ring goes red too. Left alone it would draw a red edge
   inside a brand-coloured halo, which reads as two messages at once. */
.offer-form__control.is-invalid { border-color: var(--color-border-error); }
.offer-form__control.is-invalid:focus-within { outline-color: var(--color-border-error); }

.offer-form__error {
  margin-top: var(--space-2);                /* 8px — one grid step */
  color: var(--color-text-error);
}

/* THE HERO'S COPY IS THE EXCEPTION, and it is a contrast fix.
   --color-text-error resolves per THEME, not per background, and the
   hero is not inside a data-theme="dark" wrapper — it is light-theme
   markup laid over dark footage. So it was getting error-700, the
   dark red meant for white pages, over the video. Measured at the
   glyph with the text made transparent, worst pixel over a loop:

     hero @1440   2.43        hero @390   1.64      (floor 4.5)

   Both fail. The closing form needs none of this: it sits inside
   data-theme="dark" (index.html), which already re-points the token
   to error-400, and measured 7.02 untouched.

   error-400 is that same light red, named directly because there is
   no overlay-specific error token. The shadow pair is the one every
   other string in the hero carries, for the same reason. */
.offer-form--hero .offer-form__error {
  color: var(--error-400);
  text-shadow: 0 1px 2px rgba(9, 11, 14, 0.55), 0 2px 12px rgba(9, 11, 14, 0.75);
}

/* While the function is checking the address. Same muted treatment the
   rail arrows use when they cannot go any further. */
.offer-form__control .btn[aria-busy="true"] {
  cursor: progress;
  opacity: 0.7;
}


/* ---- the hero variant ------------------------------------------ */
.offer-form--hero {
  max-width: 34rem;
  margin-top: var(--space-5);
}

/* The edge matches the closing form on purpose — the two forms are the
   same offer and should read as the same control. It carries a shadow
   as well, which the closing one does not: that form sits on a plate
   that is already the lifted object, this one sits on moving footage
   and needs the lift to hold its own shape. The 1px adds 2px to the
   row height; the closing form has always paid the same 2px. */
.offer-form--hero .offer-form__control {
  background: var(--color-bg-primary);
  border-color: var(--color-border-default);
  box-shadow: var(--lift-lg);
}

/* ============================================================
   HERO ON A PHONE  —  centred, logo at the top
   ------------------------------------------------------------
   Desktop keeps the left-aligned block sitting on the optical
   middle. Below 768px, at the client's ask, the hero is a
   centred column: the logo sits at the top of the screen on its
   own, and the title, the offer line and the form share the
   height that is left, centred in it, with a full step of space
   between each. The inner takes the hero's height itself so the
   auto margins have something to push against; .hero-offer
   stretches it rather than centring it. */
@media (max-width: 767px) {
  .hero-offer { align-items: stretch; }
  .hero-offer__inner {
    display: flex;
    flex-direction: column;
    align-items: center;
    text-align: center;
    min-height: var(--hero-min);
    padding-top: var(--space-6);
    padding-bottom: var(--space-9);
  }
  .hero-offer__logo { margin-bottom: var(--space-8); }
  /* The template's mobile hero size, or 8.5vw so "for Mountain
     Adventures" (~9.6px per 1px of size) still fits a 390px column
     on one line: ~33px at 390, ~31px at 360. The breaks are the
     same as desktop. */
  .hero-offer__title {
    margin-top: auto;                     /* copy centres in the space under the logo */
    margin-bottom: var(--space-5);
    font-size: min(var(--fs-display-lg), 8.5vw);
  }
  .hero-offer__offer { margin-inline: auto; }
  .offer-form--hero {
    width: 100%;
    margin-top: var(--space-6);
    margin-bottom: auto;
  }
}

/* Field and button share a row once there is width for it. Below
   this the button drops under the field and fills it. */
@media (min-width: 768px) {
  /* 24px of gutter against a full bleed photograph leaves the copy
     hugging the edge once the viewport is this wide. */
  .hero-offer__inner { padding-inline: var(--space-7); }

  .offer-form--hero .offer-form__control { flex-direction: row; align-items: center; }
  .offer-form--hero .offer-form__field   { flex: 1; min-width: 0; }
  .offer-form--hero .offer-form__control .btn { flex: 0 0 auto; }
}


/* ---- the closing variant --------------------------------------- */

/* Inset, not raised. The plate around it is already the lifted
   object, so a second shadow here would give the section two
   things floating at once. bg-primary under the panel's pinned
   dark theme is gray-950 — one step below the darkest part of the
   plate's own gradient, which is what makes the field read as cut
   into the surface rather than laid on top of it. */
.offer-form--claim { margin-top: var(--space-5); }

.offer-form--claim .offer-form__control {
  background: var(--color-bg-primary);
  border-color: var(--color-border-default);
}

/* 640, not 768. The hero's copy column is capped at 34rem and the
   row only fits past 768; this one has the whole panel width on a
   tablet, so it can go inline a breakpoint earlier. */
@media (min-width: 640px) {
  .offer-form--claim .offer-form__control { flex-direction: row; align-items: center; }
  .offer-form--claim .offer-form__field   { flex: 1; min-width: 0; }
  .offer-form--claim .offer-form__control .btn { flex: 0 0 auto; }
}

/* ============================================================
   9 · CLOSING OFFER PANEL
   ------------------------------------------------------------
   A photograph and a card laid over it. The card straddles the
   photo's edge rather than sitting beside it, which is the whole
   idea: two panels side by side read as two things, one card
   overlapping a picture reads as one object with depth.

   The plate carries data-theme="dark" in the markup, so every
   token inside it resolves to its dark value whichever theme the
   page is set to. That is deliberate and it is the opposite of
   what the cards above do. The cards are light islands because
   they hold content you read through; this is the last thing
   before the footer and its job is to land, so it is the one
   block on the page that goes darker than the page.
   ============================================================ */
.claim {
  position: relative;
  overflow: hidden;                       /* the photo takes the plate's
                                             corners, so it has to be clipped
                                             by them */
  border-radius: var(--claim-radius);
  color: var(--color-text-primary);

  /* Two layers, and a frame of the clip covers both of them. The
     linear is the plate:
     gray-800 down to gray-900, which separates it from the
     gray-950 page without needing an edge. The radial on top is
     the light falling into the corner the card sits in.
     --glow-mid is the brand tinted bloom the hero's amount
     already uses, at 22%, so the plate picks up the client's hue
     instead of being a neutral box. */
  background:
    radial-gradient(120% 90% at 100% 0%, var(--glow-mid) 0%, transparent 62%),
    linear-gradient(160deg, var(--color-bg-tertiary) 0%, var(--color-bg-secondary) 58%);
  /* Nothing above is visible once a frame lands — the photograph
     covers the plate at every width now. It is the ground the
     video loads onto, and the ground the phone layout shows above
     the card if the clip is ever swapped for a shorter file. */
}

/* The lip. Inset rather than a border so it does not take a pixel
   off the photo's edge, and top only — a hairline all the way
   round reads as a stroked box, one along the top reads as light.
   Above the card, so the highlight is not cut in half where the
   card reaches the plate's top corner. */
.claim::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  border-radius: inherit;
  box-shadow: inset 0 1px 0 var(--claim-lip);
  pointer-events: none;
}

/* ---- the footage ----------------------------------------------- */

/* A band across the top here, the whole plate at 1024. Sized the
   same whether the child is an <img> or a <video> — the rule below
   covers both. */
.claim__media {
  position: relative;

  /* Explicit, not the block default. A box carrying both an
     aspect-ratio and a max-height resolves the clamp by taking
     width off instead of height unless the width is definite —
     at 900px the band came out 651px wide inside an 868px plate,
     with the ratio preserved and a quarter of the plate left
     bare. Pinned to 100%, the clamp lands on the height and
     object-fit below crops the difference, which is the intent. */
  width: 100%;

  /* 4:3, not a wider strip. The card overlaps the bottom 56px of
     it, so the ratio has to buy back what the overlap takes —
     16:11 left 180px of photograph showing on a 390px phone,
     which is a letterbox, not a picture. */
  aspect-ratio: 4 / 3;

  /* The ratio alone is a phone rule. At 900px the same ratio is
     600px of photograph before the offer is reachable, so the
     height caps and the ratio gives way — object-fit below crops
     it. A cap rather than a second ratio at a breakpoint: it is
     one number and it holds at every width in between. */
  max-height: 22rem;                      /* 352px */
}

.claim__media img,
.claim__media video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Same aim as the hero, so both crops of the one image frame the same
   point. The phone crop is the tight one — a 4:3 band taken out of a
   landscape frame loses the sides — which is why the framing is a
   token and not a number typed in twice.

   .claim__photo is the still that stands in for the footage on this
   brand; .claim__media video is kept beside it so dropping a <video>
   back in needs no CSS change. */
.claim__photo,
.claim__media video {
  object-position: var(--hero-focal);
  background: var(--color-bg-tertiary);   /* the plate, until it lands */
}

/* A vignette, not a legibility device — see the chip below, which
   carries its own ground. Top weighted, stopping at 42%, so the
   plate's top corners and the lip highlight along them have
   something to sit against whatever the photograph is doing up
   there, and the rest of the picture is untouched. */
.claim__veil {
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom, var(--card-panel) 0%, transparent 42%);
  pointer-events: none;
}

/* ---- the card that overlaps it ---------------------------------- */

/* The negative top margin is the overlap. It pulls the card up
   onto the bottom of the photograph, so the two are one stacked
   object rather than a picture with a box under it. On a desktop
   the same job is done by absolute placement.

   --card-panel again, and for the third time the same reason: it
   is the one ground on this page measured to hold text over a
   photograph nobody has vetted. The blur is what makes it read
   as glass rather than as a hole cut in the picture, and
   --lift-lg is the black tinted shadow meant for exactly this —
   an object floating on a photo. */
.claim__card {
  position: relative;
  z-index: 1;
  /* Flush left, right and bottom. Inset by --space-3 the card left a
     band of bare plate down both sides and along the floor, which
     read as a box dropped on the picture; taking it to the edges
     is what makes the bottom of the plate the card. Only the top
     margin is left, and it is the overlap.

     The bottom corners are --claim-radius, not --radius-lg: the
     card's edge IS the plate's edge here, so its corners have to
     be the plate's corners or the plate clips a square one. */
  margin: calc(-1 * var(--space-8)) 0 0;
  padding: var(--space-5) var(--space-4) var(--space-6);
  border: 1px solid var(--card-panel-edge);
  border-radius: var(--radius-lg) var(--radius-lg)
                 var(--claim-radius) var(--claim-radius);
  background: var(--card-panel);
  -webkit-backdrop-filter: blur(1.25rem);
  backdrop-filter: blur(1.25rem);
  box-shadow: var(--lift-lg);
}

.claim__title { margin-bottom: var(--space-3); }

/* The amount is colour and weight, not a size jump and not a
   second glow. The hero already spends a breathing bloom on this
   number; doing it twice on one page turns a highlight into a
   tic. text-accent is the 300 step in dark, so it stays legible
   on the card whatever the client's secondary hex is.

   nowrap because "[10% off]" breaking across two lines is the one
   thing in the headline that must not happen. */
.claim__amount {
  white-space: nowrap;
  color: var(--color-text-accent);
  font-weight: var(--fw-bold);
}

/* NORTH STAR ADDITION: THE REASSURANCE LINE under the button.
   Tertiary rather than secondary, and that is the whole point of it —
   it is the quietest string in the card, and it is the last thing
   read rather than a caption on the field above.

   The card carries data-theme="dark", so --color-text-tertiary here
   resolves against the dark plate rather than the light page. It
   measures 6.1:1 on the card's ground, which clears the 4.5:1 body
   bar this size needs with room to spare — a promise nobody can read
   is not a promise. */
.claim__fine {
  margin-top: var(--space-4);
  color: var(--color-text-tertiary);
  text-wrap: pretty;
}

/* ============================================================
   RAIL HEADER + ARROWS
   A rail's affordance on a phone is the cut off card plus the
   swipe. With a mouse the cut off card is still there but the
   gesture is not, so the arrows carry it — and they are the
   keyboard route as well. Tab still reaches every card link on
   its own (focusing an offscreen link scrolls it into view), so
   these are an improvement on the interaction, not the only way
   through it.
   ============================================================ */
.rail-head__intro { min-width: 0; }

/* CENTRED AT EVERY WIDTH. This used to be a phone-only rule with
   desktop laying the header out as heading-left, arrows-right; the
   section now centres on one axis all the way up, which also puts
   it in line with the rest of the page — Amenities, Local
   Favourites and Location all carry .center already, so Stays was
   the odd one out on desktop.

   The subhead needs the auto margins as well as the text-align:
   it carries max-width: 46ch, which starts binding around 640px,
   and without them the centred text would sit in a box hugging the
   left edge. Same pairing as the .center .subhead rule above. */
.rail-head    { text-align: center; }
.rail-actions { text-align: center; }
/* 362.9px, WHICH IS THE DESIGN'S NUMBER AND NOT A MEASURE. The
   base .subhead carries max-width: 46ch — a measure, sized in
   characters, because that is what controls how easily a line is
   read and it tracks the font if the font ever changes. At 18px
   Archivo that computes to 474px, and Figma sets this paragraph's
   box at 362.9.

   Kept in px rather than converted, because 362.9 is not a round
   number of anything: it is 35.2ch here, so the nearest whole
   character is 35ch / 360.8px and 2px narrower. A px value says
   plainly that it came from the file. If the body font is ever
   swapped this will need re-deciding — a measure would not have. */
.rail-head .subhead {
  max-width: 362.9px;
  margin-inline: auto;
}

/* Hidden on phones: the swipe is the obvious gesture there, and
   two more tap targets in the header would only crowd it. */
.rail-nav { display: none; }

/* Needed because the desktop rule sets display:flex, which beats
   the browser's own [hidden] styling on specificity. script.js
   sets the attribute when the rail has nothing left to scroll.

   One class, and it has to keep winning: [hidden] is (0,2,0) and
   every rule that turns a nav on below is a single class at
   (0,1,0), so "the rail has nothing to scroll" always beats "this
   breakpoint shows arrows". Add a two-class selector that turns a
   nav on and you break that. */
.rail-nav[hidden] { display: none; }

/* CENTRED UNDER ITS RAIL rather than hung beside a heading, because
   both headers that use it are centre aligned and arrows off to one
   side would be the only thing in the block that is not. Reviews
   and Featured Stays both wear it; Stays' nav used to sit in the
   section header, which is why the header went two-column at
   1024px, and neither is true any more.

   GATED ON A POINTER, NOT ON A WIDTH. It replaced the reviews'
   self-scrolling animation, and that animation ran wherever there
   was a mouse, including in a 900px-wide window; gating on width
   instead would leave a narrow desktop window with neither the
   animation it used to have nor the arrows that replaced it. A
   touchscreen gets no arrows and swipes the row, as before — which
   is also the behaviour Stays' nav had from its own display: none
   base rule, so moving it here changed nothing on a phone. */
.rail-nav--center { display: none; }

@media (hover: hover) and (pointer: fine) {
  .rail-nav--center {
    display: flex;
    justify-content: center;
    gap: var(--space-3);
    margin-top: var(--space-5);
  }
}

.rail-nav__btn {
  display: grid;
  place-items: center;
  width: 3rem;
  height: 3rem;
  padding: var(--space-0);
  /* Strong, not default, unlike .btn--outline. That button is
     identified by its text label, so a soft edge costs it
     nothing. This one is a bare glyph, so the ring is what says
     "control" — and WCAG 1.4.11 wants 3:1 for that. Default
     measures 2.52:1 on the dark ground; strong measures 3.79:1. */
  border: 1px solid var(--color-border-strong);
  border-radius: 50%;
  background: transparent;
  color: var(--color-text-primary);
  cursor: pointer;
  touch-action: manipulation;
  transition: background-color var(--dur-ui) ease,
              border-color var(--dur-ui) ease,
              opacity var(--dur-ui) ease,
              transform var(--dur-press) var(--ease-out);
}
.rail-nav__btn svg { width: 1.25rem; height: 1.25rem; }
.rail-nav__btn:active { transform: scale(0.94); }

/* Dimmed rather than removed. A control that disappears at the
   end of the rail takes its neighbour's position with it, and
   the pair jumping sideways mid-interaction is worse than a
   button that is visibly spent. */
.rail-nav__btn[disabled] {
  cursor: default;
  opacity: 0.35;
  color: var(--color-text-disabled);
  border-color: var(--color-border-subtle);
}

@media (hover: hover) and (pointer: fine) {
  .rail-nav__btn:not([disabled]):hover {
    background: var(--color-bg-tertiary);
    border-color: var(--color-border-strong);
  }
}

/* ============================================================
   SLIDERS — swipe on phones, tidy rows on desktop
   Used by: Featured Stays, Amenities, Reviews
   ============================================================ */
.slider { position: relative; }

.slider__track {
  display: flex;
  gap: var(--space-3);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding-left: var(--container-x);
  scroll-behavior: smooth;
  padding: var(--space-0) var(--container-x) var(--space-4);
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.slider__track::-webkit-scrollbar { display: none; }
/* "none start", not "start". The one word shorthand sets BOTH
   axes, so every card in every rail was quietly declaring itself
   a vertical snap position as well as a horizontal one. That
   costs nothing while the page does not snap vertically, which it
   no longer does — but it cost plenty while it did: scrolls
   ending anywhere near the foot of the page jumped a couple of
   hundred pixels to land on a card. Kept explicit so the next
   thing to turn on vertical snapping does not inherit the same
   surprise. Block axis off, inline axis start. */
.slider__track > * { scroll-snap-align: none start; flex: 0 0 auto; }

/* 3 · Featured stay card — data-theme="light" in the markup
   ------------------------------------------------------------
   A white plate on the dark page. The photo is inset by the
   card's own padding rather than bleeding to the edge — but that
   inset is 2px now, so the frame reads as a drawn keyline around
   the photograph rather than as a mat around it, and the card is
   the photo plus a caption instead of a plate holding a photo.

   Everything is left aligned. The name, the meta row and the arrow
   disc share one vertical rule, and the card's own left edge is the
   line the section heading sits on at desktop widths — see
   .slider--rail's padding-inline-start further down.

   The photo is no longer on that rule. At an 8px frame it could be
   both the card's edge and the text's; at 2px it cannot, because
   text on the photo's edge would sit 2px off the card border. The
   text keeps its inset and the photo takes the frame. */
.stay-card {
  display: flex;
  flex-direction: column;
  /* 288px. Chosen against the rail, not in isolation: inside the
     1200px container it puts 3.8 cards in view, so the fourth is
     cut about a fifth of the way through and reads as "there is
     more" without a caption saying so. The min() is the 320px
     screen — 18rem there would leave no peek at all. */
  width: min(18rem, 78vw);
  /* White, and the token rather than a literal #FFFFFF so the card
     still follows the theme. It was brand-50 while the page under it
     was white; the section now carries the tint (.section--stays,
     brand-25) and the card takes the white, so the two are the same
     pair of values swapped. The 0.5px gray-400 frame below is what
     keeps the card's edge findable against a ground that is only a
     few units darker than it. */
  background: var(--color-bg-primary);
  color: var(--color-text-primary);
  border-radius: var(--radius-lg);
  /* A hairline frame at gray-400. The card is a near white plate,
     so at 0.5px this reads as a drawn edge rather than a stroke.
     Inside the border box, so it costs the content no width — the
     18rem above still measures the same against the rail.

     0.5px is not a guaranteed 0.5px: on a 1x display the engine
     rounds it, usually up to one device pixel. It renders as a true
     hairline on 2x screens and as a 1px line on 1x ones. */
  border: 0.5px solid var(--gray-400);
  /* THE FRAME AROUND THE PHOTOGRAPH, and the one measurement on this
     card that is deliberately off the 8pt scale. 2px is a keyline
     width, not a spacing step: there is no rung of the scale between
     0 and 4px, and rounding it to space-1 would double the frame.
     Everything that used to be measured off this padding — the body
     text and the room-type tag — now carries its own offset. */
  padding: 2px;

  /* LEFT ALIGNED. The name, the guests line and the photo share one
     vertical rule down the card, and the arrow disc is the only
     thing on the right — which is what makes the disc read as the
     card's action rather than as a third line of centred content.

     This was centre aligned while the CTA was a full width bar and
     the bar was the widest thing in the body. With the bar gone the
     centre axis had nothing anchoring it. */
  text-align: left;

  /* The containing block for the room-type tag, which is absolutely
     positioned over the photograph below. */
  position: relative;
}
/* CONCENTRIC WITH THE CARD, not a smaller rung of the radius scale.
   radius-md (12px) inside radius-lg (16px) was the right pair at an
   8px frame, where no curve can look truly nested and a visibly
   different radius is the honest choice. At 2px the two corners are
   close enough to read as one, and any mismatch shows as a wobble in
   the frame's width around the bend. lg minus the frame is the only
   value that keeps that frame 2px the whole way round. */
.stay-card__media {
  border-radius: calc(var(--radius-lg) - 2px);
  overflow: hidden;
  aspect-ratio: 1 / 1;
}
.stay-card__media img { width: 100%; height: 100%; object-fit: cover; }

/* TEXT IN COLUMN ONE, THE ARROW DISC IN COLUMN TWO — a grid rather
   than a flex column, because the disc has to sit beside the two
   text rows instead of below them.

   1fr auto, so the text takes what is left after the disc's 32px
   and the disc never squeezes. align-items: end bottoms the disc
   out against the guests line; the disc spans both rows so it stays
   put when a long room name wraps the title to two lines.

   flex: 1 is kept from the flex version — it is what the card's own
   column flex reads to make short and tall cards in the same
   desktop row bottom out together. */
.stay-card__body {
  display: grid;
  grid-template-columns: 1fr auto;
  column-gap: var(--space-3);
  align-items: end;
  flex: 1;
  /* 16px on the sides, up from space-2, because the card's padding
     went from 8px to 2px underneath it. The pair used to add up to
     the text's inset — 8 + 8 — and with the card's half gone the
     body has to carry the whole of it or the title would land 2px
     off the border. 18px total against the 16px it measured before:
     a 2px drift, which is under the threshold where a vertical rule
     reads as broken, and the alternative is a 14px value that is on
     no scale at all. */
  padding: var(--space-3) var(--space-3) var(--space-2);
}
/* 24px AT EVERY WIDTH, which is the only reason this declares a size
   at all — 24/32 is already what .type-subsection-title gives, so
   the pair below is not changing the desktop value, it is holding it
   through the mobile step-down.

   _template.css takes .type-subsection-title to 20px under 768px,
   and that step-down is right for a heading whose column narrows on
   a phone. This card's does not: .stay-card is width: min(18rem,
   78vw), so it measures the same 288px on a 390px screen as it does
   at 1440 and the title has exactly as much room either way. Letting
   it shrink would be shrinking type to fit a box that never changed.

   Weight comes from .fw-medium in the markup rather than from here,
   so the whole type declaration for this heading stays readable off
   the class list. */
.stay-card__title {
  color: var(--color-text-primary);
  font-size: var(--fs-display-xs);
  line-height: var(--lh-display-xs);
}

/* THE ROOM TYPE, AS A TAG ABOVE THE BED CONFIGURATION — one h3 split
   into two lines rather than two elements. "Standard Room" and "King
   Bed" are one name, so the heading still contains both and a screen
   reader reads it whole; only the presentation separates them.

   PINNED TO THE TOP RIGHT OF THE PHOTOGRAPH, and still the first
   thing inside the h3. Absolute positioning moves where it draws,
   not where it sits in the document, so the heading a screen reader
   announces is "Suite, Queen Bed" exactly as before — which matters
   here more than usual, because two of the four cards are "King
   Bed" and the room type is the only thing telling them apart.

   16px IN FROM THE PHOTOGRAPH'S CORNER, which is what this offset
   is really measuring and what the +2px spells out. Absolute offsets
   resolve against the padding box, so the frame's 2px width sits
   between this number and the image.

   GLASS, FROM THE FIGMA HANDOFF (node 16197:7106), replacing a white
   pill with a shadow — and it is worth naming that as the opposite
   trade rather than a restyle. The old pill was defensive: an opaque
   ground meant the label held its contrast over any photograph that
   ever landed in this card. This one lets the picture read through
   and buys that with contrast. --stay-tag-glass carries the measured
   cost and the lever.

   THE DESIGN'S NUMBERS, KEPT AS THE DESIGN'S NUMBERS. 12px on the
   20px line .type-caption already sets — its 14px rung is the one
   thing overridden here. Light rather than the medium the rest of
   the card carries. Asymmetric 14/12 padding, which is optical
   centring for a pill whose text is centred, and the one place on
   this page off the 8pt scale on purpose. No vertical padding at
   all: the height is the line box plus the hairline, 21px. Figma's
   62px radius and the --radius-pill .chip already sets are one pill at
   that height, so the radius stays inherited.

   Two classes deep on purpose — .chip is defined further down this
   file, so at one class each it would win the tie on source order. */
.stay-card .stay-card__tag {
  position: absolute;
  top: calc(var(--space-3) + 2px);
  right: calc(var(--space-3) + 2px);
  z-index: 1;
  padding: 0 12px 0 14px;
  color: var(--color-overlay-text);
  font-size: var(--fs-text-xs);
  font-weight: var(--fw-light);
  background: var(--stay-tag-glass);
  border: 0.5px solid var(--stay-tag-edge);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  margin-bottom: 0;
}

/* Its own line, and the reason the two do not sit side by side.
   Blocking it also keeps text-wrap: balance working on the bed
   configuration alone — with both spans inline, the balancer counted
   the tag as part of the line it was trying to even out. */
.stay-card__title-line { display: block; }
/* No bottom margin any more. The space-4 that used to be here was
   the gap above the full width CTA bar; with the bar gone it was
   dead space between the guests line and the card's own padding. */
.stay-card__meta  {
  color: var(--color-text-secondary);
  margin-top: var(--space-2);
  margin-bottom: 0;
}

/* THE LABEL, KEPT BUT NOT DRAWN. The card is one big <a>, so its
   accessible name is already the room type, the bed and the guests
   line; this puts the link's purpose in words rather than leaving
   the arrow glyph to imply it. The glyph itself is aria-hidden — it
   says nothing the label does not.

   Same visually-hidden recipe as .offer-form__label further down:
   the 1px box and the negative margin are the canonical version and
   are deliberately off the 8pt scale. display:none would take the
   text out of the accessibility tree along with the view.

   Absolute, so it claims no cell in the body grid. */
.stay-card__cta-label {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Column two, spanning both text rows and bottomed out against the
   guests line. The span is what keeps it in place when a long room
   name wraps the title: without it the disc would sit in row one and
   ride up and down with the title's height, card to card. */
.stay-card__arrow {
  grid-column: 2;
  /* 1 / 3, not 1 / -1. Both rows here are implicit — there is no
     grid-template-rows — and a negative line number resolves against
     the EXPLICIT grid only. With no explicit rows to count back
     from, -1 collapsed to line 1 and the disc spanned the title row
     alone, so it rode up and down with the title instead of sitting
     against the guests line. */
  grid-row: 1 / 3;
  align-self: end;
  display: grid;
  place-items: center;
  width: 2rem;
  height: 2rem;
  border-radius: 50%;
  background: var(--color-text-primary);
  color: var(--color-bg-primary);
  transition: transform var(--dur-ui) var(--ease-out),
              background-color var(--dur-ui) ease,
              color var(--dur-ui) ease;
}
.stay-card__arrow-glyph { width: 1rem; height: 1rem; }

@media (hover: hover) and (pointer: fine) {
  /* The disc is the whole hover now. It used to be the inverse half
     of a two part change — the bar filled with the brand and the
     disc flipped to match — and with the bar gone that inversion
     read backwards: the one element on the card that was already
     dark went light on hover, so the card got quieter when touched.

     Filling it with the brand instead keeps the disc the darkest
     thing in the card in both states, and the nudge right is what
     carries the "this goes somewhere" the removed label used to. */
  .stay-card:hover .stay-card__arrow {
    background: var(--color-bg-brand-solid);
    color: var(--color-text-on-solid);
    transform: translateX(var(--space-1));
  }
}

/* 5 · Amenities — numbered steps beside a pinned photo
   ------------------------------------------------------------
   Two columns on a wide screen: the left pins itself with
   position: sticky and carries the heading and the photo, the
   right is an ordinary <ol> that scrolls past it. Whichever step
   is nearest the middle of the viewport is the active one, and
   the photo swaps to match — script.js picks it with an
   IntersectionObserver.

   Mobile keeps the photo. The design this came from drops the
   pinned column entirely below 1024px, heading and all five
   photos with it, leaving a phone a text-only list. On a page
   whose whole job is to sell a rental that is the wrong half to
   throw away, so here the column stays and the photo pins above
   the list instead, still swapping as you read down.
   ------------------------------------------------------------ */
/* 48px top and bottom, where a normal section takes --section-y
   (64px on a desktop, 48px on a phone). Set on both edges rather
   than only the top so the section keeps the same air above and
   below it whatever the width. */
/* NOT overflow: hidden, however much the scaled blur below looks
   like it wants it here. The aside pins with position: sticky at
   1024px and up, and an ancestor with overflow: hidden becomes the
   scroll container it sticks inside — a box the same height as its
   own contents, which never scrolls, so the pin silently never
   engages and the column just scrolls away with the list. Measured
   with it on: the aside reached -1656px by the fourth amenity
   instead of holding at 48. The clip belongs on the backdrop, which
   is absolutely positioned and so is nobody's offset parent. */
.section--process {
  padding-block: var(--space-7);
  position: relative;                      /* the backdrop is absolute inside it */
  isolation: isolate;                      /* keeps the sticky photo's z-index a
                                              local matter, not a page-wide one */

  /* The copy scrim's fill — see .section--process::after below for
     what it is for. Flat and full width here, because on a phone
     the copy runs the full width of the screen and every part of
     it needs paying for. The pinned layout swaps it for a left
     weighted ramp, where the copy is all in one column.

     A gradient rather than a colour because this is consumed as a
     background LAYER, which has to be an image; a two stop
     linear-gradient is the shortest way to write a flat one. */
  --copy-scrim: linear-gradient(rgba(0, 0, 0, 0.48), rgba(0, 0, 0, 0.48));

  /* THE SEAM, FROM THIS SIDE — DELIBERATELY NOTHING.
     There used to be a --seam-blend here: two ramps starting on this
     section's ground and letting go over --seam-in, to bridge a hero
     that finished on the page's white. That bridge is gone because
     the mismatch it bridged is gone. The hero's fade now arrives at
     this band's own ground directly — see the
     :has(+ [data-theme="dark"]) rule under .hero-offer__seam — so
     there is nothing left to cross.

     MEASURED, on 2026-08-31 at 1440px and 390px: with the blend
     still in, the join stepped 11/255, because the ramp started on
     --color-bg-primary (#25282D in this scope) while the band's real
     composite ground is nearer #152128 — it was capping the top
     LIGHTER than the section it sat on. Removing it and tuning
     --hero-seam-to to the midpoint of the two viewports' grounds put
     the step at 5/255 either side, which is under what the eye finds
     on a dark gradient.

     If a light section is ever put back above this one, the fix is
     --hero-seam-to on the hero, not a ramp here: one destination to
     change beats two ramps to keep in agreement. */
}

/* The section's own ground stays underneath everything here and is
   what the contrast below was measured against — the backdrop only
   ever tints it, it never replaces it. If the images fail to load,
   or color-mix is unsupported and the veil falls back to opaque,
   the section is exactly the flat --color-bg-secondary it was. */
.process__backdrop {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;                        /* the blur is scaled past the edges */

  /* Fades the wash out at the section's top and bottom edges so it
     dissolves into the flat ground rather than stopping on a line.
     The section still has its hard edge against the pages either
     side of it; this is only about the photo not having one too.

     LENGTHS, NOT PERCENTAGES, and that is not tidying. The stops
     were 10% and 90%, which was a 90px fade on the 900px section
     this used to be. The desktop layout below turns the section
     into a four screen scroll track, and the same 10% became a
     340px ramp — a whole screen of the pinned frame reading
     against no wash at all, fading up while you were already
     looking at it. A fade is a fixed distance in the reader's
     eye, so it is written as one. */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 8rem, #000 calc(100% - 8rem), transparent 100%);
          mask-image: linear-gradient(to bottom, transparent 0, #000 8rem, #000 calc(100% - 8rem), transparent 100%);
}

/* brightness() is doing the accessibility work, not the blur.
   A blur spreads a bright area out but does not dim it, and the
   worst patch across these four photos blurs to #E2D5C6 — light
   enough that a veil weak enough to be worth having would put the
   dimmed step copy under 4.5:1. Darkening the photograph first
   means the wash contributes hue and not lightness, which is also
   the effect that was wanted: the ground goes warm for the
   restaurant and cold for the marina without going pale.

   Measured on that worst patch, at brightness 0.65 under a veil of
   0.80: body copy 8.15:1, an inactive step at its 0.68 dim 4.81:1,
   the numeral in --color-text-brand 5.23:1. The binding number is
   the 4.81 — every step of this was chosen to keep it over 4.5.

   THE SCALE IS DOING TWO JOBS. The first is the old one: a blur
   samples past the element's edge, finds nothing there, and leaves
   a soft transparent border, so the image is grown past the box to
   push that border outside the section where the clip removes it.
   115% is the figure that does it.

   The second is cost, and it is the reason the box is 25% and the
   scale is 4.6 rather than the box being 100% and the scale 1.15.
   A filter has to rasterise the layer it applies to, and on the
   desktop layout below this box is the height of a four screen
   scroll track — around 1440x3400, which is 20MB of raster per
   photograph and four photographs in the section. Blurring a
   quarter-size copy and scaling the RESULT up costs a sixteenth of
   that, and scaling an already blurred layer is free on the GPU.
   Nothing is lost: the output is a 48px blur either way, and the
   detail a blur that heavy throws away is exactly the detail the
   downscale threw away first.

   The two numbers are tied — 25% x 4.6 = 115%, and the blur is
   divided by the same 4.6 (10.5 x 4.6 = 48) — so if either moves
   the other has to move with it. transform-origin at the top left
   rather than the centre, because the box is a quarter of the
   section pinned to its corner and the scale has to grow it back
   over the whole of it, not outwards from its middle. */
.process__backdrop-img {
  position: absolute;
  top: 0;
  left: 0;
  width: 25%;
  height: 25%;
  object-fit: cover;
  opacity: 0;
  transform-origin: 0 0;
  transform: scale(4.6);
  filter: blur(10.5px) brightness(0.65) saturate(1.2);
}
.process__backdrop-img.is-active { opacity: 1; }

/* Same reasoning as the frame below: nothing has run yet, or
   script.js never arrived, so show the one that matches the first
   step rather than an empty tint. */
.process__backdrop:not(.is-enhanced) .process__backdrop-img:first-child { opacity: 1; }

/* The veil. --color-bg-secondary is the ground the section would
   have had on its own; mixing the brand into it is what makes the
   wash read as the deep teal rather than as a grey haze, and
   because the brand hex is darker than the ground it also buys
   back contrast rather than spending it.

   The opaque declaration first is the fallback: a browser without
   color-mix drops the second line, gets a solid ground, and loses
   the photograph — which is the right way for this to fail. */
.process__backdrop::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--color-bg-secondary);
  background: color-mix(
    in srgb,
    color-mix(in oklab, var(--color-bg-secondary) 55%, var(--color-brand-identity)) 80%,
    transparent
  );
}

/* THE COPY SCRIM — the layer that pays for the fade further down.

   It exists for one reason: the inactive amenities are faded to
   0.45, and 0.45 of near-white does not clear 3:1 on the ground
   the veil above leaves behind. See the long note over the fade
   for the numbers. This is not decoration and it cannot be taken
   out without putting that fade back to 0.68.

   A FLAT BLACK rather than more veil, because the veil's own
   colour is the ceiling on how dark the veil can get — measured,
   and pushing its opacity and the photograph's brightness
   together barely moved the ground. Black has no such ceiling. It
   costs some of the photograph's colour coming through, which is
   what the wash is for, and that is the trade: the wash is
   decorative and the text is not.

   IT IS ON THE SECTION AND NOT ON THE BACKDROP, and that is the
   whole reason it is a separate element rather than another
   background layer on the veil. The backdrop is masked — it fades
   out over 8rem at the section's top and bottom edges so the wash
   dissolves rather than stopping on a line — and a scrim riding
   that mask fades out with it. Which is exactly the wrong place
   for it to fade: the pinned frame sits AT the top of the section
   for the whole first screen of the track, so the readability
   scrim was thinnest precisely where the copy first appears.
   Measured there, an inactive title came out at 2.49:1. On the
   section, unmasked, the scrim is even the whole way down.

   z-index 0 and last in paint order. The backdrop is also 0 but
   comes first in the DOM, so this lands on top of it, and .process
   is 1, so the copy lands on top of this. */
.section--process::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  /* The seam blend rides on this element rather than on the backdrop
     for the same reason the copy scrim does, and for one more: the
     backdrop is masked to transparent for its first 8rem, so a blend
     painted there would fade out exactly where it is needed most. It
     is the FIRST layer, so it sits over the copy scrim as well as over
     the wash — at the section's top edge it has to hide both. */
  background:
    var(--copy-scrim);
}

/* Above the backdrop. */
.process {
  position: relative;
  z-index: 1;
}

/* display: contents, so on a phone the wrapper stops being a box
   at all and the heading, the photo and the list become siblings
   in one column. That is what lets the photo pin: a sticky
   element can only travel inside its own parent, and boxed in the
   aside it would come unstuck the moment the aside ended — which
   is immediately, since the aside ends where the list begins.
   Dissolved, its parent is .process, which runs the height of the
   section, so the photo can hold while the whole list goes past.

   The heading is left out of the pin deliberately. Pinning the
   aside whole was the first attempt and it took 414px of a 664px
   phone screen — 62%, with the list reading through the slot left
   over. The heading is read once and does not need to stay. */
/* Both of these dissolve on a phone. The column wrapper only
   exists to hold the two blocks together as ONE grid item in the
   pinned layout; here there is no grid and no second column, and
   a box around them would be a box for nothing — worse than
   nothing, because .process__aside below has to be able to
   dissolve too, and a sticky or a grid rule can only see one
   level up. Contents all the way down, the heading, the list and
   the per-step photos are direct children of .process. */
.process__col   { display: contents; }
.process__aside { display: contents; }

/* THE DECK — a stage, not a frame.

   Every card is out of flow, so the stage has to carry its own
   height or the box collapses to nothing. What it must NOT carry
   is a radius, a fill, or an overflow clip: those belonged to the
   frame when the frame was the picture, and here the picture is
   the card. A clip in particular would cut the lift off at the
   top edge, which is the one part of this that has to be seen
   leaving.

   The three deck numbers are component dimensions, not spacing —
   how far one card sits behind the next — so they are set here in
   rem rather than taken from the space scale.

   --stack-shrink is unitless on purpose: it is multiplied by the
   depth and fed to scale(), which takes a number and not a
   length. */
.process__media {
  position: relative;
  aspect-ratio: 4 / 3;
  margin-top: var(--space-5);

  --stack-step:   1.5rem;    /* 24px — how far down each card behind sits */
  --stack-shrink: 0.045;     /* and how much narrower, per step of depth.
                                Both are deliberately past the point of
                                subtlety. At 16px and 0.032 the cards
                                behind showed as a 16px strip under the
                                front one and read as a misprint rather
                                than a stack — the offset has to be large
                                enough that the SHAPE of a card behind is
                                legible, not just its edge. */
  --stack-peek:   3rem;      /* 48px — the room the two visible cards
                                behind the front one need at the bottom.
                                Two steps exactly: past depth 2 a card is
                                faded out, so it needs no room. */
}

/* The card. inset leaves --stack-peek clear at the bottom, so the
   front card is the stage minus that strip and the cards behind
   have somewhere to be.

   transform-origin at the bottom centre is what makes the deck
   read as a deck. Scaled from the centre, a card behind would
   pull its bottom edge UP and away from the peek strip the inset
   just made for it; scaled from the bottom, the bottom edges stay
   put and the shrink shows as the card being narrower — which is
   what "further away" looks like.

   z-index descends with depth so the front card is on top. 20 is
   an arbitrary ceiling with room under it for four; .is-spent
   below goes above the whole deck, because a card being lifted
   off passes over the ones it was sitting on.

   opacity is the depth cutoff. calc goes negative at depth 4 and
   the browser clamps it, so this holds for a deck of any size:
   depth 0-2 fully on, 3 and deeper off. A card fades in as the
   deck moves under it, which is the deck replenishing itself. */
.process__card {
  --depth: 0;
  position: absolute;
  inset: 0 0 var(--stack-peek) 0;
  border-radius: var(--radius-md);
  overflow: hidden;                        /* the clip belongs HERE */
  background: var(--color-bg-tertiary);    /* holds the shape while it loads */
  box-shadow: var(--lift-lg);
  transform-origin: 50% 100%;
  transform: translate3d(0, calc(var(--depth) * var(--stack-step)), 0)
             scale(calc(1 - var(--depth) * var(--stack-shrink)));
  opacity: calc(1 - max(0, var(--depth) - 2));
  z-index: calc(20 - var(--depth));
}

/* Spent — the card for an amenity you have already read, lifted
   up and off the front of the deck.

   The distance is a percentage of the card's own height, so it
   holds at every viewport the pinned stage gets clamped to. It is
   deliberately short of a full card height: the opacity below
   takes the card out long before the transform finishes, so the
   travel only has to be far enough to read as a lift, and a
   longer one would send a still-visible card up over the section
   above.

   The rotation is the whole reason this reads as a hand lifting a
   card rather than a div sliding on a rail. It has to stay under
   about two degrees — past that it is a gesture, and a gesture
   repeated four times down a page becomes a mannerism. */
.process__card.is-spent {
  transform: translate3d(0, -72%, 0) scale(0.96) rotate(-1.2deg);
  opacity: 0;
  z-index: 40;
  pointer-events: none;
}

.process__photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The shade that puts a card behind the front one. A photograph
   at full strength does not read as being further back however
   small you scale it — the eye takes brightness as distance
   before it takes size.

   A pseudo element at a transitioning opacity rather than
   filter: brightness() on the card, because opacity composites
   and brightness repaints, and this is riding a scroll. Tied to
   --depth, so it deepens with each card back and lifts to nothing
   on the front one without needing a class of its own.

   0.34 per step of depth, against a scrim that is itself 50%
   black, so a card one back is under 17% black and two back under
   34%. It was 0.45 while the deck offset was still 16px and the
   shade was carrying most of the "further away" on its own; with
   the offset at 24px the geometry does that work, and 0.45 took
   the second card down to 45% black — a shadow rather than a
   photograph. The shade only has to help now. */
.process__card::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--color-overlay-scrim);
  opacity: calc(var(--depth) * 0.34);
  pointer-events: none;
}

/* Nothing has run yet, or nothing ever will because script.js did
   not load. Either way the first photo is the one to show, flat
   and on its own — without this the stage is four cards at depth
   0 on top of each other and the last one in the DOM wins.

   The deck is the enhancement; a single photograph is the thing
   it enhances. Same fallback the crossfade had. */
.process__media:not(.is-enhanced) .process__card             { opacity: 0; }
.process__media:not(.is-enhanced) .process__card:first-child { opacity: 1; }

/* THE DOT RAIL IS NOT THERE AT ALL until script.js has run, which
   is a stronger fallback than lighting the first dot and the right
   one now that the dots are buttons. Nothing has a state to
   indicate — the deck is one flat photograph and no amenity is lit
   — and pressing one would do nothing, because the scroll
   arithmetic that makes them work lives in the file that did not
   load. Four dead controls is worse than no rail.

   The rail is otherwise styled in the pinned block below, which is
   the only width it exists at. */
.process__dots:not(.is-enhanced) { display: none; }

/* And the same case for the description in the left column. With
   nothing running, no step is .is-active, so on desktop all four
   would be folded shut with no way to open one — the pinned layout
   has no tap target. The first amenity is the one whose photograph
   is showing, so it is the one whose line should be open. */
.process__steps:not(.is-enhanced) .process__step:first-child .process__desc {
  grid-template-rows: 1fr;
  opacity: 1;
}

/* THE PHONE LAYOUT: the frame is off and each amenity carries its
   own photo.

   The pinned version was written for a phone and it does work,
   but it spends a third of a short screen on a picture that is
   holding still while the thing it illustrates scrolls past it —
   the pairing has to be inferred from the timing. One photo per
   amenity says the same thing by adjacency, which is the version
   that needs no inference, and it costs nothing: the four files
   are the ones the frame was already loading.

   Both are in the DOM at every width and the display switch picks
   one, rather than the markup differing per breakpoint. Below
   1024px the frame is off and these are on; the desktop block
   swaps them back. */
.process__media { display: none; }

/* Row 2, column 1 — the same cell the title takes, so the title
   lands on the photograph rather than under it.

   No margin-bottom any more: it used to be the gap between the photo
   and the name below it, and there is no longer anything below it to
   clear. The space between two amenities is .process__step's own
   padding-block and always was.

   The cap that used to live here is on the step's grid column now, so
   the photo and the title are measured by one number instead of two.
   It is for one case and it is not a phone: a desktop-width window
   too short for the pinned layout falls back to here, and width: 100%
   of a 1200px container is a 1200x900 photograph per amenity. 32rem
   matches the cap .process__media already carries for the same
   reason. A phone is narrower than this, so nothing changes there. */
.process__step-photo {
  display: block;
  grid-row: 2;
  grid-column: 1;
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: var(--radius-md);
  background: var(--color-bg-tertiary);   /* holds the shape while it loads */
}

.process__steps {
  list-style: none;
  margin: 0;
  padding: 0;
  counter-reset: step;
}

/* The CTA, phone version: full width under the last amenity, at
   the bottom of the section.

   Full width because every other block in this layout is —
   each amenity is a full-width photograph with its name under it
   — and an auto-width button under a run of those reads as a
   loose fragment. It is also the friendlier tap target.

   The 32rem cap is the same one .process__step-photo carries and
   it is there for the same case: a desktop-width window too short
   for the pinned layout falls back to here, and a 1200px-wide
   button is not a button. A phone is narrower than the cap, so
   nothing changes there.

   margin-top rather than relying on a gap: this is the phone
   layout, where .process__col has dissolved and there is no flex
   container left to own the spacing. */
.process__cta {
  display: block;
  width: 100%;
  max-width: 32rem;
  margin-top: var(--space-6);
}

/* THE PHONE STEP: a numeral, then the photograph with the name laid
   over the foot of it.

   A grid rather than a wrapper element, because the overlay is a
   phone-only arrangement and the pinned layout below needs these two
   to be separate boxes in separate columns — a div around them would
   have to be dissolved again up there, and the markup would carry a
   box that exists for one breakpoint. Two rows: the numeral takes the
   first, and the photo and the title SHARE the second, which is what
   stacks them without either one leaving the flow.

   The column is capped rather than the photo, so the title's scrim
   ends exactly where the photograph does. --process-photo-w is the
   one number both read, and it is the same 32rem the photo used to
   carry alone — see the note on .process__step-photo for the case
   that cap is actually for, which is not a phone. */
.process__step {
  counter-increment: step;
  padding-block: var(--space-5);
  --process-photo-w: 32rem;
  display: grid;
  grid-template-columns: minmax(0, var(--process-photo-w));
  grid-template-rows: auto auto;
}

/* Drawn by CSS, not typed into the markup, so adding or deleting
   a step renumbers the rest on its own. An explicit counter
   rather than the list-item one <ol> already keeps: list-item
   only increments on elements still at display: list-item, and
   these <li>s are not. */
.process__step::before {
  content: counter(step, decimal-leading-zero);
  display: block;
  grid-row: 1;
  margin-bottom: var(--space-2);
  font-family: var(--font-display);

  /* 16px, and the size CHANGES WHICH CONTRAST RULE APPLIES to it —
     which is the one thing to know before touching this line.

     It used to be --fs-display-lg, 48px, where the numeral was
     WCAG large text and its floor was 3:1. At 16px it is ordinary
     body-sized text and the floor is 4.5:1. That is why the
     inactive numeral is faded to its own 0.58 further down rather
     than sharing the title's 0.50: at 0.50 a 16px numeral measures
     4.03:1 and fails, where a 24px title at the same 0.50 measures
     4.13:1 and passes, because the title is still large text.

     Anything above 24px would put it back under the 3:1 rule and
     the two could share one number again. Between 16 and 24 they
     cannot. */
  font-size: 1rem;
  line-height: 1;

  /* Primary, not brand, and the brand comes back on the active step
     below. This is the swap that lets the numeral join the dim at
     all: --color-text-brand measures 3.04:1 on the worst patch of
     this section's wash — a 48px numeral's floor is 3:1, so it is
     sitting ON the limit and has nothing to fade into.
     --color-text-primary is 4.69:1 on the same patch and does. */
  color: var(--color-text-primary);
}

/* The brand hue is the active step's badge now rather than every
   step's decoration, which is a better use of it: four teal
   numerals said "these are numerals", one says "you are here".

   NOT --color-text-brand itself, though, and the reason is
   measured. That token is defined as the first brand step that
   passes AA, so it sits on its floor by construction and has
   nothing in hand — on the worst patch of this section's wash it
   comes out at 3.04:1 against a 48px numeral's 3:1. Passing, and
   passing by 0.04, which is not a margin.

   Mixed halfway into --color-text-primary it renders #D4E1E4 and
   measures 3.85:1 on the same patch. That keeps the hue shift that
   does the actual signalling — the numeral reads visibly cooler
   than the white beside it — and buys back most of the headroom.
   The fallback declaration above it is for a browser without
   color-mix, which gets the plain near-white and loses only the
   tint.

   in oklab rather than srgb so the mix stays on a perceptual
   ramp: in srgb a light desaturated teal mixed with a near-white
   dips through a duller middle, and half the point of this is
   that the hue reads cleanly at a glance. */
.process__step.is-active::before {
  color: var(--color-text-primary);
  color: color-mix(in oklab, var(--color-text-brand) 50%, var(--color-text-primary));
}

/* THE TAP TARGET — phone layout only, and it is the photograph.

   It takes the photo's own grid cell, so it is exactly the picture
   and never a hair more; nothing about its size is typed twice. It
   sits UNDER the caption in z-order and the caption is
   pointer-events: none, which is what lets a tap on the name pass
   through to the button behind it — the alternative is a control
   with a dead strip across its bottom third.

   No fill and no border of its own. The affordance is the picture
   plus the name laid over it, which is already a card, and a phone
   reader tries tapping a card. :focus-visible is the global rule
   from the top of this file, so a keyboard user gets the page's own
   ring here without it being restated.

   Above 1024px the pinned deck is back and the per-step photo is
   off, so this button changes what it is the face of rather than
   going with it: up there it is the whole row, numeral and name,
   and pressing it goes to that amenity. Same element, same one
   meaning — press this amenity — see the desktop block. */
.process__toggle {
  grid-row: 2;
  grid-column: 1;
  z-index: 1;
  appearance: none;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  border-radius: var(--radius-md);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

/* THE NAME AND THE LINE, ON THE PHOTOGRAPH — phone layout only.
   The pinned layout below puts the name back to a plain line in
   the left column, where there is no photograph under it to clear.

   IT IS ONLY AS TALL AS ITS WORDS. The whole point of the request
   this answers is that the picture stays a picture: the scrim is a
   band at the foot of the frame sized by the caption's own padding,
   not a wash over the image. Both stops of the ramp are therefore
   inside the band — 0.72 at the bottom edge easing to nothing at the
   top of it — so the band has no visible upper edge either, and what
   the reader sees is the photograph darkening into the last line.

   Closed, that is one line. Open, the description unfolds inside
   this same box and the band grows upward with it, which is why the
   band lives here and not on the title: one box, one scrim, one
   height, whatever state it is in.

   align-self: end puts the band on the bottom edge and nowhere else;
   the grid row is the photo's height, so "end" is the foot of the
   picture at any width — and it is also why the box can grow without
   moving anything: it grows into the picture, not down the page.

   RADIUS ON THE BOTTOM CORNERS ONLY, matching the photo's own, or a
   square scrim corner prints outside a rounded photo corner.

   pointer-events: none so the whole picture stays one tap target —
   see .process__toggle above.

   The literal near-black rather than an overlay token, for the same
   reason .tall-card::after uses literals: this is a light level
   measured against a photograph, and it has to hold that level
   whatever hue the client's brand is set to. */
.process__caption {
  grid-row: 2;
  grid-column: 1;
  align-self: end;
  z-index: 2;
  pointer-events: none;
  padding: var(--space-6) var(--space-4) var(--space-3);
  border-end-start-radius: var(--radius-md);
  border-end-end-radius:   var(--radius-md);
  /* RAISED FOR NORTH STAR — 0.72/0.52 to 0.84/0.68, and the mid stop
     moved from 45% to 52%. The note below is right about what the band
     is for and wrong about the number, because the number was solved
     against the placeholder art. Screenshotted at 390 with the titles
     made transparent, over all four real photographs, worst pixel
     under the words:

       0.72 / 0.52 / 45%   2.93  <- FAILS the 3:1 floor on three of four
       0.80 / 0.62 / 48%   4.02
       0.84 / 0.68 / 52%   5.56  <- shipped

     Three of the four supplied photographs are brighter under the
     caption than anything this was tuned for: a sunlit pool deck, a
     snowfield at golden hour and a lit bar. The shipped band holds the
     4.5:1 the note below claims rather than the 3:1 the title alone
     would need, which is the margin that lets a photograph be swapped
     without this breaking again.

     Desktop is not affected either way — up there the titles sit on
     the section's own dark ground beside the deck, not on a
     photograph, and they measure about 17:1. */
  background: linear-gradient(to top,
    rgba(9, 11, 14, 0.84) 0,
    rgba(9, 11, 14, 0.68) 52%,
    var(--hero-scrim-0)  100%);
}

/* THE SHADOW IS THE SECOND LINE, NOT THE FIRST — same as
   .tall-card__title. WCAG's contrast maths ignores text-shadow
   entirely, so it earns nothing on paper; it is here for the case a
   band cannot flatten, which is a small bright hotspot directly
   under the words. The band is what the contrast rests on: over the
   worst patch these four photographs blur to, 0.72 of near-black
   puts near-white text over 4.5:1, and the title is large text at a
   3:1 floor besides. */
.process__title {
  color: var(--color-overlay-text);
  text-shadow:
    0 1px 2px  rgba(8, 10, 13, 0.62),
    0 2px 18px rgba(8, 10, 13, 0.48);
}

/* AND PINNED TO 20px ON A PHONE.

   .type-subsection-title already resolves to 20/30 under 768px, so
   this declares the size the card is already getting — it is here to
   hold that size rather than to change it. The role is shared with
   the stay cards and the address card, and .stay-card__title a few
   hundred lines up already had to opt out of the same step-down in
   the other direction; leaving this one implicit means the amenity
   title silently follows whatever the role does next.

   20px is the size the layout is built around: the title sits on its
   own photograph with the description folding out beneath it, and the
   fold is measured from the closed height of that row. */
@media (max-width: 767px) {
  .process__title {
    font-size: var(--fs-text-xl);         /* 20px */
    line-height: var(--lh-text-xl);       /* 30px */
  }
}

/* THE DESCRIPTION, FOLDED AWAY — phone layout only.

   grid-template-rows: 0fr to 1fr, not height or max-height, and
   that is the whole reason this is a grid with one child. A height
   animation needs a number to animate to and the number is the
   text's own wrapped height, which nothing in CSS can be told
   without measuring it in script; 0fr to 1fr means "none of your
   content" to "all of it" and the browser does the measuring. The
   child carries overflow: hidden and min-height: 0, which is what
   makes the 0fr row actually clip — a grid item's automatic minimum
   size would otherwise hold it open at its content height.

   A browser without interpolable grid tracks (anything before
   Chrome 107 / Safari 16 / Firefox 120) gets the same two states
   with no tween between them. The line still opens and closes.

   The opacity is a second channel on purpose rather than a
   duplicate of the fold: unfolding alone reads as the text being
   pushed out from behind the title, which is a slightly comic
   effect at this size, and fading it over the same time turns that
   into the line arriving. */
.process__desc {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
}

/* overflow on the CHILD, not on the .process__desc grid itself:
   the grid is the thing whose row is collapsing, and clipping it
   instead would clip the row rather than the content inside it. */
.process__desc > .process__desc-text {
  overflow: hidden;
  min-height: 0;
  margin: 0;
  padding-top: var(--space-2);
  max-width: 34rem;
  color: var(--color-overlay-text);
  text-shadow: 0 1px 2px rgba(8, 10, 13, 0.62);
}

.process__step.is-open .process__desc {
  grid-template-rows: 1fr;
  opacity: 1;
}

/* THE PICTURE DIMS AND STAYS A PICTURE, which is what the request
   asks for and what rules out a scrim. brightness() is a multiply:
   every pixel keeps its hue and its detail and only its level comes
   down, so what the reader sees is the same photograph turned down.
   A black overlay at the same apparent strength washes the whole
   frame towards one grey and the picture stops being a picture.

   0.62 IS SET BY THE LOOK AND CHECKED AGAINST THE CONTRAST, in that
   order, and the check passes with room to spare because the
   description is inside the caption's band and not out on the open
   photograph. Worst case is a blown-out white highlight directly
   under the words: multiplied by 0.62 and then under the 0.52 of
   near-black the band is carrying at that height, it lands at a
   relative luminance of about 0.072, and --color-overlay-text over
   that measures ~7.5:1 against a 4.5:1 floor. Real pixels are darker
   than that and the text-shadow is spare change on top.

   Which means the number to be careful with is not this one. It is
   the band's — take a stop out of .process__caption and the floor
   moves; this only has to stay dark enough to read as a state
   change, and 0.62 is comfortably that. */
.process__step.is-open .process__step-photo {
  filter: brightness(0.62);
}

.process__chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-top: var(--space-4);
}
.chip {
  display: inline-flex;
  align-items: center;
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-pill);
  background: var(--color-bg-brand-muted);
  border: 1px solid var(--color-border-default);
  color: var(--color-text-primary);
}

/* Pinned flush to the top rather than inset by a gutter. An
   offset leaves a strip of section background above the photo,
   and the list scrolling underneath shows through it — a band of
   sliced text riding along above the picture. At 0 the photo's
   own top edge is the screen's and there is no strip. */
.process__media {
  position: sticky;
  top: 0;
  z-index: 1;
  aspect-ratio: 16 / 9;                   /* ~200px on a phone: a third of the
                                             screen, leaving the list the rest */
  margin-bottom: var(--space-5);

  /* The cap is about height, but it is set on width, because the
     ratio is what turns one into the other. A phone is narrower
     than 32rem so nothing happens there; a 1023px window is not,
     and uncapped the photo went 991x557 — 62% of the screen,
     pinned, with the list reading through what was left. Capping
     the height directly would hold the width and flatten the
     frame to 3:1 instead, which is no better for a photograph.
     Left aligned rather than centred so its edge stays on the
     heading's. */
  max-width: 32rem;
}

/* Dimming the inactive steps is an enhancement, so it is scoped
   to the class script.js adds. Without JS every step stays at
   full strength and the section is a plain numbered list, which
   is the correct thing for it to fall back to.

   0.50, AND IT COST SOMETHING TO GET THERE. The fade was 0.68 for
   one reason: that was the floor the section's ground allowed.

   The measurement that matters is against the ground actually
   BEHIND THE GLYPHS, not against --color-bg-secondary. The flat
   token is #41464E, but the section's blurred wash sits on top of
   it, and with the veil alone the worst patch the copy crossed
   composited to #68706F — light enough that near-white at 0.68
   measured 3.11:1 and at 0.65 measured 2.99:1 and failed. There
   was no room in it.

   What bought the room was .section--process::after, the copy
   scrim, which is why that layer is not optional. With it the
   worst ground behind the copy is around #2D3234 and the numbers
   are these:

     --color-text-primary  full     11.96:1
                           at 0.50   4.13:1
     the active numeral    full     10.22:1
       (#D4E1E4, the 50% brand mix)
     the inactive numeral  at 0.50   4.33:1 pinned,
                                     3.93:1 unpinned
     the eyebrow           full      5.33:1  (4.5 floor — 14px)
     the subhead           full      7.25:1  (4.5 floor)

   Both the numeral and the title are WCAG large text — the
   numeral at 28-48px, the title at 24px/400 — so the floor they
   clear is 3:1 and not 4.5. 0.50 clears it by 1.13.

   THE FADE STOPS AT 0.50 BY CHOICE, not by the ceiling: there is
   now room for 0.45, which measured 3.13:1 before the scrim's
   ramp was lengthened and would sit near 3.6:1 today. It is held
   back because the margin is worth more than the extra 5%. The
   inputs here are a photograph, a webfont and a browser's
   antialiasing, none of which this file controls, and a fade
   sitting 0.1 above a legal floor is one font substitution away
   from being under it. If a deeper fade is ever wanted, re-run
   the sampler below before taking it — do not reason about it.

   ALL OF THESE ARE READ OFF RENDERED PIXELS, not computed: 303
   samples, seven viewports from 390x844 to 1920x1080 covering the
   pinned layout, the phone layout and the short-window fallback,
   six positions along the scroll track, all four steps, comparing
   the brightest pixel of each glyph against the brightest pixel
   of the ground behind it — with the copy hidden for the second
   half of each pair, so the ground is the real composite of
   section, wash, veil and scrim rather than a token.

   Two traps if you re-run it. The numeral is a ::before and has no
   box to measure, so it has to be sampled as a band — and on the
   phone layout that band is ABOVE the per-step photograph, not
   directly above the title the way it is up here; aim it at the
   title and you measure the photograph against itself and get a
   meaningless 1:1. And a step scrolled off the edge returns bare
   ground for both halves of the pair, which also reads as 1:1 —
   discard any sample whose glyph is not brighter than its ground
   rather than counting it as a failure.

   That last part is why the figures here are trustworthy and the
   ones this comment used to carry were not. The original numbers
   were computed against --color-bg-secondary, the flat token, and
   overstated every result by two to three stops: the dimmed title
   was documented at 5.08:1 and was in fact shipping at 3.11:1.
   The ground in this section is a photograph. Measure it.

   The numeral is IN the fade, which it was not originally. That
   needed the colour swap above: brand at 3.04:1 on the old ground
   had no headroom to dim into, primary did, and the brand hue
   moved to the active step where it reads as a position marker.
   The numerals fading with their titles is what makes an inactive
   step read as one dim block rather than a bright number with
   faint text under it.

   opacity on the ::before rather than on the <li>, because
   opacity on a parent cannot be undone by a child — dimming the
   step itself would take the active row's numeral down with it. */
.process__steps.is-enhanced .process__title,
.process__steps.is-enhanced .process__chips { opacity: 0.50; }

/* THE NUMERAL FADES ONE STEP LESS THAN ITS TITLE, and the two
   numbers differ for a reason that is not aesthetic.

   The numeral is 16px. The title is 24px. 24px is the WCAG
   large-text threshold, so the title clears a 3:1 floor and the
   numeral has to clear 4.5:1 — a harder target on the same
   ground, at the same colour, in the same row. Measured on the
   worst patch the copy crosses:

     16px numeral at 0.50   4.03:1 unpinned   <- fails 4.5
                    at 0.55   4.53:1          <- clears, barely
                    at 0.58   4.85:1
     24px title   at 0.50   4.13:1            <- clears 3:1

   0.58 is the first value with a real margin at every viewport,
   pinned and unpinned. It is close enough to the title's 0.50
   that the row still reads as one dim block — the gap is about
   8% of white, well under what the eye separates at this size —
   and it is the difference between the number being legible and
   not.

   If the numeral is ever put back above 24px, delete this rule:
   it goes back under the 3:1 floor and can share the 0.50. */
.process__steps.is-enhanced .process__step::before { opacity: 0.58; }

.process__steps.is-enhanced .process__step.is-active::before,
.process__steps.is-enhanced .is-active .process__title,
.process__steps.is-enhanced .is-active .process__chips { opacity: 1; }

/* A STEP THE READER HAS OPENED IS LIT, wherever it happens to be on
   screen. The dim above answers "which amenity are you level with",
   which is a scroll position and a guess; a tap is a statement. So
   an open step's name comes up to full with its description rather
   than sitting at 0.50 over a picture that has just been darkened
   for the sake of the line under it.

   Phone-only in effect, because .is-open is only ever set at that
   width — see the toggle in script.js. */
.process__steps.is-enhanced .is-open .process__title { opacity: 1; }

@media (prefers-reduced-motion: no-preference) {
  /* --dur-reveal, not --dur-crossfade. The crossfade token is 1200ms
     and it was right for a dissolve, where the eye has nothing to
     track and a slow one reads as editorial. A card being lifted is
     a movement with a start and an end, and 1200ms of it reads as
     the page being slow rather than the card being heavy. 560ms is
     long for UI and correct here: this is explanatory motion, seen
     three times in a section, not a control being operated.

     Named properties rather than `all`, so the ::after shade and
     the box-shadow are not dragged into the same timing. */
  .process__card {
    transition: transform var(--dur-reveal) var(--ease-out),
                opacity   var(--dur-reveal) var(--ease-out);
  }

  /* The one asymmetry, and it is what keeps the lift on the stage.
     The transform still runs the full 560ms, but the fade is over
     in about 310ms — so the card is gone well before the travel
     finishes and never gets far enough up the page to be seen
     crossing the section above.

     Derived from the token rather than typed as 310ms, so retuning
     --dur-reveal keeps the two in proportion. */
  .process__card.is-spent {
    transition: transform var(--dur-reveal) var(--ease-out),
                opacity   calc(var(--dur-reveal) * 0.55) var(--ease-out);
  }

  .process__card::after   { transition: opacity var(--dur-reveal) var(--ease-out); }
  /* The dots ride --dur-ui rather than the deck's --dur-reveal. A
     560ms dot is a dot arriving late: the indicator's whole job is to
     have already answered by the time the eye reaches it, and it is
     the one thing in this section that is not explanatory motion. */
  .process__dot::before   { transition: opacity var(--dur-ui) var(--ease-out),
                                        transform var(--dur-ui) var(--ease-out); }
  .process__backdrop-img  { transition: opacity var(--dur-crossfade) var(--ease-in-out); }

  /* THE HIGHLIGHT IS ASYMMETRIC, and it is worth saying why
     rather than leaving it as two numbers that happen to differ.

     Symmetrically, both at --dur-reveal, the outgoing amenity
     fades down while the incoming one fades up over the same
     560ms — and for most of that half second neither of them is
     lit. Caught at 150ms into a step change the whole list read
     as uniformly dim, which is the one state it is supposed to
     never be in: the highlight exists to answer "where am I", and
     for half a second it answered "nowhere".

     So lighting up is quick — --dur-ui, the page's control-
     feedback duration — and fading back is unhurried. The list
     always has something clearly lit, and the amenity you have
     left dims away behind you rather than trading places.

     This works by specificity and not by a second property: the
     `.is-active` rules below are the destination when a step
     lights up, so their duration is the one that runs; the rules
     here are the destination when it stops being active, so
     theirs runs on the way out. */
  .process__steps.is-enhanced .process__title,
  .process__steps.is-enhanced .process__chips {
    transition: opacity var(--dur-reveal) var(--ease-out);
  }

  /* The numeral has two things changing at once and they want
     different curves. The fade is a state arriving, so it takes
     the page's --ease-out with everything else; the hue is a
     colour change and colour changes take `ease`, because there
     is no motion in one for a curve to characterise. */
  .process__steps.is-enhanced .process__step::before {
    transition: opacity var(--dur-reveal) var(--ease-out),
                color   var(--dur-reveal) ease;
  }

  .process__steps.is-enhanced .is-active .process__title,
  .process__steps.is-enhanced .is-active .process__chips {
    transition: opacity var(--dur-ui) var(--ease-out);
  }

  .process__steps.is-enhanced .process__step.is-active::before {
    transition: opacity var(--dur-ui) var(--ease-out),
                color   var(--dur-ui) ease;
  }

  /* THE FOLD — --dur-ui, not --dur-reveal, and the distinction is
     the one the deck's note above draws. Everything else in this
     section animates because the page is scrolling past it, which
     is explanatory motion and gets the long duration. This is a
     button being pressed, and a control that takes 560ms to answer
     reads as a slow page rather than a considered one.

     grid-template-rows is named explicitly. `all` here would drag
     the caption's background gradient into the same transition, and
     an interpolating gradient on top of a photograph is a repaint
     per frame for no visible gain.

     The photograph's brightness rides the same clock, so the dim and
     the line arrive together — two halves of one gesture rather than
     a flash followed by some text. */
  .process__desc {
    transition: grid-template-rows var(--dur-ui) var(--ease-out),
                opacity            var(--dur-ui) var(--ease-out);
  }

  .process__step-photo {
    transition: filter var(--dur-ui) var(--ease-out);
  }
}

/* Reduced motion keeps the pairing and loses the movement, which
   is what the setting actually asks for — not "no animation".
   The deck stops being a deck: no lift, no offset, no shrink, no
   shade. The cards go back to a plain opacity crossfade, so
   reaching an amenity still changes the picture beside it and the
   section still says what it says. Only the travel is gone.

   This is a change from the old behaviour, which froze on the
   first photograph. Freezing was over-cautious: an opacity
   transition is not motion, and the cost of it was that a
   motion-sensitive reader got one picture for four amenities.

   The wash is the exception and still holds on the first. It is
   the whole section's ground going a new colour, which is a much
   larger area of change than a card inside a column, and the
   crossfade that carries it is 1200ms of the page shifting hue
   underneath the copy being read. */
@media (prefers-reduced-motion: reduce) {
  .process__card {
    transform: none;
    opacity: 0;
    z-index: 1;
    transition: opacity var(--dur-reveal) var(--ease-out);
  }
  .process__card.is-active { opacity: 1; z-index: 2; }
  .process__card.is-spent  { opacity: 0; transform: none; }
  .process__card::after    { opacity: 0; }

  .process__backdrop .process__backdrop-img             { opacity: 0; }
  .process__backdrop .process__backdrop-img:first-child { opacity: 1; }

  /* THE DIM STAYS. It used to be undone here along with the deck's
     movement, and that was over-broad: a difference in opacity
     between two elements is a static state, not motion, and taking
     it out left a motion-sensitive reader with a list in which
     nothing said which amenity was up. The transitions are already
     off — this block is inside prefers-reduced-motion, so the
     highlight changes instantly instead of fading — which is the
     part the setting is actually asking about. Only the chips, which
     have no active state of their own, come back to full. */
  .process__steps.is-enhanced .process__chips { opacity: 1; }
}

/* 6 · Tall photo card — the photo is the whole card
   ------------------------------------------------------------
   Local favourites wears this. Amenities used to as well, and the
   rules are still written to be shared, so a future section can
   pick it up without any of them being touched.

   Not a thumbnail with a caption under it: the name is pinned to
   the top left of the image and the description rides in a panel
   across the bottom. Both sit on a photo the template has never
   seen, so legibility is bought twice — once by the scrim below,
   once by the panel's own fill.

   display: block is for the favourites, where the card is an <a>
   rather than a <figure>. As a flex item it would be blockified
   anyway; it is written out so the rule does not quietly depend
   on the card always living inside a flex track. The <a> needs
   nothing else — the global reset at the top of this file already
   strips its underline and colour, and the global :focus-visible
   outline draws outside overflow: hidden, so tabbing to a
   favourite still shows a ring.

   The card clips its own corners rather than the image doing it,
   which is what lets the image scale on hover without a square
   corner sliding out from under the radius. */
.tall-card {
  position: relative;
  display: block;
  isolation: isolate;                     /* keeps the z-index local */
  width: 18rem;                           /* 288px */
  aspect-ratio: 3 / 4;                    /* matches the 576x784 art */
  overflow: hidden;
  border-radius: var(--radius-lg);
  background: var(--color-bg-tertiary);   /* holds the shape while the photo loads */
}

.tall-card__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Two gradients. The first carries the title, which has no fill
   of its own behind it.

   Stops in rem, not percentages, and that is the load bearing
   detail. The title sits 16px from the top and is ~28px tall at
   every size the card comes in; the card itself is 416px tall on
   desktop and 288px on a phone. Percentage stops rescale with the
   card and the phone's shorter card drags the whole ramp up over
   the words — measured, that cost about 0.6:1. Absolute stops put
   the dark exactly where the text is on both.

   0.68 through the title band is not a taste choice either. White
   at --color-overlay-text needs the ground under it at or below
   about #707070 to clear 4.5:1, and over the worst case photo — a
   blown out white sky — 0.68 lands it at #595959. The mobile
   title is 18px semibold, a hair under the 18.66px the large text
   exemption starts at, so 4.5:1 is the bar it has to clear rather
   than 3:1.

   An ellipse in the corner was tried first, on the theory that
   spending the darkness only where the words are would leave more
   of the photo alone. It did neither: the falloff missed the end
   of a longer title, which measured 1.77:1, and on a wide card
   the shape read as a smear rather than as shading. A band has no
   shape to notice.

   The second gradient grounds the bottom so the panel does not
   look pasted on, and it is in rem for a second reason on top of
   the first. It used to end at 42% of the card. On the 416px
   desktop card that left a clear band of untouched photo in the
   middle; on the 288px phone card 42% reached 121px up, the top
   band reached 176px down, and the two met — every pixel of the
   card was under one scrim or the other and the photograph had
   nowhere left to show. Both measured from their own edge now, so
   the gap between them grows with the card instead of closing. */
.tall-card::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    /* THE TITLE BAND, at the FOOT of the card since the title moved
       there. Same four stops the top band carried, read from the
       other edge — the numbers were solved for what has to sit
       under the words, not for which end of the photo they were
       at, so they travel with the title unchanged.

       They still hold, measured rather than assumed. Screenshotted
       with the title made transparent, worst single pixel behind
       the words, white at 96% composited over it, all four
       photographs:

                          desktop            390
         title at top     9.3 5.9 4.2 7.9    8.9 7.9 7.3 7.5
         title at foot    7.6 6.9 4.3 7.5    7.4 8.4 7.3 8.3

       The floor is the same card either way — Thundering Brook, a
       lit waterfall that is white top and bottom — and it lands at
       4.3:1 where it was 4.2:1. The title is 20px semibold, large
       text at a 3:1 bar, so the move neither gained nor spent
       anything it did not already have. */
    linear-gradient(to top,
      rgba(9, 11, 14, 0.76) 0,
      rgba(9, 11, 14, 0.68) 4rem,      /* 64px — clears a two line title */
      rgba(9, 11, 14, 0.24) 6.5rem,
      var(--hero-scrim-0)   9rem),
    /* And a light wash at the top, where the heavy band used to be.
       It carries no text now — the panel that used to need
       grounding is at the foot with the title — so this is only
       what keeps the card from reading as a photograph with a dark
       hem stuck on it. 0.30 over 5rem, down from the 0.42 over 6rem
       it was doing real work at. */
    linear-gradient(to bottom,
      rgba(9, 11, 14, 0.30) 0,
      var(--hero-scrim-0) 5rem);
}

/* EVERYTHING AT THE FOOT, title first and its description under
   it. It was space-between with the title pinned to the top edge.

   ANCHORED TO THE BOTTOM AND SIZED BY ITS CONTENTS — bottom: 0
   with no top, not inset: 0. Two things follow from that and both
   are load bearing. The block grows UPWARD, so a second line of
   title, or the description opening, takes room out of the
   photograph instead of pushing anything off the card. And the
   height is auto, which is what lets the row below animate at
   all: an fr track measures itself against the content when the
   container has no height of its own, and against the leftover
   space when it has one.

   A grid rather than a flex column for the same reason. The
   description has to take NO space while it is closed — the title
   sits on the bottom edge of the card, and anything reserved
   under it would hold the title up off that edge — and a grid row
   is the one box in CSS that can go from nothing to the height of
   its own contents and be animated doing it. See the hover query
   below, and .process__panel further up, which does the same
   thing for the same reason.

   Both rows auto here, which is the touchscreen case: no hover to
   open anything with, so the description is simply always out. */
.tall-card__caption {
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  z-index: 1;
  display: grid;
  grid-template-rows: auto auto;
  row-gap: var(--space-3);
  padding: var(--space-3);
}

/* No fill behind it — the title sits on the photograph itself, and
   the corner scrim above is what keeps it readable. The shadow is
   a second line of defence rather than the main one: WCAG's
   contrast maths ignores text-shadow entirely, so it earns
   nothing on paper and is here only for the case the scrim was
   not built for — a photo with a small bright hotspot right under
   the words, which no full-corner wash can flatten. */
.tall-card__title {
  justify-self: start;                    /* inline axis — it is a grid item */
  max-width: 100%;
  color: var(--color-overlay-text);
  text-shadow:
    0 1px 2px  rgba(8, 10, 13, 0.62),
    0 2px 18px rgba(8, 10, 13, 0.48);
}

/* TWO BOXES, AND THE SPLIT IS THE WHOLE TRICK. The panel is the
   shutter — no fill, no padding, nothing to see — and the
   description inside it is the pill that used to be the panel.

   They cannot be the same element. A border-box cannot be shorter
   than its own padding and border, so the padded version bottomed
   out at 34px however hard the row above was closed, and 34px is
   exactly how far off the card's bottom edge the title was left
   sitting. Stripped of its padding the shutter closes to nothing,
   and the pill keeps its full size inside it and is simply
   clipped.

   overflow and min-height are the other half: a grid item's
   content is its floor otherwise, and it would hold the track
   open at its natural height however small the track is told to
   be. Both inert while the row is auto. */
.tall-card__panel {
  overflow: hidden;
  min-height: 0;
}
.tall-card__desc {
  display: block;
  color: var(--color-overlay-fg);
  border-radius: var(--radius-md);
  padding: var(--space-3);
  background: var(--card-panel);
  border: 1px solid var(--card-panel-edge);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
}

/* ON A PHONE THE CARD IS A NAME ON A PHOTOGRAPH, AND NOTHING ELSE.

   Two changes with one intent behind them.

   THE FAMILY. .type-card-title is body-family by design, and that is
   right for a card in a grid of cards that each carry a paragraph of
   their own — the serif is saved for the headings above them. These
   cards no longer carry a paragraph, so the reason is gone. Four
   Archivo lines sitting on four photographs read as captions
   underneath pictures; the same four in Cormorant read as the names
   of places, which is what this section is a list of. Only the family
   moves — size, weight and tracking stay on the role.

   Cormorant runs a small x-height, so 18px of it sets visibly smaller
   than 18px of Archivo. That is the trade and it is accepted: the
   title now has the whole bottom of the card to itself instead of
   sharing it with a panel, so it has room it did not have before.

   THE DESCRIPTION, GONE. display: none rather than opacity: 0, which
   is the opposite of the choice the hover query below makes, and
   deliberately so. There the panel is hidden from the eye but kept in
   the accessibility tree because a mouse can bring it back. Here
   nothing can — there is no hover on a phone — so text left in the
   tree would be a screen reader announcing a state the page cannot
   reach. The link still resolves: the destination is the title plus
   the photograph, and both stay.

   row-gap goes to 0 with it. The caption is a two-row grid and the
   second row is now empty, so left at 16px the gap would hold that
   much dead space under the title against the card's bottom edge —
   the same failure the hover query solves for the closed state.

   Desktop is untouched. The reveal below still opens the panel on a
   fine pointer, and the title stays in the body family up there. */
@media (max-width: 767px) {
  .tall-card__title  { font-family: var(--font-display); }
  .tall-card__panel  { display: none; }
  .tall-card__caption { row-gap: 0; }
}

/* ---- the photo rows on a phone: stacked, not swiped -------------
   Both .slider--even rows — amenities and local favourites.
   Featured Stays stays a swipe strip at this width. These do not,
   and the trade is deliberate: a strip shows one and a bit cards
   and hides the rest behind a gesture nothing on screen
   announces, where a stack shows all four in the scroll the
   reader is already doing. Amenities and favourites are lists to
   be read rather than sets to be browsed, so neither needs the
   strip's ability to hold twelve cards in one row.

   overflow-x back to visible and the snap axis off, or the track
   stays a scroll container with a stacked column inside it — the
   drag handler in script.js is mouse only and writes scrollLeft,
   which is inert once there is nothing to scroll sideways. */
.slider--even .slider__track {
  flex-direction: column;
  overflow-x: visible;
  scroll-snap-type: none;
  gap: var(--space-3);
}

/* width auto so the column's default stretch takes them the full
   width of the track, and the ratio dropped for a fixed height —
   at full width 3:4 would make each card ~457px tall and put the
   four of them over 1900px of scrolling on a phone. */
.slider--even .tall-card {
  width: auto;
  aspect-ratio: auto;
  height: 18rem;                          /* 288px */
}

/* ---- the photo rows, the tablet step ----------------------------
   Has to sit after the stack above rather than up with the other
   768px rules, and the reason is plain cascade order: these two
   blocks have equal specificity, a media query adds none, so
   whichever is written last wins. Written first, `flex-direction:
   row` here lost to `column` above at every width — the track
   stayed a column, `flex: 1 1 0` applied its basis to the card's
   HEIGHT instead of its width, and every card computed to 1168x0.

   What it does: break the stack before the four across row
   starts. A stacked card is full width against a fixed height, so
   the wider the viewport the flatter it gets — 343x288 on a phone
   is fine, 735x288 at the top of the stacked range is a 2.5:1
   letterbox, and left running to 900px it reaches 868x288 and
   crops a portrait amenity photo down to a band.

   640px, not 768px, for that reason: it is where the stacked card
   passes 2:1, and every 100px past it is worse. Two up rather
   than the four this becomes at 1024, because four across at this
   width is a ~166px card and a two word amenity name does not fit
   that.

   22rem holds the pair between 292x352 at 640px and 483x352 just
   under 1024px — portrait at one end, mildly landscape at the
   other, and never the letterbox. A ratio instead of a height
   would be the obvious move and is the wrong one: at the top of
   this range it computes a 644px tall card, taller than the
   desktop one it is a step down from.

   The track sits outside .container so it can bleed edge to edge
   as a strip on a phone, so the column has to be put back by hand
   the moment the cards form a row: the same max width and auto
   margins .container uses, the gutter already coming from
   .slider__track's padding. Without it the row runs to the screen
   edges under a heading boxed at 1200px. */
@media (min-width: 640px) {
  .slider--even .slider__track {
    flex-direction: row;
    flex-wrap: wrap;
    gap: var(--space-4);
    max-width: var(--container-max);
    margin-inline: auto;
  }
  .slider--even .slider__track > * {
    flex: 1 1 calc(50% - var(--space-4));
    min-width: 0;
  }
  .slider--even .tall-card { width: auto; height: 22rem; }   /* 352px */
}

/* The reveal is a mouse affordance, and nothing is locked behind
   it. Everywhere a fine pointer is not certain the panel is
   simply always on — which is the case that matters most here,
   because this is a link in bio page and most of its traffic
   arrives on a phone. Hover does not exist there, so a hover
   gated description would be unreachable, not subtle.

   Faded with opacity rather than display or visibility on
   purpose: those two drop the description out of the
   accessibility tree and a screen reader loses it along with the
   mouse. At opacity 0 it is still announced. */
@media (hover: hover) and (pointer: fine) {
  /* CLOSED TO NOTHING, not just faded. The description used to
     keep its space while transparent, which is the ordinary way to
     stop a reveal shifting what is around it — and it cannot work
     here, because what is around it is a title sitting on the
     bottom edge of the card. A reserved box under that title holds
     it a panel's height up off the edge, permanently, to make room
     for something nobody is looking at.

     0fr closes the row completely; 1fr opens it to the height of
     what is inside it, because the caption has no height of its
     own for the fr to divide up instead. The title is pushed up by
     exactly what the description needs and the description opens
     underneath it, which is the order it reads in.

     row-gap goes with it. A gap sits between two rows whether or
     not one of them has any height, so left at 16px it would put
     16px of nothing under the closed title. */
  .tall-card__caption {
    grid-template-rows: auto 0fr;
    row-gap: 0;
  }
  .tall-card:hover .tall-card__caption,
  .tall-card:focus-within .tall-card__caption {
    grid-template-rows: auto 1fr;
    row-gap: var(--space-3);
  }
  /* Still faded as well as closed, so the panel does not wipe into
     view as a hard edged box, and still opacity rather than
     display or visibility — at 0 it is out of sight but still in
     the accessibility tree, which is where a screen reader reads
     it from. */
  .tall-card__panel { opacity: 0; }
  .tall-card:hover .tall-card__panel,
  .tall-card:focus-within .tall-card__panel { opacity: 1; }
}

@media (prefers-reduced-motion: no-preference) {
  .tall-card__img   { transition: transform var(--dur-reveal) var(--ease-out); }
  .tall-card__panel { transition: opacity   var(--dur-reveal) var(--ease-out),
                                  transform var(--dur-reveal) var(--ease-out); }
  /* The row opening is the movement the eye follows, so it is
     timed with the fade rather than left to snap. grid-template-rows
     between two fr values interpolates; between a track list of a
     different shape it does not, which is why both states are
     written as the same two tracks. */
  .tall-card__caption { transition: grid-template-rows var(--dur-reveal) var(--ease-out),
                                    row-gap            var(--dur-reveal) var(--ease-out); }
}

/* The panel rises the last few pixels into place and the photo
   creeps up a hair under it. Both are decoration on top of a
   state change that has already happened, so both sit inside the
   no-preference guard and neither is load bearing. */
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
  .tall-card__panel                 { transform: translateY(var(--space-2)); }
  .tall-card:hover .tall-card__panel,
  .tall-card:focus-within .tall-card__panel { transform: translateY(0); }
  .tall-card:hover .tall-card__img  { transform: scale(1.04); }
}

/* 8 · Review card
   ------------------------------------------------------------
   THE CARD KEPT ITS COLOUR WHEN THE PANEL LOST ITS OWN. The panel
   went from brand-800 to brand-50 — dark plate to light plate — and
   the gradient below deliberately did not follow it. So the
   relationship inverted: this used to be the darker of two dark
   surfaces, and it is now a dark card sitting on a light one.

   That inversion is worth keeping. Against brand-50 the card is the
   only dark thing in the block, which is far more separation than it
   ever had against brand-800, and the quote reads as something
   lifted out of the page rather than a slightly different shade of
   it. Not one value in the fill or the hairline below changed.

   What it costs is that the card can no longer inherit the panel's
   theme. It carries data-theme="dark" in the markup instead — the
   template's scoped inversion used twice in one subtree, light panel
   with dark cards inside it. Every card needs the attribute; see the
   note above the first one in index.html.

   Rating on top, quote in the middle, guest at the foot. The
   order is deliberate — the score is the thing a skimmer reads,
   the name is the thing that makes the quote credible, and the
   quote only earns its length once both are established. */
.review {
  display: flex;
  flex-direction: column;
  /* 320px on a phone. Wide enough for four or five words a line,
     which is where a quote stops reading like a caption, and
     narrow enough to leave the next card's edge showing.

     288px, down from the 320px it was, because a centred card has
     to leave room for a cropped one on BOTH sides now rather than
     one on the right. At 320 the two edges came to 18px each and
     the fades sat right on top of them, so the row looked like a
     single card with nothing either side of it — on a touchscreen,
     where there are no arrows, that is the only thing saying the
     row scrolls. */
  width: min(18rem, calc(100vw - var(--space-12)));
  /* Recessed rather than raised: the panel is brand-800, and a card
     one step LIGHTER (brand-700) drops the quote's secondary text to
     3.3:1. Two steps down the same ramp holds 8.2:1 at the dark end
     and 5.3:1 at the light one, and the card still reads as its own
     surface — a 1.9:1 luminance step off the panel. */
  background: linear-gradient(
    150deg,
    var(--brand-950) 0%,
    var(--brand-900) 100%
  );
  border: 1px solid var(--brand-700);
  /* The two --color-text-* overrides that used to sit here are gone.
     They hand-set gray-300 and gray-400 to undo the panel's lightened
     values; the card carries data-theme="dark" in the markup now, and
     the dark theme's own values ARE gray-300 and gray-400. The same
     two colours, declared once in the theme instead of twice here. */
  border-radius: var(--radius-lg);
  color: var(--color-text-primary);
  padding: var(--space-4);
  text-align: left;
}

/* --space-1 is the icon gap the scale exists for. The score is
   pushed off the last star by a full step so it reads as a
   separate fact and not a sixth glyph. */
.review__rating {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  color: var(--color-fg-rating);
  margin-bottom: var(--space-3);
}
/* Secondary, not tertiary — and it stays that way now the card is
   the dark surface again. The reference builds its three tier
   hierarchy purely out of lightness, and the bottom tier is the one
   that fails whichever way round the card is: tertiary at 14px does
   not clear 4.5:1 against the foot of this gradient. Every 14px line
   here is secondary, and the hierarchy comes from position and
   weight instead — the more durable way to build one anyway, and the
   reason this rule survived the panel inverting untouched. */
.review__score {
  margin-left: var(--space-2);
  color: var(--color-text-secondary);
  font-variant-numeric: tabular-nums;
}

/* margin-top: auto pins the guest to the bottom edge, so a short
   quote and a long one still line their names up across the
   rail. Without it a two line review floats its name halfway up
   the card and the row looks broken. */
.review__body  { color: var(--color-text-secondary); margin-bottom: var(--space-4); }
.review__guest { margin-top: auto; }
/* .review__when and .review__unit were the date and the room type.
   Both are gone from the markup: the reviews are Google's now and
   Google dates them relatively ("3 months ago"), which a static page
   cannot hold, and there is one property so there is no unit to name.
   .review__source took the line they shared — same tier, same
   position under the name, saying where the quote is from instead of
   when it was left. That attribution is not decoration: the badge
   above the rail is Airbnb's and the quotes are not, and this line is
   what keeps a reader from reading them as one claim. */
.review__source { color: var(--color-text-secondary); }

/* 8 · Review rail
   ------------------------------------------------------------
   A scroll track that nothing moves on its own. A thumb swipes
   it; where there is a mouse, the arrows under it step it a card
   at a time.

   IT USED TO SCROLL ITSELF, on a 60s loop built from two cloned
   copies of the cards translated -50%, with hover as the only
   stop control. That is gone, and what went with it is worth
   knowing because it removed three separate constraints:

     · The clone. The card count had to be EVEN or the loop's
       seam did not line up. Any count works now.
     · The LEFT edge fade. On a snapped track the first card rests
       flush against the panel's inner edge, so a fade there sits
       on top of it and washes out the start of every line the
       reader is actually trying to read. The right one is kept
       below — see the note on it.
     · The hover-and-focus-within pause, which was there because
       motion a reader cannot stop fails WCAG 2.2.2. Nothing moves
       unless the reader moves it, so the requirement no longer
       applies rather than being satisfied by a workaround. */
/* CENTRE FOCUSED. The card in the middle of the rail is the one
   being read and the cards either side of it are cropped, which
   is what says the row continues in both directions. */
.review-rail { position: relative; margin-top: var(--space-5); }

/* No scroll-padding. It offsets the box a snap point is measured
   against, and these cards snap on their CENTRE — a left padding
   would shift every resting position half its own width off the
   middle. The gutter is carried entirely by the track. */
.review-rail__viewport {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding: 0;
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.review-rail__viewport::-webkit-scrollbar { display: none; }

/* THE PLAIN GUTTER AT EACH END, and nothing more.

   It was half a viewport minus half a card, which is the room the
   first and last card need in order to come to rest in the middle
   like the ones between them — and the cost of that room is that
   it is still there when the rail is scrolled to either stop. At
   1440 it came to 359px: scrolled fully left, the first review sat
   in the middle of the panel with a third of the panel empty
   beside it, and the same at the other end.

   So the ends are flush instead. The first and last card cannot
   reach the middle any more — the browser clamps them to the
   scroll range, which is exactly the behaviour that keeps the
   panel full — and every card between them still centres. The
   rail OPENS on one of those, so the centred composition is what
   is seen first; see the note in script.js. */
.review-rail__track {
  display: flex;
  align-items: stretch;
  width: max-content;
  padding-inline: var(--container-x);
  /* CENTRED WHEN IT FITS, FLUSH WHEN IT DOES NOT. width: max-content
     makes the track exactly as wide as its cards, which is what the
     snap maths needs — and it means that with few enough cards to fit
     the panel, the track is NARROWER than the viewport and sits hard
     left with the spare room piled up on the right. Two reviews at
     1280px left about a third of the panel empty beside them.

     Auto margins fix that without touching the scrolling case: they
     split the spare space when there is any, and resolve to zero when
     the track is wider than the viewport, so a full rail still starts
     flush at the left and every scroll position stays reachable.

     justify-content: center on the flex track is the other way to do
     this and it is the wrong one — on an OVERFLOWING flex container
     it centres the overflow too, and the first card becomes
     unreachable off the left edge of the scroll range. */
  margin-inline: auto;
}

/* The gap is a margin on each card rather than a flex gap on the
   track, and that is load bearing for the loop further down: a
   flex gap sits BETWEEN items, so ten cards carry nine gaps, and
   half of nine is not a whole number. Ten margins split evenly.
   Do not swap it back to gap.

   "none start" for the same reason the other rails use it — the
   one word shorthand would set the block axis too and turn every
   card into a vertical snap point. */
.review-rail__track > * {
  margin-right: var(--space-3);
  scroll-snap-align: none center;
}
/* The trailing margin is dropped, and that is not cosmetic: it
   sits past the last card's resting point, so leaving it on adds
   16px of scroll the arrows can never account for and the next
   arrow never reaches its disabled state. */
.review-rail__track > *:last-child { margin-right: 0; }

/* NO EDGE FADES ANY MORE, and the reason they went is worth the
   paragraph, because on a dark panel they were the right call.

   There were two: a --brand-800 gradient down each side of the rail,
   softening the crop where the panel cuts the cards either side of
   the middle one. Against brand-950 cards on a brand-800 plate that
   is a couple of steps of one ramp — invisible as a shape, doing its
   job as a softener.

   The panel is brand-50 now and the cards did not follow it, so the
   same two gradients became near-white washing across near-black. At
   64px on desktop that is not a fade, it is a glare down both sides
   of the rail — and it lands on the review text, which is the one
   thing in the block nobody should have to read through anything.

   Fading to the CARD colour instead is not the fix either: the strip
   sits over card and panel both, so whichever end it matches, it is
   a hard band against the other.

   Nothing is lost by removing them. The crop was never the only
   thing saying the row scrolls — the middle card is flanked by two
   cut-off ones, the arrows sit under it on a mouse, and the track
   snaps. A clean vertical edge against the panel reads as a card
   continuing past the frame, which is what it is.

   If the panel ever goes back to a dark ground, this is the thing to
   restore. It is in git before the light-panel change. */




/* ============================================================
   4 · MARQUEE
   ============================================================ */
.marquee {
  overflow: hidden;
  background: var(--color-bg-secondary);
  border-top: 1px solid var(--color-border-subtle);
  border-bottom: 1px solid var(--color-border-subtle);
  padding-block: var(--space-3);
}
.marquee__track {
  display: flex;
  align-items: center;
  gap: var(--space-5);
  width: max-content;
  /* 40s, up from 30s. The keyframe travels -50% of the track, and
     script.js doubles the seed until one half covers the viewport,
     so the DISTANCE per cycle is content width rather than a fixed
     number — which makes this a duration, not a speed. Measured:

       viewport      390-1440      1920+
       half track      1823px     3662px
       at 30s        60.8 px/s  122.1 px/s   <- was
       at 40s        45.6 px/s   91.6 px/s   <- now

     The step at 1920 is the doubling in script.js landing: one more
     copy is added, the half doubles, and the same 40s covers twice
     the ground. See the note in script.js if that ever needs to be
     a constant px/s instead. */
  animation: marquee-scroll 40s linear infinite;  /* raise the seconds to slow it down */
}
.marquee__item, .marquee__dot { white-space: nowrap; color: var(--color-text-primary); }
.marquee__dot { opacity: .5; }

@keyframes marquee-scroll { to { transform: translateX(-50%); } }


/* ============================================================
   6 · LOCAL FAVOURITES
   No rules of its own — the section reuses .slider--even and
   .tall-card from section 5 above, so the two rows stay identical
   by construction rather than by upkeep.
   ============================================================ */

/* ============================================================
   7 · LOCATION
   ------------------------------------------------------------
   The intro is centred like every other section heading on the
   page; the pair below it — detail column and map — is the part
   that stays on a grid. Centre the intro as one block: eyebrow,
   heading and lead share the same axis, and the lead keeps its
   measure by margin rather than by moving off it.
   ============================================================ */
.location-intro {
  max-width: 56rem;
  margin-inline: auto;
  margin-bottom: var(--space-7);
  text-align: center;
}

/* The label on its own, on the block's axis. No hairline: the
   rule was there to anchor a left edge, and there is no left
   edge to anchor now. */
.location-intro__eyebrow {
  color: var(--color-text-tertiary);
  margin-bottom: var(--space-4);
}

.location-intro__title {
  color: var(--color-text-primary);
  margin-bottom: var(--space-4);
}

/* The span stays a block so a second line could be added back
   without it running on. text-wrap: balance comes in with
   .type-section-title and is left alone — centred, the balanced
   wrap is what keeps the last line from stranding a word. */
.location-intro__line {
  display: block;
}

/* The measure is kept, the centring is not given up: auto margins
   hold the column on the intro's axis. 600px rather than a ch
   measure because the cap is a layout decision here, not a
   reading one — it is the width the lead is allowed to be under
   a centred heading, independent of the face's character width. */
.location-intro__lead {
  color: var(--color-text-secondary);
  max-width: 37.5rem;                     /* 600px */
  margin-inline: auto;
}

/* ---- the address / map pair ------------------------------------
   One column on a phone, a third and two thirds side by side once
   there is room. The map is the second grid child and the grid
   stretches by default, so the map's height is the detail
   column's height — it is never told a desktop height of its own.
---------------------------------------------------------------- */
.location-grid {
  display: grid;
  gap: var(--space-5);
}

.location-detail {
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
}

/* ---- the address card ---- */
/* Accent-subtle, not bg-secondary: this section's ground is the
   accent ramp, and the neutral surface tokens are a different hue
   family — a gray card on a warm ground reads as a foreign object
   pasted on top. One step along the same ramp is the whole
   differentiation the card needs; the hairline does the rest. */
/* White on the section's brand-50 ground: one step up the brand ramp
   would be two near identical light values and the card would stop
   reading as a card. The hairline does the rest. */
.address-card {
  background: var(--color-bg-primary);
  border: 1px solid var(--color-border-subtle);
  border-radius: var(--radius-lg);
  padding: var(--space-5);
}

/* The name sits directly on the hairline now that the street
   address is gone, so it needs the wider step under it — 16px
   would read as a label attached to the rule. */
.address-card__name {
  color: var(--color-text-primary);
  margin-bottom: var(--space-4);
}

/* The route button is the section's one filled control, so it is
   .btn--primary and it spans the card. */
.address-card .btn { width: 100%; }

.address-card .btn .icon { vertical-align: -0.1875em; margin-right: var(--space-2); }

/* ---- the drive times ---- */
/* A <ul>: three distances are a list, and with no body copy under
   each row the markers are the only thing to suppress. It sits
   inside the address card, so the hairline above it separates the
   distances from the address without making a second card of
   them — the two answer the same question. */
.directions {
  list-style: none;
  margin: 0 0 var(--space-4);
  padding: var(--space-4) 0 0;
  border-top: 1px solid var(--color-border-subtle);
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

.directions__title {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  color: var(--color-text-primary);
}
.directions__title .icon { color: var(--color-fg-secondary); }

/* ---- the map frame ----
   overflow: hidden is what rounds the corners of whatever goes
   inside, so an <img> placeholder and a live Google iframe get the
   same frame with no extra rules. */
.location-map {
  position: relative;
  min-height: 25rem;                      /* 400px */
  border: 1px solid var(--color-border-subtle);
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--color-bg-accent-subtle);
}
.location-map > img,
.location-map > iframe {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
  object-fit: cover;
}

/* A live map arrives in Google's colours, which belong to Google
   and not to the client. Desaturated it reads as part of the page;
   full colour on hover, once someone is actually reading it. Add
   invert(0.92) to the first filter for a dark brand — it turns
   Google's white paper dark, at the cost of inverting the pins. */
@media (hover: hover) and (pointer: fine) {
  .location-map > img,
  .location-map > iframe {
    filter: grayscale(1) contrast(0.92);
    transition: filter var(--dur-crossfade) var(--ease-out);
  }
  .location-map:hover > img,
  .location-map:hover > iframe { filter: none; }
}

/* ============================================================
   8 · TESTIMONIAL PANEL — brand-50 on the section's brand-900
   ============================================================ */
/* IT WAS brand-800 ON brand-900 — three steps of one ramp, all of
   them dark. It is brand-50 now: the far light end of the same ramp,
   dropped into a section that is still dark. That is a much louder
   move than a one-step change and it is meant to be. The reviews are
   the page's proof, and a light plate is the one block on a dark page
   the eye cannot scroll past.

   THE ATTRIBUTE DOES MOST OF THE WORK. .panel carries
   data-theme="light" in the markup, so every token inside it — text,
   borders, disabled states, the rail arrows' ring — resolves against
   a light surface instead of a dark one. One attribute, not a colour
   override on each of a dozen children.

   TERTIARY STILL HAS TO BE DARKENED BY HAND, and it is the one thing
   the theme does not get right here. The light theme points tertiary
   at gray-600, which is built for --color-bg-primary — plain white.
   brand-50 is a shade under white, and measured against it gray-600
   is 3.61:1. The only thing wearing tertiary in this panel is the
   TESTIMONIALS eyebrow: 12px, uppercase, small text, owed 4.5:1. So
   tertiary drops a step to gray-700 and measures 5.42:1.

   That collapses tertiary onto secondary, which is a real loss of a
   tier — and it costs nothing here, because the only other line
   using secondary is the "Reviews" stat label sitting well away from
   the eyebrow. Two tiers are enough for a block with four lines of
   chrome in it. gray-500 was the alternative that keeps the tier and
   it measures 2.86:1, which is not a choice.

   THE HAIRLINE MOVES WITH THE GROUND. brand-700 is a dark line, and
   on a near-white plate it stops being an edge and becomes a drawn
   frame. brand-200 is the same relationship the old pair had — one
   quiet step off the ground — measured from the other end of the
   ramp. 0.5px so it stays a hairline on a retina screen. */
.panel {
  background: var(--brand-50);
  color: var(--color-text-primary);
  border: 0.5px solid var(--brand-200);
  border-radius: var(--radius-lg);
  --color-text-tertiary: var(--gray-700);
  padding-block: var(--space-5);
  text-align: center;
}

/* THE ARROWS' RING, DARKENED FOR THE LIGHT PLATE. .rail-nav__btn is
   a shared control and its own rule explains why the ring is there:
   the button is a bare glyph, so the ring is the only thing saying
   "control", and WCAG 1.4.11 wants 3:1 for that. It reaches for
   --color-border-strong, which is gray-400 under the light theme —
   correct against white-ish page chrome, and 1.94:1 against brand-50.
   The ring all but vanished.

   gray-600 measures 3.61:1 and clears it. Scoped to .panel so the
   shared component is untouched everywhere else — the Featured Stays
   rail still uses the token, on a dark ground, where it measures
   fine. The disabled state is left alone: 1.4.11 exempts inactive
   controls, and it already reads as spent through opacity. */
.panel .rail-nav__btn:not([disabled]) {
  border-color: var(--gray-600);
}
.panel > .eyebrow,
.panel > .heading { padding-inline: var(--space-4); }

.stats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4) var(--space-0);
}
/* NORTH STAR: TWO CELLS, NOT THREE. One stat came in — 400+ reviews —
   so the third column had nothing to hold, and an empty 1fr track
   pushed the badge and the number off the panel's centre axis.

   And a flex row rather than two equal columns. Two 1fr tracks
   across a 720px block put the badge and the number at the
   quarter points, half a panel apart, reading as two unrelated
   facts. They are one claim — 400+ reviews as a Superhost — so
   they sit next to each other with a fixed gap and the pair is
   centred as a unit. */
.stats--pair {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: var(--space-5);
}

/* No chip behind the mark — it sits straight on the panel. That is
   only legible because the file it points at is the REVERSED cut of
   the artwork: the supplied PNG carries a near black (#3A3A3C)
   wordmark, which lands at about 1.4:1 on brand-800 and disappears.
   super-host-logo-reversed.png is that same file with the neutral
   wordmark taken to white and the coral and gold left untouched.
   The original is kept alongside it for a light ground. Swap the src
   back if this section ever moves onto a light surface. */
.stats__badge {
  justify-self: center;
  width: fit-content;
}
.stats__badge img { display: block; max-width: 7rem; height: auto; margin-inline: auto; }
.stat__number { margin-bottom: var(--space-2); font-variant-numeric: tabular-nums; }
.stat__label  { color: var(--color-text-secondary); }

/* ============================================================
   10 · FOOTER
   ============================================================ */
.footer {
  padding: var(--space-5) var(--container-x) var(--space-6);
  text-align: center;
  /* brand-950, the bottom rung of the ramp, and a raw primitive for
     the same reason .section--alt takes brand-900: it is chosen as a
     specific step darker than the band above it, not as a semantic
     surface. Both pages end on a dark block — the closing offer's
     footage on index.html, the stays rail on welcome.html — and a
     white strip under either read as the page running out rather
     than ending. The <footer> carries data-theme="dark" in the
     markup so the label and the rule take their dark values; the
     wordmark is the reversed cut of the SVG because it is an <img>,
     and currentColor inside an <img> is black whatever the page
     says. The border-top now separates two dark grounds, so it
     resolves to gray-800 and reads as a seam rather than a line. */
  background: var(--brand-950);
  border-top: 1px solid var(--color-border-subtle);
}
/* THE AGENCY CREDIT SITS BACK. It is a signature, not something
   the page is asking anyone to do, so the line and the wordmark —
   one unit, so one treatment — are held at half strength.

   INSIDE .footer__credit, not on it. The wrapper is a direct
   child of a data-reveal-group and the reveal sets opacity: 1 on
   those at (0,3,0) as they come in, which would win. See the note
   in index.html.

   Full strength on hover and on focus. It is a real link, and a
   link a pointer is over or a keyboard has landed on should not
   look like a disabled one. --dur-ui is zeroed under reduced
   motion by _template.css, so the change is instant there. */
.footer__credit > * { opacity: 0.5; }
.footer__label { color: var(--color-text-tertiary); margin-bottom: var(--space-2); }
.footer__logo {
  display: inline-block;
  transition: opacity var(--dur-ui) var(--ease-out);
}
.footer__credit > .footer__logo:hover,
.footer__credit > .footer__logo:focus-visible { opacity: 1; }
.footer__logo img { max-width: 11.5rem; margin-inline: auto; }

/* ============================================================
   11 · WELCOME  ·  the post sign-up page (welcome.html)
   ------------------------------------------------------------
   A modifier on .hero-offer, not a second hero. The video, both
   scrims, the seam, the logo and the entrance animation are the
   components section 1 already ships; only three things differ,
   and each is here for a reason that is written next to it.

   THE ONE THING TO KNOW BEFORE EDITING: the copy is CENTRED on
   this page and left aligned on the marketing hero, and that is
   what the scrim override at the bottom of this block is about.
   The desktop scrim in the breakpoint block is a diagonal,
   weighted into the left third because that is where the
   marketing hero's copy lives. Centred copy runs straight out of
   it and into the bright side of the frame. The override is two
   classes deep so it beats that rule wherever it sits in the
   file, and it carries all three of this page's scrim layers —
   including the copy band, which cannot live on the inner here.
   The reason why is written over layer 2.
   ============================================================ */
.hero-offer.hero-offer--welcome {
  align-items: center;
  /* Shorter than the marketing hero at both breakpoints. That one
     is a banner and wants the room; this one has a job, and the
     code has to be reachable without a scroll. The content is
     taller than this number on every viewport anyway — it is a
     floor that stops a short frame, not a height. */
  min-height: clamp(28rem, 80svh, 38rem);
}

/* Centred, and narrower than the frame. The padding is the
   marketing hero's stepped down: that one pads for a banner, and
   96px of air above a back link only pushes the code further from
   the fold. */
.hero-offer__inner.welcome {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  padding-block: var(--space-9);
}

/* Every string on this page is --color-overlay-text, for the same
   reason the marketing hero's are: dimming secondary copy to
   --color-overlay-fg measures ~4.0:1 against the scrim's worst
   case and fails AA. Hierarchy is carried by size and weight.

   The shadow pair is load bearing and is the same pair, for the
   same reason — see the note over the hero's title. The 2px layer
   is a hard edge against whatever is immediately behind a glyph;
   the 12px layer is the pad that catches a bright patch of
   footage drifting under a counter. */
.welcome__title,
.welcome__lead,
.welcome__note,
.back-link {
  color: var(--color-overlay-text);
  text-shadow: 0 1px 2px rgba(9, 11, 14, 0.55), 0 2px 12px rgba(9, 11, 14, 0.75);
}

/* max-width is inherited from .hero-offer__title, which caps at
   34rem and 38rem. Centred, that is the right measure for a
   greeting; only the auto margins are new. */
.welcome__title { margin-inline: auto; margin-bottom: var(--space-5); }

/* The sign off, and it sits at the FOOT of the copy now rather
   than over the plate. It keeps body-lg: it is the warmest line
   on the page and the one that should still read at a glance
   from across a room, and a full step of space above it is what
   separates a sign off from the housekeeping it follows. */
.welcome__lead {
  max-width: 34rem;
  margin-inline: auto;
  margin-top: var(--space-4);
}

.welcome__note { max-width: 34rem; }
/* The housekeeping line. One rung down and no dimming — see the
   colour note above. */
.welcome__note--fine { margin-top: var(--space-2); }

/* ---- the back link --------------------------------------------
   Top left of the copy column rather than pinned to the frame's
   corner: pinned, it would sit on the brightest part of an
   unvetted photograph with no scrim under it, and it would drift
   away from everything it belongs to as the viewport widened.
   align-self pulls it out of the centred column without needing
   a wrapper.

   The 44px min-height is the WCAG 2.5.8 target size, met by
   padding rather than by a fixed box so the label can grow. The
   negative inline margin pulls the glyph's own padding back to
   the column's left edge, so the chevron optically aligns with
   the copy below instead of the label's leading edge.
---------------------------------------------------------------- */
.back-link {
  align-self: flex-start;
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 2.75rem;                    /* 44px — a target, not spacing */
  padding: var(--space-2) var(--space-3);
  margin-left: calc(var(--space-3) * -1);
  margin-bottom: var(--space-5);
  border-radius: var(--radius-md);
  transition: background-color var(--dur-ui) ease,
              transform var(--dur-press) var(--ease-out);
}
.back-link svg { width: 1rem; height: 1rem; flex: 0 0 auto; }
.back-link:active { transform: scale(0.98); }

@media (hover: hover) and (pointer: fine) {
  .back-link:hover { background: var(--color-overlay-fill); }
}

/* ---- the code plate -------------------------------------------
   The one thing this page exists to deliver, so it is the one
   thing on it that does not trust the photograph behind it — and
   the only bright object on a page that is otherwise footage and
   white text. That inversion IS the emphasis: nothing else here
   has to compete with it, so the plate needs no glow and no
   outline to be the first thing the eye lands on.

   Built to read as a coupon rather than as a form field, which is
   four things and not one: the brand-50 ground, the dashed accent
   edge, the notch bitten out of each end, and the code set large
   and wide-tracked in brand-950 rather than at body size. Drop any
   one of them and it goes back to looking like an input box that
   nobody can type in.

   brand-50 is L 0.973 — effectively white with a breath of the
   brand's teal in it — so the plate is fully opaque and the
   footage underneath cannot reach the code at all. That is why
   this block stops using --card-panel: the old pair solved
   "readable text over an unvetted frame" by blending the frame
   down to #363636, and an opaque ground solves it outright.

   26rem, not the 34rem the copy runs to. A code is eight
   characters; a plate three times wider than its contents reads
   as an empty field waiting to be filled in.

   The bottom margin is deliberately SHORT. The two lines under
   the plate answer the question the plate raises — where else is
   this code — and at a full --space-6 they read as an unrelated
   block stranded further down the frame.
---------------------------------------------------------------- */
.code {
  width: 100%;
  max-width: 26rem;
  margin-block: var(--space-6) var(--space-3);
}

/* align-items: stretch is doing the work — the divider between the
   code and the button is the button's own left border, and it only
   runs the full height of the plate because the button is stretched
   to it.

   The notches are the ticket-stub cue, and they are a mask rather
   than two positioned circles: a circle would have to be painted
   in the page's background colour to read as a hole, and there is
   no page background here — there is video. The mask cuts real
   holes, so the footage shows through them.

   radial-gradient stops are hard (same position twice) so the
   bite has a clean edge instead of a 10px feathered smudge, and
   the -1px inset on each keeps the cut biting INTO the border
   rather than stopping a hair short of it and leaving a thread of
   dashed line across the mouth of the notch. */
.code__plate {
  position: relative;
  display: flex;
  align-items: stretch;
  border: 2px dashed var(--accent-400);
  border-radius: var(--radius-lg);
  background: var(--brand-50);
  overflow: hidden;
  -webkit-mask:
    radial-gradient(circle 0.5rem at -1px 50%, transparent 100%, #000 100%),
    radial-gradient(circle 0.5rem at calc(100% + 1px) 50%, transparent 100%, #000 100%);
          mask:
    radial-gradient(circle 0.5rem at -1px 50%, transparent 100%, #000 100%),
    radial-gradient(circle 0.5rem at calc(100% + 1px) 50%, transparent 100%, #000 100%);
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
}

/* user-select: all so one click takes the whole code and never
   half of it. It is the fallback path, not the main one — the
   button beside it is — but it is the path a visitor with no
   JavaScript gets, so the value is real selectable text and not
   a background image or a ::before.

   brand-950 on brand-50 is the full width of one ramp, which is
   where the plate gets its authority: measured, the code lands at
   16.4:1. The tracking is the coupon tell — a redemption code is
   read one character at a time, not as a word, and 0.08em is what
   stops WELCOM10 reading as a misspelling.

   min-height is bumped a step over --field-h. The plate is the
   payload of the page and a field-height box makes it look like
   one input among several, which is the thing this rewrite is
   getting away from. */
.code__value {
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: calc(var(--field-h) + var(--space-3));
  padding: var(--space-3) var(--space-4);
  color: var(--brand-950);
  letter-spacing: 0.08em;
  -webkit-user-select: all;
          user-select: all;
}

/* The button is the one saturated thing on the plate, which is
   what makes the coupon read as actionable rather than as a
   printed slip. brand-400 — the primary ramp, not the accent one,
   which on this brand generates greys and left the button reading
   as a grey chip rather than as the page's colour.

   AND THE LABEL IS INK, NOT WHITE, which is the part that cannot
   be undone independently. brand-400 is a LIGHT step: measured, it
   is rgb(146,180,200), and --color-overlay-text on it comes in at
   2.19:1 against a 4.5:1 bar for a 14px 600 label. brand-950 on
   the same ground measures 6.93:1. It is also the colour the code
   itself is set in two inches to the left, so the two halves of
   the coupon now share one ink and the tear line is the only
   thing dividing them.

   The divider is a dashed rule in the same accent as the outer
   edge — the tear line a real coupon has, and the reason the
   button reads as the detachable half rather than as a chip
   dropped onto the plate. */
.code__copy {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  padding: var(--space-2) var(--space-4);
  border: 0;
  border-left: 2px dashed var(--accent-400);
  /* padding-box, and it is the whole reason the tear line reads as
     one. A background paints under its own border by default, so
     the gaps between the dashes filled with the button's gold and
     the perforation flattened into a faint solid rule. Clipped to
     the padding box the gold stops at the border's inner edge and
     the gaps show the plate's brand-50 through — an actual row of
     holes. */
  background: var(--brand-400);
  background-clip: padding-box;
  color: var(--brand-950);
  cursor: pointer;
  touch-action: manipulation;
  transition: background-color var(--dur-ui) ease,
              transform var(--dur-press) var(--ease-out);
}
.code__copy:active { transform: scale(0.96); }

/* The button ships hidden and script.js reveals it — see the note
   over the driver. This rule is why that works: an author
   declaration of display beats the [hidden] rule in the UA sheet
   whatever its specificity, so .code__copy's own display: flex
   would quietly un-hide it. Same shape as the .rail-nav[hidden]
   rule further up, and for the same reason. */
.code__copy[hidden] { display: none; }

/* One step darker, not a translucent wash: the button has its own
   ground, and --color-overlay-fill over it barely moves it. 500
   rather than 400, and it is the last step that still works — the
   brand-950 label measures 4.77:1 on brand-500, so the hover state
   clears AA on its own. 600 would take it to 3.51:1 and fail. */
@media (hover: hover) and (pointer: fine) {
  .code__copy:hover { background: var(--brand-500); }
}

/* The confirmation. role="status" in the markup makes it a polite
   live region, so the same string that appears here is announced;
   a button that quietly relabels itself confirms the copy to a
   sighted visitor and to nobody else.

   min-height, not a margin that appears with the text: the line
   holds its own room from first paint so confirming a copy never
   shoves the two notes below it down the page. */
.code__status {
  min-height: 1.5rem;
  margin-top: var(--space-2);
  color: var(--color-overlay-text);
  text-shadow: 0 1px 2px rgba(9, 11, 14, 0.55);
}

/* The scrim, re-shaped for centred copy. Two classes deep on
   purpose — see the note at the top of this block. A radial puts
   the weight under the copy and lets the corners of the frame
   stay bright, which is what stops the whole thing reading as a
   dark slab with a photograph somewhere behind it. The linear
   underneath is the bottom weighting the seam needs to land on.

   The stops are the hero's own tokens, not new ones: 0.40 over
   the copy, composited with the 0.34 band below to an effective
   0.60 — the same figure the phone hero on index.html carries,
   where the title measures 3.51:1 at the glyph against a 3:1 bar
   for large text. */
.hero-offer--welcome .hero-offer__scrim {
  background:
    /* 1 · THE COPY WEIGHT. A radial puts it under the copy and
       lets the corners of the frame stay bright, which is what
       stops the whole thing reading as a dark slab with a
       photograph somewhere behind it. */
    radial-gradient(78% 62% at 50% 46%,
      var(--hero-scrim-1)  0%,
      var(--hero-scrim-1) 34%,
      var(--hero-scrim-2) 68%,
      var(--hero-scrim-0) 92%),
    /* 2 · THE BAND, and it is HERE rather than on .welcome — which
       is where the marketing hero puts its copy scrim — for one
       reason: that element is capped at --container-max, so on a
       viewport wider than 1200px a gradient on it ends in two
       hard vertical edges down the photograph. The marketing hero
       never shows them because it switches this band off at the
       same breakpoint the frame goes wide. This page has no
       diagonal to hand the job to, so the band has to stay on at
       every width — and a full bleed layer is the only place it
       can stay on from. */
    linear-gradient(to top,
      var(--hero-scrim-0)        0%,
      var(--hero-scrim-copy-1)  14%,
      var(--hero-scrim-copy-1)  82%,
      var(--hero-scrim-copy-2)  94%,
      var(--hero-scrim-0)      100%),
    /* 3 · THE TOP. The marketing hero has no third layer because it
       has nothing at the top of its frame — the copy sits low and
       the picture is allowed to be a picture up there. This page
       puts the back link in the top left corner, over whatever the
       footage happens to be doing, and measured over the clip that
       corner runs to rgb(175,166,161): the label came in at
       2.31:1 against a 4.5:1 bar. The plateau runs to 16% so it
       clears the link's own 44px target box at every viewport,
       then it is gone by 46% — well above the greeting, so it
       reads as a top vignette and not as a second slab. */
    linear-gradient(to bottom,
      var(--hero-scrim-1)  0%,
      var(--hero-scrim-1) 16%,
      var(--hero-scrim-2) 30%,
      var(--hero-scrim-0) 46%),
    /* 4 · the bottom weighting the seam lands on. Deeper than the
       marketing hero's, and for the same reason as layer 3: the
       two reassurance lines sit down here, and on a phone they are
       body and caption sized — a 4.5:1 bar, not the 3:1 the
       headline above them gets to use. */
    linear-gradient(to top,
      var(--hero-scrim-1)  0%,
      var(--hero-scrim-2) 30%,
      var(--hero-scrim-0) 52%);
}

/* And the inner carries no band of its own at any width — see
   layer 2 above. Two classes, so it also beats the mobile
   .hero-offer__inner rule that paints one. */
.hero-offer__inner.welcome { background: none; }

/* The welcome hero's hairline override is gone — the base
   .hero-offer__seam is that hairline now, so the two pages share one
   declaration instead of this one restating it. */


/* The entrance, extending the hero's four-element stagger to the
   two elements this page adds. Same curve, same duration, same
   70ms step, so the two pages open the same way. */
@media (prefers-reduced-motion: no-preference) {
  .welcome .back-link,
  .welcome .welcome__lead,
  .welcome .code,
  .welcome .welcome__note {
    animation: hero-rise var(--dur-reveal) var(--ease-out) both;
  }
  /* The logo and the title are already in the hero's own stagger
     at 40ms and 110ms — .welcome__title carries .hero-offer__title.
     These four continue the same 70ms step from there.

     THE ORDER HERE IS DOCUMENT ORDER, and it has to stay that
     way. The lead used to run at 180ms because it used to sit
     above the plate; now that it signs off at the foot it takes
     the last slot, or the page opens with its bottom line
     arriving before the coupon it is talking about. */
  .welcome .back-link           { animation-delay:   0ms; }
  .welcome .code                { animation-delay: 180ms; }
  .welcome .welcome__note       { animation-delay: 250ms; }
  .welcome .welcome__note--fine { animation-delay: 320ms; }
  .welcome .welcome__lead       { animation-delay: 390ms; }
}

/* ============================================================
   DESKTOP — everything above is the phone layout
   Type steps itself at 767px inside _template.css. This
   breakpoint is layout only.
   ============================================================ */
@media (min-width: 1024px) {
  /* SECTION TITLES AT 48px UP HERE, one rung above the role's 36px.
     Display lg — an actual rung on the scale, unlike the 32px
     literal the phone block reaches for, so nothing new enters the
     type system.

     Same reasoning as the phone override, at the other end: every
     section header on this page is a centred title over a
     full-bleed band with an 18px subhead under it, and at 36px in a
     1440px frame the title was not winning that pairing by enough.
     48/60 keeps the 1.25 Display ratio and the 8pt grid, and still
     sits a clear two rungs under the 72px hero so the hero stays
     the largest thing on the page.

     The 768-1023px band is deliberately left at the role's 36px —
     the ramp is 32 -> 36 -> 48 rather than a jump.

     Scoped to the role, so all five headers move together. */
  .type-section-title {
    font-size: var(--fs-display-lg);      /* 48px */
    line-height: var(--lh-display-lg);    /* 60px */
  }

  /* ---- 1 + 2 · hero offer ---- */
  .hero-offer        { min-height: var(--hero-min-lg); }
  .hero-offer__inner { padding: var(--space-16) var(--space-9); }
  /* No .hero-offer__seam height here: the boundary is 1px at every
     width now, and there is nothing to grow. */
  .hero-offer__logo  { margin-bottom: var(--space-7); }
  .hero-offer__logo img {
    height: var(--hero-logo-lg);
    max-width: min(var(--hero-logo-max-lg), 100%);
  }
  /* THE COLUMN IS CAPPED BY THE FRAME AS WELL AS BY THE MEASURE,
     and the vw half of that is a contrast fix rather than a
     typographic one.

     38rem is a fixed 608px, and the scrim that has to carry these
     two strings is a diagonal whose plateau and release are
     fractions of the window. The two disagree as the window
     narrows: at 1440 the copy ends around 51% of the frame, well
     inside the ramp, and at 1024 the same 608px reaches 61% —
     past the mid stop, out where the scrim has already faded back
     to the photograph. Measured at 1024 with the strings made
     transparent, the worst pixel behind the headline was 1.87:1
     against a 3:1 floor, and behind the 16px offer line 1.99:1
     against 4.5:1. This predates the new headline: the same probe
     against the copy that was here this morning read 1.46:1.

     42vw is the width at which the column's right edge stays
     inside the plateau at every desktop size. It only binds below
     1450px or so — 38rem still wins above that — and it costs the
     headline one extra line at 1024. Measured after, worst pixel:

       width   1024  1100  1200  1280  1440  1920
       h1      4.27  4.43  4.42  4.00  3.70  3.57   (floor 3.0)
       offer   5.00  5.01  5.01  5.01  5.08  5.30   (floor 4.5)

     Capping the column rather than holding the scrim's plateau
     open is what keeps the photograph: every version that fixed
     this from the scrim side darkened the peak to do it. */
  .hero-offer__offer { max-width: min(38rem, 42vw); }

  /* The title is nowrap and fluid rather than capped (see the base
     rule), and "for Mountain Adventures" sets the size: 4.75vw makes
     that line ~46vw wide, so its right edge sits at 52-58% of the
     frame from 1024 to 1440, and at 1920 the 4.5rem ceiling lands it
     at 58% too. That is a few points past where the old 42vw column
     ended, which is what the scrim stops below pay for. */
  .hero-offer__title { font-size: min(var(--fs-display-2xl), 4.75vw); }

  /* The diagonal below takes over from the copy scrim. */
  .hero-offer__inner { background: none; }

  /* A diagonal vignette rather than a bottom weighted one: the copy
     lives in the left third, so the right of the photograph stays
     bright and the frame still reads as a photograph.

     ITS OWN STOPS, not the shared --hero-scrim pair, and the reason
     is that this is the only scrim in the page carrying a hero's
     worth of white text on its own. Below 1024px the copy scrim
     stacks with this one and the two together are plenty; up here the
     inner's band is switched off and the diagonal is alone. Against
     the dark aerial this hero used to carry, 0.40 was enough. Against
     a sunset it is not — measured at the glyph, the title came in at
     1.96:1 and the offer line at 1.97:1, both against a 3:1 floor.
     See --hero-scrim-lg-1 for the arithmetic behind the new weight.

     THE PLATEAU IS LONGER AS WELL AS HEAVIER. It used to hold to 14%
     and fade out by 58%; the copy block is capped at 38rem and starts
     at the container's left edge, which on a 1440 window puts its
     right edge at about 55% — so the old ramp was already falling
     away underneath the last third of the headline. Holding to 30%
     and releasing by 66% covers the copy and the form, and still
     hands the right half of the frame back to the photograph
     untouched, which is the whole point of a diagonal.

     NORTH STAR: 38 / 56 / 73. The h1 here is a kicker over two
     nowrap lines, and the longer one, "for Mountain Adventures",
     ends at 52-58% of the frame (see the fluid size above), past
     where the old 42vw column ended. The plateau moves out with it
     so the last word is not sitting on the fade.
     The release lands at 73%, which still leaves the right quarter
     of the peak — the part of this photograph worth looking at —
     untouched. */
  .hero-offer__scrim {
    background:
      linear-gradient(100deg,
        var(--hero-scrim-lg-1)  0%, var(--hero-scrim-lg-1) 38%,
        var(--hero-scrim-lg-2) 56%, var(--hero-scrim-0) 73%),
      /* THE FOOT RAMP REACHES 45%, up from 28%, and the diagonal
         above it is untouched.

         The offer line is 16px rather than 36px since the headline
         went in above it, which puts it on the 4.5:1 small text bar
         instead of the 3:1 large text one — and it sits low in the
         frame, where a 100deg diagonal has already run past its
         plateau. Measured at 1440 with the string made transparent:

           release at 28%   offer 3.85   h1 3.69   <- offer FAILS
           release at 45%   offer 5.08   h1 3.69   <- shipped

         Widening the foot rather than reaching for the diagonal is
         what keeps the photograph. The diagonal covers the peak and
         the sky, and every version that fixed this by holding its
         plateau longer took the mountain with it; the foot only
         touches the bottom third, which is where the small type and
         the form are and where the seam is already taking the frame
         down into the next section. */
      linear-gradient(to top, var(--hero-scrim-lg-2) 0%, var(--hero-scrim-0) 45%);
  }

  .offer-form--hero { margin-top: var(--space-6); max-width: 36rem; }
  .offer-form--hero .offer-form__field,
  .offer-form--hero .offer-form__control .btn {
    min-height: calc(var(--field-h-lg) - var(--space-2));
  }
  /* Re-derived against the taller row — see .offer-form__control. */
  .offer-form--hero .offer-form__control { border-radius: calc(var(--field-h-lg) / 2); }

  /* ---- 9 · closing offer ----
     Both children come out of flow, so the plate's height is this
     number and nothing else. */
  .claim { min-height: 38rem; }

  /* The whole plate, not three quarters of it. The photograph runs
     edge to edge and the card floats on it — so the picture
     continues past the card to the plate's right corner instead of
     dissolving into a gradient behind it. No mask: there is no
     seam to hide once the photo has no edge inside the plate. The
     plate's own gradient is still under all of this and still
     earns its place, as the ground the video sits on until a frame
     lands. */
  .claim__media {
    position: absolute;
    inset: 0;
    width: 100%;
    aspect-ratio: auto;
    max-height: none;
  }


  /* Pinned to the right and centred, which puts roughly a third
     of it on the photograph at 1024 and still a quarter of it on
     a 1600px monitor: the plate grows, the card does not, and the
     photo's 72% edge moves right with the plate. So the overlap
     holds at every width without a second breakpoint.

     min() rather than a flat width — at exactly 1024 a fixed
     27rem card leaves 88px of plate showing to its right, and the
     percentage is what keeps that from happening. */
  .claim__card {
    position: absolute;
    top: 50%;
    right: var(--space-7);
    translate: 0 -50%;
    width: min(30rem, 46%);
    margin: 0;
    padding: var(--space-6);
    /* One radius all round again — off the plate's edges, the card
       is a free floating object and the corner pairing the base
       rule needs does not apply. */
    border-radius: var(--claim-radius);
  }

  /* Back to stacked, and this is the one place the form reverses
     a rule it picked up at a smaller width. Everywhere else the
     control gets wider as the page does; here it gets narrower,
     because the card is a fixed 480px rail whatever the monitor
     is. Inline, the field had 210px left after the button and the
     first client to write a longer label than "CLAIM 10% OFF"
     would have pushed it out of the card. Stacked, the button is
     the full width of the card, which is also the stronger call
     to action of the two. */
  .offer-form--claim .offer-form__control { flex-direction: column; align-items: stretch; }

  .offer-form--claim .offer-form__field,
  .offer-form--claim .offer-form__control .btn {
    min-height: calc(var(--field-h-lg) - var(--space-2));
  }
  .offer-form--claim .offer-form__control { border-radius: calc(var(--field-h-lg) / 2); }

  /* Cards wrap into rows instead of swiping */
  .slider__track {
    flex-wrap: wrap;
    justify-content: center;
    overflow-x: visible;
    column-gap: var(--space-4);
    row-gap: var(--space-6);
  }

  /* ---- 3 · featured stays ---- */
  /* The header stays one centred column up here. It used to go
     two-column at this breakpoint — heading left, arrows right,
     baseline aligned to the subhead — which is why .rail-nav had a
     display rule of its own in this block. The arrows have moved
     under the rail and wear .rail-nav--center now, so neither is
     needed and the header is the plain centred block the base
     rules already make it.

     The subhead's bottom margin still comes off: it is the last
     thing in the intro and the gap to the rail is the rail's own. */
  .rail-head .subhead { margin-bottom: var(--space-0); }
  .slider--rail { margin-top: var(--space-5); }

  /* ---- the rail itself ----
     Every other slider wraps into rows up here. This one does
     not, and the exception is deliberate: five 373px cards
     wrapped to a second row, and the only way to fit five in one
     row inside 1200px is ~220px each, which is too narrow for a
     24px serif property name. A rail keeps the card comfortable,
     and it is the only version that still works when a client
     has three properties or twelve.

     The track lives outside .container so it can bleed edge to
     edge as a swipe strip on a phone. That is what was throwing
     the alignment off up here: the heading sat in a 1200px
     centred container and the cards did not, so the first card
     started ~120px left of the section title. Repeating
     .container's own three properties — same max width, same
     auto margins, same gutter — puts card one on exactly the
     heading's left edge, and the rail scrolls inside that box
     rather than off the side of the screen. */
  .slider--rail .slider__track {
    flex-wrap: nowrap;
    overflow-x: auto;
    /* CENTRED WHILE THE CARDS FIT, START ALIGNED ONCE THEY DO NOT,
       and it has to be that pair rather than either one alone.

       Plain `center` is what this rule used to forbid, for a good
       reason: centring a flex row that is wider than its scroll
       container splits the overflow across both sides, and the
       half that lands left of the scroll origin cannot be reached
       — card one sat 184px off the left edge with scrollLeft
       already at 0. `safe` is the keyword that fixes exactly that:
       it centres when there is room and falls back to start
       alignment the moment the row overflows, which is the rail
       this section wants at three cards and the scroller it wants
       at twelve.

       flex-start is declared first as the fallback, so an engine
       that does not parse `safe center` drops the second
       declaration and keeps the behaviour this rail shipped with.
       That is the safe direction to fail in — left aligned and
       fully reachable, never centred and half unreachable. */
    justify-content: flex-start;
    justify-content: safe center;
    padding-block: var(--space-0) var(--space-4);
    column-gap: var(--space-4);

    /* THE CONTAINER'S OWN LEFT OFFSET ON BOTH SIDES, so the padded
       box the cards centre inside is the container box and the row
       lands on the same axis as the heading above it. It was the
       gutter on the end side alone before the row centred, which
       would have pulled a centred row off the heading's axis by
       half the difference.

       This does NOT box the rail inside 1200px — the track is
       still full width and the overflow still clips at the edge of
       the screen, so a row that scrolls still cuts its last
       visible card off at the viewport and says "this continues"
       that way. Padding only adds scrollable room; it does not
       move the clip. Boxing the rail properly, with a max-width,
       is what once put the clip 120px shy of the viewport and made
       the cut card read as a rendering fault.

       100% is .slider--rail's width, not the viewport's, so
       unlike 100vw it does not count the scrollbar and the left
       edge cannot drift off the heading's by a few pixels. The
       max() is the narrow case, where the expression goes
       negative and the plain gutter is what is wanted. */
    padding-inline-start: max(
      var(--container-x),
      calc((100% - var(--container-max)) / 2 + var(--container-x))
    );
    padding-inline-end: max(
      var(--container-x),
      calc((100% - var(--container-max)) / 2 + var(--container-x))
    );

    /* Snap to the padding edge, not the border edge, so a snapped
       card lands on the heading's left edge rather than tight
       against the side of the screen. */
    scroll-padding-inline-start: max(
      var(--container-x),
      calc((100% - var(--container-max)) / 2 + var(--container-x))
    );
  }

  /* THE CARDS SHRINK TO FIT BEFORE THE RAIL SCROLLS, which is what
     stops four rooms overflowing by 56px on a wide monitor.

     The base rule gives every rail child `flex: 0 0 auto`, so the
     four 288px cards plus three 24px gaps asked for 1224px inside a
     1168px padded box — 1200px container less its two gutters. That
     is a 56px overrun: too small to read as "there is more", large
     enough to drag under a trackpad, and it made a section that
     looks like a static four-up row behave like a scroller.

     Shrink is turned back on and the width becomes a maximum rather
     than a fixed size, so a row that nearly fits gives up the
     difference evenly — the four cards land at ~274px and there is
     nothing left to scroll. flex-basis stays 18rem so a row with
     room to spare still sits at the 288px the card was drawn at
     rather than stretching to fill.

     min-width is the floor that keeps this a rail rather than a
     squeeze: shrinking stops at 224px, and past that the row
     overflows and scrolls exactly as it did before. Four cards
     clear that floor at every width from 1024px up (4 x 224 + 72 =
     968, inside the 992px box a 1024px viewport gives), so the
     scroll only comes back for a client with six or twelve rooms —
     which is the case the rail exists for. 224px is also where the
     24px serif room name stops fitting on two lines, so the floor
     is a typographic limit, not an arbitrary one. */
  .slider--rail .slider__track > .stay-card {
    flex: 0 1 18rem;      /* 288px, shrinkable */
    min-width: 14rem;     /* 224px — below this the rail scrolls instead */
  }

  /* Amenity and favourite cards stretch to fill one even row.
     Reviews are deliberately not in this list any more — the rail
     scrolls itself now, so its cards keep a fixed width at every
     size instead of dividing a row. */
  .slider--even .slider__track > * { flex: 1 1 0; min-width: 0; }

  /* 384px, and the reason the rail needs five cards rather than
     three: five at this width overrun the 1200px panel, so the
     seam between the two halves of the loop is always off screen.
     Three would leave a hole in it on a wide monitor. */
  .review { width: 24rem; padding: var(--space-5); }   /* 384px */

  .review-rail { margin-top: var(--space-6); }

  /* Two up becomes four — flex: 1 1 0 above already did that by
     dropping the 50% basis — and the ratio gives way to a fixed
     height, because the accordion further down changes a card's
     WIDTH. A card still holding a ratio would grow taller as it
     widened and shove the rest of the page down; height held
     constant, the expansion is width only and object-fit: cover
     just shows more of the same photo. */
  .slider--even .tall-card {
    aspect-ratio: auto;
    height: 26rem;                        /* 416px */
  }

  /* More air under the intro once the pair below it runs side by
     side and reads as one wide block. */
  .location-intro { margin-bottom: var(--space-9); }

  /* A third and two thirds, the reference's 4-and-8 of twelve.
     minmax(0, …) rather than a bare fraction: without it a long
     unbroken string in the card — an address, a URL — sets the
     column's floor and pushes the map out of the container. */
  .location-grid {
    grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
    gap: var(--space-5);
  }

  /* The map is first in the source, because on a phone it belongs
     directly under the heading. Here the two are placed by hand
     into one row so the detail column takes the left side back —
     explicit placement rather than `order`, which would leave the
     grid's auto-flow deciding what happens when a third child
     turns up. Both are on row 1, so the row is as tall as the
     detail column and the map stretches to match it. */
  .location-detail { grid-column: 1; grid-row: 1; }
  .location-map    { grid-column: 2; grid-row: 1; }

  /* The card fills the row, so the two columns end on the same
     line instead of the card stopping short of the map. The row's
     height comes from the map, and .location-detail is a flex
     column, so the card only needs to be told to grow.

     justify-content: center rather than pushing the button to the
     bottom with margin-top: auto — the internal spacing is set
     deliberately (24px under the drive times), and dropping the
     button to the floor would stretch that one gap by whatever
     the map happens to be taller by. Centring absorbs the extra
     in the padding instead and leaves every gap as authored. */
  .address-card {
    flex: 1;
    display: flex;
    flex-direction: column;
    justify-content: center;
  }


  .panel { padding: var(--space-6) var(--space-5); }
  .stats { max-width: 45rem; margin-inline: auto; gap: var(--space-6); }
  /* max-width off: the pair is centred by its own justify-content
     and does not need a box to be centred inside. The gap stays
     at 32px rather than following .stats up to 40px — the two
     cells belong together. */
  .stats--pair { max-width: none; gap: var(--space-5); }
  .stats__badge img { max-width: 8.5rem; }

}

/* ============================================================
   5 · AMENITIES, THE PINNED LAYOUT
   ------------------------------------------------------------
   Its own media query, and the height half of it is a hard
   requirement rather than a nicety.

   The frame is exactly one screen and it is PINNED, so anything
   that does not fit inside it is not merely tight — it is off the
   bottom of the window with no scroll available to reach it,
   because scrolling moves the highlight and not the page. The
   list has to fit, and whether it fits is a question about the
   window's HEIGHT, which a min-width query cannot see: the case
   that broke was a wide short window, 1280x700 and 1100x620, an
   ordinary laptop once the browser chrome comes off a 768px
   screen. The fourth amenity was cut off and unreachable.

   720px is measured, not guessed, and re-measured after every
   change to what the column holds. As it stands, at 1024x720 — the
   corner of this query, and the worst case, because a narrower
   column wraps more — the frame offers 648px and the column's
   TALLEST STATE needs 630px. Tallest state matters and it is not
   the same as "the column": one amenity's description is open at a
   time and they are not the same length, so the figure to measure
   is the maximum over the four, not whatever happens to be up when
   you look. The four measure 630/621/612/603.

   18px of clearance, and it is 18 rather than 11 because the
   numeral's vh coefficient was trimmed from 6.6 to 6.2 for it —
   see the note on --process-num. Clearance grows by about half a
   pixel per pixel of window from there: 29px at 740, 40px at 760.

   Webfonts turn out to cost nothing here, which is a change from
   what this note used to claim. Substituting the fallback stacks
   (Georgia for Fraunces, the system sans for Spectral) and
   re-measuring gives byte-identical column heights at every window
   size in the sweep — the two pairs wrap the same and their line
   boxes match at these sizes. The 26px allowance the old note
   carried for that case was never verified and is not needed.
   Browser text zoom needs no allowance either — zoom shrinks the
   reported viewport height along with everything else, so a zoomed
   window falls below this gate on its own and takes the fallback.

   THIS NUMBER HAS MOVED AND EVERY MOVE WAS A MEASUREMENT, which is
   the point of this note:

     640px  numeral at 48px on a vh clamp; break 601
     600px  numeral flat 16px; break 532
     680px  the Check Availability button added ~100px; break 670,
            then 633 once the intro was let off its 46ch cap
     720px  the intro capped at 430px and back to five lines
     720px  the numeral moved to the side of the step at display
            size and the description moved off the pinned card into
            the left column. The two nearly cancel: the list went
            from 289px to 278px, because the description now unfolds
            inside a row the numeral had already made tall. Held at
            720 rather than moved; 630px worst case in 648px.

   The figures the 680 and 720 rows used to carry for their break
   points did not reproduce when this was re-measured, so they are
   not repeated. Trust the clearance figures above, which were
   measured against the file as it stands.

   The Check Availability button was once added AFTER the gate had
   been lowered to 600 and the gate was not re-measured — which left
   a 70px band of window heights where the pinned layout was on and
   the button was off the bottom of the screen with no way to scroll
   to it. RE-MEASURE AFTER ANY CHANGE TO THE LEFT COLUMN'S CONTENT.
   Adding a line of copy to the intro is enough to invalidate this,
   and so is lengthening one of the four descriptions.

   UNDER 720px the section falls back to the layout the phone
   uses: one column, no pin, and a photograph above each amenity's
   name. That is not a degraded version of this one, it is the
   other one this section already ships, and it cannot clip
   anything because nothing is pinned.

   script.js gates its scroll-progress driver on this exact query.
   IF THIS CHANGES, CHANGE IT THERE TOO — they have to agree, or
   one layout ends up with the other's driver and the highlight
   stops matching the deck. */
@media (min-width: 1024px) and (min-height: 720px) {
  /* ---- 5 · amenities, the pinned two column version ----
     The section is a SCROLL TRACK now, not a block of content. It
     is several screens tall and the grid inside it pins for the
     whole way down, so what you see is one still frame: the whole
     list of amenities on the left, the deck of photographs on the
     right, and scrolling moves which amenity is lit rather than
     moving the page.

     WHY THIS RATHER THAN THE 60vh-PER-STEP VERSION IT REPLACES.
     That version gave each amenity two thirds of a screen and let
     them scroll past a pinned photo, which reads perfectly well
     but shows you exactly one amenity at a time: on a 900px
     monitor, standing on 01, the other three are below the fold.
     The list could not do the one job a numbered list is for,
     which is to say how many there are and where in them you are.
     Pinned, all four are on screen, the lit one is the answer to
     "where am I", and the deck beside it says the same thing a
     second way — three cards left to go.

     The cost is honest and worth naming: the page gets four
     screens longer, and for four amenities that is the trade. It
     would not be at ten. */

  /* One screen to arrive in, then one dwell per amenity. The last
     dwell is what stops the section releasing the moment 04 lights
     up — without it the fourth amenity would flash and be gone.

     --process-count HAS TO MATCH the number of <li>s, and it is
     the one number in this section that cannot be derived in CSS.
     script.js overwrites it from the real count on load, so a
     mismatch only costs the pre-JS paint — but it is still worth
     keeping right, because a no-JS visitor gets what is written
     here and nothing else. */
  .section--process {
    --process-count: 4;
    --process-dwell: 70vh;
    min-height: calc(100vh + var(--process-count) * var(--process-dwell));
    padding-block: 0;                     /* the pinned frame carries its own */
  }

  /* The copy scrim, angled. Up here every word is in the left
     column and the right half of the section is under the deck,
     which is a photograph in its own card and needs no help — so
     the scrim is spent where the text is and released where it is
     not, and the wash keeps most of its colour around the deck
     where it still reads as one.

     THE STOPS ARE ANCHORED TO THE COLUMN, NOT TO THE VIEWPORT,
     and that is the difference between this working and nearly
     working. Written as plain percentages the ramp is a fraction
     of the SCREEN, but the copy's position is set by a centred
     1200px container — so as the window widens the container's
     left edge moves right and the copy slides down the ramp into
     the falloff. Measured: at 1440 an inactive title sat at
     3.68:1 and the same title at 1920 sat at 3.00:1, on identical
     CSS, purely because the window was wider.

     50% is the one figure that does hold, because the container
     is centred and so its midline is the viewport's at every
     width. The left column runs from the container's left edge to
     half a column-gap short of that midline, so holding full
     strength to `50% - space-8` covers the whole column with a
     little to spare, whatever the window is doing.

     THE FALLOFF THEN RUNS ALL THE WAY TO THE RIGHT EDGE, and the
     length of it is the point. An earlier version was out by
     `50% + space-7`, which is a 112px ramp on a 1440px window —
     short enough that the eye reads it as the edge of a dark
     panel rather than as a vignette, and there was a visible seam
     down the middle of the section. Spread across the whole right
     half it is a gradient nobody notices, which is what a scrim
     doing structural work should be. It costs nothing to spend
     the extra distance there: everything past the midline is
     either the deck, which is opaque, or ground.

     0.07 rather than 0 at the far edge, so the ramp does not
     arrive at exactly the unscrimmed wash on the last pixel. */
  .section--process {
    --copy-scrim: linear-gradient(
      to right,
      rgba(0, 0, 0, 0.60) 0,
      rgba(0, 0, 0, 0.58) calc(50% - var(--space-8)),
      rgba(0, 0, 0, 0.07) 100%
    );
  }

  /* The frame that pins. sticky rather than fixed, so it releases
     itself at both ends of the track instead of needing anything
     to tell it when the section is over.

     height, not min-height. The deck below sizes its cards off a
     percentage of this box, and a percentage of min-height is
     indefinite — the cards would collapse to their content, which
     for an absolutely positioned <img> is nothing.

     The padding is on the frame and not on the section, because
     the section's own padding is now spread over four screens of
     track where it does nothing, and what actually needs a margin
     is the pinned screen. clamp against vh rather than a flat
     --space-7: at 1024x768, the shortest window this layout runs
     at, 96px of padding plus the intro plus four amenities does
     not fit, and the padding is the part that can give. */
  .process {
    position: sticky;
    top: 0;
    height: 100vh;
    box-sizing: border-box;

    /* Hoisted into a property because the deck's height cap has to
       subtract it, and custom properties inherit — so .process__media
       can read the same value rather than repeating the clamp and
       drifting from it. */
    --process-pad: clamp(var(--space-4), 5vh, var(--space-7));
    padding-block: var(--process-pad);
    display: grid;
    grid-template-columns: 1fr 1fr;

    /* ONE ROW, TWO COLUMNS. It was two rows with the deck spanning
       both, and that is the arrangement this replaces: a
       row-spanning item distributes its height across the auto
       rows it crosses, so the 810px deck inflated the intro's row
       to 346px for 228px of content and opened a 118px hole under
       the heading that nothing had asked for. Measured, not
       guessed — grid-template-rows computed to
       "345.797px 428.203px".

       With the left column wrapped into a single item there is
       nothing to span and nothing to inflate. The row is as tall
       as the deck, the left column is as tall as its own content,
       and align-items decides where in the row it sits.

       align-items: center rather than start. The two columns used
       to share a top edge, which was the right call when the list
       was 48px numerals and nearly as tall as the deck; at 16px
       the left column is 478px against the deck's 810px, and
       top-aligned that leaves 330px of dead ground under the last
       amenity. Centred, the same space splits above and below and
       reads as margin. */
    align-items: center;
    column-gap: var(--space-8);
  }

  /* The left column's own rhythm, and now the ONLY thing between
     the intro and the list. */
  .process__col {
    display: flex;
    flex-direction: column;
    gap: clamp(var(--space-5), 5vh, var(--space-8));
    grid-column: 1;
    min-width: 0;                         /* so long words cannot widen the column */
  }

  /* display: block undoes the display: contents the phone layout
     needs. Dissolved, the intro is not a box at all and its three
     children become siblings of the list — which is the phone
     layout and wrong in here, where the intro has to be one flex
     item of the column above. */
  .process__aside { display: block; }

  /* The subhead's own bottom margin is double spacing in here: the
     column's flex gap already separates the intro from the list, so
     the two stack and the space comes out 32px wider than it reads
     as. Worth naming rather than just deleting, because it is not
     cosmetic — that 32px is 32px the list does not have inside a
     frame it is not allowed to overflow, and it is the single
     cheapest thing in this column. */
  .process__aside > :last-child { margin-bottom: 0; }

  /* THE INTRO'S MEASURE, SET BY HAND at 430px.

     This overrides the global max-width: 46ch, which in this column
     binds at about 414px — near enough to the same place that the two
     wrap identically. Both put the intro on five lines.

     THAT FIFTH LINE IS THE THING TO KNOW ABOUT. This rule used to be
     max-width: none, letting the intro use the whole ~536px column
     and wrap to four, and the note that stood here recorded why: the
     line it saves is ~28px, the pinned frame cannot be scrolled, and
     so every pixel the left column spends is a pixel off the margin
     the height gate below is built on. Putting the cap back spends
     it again.

     The gate was re-measured after this change rather than left to
     chance — see its note for the new numbers. Short version: the
     column now overflows the frame at a taller window than it used
     to, so the gate moved up with it, and the min-height in the
     media query above is the result. If this measure changes again,
     re-measure the gate again.

     430px rather than 26.875rem is deliberate here: it is a fixed
     measure the client asked for, not one that should stretch when
     the root font size does. */
  .process__aside .subhead { max-width: 430px; }

  /* Column 2 by hand rather than left to auto-flow, for the same
     reason .location-grid above places its two children by hand:
     auto-placement starts guessing the day a third child turns up
     in here. No grid-row any more — there is one row now, and the
     note on .process explains why spanning two was the problem. */
  .process__media { grid-column: 2; }

  /* The deck is CAPPED, not stretched, and the cap is the front
     card's height rather than the stage's.

     It used to be height: 100%, which filled whatever the pinned
     frame had — fine at 900px of window, wrong above it: at 1080
     the card came out 936px against a 784px source, so the crop
     opened up top and bottom and the deck read as a column rather
     than as a card. 786px is the source's own height, so at that
     size and above the photograph is shown uncropped and the deck
     keeps a card's proportions on any monitor.

     min(), so short windows are unaffected — there the frame is
     the smaller of the two and the cap never engages. The peek is
     added back on top for the same reason as before: it is room
     for the cards behind, not part of the front one.

     No aspect-ratio, still: the height is stated here and asking
     for a ratio as well would drive the WIDTH off it and take the
     deck out of its column. Width is the grid's business.

     A definite height also means the stage no longer stretches to
     its grid area, so it sits at the top of it — which puts the
     deck's top edge on the eyebrow's, the one alignment the two
     columns share. */
  .process__media {
    display: block;
    position: relative;
    aspect-ratio: auto;
    /* 100vh MINUS THE FRAME'S OWN PADDING, not 100%, and that is a
       circularity fix rather than a preference. The rows above are
       content-sized now so that align-content can centre them, and
       the media spans both of them — so a percentage height here
       resolves against a row whose size this element is itself
       helping to determine. The browser treats that as indefinite
       and falls back to sizing the deck off the LEFT column, which
       collapsed the card from 786px to 621px at 1440x900.

       Viewport units have no such loop: the frame is exactly one
       screen and its padding is known, so the space available to
       the deck is arithmetic. */
    --process-card-h: min(
      786px,
      calc(100vh - 2 * var(--process-pad) - var(--stack-peek))
    );
    height: calc(var(--process-card-h) + var(--stack-peek));
    margin: 0;
    max-width: none;

    /* The strip down the right the dots live in. It is taken out of
       the CARD rather than added outside the column, and that is the
       part worth knowing: the deck sits in the second half of a
       centred 1200px container, so anything hung off its right edge
       is hung into the page gutter — which is 16px at 1232px of
       window and nothing at all below that. Insetting the card keeps
       the whole arrangement inside the column at every width, and
       costs the photograph 40px of a ~556px frame. */
    --dots-rail: 2.5rem;
  }

  /* The card gives up the rail; everything else about it is
     unchanged, including the peek at the bottom. Stated here rather
     than in the base rule because the rail only exists up here — on
     a phone there is no deck to take it out of. */
  .process__card { inset: 0 var(--dots-rail) var(--stack-peek) 0; }

  /* THE DOTS: four of them down the right of the front card, saying
     how many amenities there are and which one is up.

     Centred on the CARD and not on the stage. The stage is the card
     plus --stack-peek, the strip the cards behind show through at the
     bottom; centring on that would push the rail down by half the
     peek and leave it visibly low against the photograph it belongs
     to. Subtracting the peek first puts the middle dot on the middle
     of the picture.

     right: 0 is the stage's own right edge, which is the outer edge
     of the rail the card just gave up — so the dots sit in that strip
     and never over the photograph. */
  .process__dots {
    position: absolute;
    right: 0;
    top: calc((100% - var(--stack-peek)) / 2);
    transform: translateY(-50%);
    z-index: 30;                 /* over the deck, under a spent card */
    display: flex;
    flex-direction: column;

    /* NO GAP, and that is not an oversight. Each dot is a 24px-tall
       button with a 6px mark centred in it, so the button height IS
       the spacing — 24px of pitch, the same rhythm the 6px dots had
       when they were 6px elements 18px apart. A gap on top of that
       would separate the hit areas and leave dead strips between
       them, which on a rail this small is most of the rail. */
  }

  /* THE BUTTON IS THE TARGET, THE ::before IS THE DOT, and the two
     are deliberately different sizes. A 6px dot is the right mark
     and a 6px control is not one — the rail is 40px wide and the
     dots are 22px apart, so the button takes all of that and the
     visible dot floats in the middle of it. Nothing about the look
     changes; what changes is that the thing you are aiming at is
     40x22 rather than 6x6.

     display: block on the ::before plus margin: auto centres the
     mark in a button that is much larger than it. */
  .process__dot {
    appearance: none;
    width: var(--dots-rail);
    height: 24px;                /* also the rail's pitch — see above */
    margin: 0;
    padding: 0;
    border: 0;
    background: none;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
  }

  /* 6px, and it is scaled rather than resized for the active state.
     A width/height change relayouts the rail and nudges every dot
     under it by a pixel or two on each step; a transform is composited
     and moves nothing. The mark is centred in its button either way,
     so the growth reads as it should — from the middle out.

     0.30 for an inactive dot is a decorative level and deliberately
     below any text floor. The dots duplicate what the <ol> already
     announces and each button carries its amenity's name for a
     screen reader, so nothing rests on the mark itself being
     legible — it is a shape you glance at, and four dots at a text
     contrast beside a photograph read as a row of bullet points. */
  .process__dot::before {
    content: "";
    display: block;
    width: 6px;
    height: 6px;
    margin: 0 auto;
    border-radius: 50%;
    background: var(--color-text-primary);
    opacity: 0.30;
    transform: scale(1);
  }

  .process__dot.is-active::before {
    opacity: 1;
    transform: scale(1.45);
  }

  /* A dot the pointer is over comes halfway up, which is the whole
     affordance: these look like an indicator, and the hover is what
     says the indicator is also a control. Not on the active one —
     it is already at full and there is nowhere for it to go.

     :focus-visible is the page's own ring from the top of this file
     and is not restated here, but the ring has to have something to
     draw around: an outline on a 6px pseudo element inside a 40px
     button would be a ring floating in the middle of nothing, so it
     goes on the button and gets a radius that suits the box. */
  .process__dot:hover:not(.is-active)::before { opacity: 0.62; }
  .process__dot:focus-visible { border-radius: var(--radius-sm); }

  /* The pinned deck is back, so the per-step copies would be the
     same four photographs a second time, down the other column. */
  .process__step-photo { display: none; }

  /* THE BUTTON STAYS, AND CHANGES WHAT IT IS THE FACE OF. On a
     phone it is the photograph, and pressing it opens the
     description. Up here there is no photograph in this column, so
     it becomes the WHOLE ROW — numeral and name — and pressing it
     goes to that amenity. One control per amenity at both widths,
     one meaning at both widths, and the only thing that differs is
     what "go to this one" costs: a fold on a phone, a scroll here.
     script.js carries the branch.

     grid-column: 1 / -1 rather than a width, because the row is a
     two-track grid and the button is meant to be the row. It sits
     UNDER the caption in z-order and the caption is pointer-events:
     none, so a press on the name passes through to the button
     behind it — the same arrangement the phone uses, and for the
     same reason: the alternative is a control with a dead patch
     wherever there happens to be text.

     The radius is for the focus ring, which is the page's own from
     the top of this file and needs a box worth drawing around. */
  .process__toggle {
    display: block;
    grid-column: 1 / -1;
    grid-row: 1;

    /* align-self: stretch UNDOES THE ROW'S align-items: center, and
       without it this button is 0px tall. The row centres its items
       so the numeral hangs against the middle of the name-and-line
       block beside it; an empty button obeys the same rule and
       centres at its content height, which for an empty button is
       nothing. It is still in the layout, still hit-testable, and
       still a strip you cannot land on — the failure looks exactly
       like the click handler not being wired up. */
    align-self: stretch;
    border-radius: var(--radius-sm);
  }

  /* THE LIST, COMPACT. This is what the pin bought: the steps stop
     being 60vh scroll boxes and go back to being list items, so
     all four fit on one screen with the deck beside them.

     A grid with a gap rather than padding on each step, so the
     space between two amenities is one number in one place — and
     so it can shrink on a short window without also shrinking the
     space above the first one, which is the row gap's job.

     align-self: start packs the list under the intro rather than
     stretching it down the column: at four items the leftover
     space belongs below the list, not spread through it. */
  .process__steps {
    display: grid;
    gap: clamp(var(--space-2), 3.2vh, var(--space-6));
  }

  /* THE STEP: A NUMERAL DOWN THE SIDE, the name and its line beside
     it. On a phone the numeral sits above the photograph; up here
     there is no photograph in this column and the number goes where
     a chapter number goes, in the margin of the thing it numbers.

     --process-num is the numeral's size and it is ALSO THE HEIGHT OF
     A CLOSED STEP, which is the reason it is on a vh clamp rather
     than a flat size. The description below unfolds inside this row,
     so the taller the numeral the less an amenity lighting up costs
     the list. Measured at 1024x720: a closed step is 40px, an open
     one 88px, and the column measures 630/621/612/603px with the
     first/second/third/fourth amenity up — a 27px spread end to end,
     and the reason it is not the full 48px difference between a
     closed step and an open one is that 40px of the open step's
     height was already there as the numeral. Take the numeral back
     to the 16px the phone uses and the whole 48px lands on the
     column instead.

     THE vh COEFFICIENT IS PART OF THE HEIGHT GATE'S ARITHMETIC. It
     was 6.6 and is 6.2 because the gate's clearance at its own floor
     was 11px, which is inside the noise; 6.2 buys 9 of that back and
     costs nothing above about 900px of window, where the 4.25rem cap
     is what binds anyway. Re-measure the gate if this moves.

     The first track is stated in the numeral's own units rather than
     left to `auto`, so every title starts at the same x whatever
     digit is beside it — `auto` is the width of the glyph, and a 1 is
     narrower than a 4. 0.62em is a comfortable body over the widest
     digit in this serif at any size the clamp reaches.

     align-items: center hangs the numeral against the middle of the
     name-and-line block, which is where it sits in the design: above
     the title on a closed step, spanning title and line on an open
     one, without either being told about the other. */
  .process__step {
    padding-block: 0;
    min-height: 0;
    --process-num: clamp(2.5rem, 6.2vh, 4.25rem);
    display: grid;
    grid-template-columns: calc(var(--process-num) * 0.62) minmax(0, 1fr);
    column-gap: var(--space-5);
    align-items: center;
  }

  /* The numeral, at display size and without its leading zero.

     `/ ""` IS NOT DECORATION — it is what takes the generated text
     out of the accessibility tree. The count and the position in it
     are already announced by the <ol> this sits in, so a screen
     reader reading "one Lodging two Hull and Hearth" is the same
     information twice. A browser that does not support the alt
     syntax ignores the whole `content` value rather than half of it,
     which is why the fallback declaration above it is there: it gets
     the numeral, announced, which is the behaviour this always had.

     THE CONTRAST RULE CHANGES WITH THE SIZE, and this is the case
     the note on the base rule flagged. At 16px the numeral was
     ordinary text at a 4.5:1 floor and had to be held at 0.58 to
     clear it. At 44px and up it is not being read as text at all —
     it is aria-hidden by the line above, it duplicates the list
     ordinal, and it is a rule of the layout the way a hairline is.
     That is what buys the level below, and it is the whole reason
     the design can have a ghost numeral rather than a legible one. */
  .process__step::before {
    grid-column: 1;
    grid-row: 1;
    margin-bottom: 0;
    content: counter(step);
    content: counter(step) / "";
    font-size: var(--process-num);
    line-height: 0.9;
    color: var(--color-text-primary);
  }

  /* Both halves of the step in the second column, stacked by the
     flow. The caption is not a grid item's grid — it is the box the
     phone layout needs for its scrim, and up here it is just a
     wrapper that keeps the name and the line together in one cell. */
  .process__caption { grid-column: 2; grid-row: 1; }

  /* The name is a line in the left column again, with the deck
     carrying the photographs. Everything the phone overlay needed —
     the band, the padding, the corners, the shadow — comes off,
     because there is nothing under it to be read against.

     The band is on .process__caption, so that is where most of it is
     undone. Its pointer-events: none STAYS, though — that used to
     come off here on the grounds that the name up here was ordinary
     copy rather than the face of a button, and it is the face of a
     button again: the whole row is the target now. See
     .process__toggle above. */
  .process__caption {
    padding: 0;
    border-radius: 0;
    background: none;
  }

  .process__title {
    color: var(--color-text-primary);
    text-shadow: none;
  }

  /* THE LINE, UNDER THE NAME OF THE AMENITY IT DESCRIBES, and only
     on the one the reader is level with.

     It used to be printed on the pinned photograph instead, and the
     note that stood here gave the reason: four descriptions open at
     once in a frame that cannot be scrolled is about 200px the
     column does not have. That reason does not survive the design it
     was written for — ONE is open at a time now, not four, and it
     unfolds inside a row the numeral beside it has already made
     tall. The measured cost to the column is the difference between
     a 44-68px numeral and a title plus two lines, which is about ten
     pixels, not two hundred.

     Same 0fr-to-1fr fold as the phone, and for the same reason —
     see the base rule. What changes is only what it is being read
     against: there it is white-on-photograph inside a scrim, here it
     is copy on the section's own ground, so the overlay colour and
     the text-shadow that goes with it both come off.

     :not(.is-active) is stated rather than left to the base rule
     because .is-open can survive a rotate or a resize from the phone
     layout, and a stale one would leave a second description hanging
     open under a dimmed name. Active is the only thing that opens a
     line up here. */
  .process__step.is-active .process__desc {
    grid-template-rows: 1fr;
    opacity: 1;
  }

  .process__step:not(.is-active) .process__desc {
    grid-template-rows: 0fr;
    opacity: 0;
  }

  .process__desc > .process__desc-text {
    max-width: 36rem;
    color: var(--color-text-secondary);
    text-shadow: none;
  }

  /* THE DIM, RESTATED FOR THE BIG NUMERAL. The base pair — 0.50 on
     the title, 0.58 on the numeral — was two numbers because the two
     were on opposite sides of the 24px large-text threshold and had
     different contrast floors. Up here the numeral is 44px and up,
     aria-hidden, and duplicating an ordinal the <ol> already carries,
     so it is out of that argument entirely and free to sit where the
     design wants it: a ghost in the margin, present enough to count
     and quiet enough that the name beside it is what you read. 0.08
     against 0.38 on the active step is a far wider spread than the
     title's, on purpose: a numeral four times the size of a title
     carries four times the ink at the same level, and matched levels
     would make it the louder half of the row.

     THE TITLE IS AT 0.40 AND THAT IS THE FLOOR, not a preference. It
     was 0.50 and was asked to fade further. Measured on the worst
     patch of this section's wash, across three viewports and six
     scroll positions, with the copy hidden for each sample so the
     ground under the words is the real one rather than a token:

       opacity   worst ratio
       0.50        4.44:1     where it was
       0.44        3.75:1
       0.40        3.36:1     <- here, +12% on the floor
       0.36        3.00:1     exactly on it, nothing in hand
       0.32        2.67:1     fails
       0.28        2.37:1     fails

     The floor is 3:1 rather than 4.5:1 because the title is large
     text, and it is large text BY NOTHING: it computes to exactly
     24px at every viewport, which is the threshold for non-bold type.
     SHRINK THIS TITLE AND THE WHOLE TABLE MOVES UP A FLOOR. At 4.5:1
     nothing in it passes, 0.50 included, and the dim would need
     re-deriving rather than re-tuning. If the type scale is ever
     retuned, re-run the sweep before trusting any of these figures.

     :not(.is-active) rather than leaning on the base rule and undoing
     it, so a stale .is-open left over from the phone layout cannot
     light a step the reader is not on. */
  .process__steps.is-enhanced .process__step:not(.is-active) .process__title {
    opacity: 0.40;
  }

  .process__steps.is-enhanced .process__step::before {
    opacity: 0.08;
  }

  /* HOVER IS THE WHOLE AFFORDANCE. A dim row with a ghost numeral
     beside it looks like a list, because up until now it was one —
     nothing about the arrangement says the row can be pressed. The
     lift on hover is what says it, and it is the same move the dots
     make for the same reason.

     Halfway rather than all the way: at full the hovered row is
     indistinguishable from the active one and the section briefly
     claims you are somewhere you are not. 0.70 against 0.40 is
     plainly a response and plainly not the highlight.

     :not(.is-active) because the active row is already at full and
     has nowhere to go — and a hover that visibly does nothing reads
     as a control that is broken rather than one that is current.

     The numeral comes up with its title. It is decorative and out of
     the contrast argument either way, so the only question is
     whether the row lifts as one block, and it should — a title
     brightening beside a numeral that does not reads as two things
     in a row rather than one row. */
  .process__steps.is-enhanced .process__step:hover:not(.is-active) .process__title {
    opacity: 0.70;
  }

  .process__steps.is-enhanced .process__step:hover:not(.is-active)::before {
    opacity: 0.20;
  }

  /* And the active one. No hue swap up here, unlike the 16px version
     in the base rules: that teal was doing the work of saying "you
     are on this one" when the numeral was small and the title beside
     it only 0.50 away from its neighbours. At this size the jump from
     0.08 to 0.38 — nearly five times the ink — is the signal on its
     own, and a tint at 38% of white is a colour nobody can see. */
  .process__steps.is-enhanced .process__step.is-active::before {
    color: var(--color-text-primary);
    opacity: 0.38;
  }

  /* The CTA, pinned version: back to hugging its own text and
     sitting on the list's left edge.

     align-self, not text-align. The column is a flex container up
     here, so a stretched item is the default and the button would
     run the full width of the column — 536px of brand fill next
     to a 16px numeral. align-self: flex-start plus width: auto is
     what takes it back to its content width; text-align would
     only move the label inside a box still 536px wide.

     And NOT display: inline-block, which was the first thing
     written here and does nothing: a flex container blockifies
     its items, so the declaration is discarded and the computed
     value comes back "block" either way. The width and the
     alignment are the whole mechanism.

     The margin-top goes because the column's own gap now owns
     that space, and the two would otherwise stack. */
  .process__cta {
    width: auto;
    max-width: none;
    align-self: flex-start;
    margin-top: 0;
  }


  /* Reduced motion turns the deck back into a crossfade, so the two
     cards that were peeking out at the bottom are not there any
     more and the strip they needed would be bare stage under the
     photograph. Zeroed, the front card fills the frame. */
  @media (prefers-reduced-motion: reduce) {
    .process__media { --stack-peek: 0px; }
  }

  /* The steps are not scroll boxes any more, so there is nothing
     for a snap point to snap to — the page scrolls past a pinned
     frame and every position in the track is a legitimate place to
     stop. The document-level scroll-snap-type this section used to
     set has gone with them, which also gives the nav anchors their
     ordinary behaviour back on the way past. */
}

/* ============================================================
   SCROLL REVEAL  —  a section arrives as you reach it
   ------------------------------------------------------------
   Everything below the hero starts a little low and a little
   transparent and settles as it comes into view. Once. Scrolling
   back up does not replay it: a page that re-animates on every
   pass stops being a page and starts being a toy.

   THE HIDDEN STATE IS GATED ON .reveal-on, and that gate is the
   whole safety story. The class goes on <html> from the inline
   script in <head>, and only ever where there is an
   IntersectionObserver to take it off again and the visitor has
   not asked for less motion. So the page with no JavaScript, the
   page whose JavaScript failed, and the page whose visitor wants
   it still are all the same page: everything visible, nothing
   waiting on an event that is never coming. Never write the
   hidden state into a plain selector.

   TWO SHAPES IN THE MARKUP:

     [data-reveal]         one element, on its own.
     [data-reveal-group]   a parent whose direct children come in
                           one after another. Nothing goes on the
                           children — the selectors below reach
                           them — so adding a sixth stay card is
                           still just adding a sixth stay card.

   THREE WAYS IN, and which one an element gets is not taste, it
   is what that element can safely be transformed by:

     (default)   RISE     translateY + fade. Ordinary blocks.
     "settle"    SETTLE   scale 0.98 -> 1 + fade. Cards inside a
                          rail. A downward translate inside a
                          scroll container adds real scrollable
                          height to it; a scale down cannot.
     "fade"      FADE     opacity alone. Anything already moving
                          (the review rail) or pinned with
                          position: sticky, where a transform is a
                          containing block waiting to happen.

   Written on the group as data-reveal-group="settle" to set the
   default for its children, or on one element as
   data-reveal="fade" for that element alone. A child's own
   attribute wins, which is what the ordering of the two variant
   blocks below is for — do not reorder them.
   ============================================================ */
:root {
  --reveal-lift:    var(--space-3);   /* 16px. The hero rises 8px, but a
                                         whole section is a bigger object
                                         and reads as stuck at that
                                         distance. */
  --reveal-stagger: 70ms;             /* the hero's interval, kept, so the
                                         page has one rhythm and not two */
}

/* --dur-reveal, not --dur-ui: this is a first-time entrance, the
   category the 300ms ceiling for UI does not apply to. And the
   same two properties the rest of the page animates — transform
   and opacity are the two a compositor can do without going back
   through layout. */
.reveal-on [data-reveal],
.reveal-on [data-reveal-group] > * {
  opacity: 0;
  transform: translate3d(0, var(--reveal-lift), 0);
  transition: opacity   var(--dur-reveal) var(--ease-out),
              transform var(--dur-reveal) var(--ease-out);
  transition-delay: calc(var(--reveal-i, 0) * var(--reveal-stagger));
}

/* The ladder, in CSS rather than counted out in the script,
   because a child that is handed its delay after first paint has
   already been painted without one. It stops climbing at the
   seventh child: past there the delay holds, so a client shipping
   twelve stays does not leave the last one waiting most of a
   second — long after the eye has moved on and the run has
   stopped reading as one movement. */
.reveal-on [data-reveal-group] > *:nth-child(2)    { --reveal-i: 1; }
.reveal-on [data-reveal-group] > *:nth-child(3)    { --reveal-i: 2; }
.reveal-on [data-reveal-group] > *:nth-child(4)    { --reveal-i: 3; }
.reveal-on [data-reveal-group] > *:nth-child(5)    { --reveal-i: 4; }
.reveal-on [data-reveal-group] > *:nth-child(6)    { --reveal-i: 5; }
.reveal-on [data-reveal-group] > *:nth-child(n+7)  { --reveal-i: 6; }

/* 0.98, not 0. Nothing in the world appears out of nothing, and a
   card that grows from a point reads as a notification rather
   than as a photograph that was always there. */
.reveal-on [data-reveal-group="settle"] > * { transform: scale(0.98); }
.reveal-on [data-reveal-group="fade"]   > * { transform: none; }

/* Second, and deliberately: these weigh the same as the group
   rules above, so it is source order that lets one child opt out
   of what its group does. */
.reveal-on [data-reveal="settle"] { transform: scale(0.98); }
.reveal-on [data-reveal="fade"]   { transform: none; }

.reveal-on [data-reveal].is-revealed,
.reveal-on [data-reveal-group] > .is-revealed {
  opacity: 1;
  transform: none;
}

/* The gate is read once, at load. This is for the visitor who
   turns the preference on mid-session — without it every block
   still below them stays at opacity 0 for good, because
   _template.css has just collapsed the transition that was going
   to bring it back. */
@media (prefers-reduced-motion: reduce) {
  .reveal-on [data-reveal],
  .reveal-on [data-reveal-group] > * { opacity: 1; transform: none; }
}

/* ---- 5 & 6 · the accordion ----------------------------------------
   The row shares its width out by flex-grow, so hovering one card
   is the whole mechanism: it claims 2.6 shares against its
   neighbours' 1 each, and because every card is flex: 1 1 0 the
   others give the room up in the same tick. No JS, no widths to
   keep in sync, and it stays correct whether the client ships
   three amenities or six.

   Inside the no-preference guard because a card resizing over
   half a second is motion, whatever it is in aid of. With motion
   reduced the cards hold their even row and hovering still brings
   the panel up — the reveal never depended on the extra width. */
@media (min-width: 1024px) and (hover: hover) and (pointer: fine)
   and (prefers-reduced-motion: no-preference) {
  /* All three properties in one shorthand on purpose. This rule
     and the scroll reveal's both weigh 0,2,0 and this one is
     second, so a bare `transition: flex-grow` here would quietly
     drop the reveal's fade on these four cards alone — the one
     row on the page that failed to animate in, for no visible
     reason. Keep the list complete. */
  .slider--even .tall-card       { transition: flex-grow var(--dur-reveal) var(--ease-out),
                                               opacity   var(--dur-reveal) var(--ease-out),
                                               transform var(--dur-reveal) var(--ease-out); }
  .slider--even .tall-card:hover { flex-grow: 2.6; }
}
