/* =============================================================================
   Flathead Lake Luxury Lodging

   The palette is taken from the photography rather than chosen against it:
   the homes are black-clad timber in dark pines, the interiors run warm
   neutrals, and the one recurring accent in the actual buildings is the
   geometric gold wall in The Lakeside House. So: near-black ground, bone
   text, brass accent. Anything brighter fights the pictures, and on a
   property site the pictures do the selling.

   Light mode is not offered and no section is light. This is a single
   committed dark presentation -- every section is either a photograph or
   near-black -- so a prefers-color-scheme pair would have nothing to swap.
   An earlier pass added an ivory panel and a light palette override. Both
   are gone. The note that used to sit here said the palette "was never
   rendered by any template"; that was not true when it was written --
   tools/pages.py was emitting `section--ivory` on three sections (four
   rendered, across the home page and the properties index) with no rule
   left to match, so those bands computed to the same transparent ground as
   a plain .section. The class has been taken out of the templates too. If a
   band ever wants its own ground again it gets a dark one, the way
   .section--forest and .section--raised do, and audit_seo.py now fails the
   build on a class no rule defines.
*/

:root {
  /* Everything the browser draws for us -- scrollbars, the <select> popup,
     the date picker, autofill -- is drawn light unless we say otherwise, and
     a light scrollbar down the side of a near-black page is the single most
     obvious tell that the dark presentation is skin deep. Safari in
     particular renders a form control's own chrome from this, not from our
     background-color. Ivory sections opt into a light scheme locally so
     their native date pickers and select menus match the paper surface. */
  color-scheme: dark;

  /* Ground */
  --ink:          #0b0c0b;   /* page base, near-black with a green cast    */
  --ink-2:        #131513;   /* raised surface: cards, sticky bars         */
  --ink-3:        #1c1f1c;   /* hairlines, input fills                     */
  --forest:       #17372f;   /* evergreen between the photographic bands  */
  --pine:         #17372f;   /* the one deep green: the scroll cue         */

  /* Text */
  --bone:         #f4f1ea;   /* primary text on dark                       */
  --bone-dim:     #b8b3a8;   /* secondary text                             */
  --bone-faint:   #7e7a72;   /* captions, meta, disabled                   */

  /* Accent */
  --brass:        #c9a227;   /* the gold wall; used sparingly              */
  --brass-soft:   #e0c87a;   /* hover / on very dark                       */
  --lake:         #6f9ba8;   /* secondary accent: water                    */

  /* Feedback */
  --ok:           #7fa87f;
  --warn:         #d99a5b;
  --err:          #c46a5e;

  /* Type */
  --serif: "Cormorant Garamond", "Cormorant Fallback", "Iowan Old Style",
           "Palatino Linotype", Palatino, Georgia, serif;
  --sans:  "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
           "Helvetica Neue", Arial, sans-serif;

  /* Rhythm. One scale, used everywhere, so vertical spacing stays related. */
  --s1: 0.5rem;  --s2: 0.75rem; --s3: 1rem;    --s4: 1.5rem;
  --s5: 2rem;    --s6: 3rem;    --s7: 4.5rem;  --s8: 7rem;

  --measure: 62ch;           /* prose line length                          */
  --gutter:  clamp(1.25rem, 5vw, 4.5rem);
  --maxw:    1360px;

  --radius:   2px;           /* almost square: luxury reads as sharp       */
  --radius-lg: 3px;

  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  /* A long, decelerating curve. Motion on a site like this should look like
     something heavy coming to rest, not something springing into place --
     overshoot and bounce read as cheap at this price point. */
  --ease-out: cubic-bezier(0.16, 0.84, 0.28, 1);
}

/* Cormorant Garamond comes from Google Fonts, and a meaningful share of
   visitors never get it: a corporate proxy, a blocker, a flaky third-party
   request, or simply the swap period on a slow connection. Measured in the
   browser at 100px, the string "Handgloves Montana 0123" sets 1014px in
   Cormorant and 1187px in Palatino or Georgia -- the fallback runs 17%
   wider. On a headline at clamp(2.9rem, 8.5vw, 6.5rem) that is the
   difference between two lines and three, so the page a blocked visitor
   sees is not the page that was designed, and the swap when the webfont
   finally lands reflows the hero.

   size-adjust scales the fallback's glyphs so it sets to the same width.
   85% is 1014/1187. It also pulls Palatino's much larger x-height (48 vs
   Cormorant's 39 at the same size) back towards Cormorant's, so the
   fallback reads as the same typeface choice rather than a different one.
   It cannot fix weight: Cormorant is used at 300 and the fallbacks ship
   only a regular, so a no-webfont render is always a little heavier. That
   is the part of the degradation we accept. */
@font-face {
  font-family: "Cormorant Fallback";
  src: local("Palatino Linotype"), local("Palatino"),
       local("Iowan Old Style"), local("Georgia");
  size-adjust: 85%;
  font-weight: 100 900;
  font-display: swap;
}

/* ---- reset ------------------------------------------------------------- */

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* The sticky header would otherwise cover the target of an in-page link. */
  scroll-padding-top: 6rem;
}
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important; animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

body {
  margin: 0;
  background: var(--ink);
  color: var(--bone);
  font-family: var(--sans);
  font-size: 1rem;
  line-height: 1.65;
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

img, svg, video { display: block; max-width: 100%; }
img { height: auto; background: var(--ink-3); }

h1, h2, h3, h4 { margin: 0; font-weight: 400; line-height: 1.1; }
p  { margin: 0; }
ul, ol { margin: 0; padding: 0; list-style: none; }
/* figure and blockquote were missing from this list and it was not
   theoretical. The user agent gives both `margin: 1em 40px`, and a guest
   review on this site is a <figure> holding a <blockquote>: measured at
   375px, the card was 255px wide inside a 335px grid track and the quotation
   inside it was 125px -- about four words a line, set in a serif at 1.1rem,
   which is the shape of a newspaper column and not of anything anyone wants
   to read on a phone. The same 80px came off every review card at every
   width. .lightbox__fig has carried its own `margin: 0` since the viewer was
   written, which is the same bug noticed once and fixed locally. */
figure, blockquote, figcaption { margin: 0; }

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

button, input, select, textarea { font: inherit; color: inherit; }

/* A visible focus ring on every interactive thing. Brass on near-black
   clears WCAG AA comfortably and matches the accent rather than fighting it.
   `summary` is in the list because a <details> is focusable and the FAQ is
   eight of them in a row; without it those eight were the only controls on
   the site still wearing the browser's own ring. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--brass);
  outline-offset: 3px;
  border-radius: var(--radius);
}

/* ---- typography -------------------------------------------------------- */

.display, h1.display { font-family: var(--serif); font-weight: 300; letter-spacing: -0.01em; }

.t-hero    { font-family: var(--serif); font-weight: 300; font-size: clamp(2.9rem, 8.5vw, 6.5rem);  line-height: 0.98; letter-spacing: -0.02em; }
.t-section { font-family: var(--serif); font-weight: 300; font-size: clamp(2.1rem, 5vw, 3.6rem);    line-height: 1.06; letter-spacing: -0.015em; }
.t-sub     { font-family: var(--serif); font-weight: 300; font-size: clamp(1.5rem, 3vw, 2.1rem);    line-height: 1.18; }
.t-card    { font-family: var(--serif); font-weight: 400; font-size: clamp(1.3rem, 2.2vw, 1.65rem); line-height: 1.2; }

/* Display type at this size makes a widow enormous -- one word alone on a
   line of 6.5rem Cormorant is the most expensive-looking mistake on the
   page, and it moves with every breakpoint, so no hand-placed <br> fixes it
   everywhere. `balance` evens the lines for us; where a heading already
   carries a hard break it balances each side of it independently, so the
   authored breaks still win. `pretty` on running text only pulls a word
   down to keep the last line from being a single orphan. Both degrade to
   normal wrapping in browsers that do not have them, which is exactly
   today's behaviour. */
.t-hero, .t-section, .t-sub, .t-card, .display,
.faq summary, .amenity__t, .table tbody th { text-wrap: balance; }
.lede, .prose, .hero__lede, .card__meta, .faq__a, .review__text { text-wrap: pretty; }

/* The small-caps label above a heading. Does a lot of work on this site --
   it is most of what separates "expensive" from "a page with photos on it". */
.eyebrow {
  font-family: var(--sans);
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--brass);
}
.eyebrow--dim { color: var(--bone-faint); }

.lede { font-size: clamp(1.05rem, 1.7vw, 1.25rem); line-height: 1.6; color: var(--bone-dim); max-width: var(--measure); }
.prose { max-width: var(--measure); color: var(--bone-dim); }
.prose p + p { margin-top: var(--s3); }

.muted { color: var(--bone-faint); }
.nowrap { white-space: nowrap; }

/* Screen-reader-only, but focusable (used by the skip link). */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.skip-link {
  position: absolute; left: var(--s3); top: -4rem; z-index: 200;
  background: var(--brass); color: var(--ink); padding: var(--s2) var(--s4);
  font-weight: 600; transition: top 0.2s var(--ease);
}
.skip-link:focus { top: var(--s3); }

/* ---- layout ------------------------------------------------------------ */

.wrap { width: 100%; max-width: var(--maxw); margin-inline: auto; padding-inline: var(--gutter); }
.wrap--narrow { max-width: 860px; }

.section      { padding-block: clamp(var(--s6), 9vw, var(--s8)); }
.section--tight { padding-block: clamp(var(--s5), 6vw, var(--s6)); }
.section--forest { background: var(--forest); }
.section--raised { background: var(--ink-2); }

/* The seam. Every band on this site is either a photograph or one of three
   near-blacks, and between two of the near-blacks the change of ground is a
   difference of about eight levels -- true to the palette and, on the
   owner's screen, invisible. So the join is stated: a brass hairline that
   fades out toward both page edges, with the mark's own diamond sitting on
   it. It is the crest device from the footer at a twentieth of the size,
   which is the point -- a rule with a mark on it, used more than once, is a
   motif; used once it is an ornament.

   Drawn at rest, not undrawn, so a page with no JavaScript and a browser
   with no scroll-driven animations gets a finished seam rather than none. */
.section--forest, .section--raised { position: relative; }
.section--forest::before, .section--raised::before {
  content: ""; position: absolute; left: 0; right: 0; top: 0; height: 1px;
  background: linear-gradient(to right, rgba(201,162,39,0) 0%,
              rgba(201,162,39,0.5) 28%, rgba(201,162,39,0.5) 72%,
              rgba(201,162,39,0) 100%);
  transform-origin: center;
}
.section--forest::after, .section--raised::after {
  content: ""; position: absolute; top: -3.5px; left: calc(50% - 3.5px);
  width: 7px; height: 7px; background: var(--brass);
  transform: rotate(45deg);
}

.section-head { margin-bottom: clamp(var(--s5), 5vw, var(--s6)); }
.section-head .eyebrow { display: block; margin-bottom: var(--s3); }
.section-head .lede { margin-top: var(--s3); }

.grid { display: grid; gap: clamp(var(--s3), 2.5vw, var(--s5)); }
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); }
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr)); }
.grid--4 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); }

.rule { height: 1px; background: var(--ink-3); border: 0; margin: 0; }

/* ---- buttons ----------------------------------------------------------- */

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 0.6em;
  padding: 0.95em 1.9em;
  font-size: 0.8125rem; font-weight: 600;
  letter-spacing: 0.14em; text-transform: uppercase;
  border: 1px solid transparent; border-radius: var(--radius);
  cursor: pointer; text-align: center;
  transition: background-color 0.25s var(--ease), color 0.25s var(--ease),
              border-color 0.25s var(--ease), transform 0.25s var(--ease);
}
.btn { position: relative; overflow: hidden; }
/* Two different things move a button -- the hover lift, and the magnetic
   pull site.js applies on fine pointers -- and they used to be one
   `transform` declaration that each would have had to overwrite. Composing
   them through custom properties lets both exist at once.

   Writing a variable still runs the transform transition above, and that is
   what gives the magnet its weight: the button trails a quarter of a second
   behind the pointer instead of being stuck to it. A magnet that tracks
   exactly reads as a cheap gimmick; one that lags reads as mass. */
.btn { transform: translate3d(var(--mx, 0px), calc(var(--my, 0px) + var(--lift, 0px)), 0); }
.btn:hover { --lift: -1px; }
.btn:active { --lift: 1px; }
/* A single pass of light across the button on hover. Slow enough to read as
   a material catching the light rather than a loading shimmer. */
.btn::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(105deg, transparent 35%,
              rgba(255,255,255,0.22) 50%, transparent 65%);
  transform: translateX(-120%);
  transition: transform 0.75s var(--ease-out);
  pointer-events: none;
}
.btn:hover::after { transform: translateX(120%); }
@media (prefers-reduced-motion: reduce) {
  .btn::after { display: none; }
  .btn:hover { transform: none; }
}
.btn--primary { background: var(--brass); color: #16140c; border-color: var(--brass); }
.btn--primary:hover { background: var(--brass-soft); border-color: var(--brass-soft); }

.btn--ghost { background: transparent; color: var(--bone); border-color: rgba(244,241,234,0.32); }
.btn--ghost:hover { border-color: var(--bone); background: rgba(244,241,234,0.07); }

.btn--solid { background: var(--bone); color: var(--ink); border-color: var(--bone); }
.btn--solid:hover { background: #fff; }

.btn--sm { padding: 0.7em 1.3em; font-size: 0.75rem; }
.btn { white-space: nowrap; }
.btn--block { width: 100%; }

/* A disabled button used to be the live button at 45% opacity. That fades
   the label exactly as hard as the fill, and the one place this site
   disables a button is the "Sending..." state of a booking request -- the
   moment the label is carrying the most information. Brass at 0.45 with its
   own dark text also faded to 0.45 measures 2.3:1.

   Flattening it to the muted surface instead reads as a state rather than
   as a half-painted button, keeps the label and the spinner legible at
   3.9:1, and takes the call-to-action colour off something that cannot be
   acted on. It has to come after the variants and name :hover explicitly,
   because .btn--primary:hover would otherwise win on source order. */
.btn[disabled], .btn[disabled]:hover, .btn[disabled]:active,
.btn[aria-disabled="true"], .btn[aria-disabled="true"]:hover {
  background: var(--ink-3); color: var(--bone-faint); border-color: var(--ink-3);
  cursor: not-allowed; transform: none;
}
/* Neither the lift nor the pass of light belongs on something inert. */
.btn[disabled]::after, .btn[aria-disabled="true"]::after { display: none; }
/* Native [disabled] already swallows the click and the pointer cursor.
   aria-disabled does not, and nothing on the site listens for it, so that
   one still has to be made inert by hand. */
.btn[aria-disabled="true"] { pointer-events: none; }

/* A text link that reads as a link without being blue. */
.link-arrow {
  display: inline-flex; align-items: center; gap: 0.5em;
  font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.14em;
  text-transform: uppercase; color: var(--brass);
  border-bottom: 1px solid rgba(201,162,39,0.35); padding-bottom: 0.25em;
  transition: color 0.2s var(--ease), border-color 0.2s var(--ease);
}
.link-arrow:hover { color: var(--brass-soft); border-color: var(--brass-soft); }
.link-arrow::after { content: "\2192"; transition: transform 0.25s var(--ease); }
.link-arrow:hover::after { transform: translateX(3px); }

/* ---- header ------------------------------------------------------------ */

.header {
  position: fixed; inset: 0 0 auto 0; z-index: 100;
  transition: border-color 0.35s var(--ease);
  border-bottom: 1px solid transparent;
}
/* The header sits over whatever photograph the page opens with, and some of
   those open on a bright sky. Relying on the hero's own scrim meant the nav
   was legible on the sunset shots and invisible on the daylight ones, so the
   header carries its own gradient and stops depending on the image. It
   fades out once the header goes solid and no longer needs it. */
.header::before {
  content: ""; position: absolute; inset: 0 0 -3rem 0; z-index: -1;
  background: linear-gradient(to bottom, rgba(8,9,8,0.78) 0%,
                              rgba(8,9,8,0.45) 55%, rgba(8,9,8,0) 100%);
  opacity: 1; transition: opacity 0.35s var(--ease); pointer-events: none;
}
.header.is-solid::before, .header.is-stuck::before { opacity: 0; }
/* Transparent over a hero, solid once scrolled. The class is toggled in JS
   because there is no CSS way to know the hero has passed. */
/* The solid background and its blur live on a pseudo-element, NOT on the
   header itself, and this is load-bearing rather than fussy.

   `backdrop-filter` makes an element the containing block for any
   `position: fixed` descendant. The mobile nav panel is a fixed child of
   the header, so with the filter on the header its `inset: 5rem 0 0 0`
   resolved against an 81px-tall header instead of the viewport: the menu
   opened as a 104px sliver showing one and a half links. It looked fine in
   every check that asked whether the menu had opened, because it had.

   A pseudo-element carries the filter without the header itself becoming a
   containing block, so the blur survives and the panel fills the screen. */
.header::after {
  content: ""; position: absolute; inset: 0; z-index: -1;
  background: rgba(11,12,11,0.92);
  backdrop-filter: saturate(140%) blur(14px);
  -webkit-backdrop-filter: saturate(140%) blur(14px);
  opacity: 0; transition: opacity 0.35s var(--ease); pointer-events: none;
}
.header.is-stuck::after,
.header.is-solid::after { opacity: 1; }
.header.is-stuck,
.header.is-solid { border-bottom-color: var(--ink-3); }
.header__bar {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s4);
  min-height: 5rem;
}
/* ---- the brand lockup --------------------------------------------------- */

/* The mark and the words, side by side. Three decisions are packed in here
   and each one had an obvious alternative that is worse.

   **The mark does not replace the words.** It is wordless -- five
   overlapping peaks, no type in it -- so on its own in the corner it tells
   a first-time visitor nothing at all, and the header is the one place on
   the site where the name has to be stated. Mark plus wordmark.

   **The wordmark is live text, not the rendered lockup.** The brand's own
   lockup file (brand/lockup.svg) stacks the mark above the type, which at
   the 56px a header bar can give it puts the cap height of "FLATHEAD LAKE"
   at five pixels. Live text is legible at any size, selectable,
   translatable, and free. What it must not do is disagree with the real
   wordmark, so it no longer does: the artwork sets both lines in a light
   humanist sans, uppercase and letterspaced, and this now matches it. It
   used to be Cormorant Garamond in title case, which was a handsome corner
   and was also the site saying the company's name in a typeface the
   company does not use. That mattered less when the logo was a placeholder
   glyph; with the real lockup shipping in the structured data and the
   share image, two different wordmarks for the same words is the mistake.
   Cormorant is not diminished by this -- it keeps every headline on the
   site, and the split now reads as identity in the sans, editorial voice
   in the serif.

   **The mark takes a site colour rather than its own.** The artwork is a
   warm sand, roughly #dfccbb. Beside bone #f4f1ea in the same lockup that
   is not a palette, it is two warm near-whites a shade apart, which reads
   as a colour-management fault rather than a decision. So the rule is: the
   mark is bone wherever it sits next to the wordmark and is therefore part
   of it, and brass wherever it stands alone and is the whole message --
   the favicon, the touch icon, the crest in the footer. No third neutral
   enters the palette. `mask` rather than `<img>` is what makes that
   possible: an SVG loaded through <img> cannot be recoloured from the page
   and mark.svg's `currentColor` would resolve to the renderer's default
   text colour, i.e. black on a near-black header.

   The wordmark and the button must never wrap: a two-line "FLATHEAD /
   LAKE" in the corner reads as a layout bug. */
.brand { display: flex; flex-direction: column; line-height: 1.15; flex: none; }
.brand__a {
  font-size: clamp(0.8125rem, 1.35vw, 0.95rem); font-weight: 300;
  letter-spacing: 0.15em; text-transform: uppercase; white-space: nowrap;
}
.brand__b {
  font-size: clamp(0.5rem, 0.85vw, 0.5938rem); font-weight: 600;
  letter-spacing: 0.26em; text-transform: uppercase;
  color: var(--brass); white-space: nowrap;
}

/* The mark itself is opt-in behind mask support, and the fallback is the
   lockup exactly as it was before it existed. Without the guard a browser
   with no `mask-image` paints the background-color with nothing cutting
   the shape out of it, i.e. a solid bone rectangle where the logo should
   be -- a worse failure than no logo. */
@supports (mask-image: url("m.svg")) or (-webkit-mask-image: url("m.svg")) {
  .brand {
    display: grid; align-items: center;
    /* max-content and an explicit start, not `auto auto`. An `auto` track
       absorbs free space, and in the footer .brand is a block-level <p>
       filling a grid column -- so the mark stayed at the left edge while
       the words were pushed a hundred pixels away from it and the lockup
       came apart. In the header it looked right only because a flex item
       with flex:none is already sized to its content. */
    grid-template-columns: max-content max-content;
    justify-content: start;
    column-gap: clamp(0.5rem, 1.1vw, 0.8rem);
  }
  .brand::before {
    content: ""; grid-column: 1; grid-row: 1 / 3;
    width: var(--mark-w, 84px);
    /* The frame is sized from the artwork's own ratio, so the mark costs no
       layout shift while the SVG is still in flight. */
    aspect-ratio: 719.33 / 307.46;
    background-color: currentColor;
    -webkit-mask: url("/assets/img/brand/mark.svg") center / contain no-repeat;
            mask: url("/assets/img/brand/mark.svg") center / contain no-repeat;
  }
  .brand__a, .brand__b { grid-column: 2; }
  /* The lockup is the only link in the header bar and it was a 30px-tall
     target on a phone. The bar has 80px to spend and the mark is 30 of
     them, so the rest is padding rather than a bigger logo. */
  .header__bar .brand { padding-block: 0.45rem; }
  /* One mark per footer. The crest above is the same drawing at up to
     256px in brass; the lockup a hundred pixels below it repeating it at
     84px in bone reads as a mistake rather than a motif. The attribute is
     set by site.js only once the crest is actually in the page, so a
     footer with no crest keeps its mark. The gap goes with it: an empty
     first column still costs its column-gap, and 12.8px was enough to take
     the wordmark out of line with the paragraph under it. */
  .footer[data-crest] .brand::before { display: none; }
  .footer[data-crest] .brand { column-gap: 0; }
  /* The line in the artwork is 0.83% of its own width, so the width below
     is really a decision about line weight: at 84px it lands just under one
     CSS pixel and reads as the fine drawing it is, and much under 70px it
     stops resolving at all on a one-times screen. 70px is the floor, taken
     on a phone where the lockup is already competing with the hamburger and
     every screen is at least two-times. */
  @media (max-width: 560px) { .brand { --mark-w: 70px; } }
  /* Forced colours replace every author background with a system one, so a
     mask painted with background-color vanishes and the lockup loses its
     mark. A system colour keyword is the one value that survives, and
     CanvasText is the same ink the words beside it are drawn in. */
  @media (forced-colors: active) { .brand::before { background-color: CanvasText; } }
}

.nav { display: flex; align-items: center; gap: clamp(var(--s3), 2vw, var(--s5)); }
.nav a {
  font-size: 0.8125rem; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--bone-dim); transition: color 0.2s var(--ease); position: relative;
  white-space: nowrap;
}
/* The nav carries its own booking button for the mobile panel, where the
   header's one is hidden. On desktop the header button is the visible one,
   so this duplicate has to go or both render side by side. */
.nav > .btn { display: none; }
.nav a:hover, .nav a[aria-current="page"] { color: var(--bone); }
/* One rule for both states: the underline is always present and scales from
   nothing, so hovering and landing on the current page use the same motion
   instead of one appearing and the other sliding. */
.nav a::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -0.5em;
  height: 1px; background: var(--brass);
  transform: scaleX(0); transform-origin: left;
  transition: transform 0.35s var(--ease-out);
}
.nav a:hover::after, .nav a[aria-current="page"]::after { transform: scaleX(1); }
.header__cta { display: flex; align-items: center; gap: var(--s3); flex: none; }
/* Below this the six nav items plus the brand stop fitting on one line, so
   the nav collapses into the panel rather than wrapping. */
@media (max-width: 1180px) and (min-width: 961px) {
  .nav { gap: var(--s3); }
  .nav a { font-size: 0.75rem; letter-spacing: 0.06em; }
}

.nav-toggle {
  display: none; background: none; border: 0; cursor: pointer;
  padding: var(--s2); margin-right: calc(var(--s2) * -1);
}
.nav-toggle svg { width: 22px; height: 22px; stroke: var(--bone); fill: none; stroke-width: 1.5; }

@media (max-width: 960px) {
  .nav-toggle { display: block; }
  .header__cta .btn { display: none; }
  /* The mobile menu is a full-height panel rather than a dropdown: with six
     top-level destinations plus a booking CTA, a dropdown gets cramped. */
  .nav {
    position: fixed; inset: 5rem 0 0 0;
    flex-direction: column; align-items: flex-start; justify-content: flex-start;
    gap: 0; padding: var(--s5) var(--gutter) var(--s7);
    background: var(--ink); overflow-y: auto;
    transform: translateY(-8px); opacity: 0; pointer-events: none;
    transition: opacity 0.28s var(--ease), transform 0.28s var(--ease);
  }
  .nav[data-open="true"] { transform: none; opacity: 1; pointer-events: auto; }
  .nav a {
    font-family: var(--serif); text-transform: none; font-size: 1.85rem;
    letter-spacing: 0; color: var(--bone);
    padding-block: var(--s3); width: 100%; border-bottom: 1px solid var(--ink-3);
  }
  .nav a[aria-current="page"]::after { display: none; }
  .nav a[aria-current="page"] { color: var(--brass); }
  .nav > .btn {
    display: inline-flex; margin-top: var(--s5); font-family: var(--sans);
    font-size: 0.8125rem; text-transform: uppercase; border-bottom: 0;
    width: auto; padding: 0.95em 1.9em; color: #16140c;
  }
}

/* ---- hero -------------------------------------------------------------- */

.hero { position: relative; display: grid; align-items: end; isolation: isolate; }
.hero--full { min-height: 100svh; }
.hero--tall { min-height: 78svh; }
.hero--mid  { min-height: 58svh; }

.hero__media { position: absolute; inset: 0; z-index: -2; overflow: hidden; }
.hero__media img { width: 100%; height: 100%; object-fit: cover; }

/* Two scrims, doing different jobs. The horizontal one darkens the left
   column, where every hero's text sits, so an eyebrow in 11px brass stays
   readable even when the photograph behind it is a bright sky; it fades out
   by three quarters across, leaving the right side of the image open. The
   vertical one anchors the bottom edge and keeps the top dark enough for the
   header. A single uniform overlay would have to be far heavier to do both,
   and would flatten every photograph on the site to rescue two. */
.hero__media::after {
  content: ""; position: absolute; inset: 0;
  /* Above every plate the hero crossfades between. A generated ::after
     already paints after all real children, so this is belt and braces --
     but the thing it protects is the only reason audit_contrast.py exists,
     and one stray z-index on a photograph would put the headline on an
     unmeasured image with nothing failing. */
  z-index: 2;
  background:
    linear-gradient(100deg, rgba(8,9,8,0.84) 0%, rgba(8,9,8,0.74) 26%,
                    rgba(8,9,8,0.44) 48%, rgba(8,9,8,0.12) 66%,
                    rgba(8,9,8,0) 80%),
    linear-gradient(to top, rgba(11,12,11,0.86) 0%, rgba(11,12,11,0.44) 28%,
                    rgba(11,12,11,0.10) 62%, rgba(11,12,11,0.26) 100%);
}
/* On a phone the text runs the full width, so the horizontal scrim has
   nothing to fade toward and just looks lopsided. */
@media (max-width: 700px) {
  .hero__media::after {
    background:
      linear-gradient(to top, rgba(11,12,11,0.94) 0%, rgba(11,12,11,0.74) 38%,
                      rgba(11,12,11,0.42) 72%, rgba(11,12,11,0.46) 100%);
  }
}
.hero__inner {
  position: relative; padding-block: clamp(var(--s6), 12vh, var(--s8)); width: 100%;
  /* A wide, soft shadow rather than a hard one. Over the dark half of a
     photograph it is invisible; over a bright sky it is the difference
     between readable and not. Cheaper and less destructive than darkening
     the whole scrim, which would flatten every image to protect a few. */
  text-shadow: 0 1px 28px rgba(0, 0, 0, 0.62), 0 1px 3px rgba(0, 0, 0, 0.38);
}
/* Buttons have their own solid or bordered background and inherit the
   shadow as a smudge on the label, so opt them out. */
.hero__inner .btn { text-shadow: none; }
.hero .eyebrow { display: block; margin-bottom: var(--s3); }
.hero__lede { margin-top: var(--s4); max-width: 46ch; font-size: clamp(1.05rem, 1.8vw, 1.3rem); color: var(--bone-dim); }
.hero__actions { display: flex; flex-wrap: wrap; gap: var(--s3); margin-top: clamp(var(--s4), 4vw, var(--s5)); }
/* Once the two buttons stop fitting side by side they stack, and because
   .btn is nowrap they stack at two different widths -- "EXPLORE THE
   PROPERTIES" at 263px above "BOOK THE WHOLE ESTATE" at 214px, left
   aligned, which reads as a rag rather than a pair. Full width below the
   breakpoint makes them a stacked pair and takes the tap target to the
   full column while it is at it. */
@media (max-width: 560px) {
  .hero__actions { flex-direction: column; align-items: stretch; }
  .hero__actions .btn { width: 100%; }
}

/* The row of hard facts under a property hero. */
.hero__facts {
  display: flex; flex-wrap: wrap; gap: var(--s4) clamp(var(--s4), 4vw, var(--s6));
  margin-top: var(--s5); padding-top: var(--s4); border-top: 1px solid rgba(244,241,234,0.18);
}
.fact { display: flex; flex-direction: column; gap: 0.15em; }
.fact__n { font-family: var(--serif); font-size: 1.65rem; line-height: 1; }
.fact__l { font-size: 0.6875rem; letter-spacing: 0.18em; text-transform: uppercase; color: var(--bone-faint); }

/* ---- cards ------------------------------------------------------------- */

.card {
  position: relative; display: flex; flex-direction: column;
  background: var(--ink-2); border: 1px solid var(--ink-3);
  border-radius: var(--radius-lg); overflow: hidden;
  transition: border-color 0.3s var(--ease), transform 0.3s var(--ease);
}
.card:hover, .card:focus-within {
  border-color: rgba(201,162,39,0.5); transform: translateY(-9px);
  /* A pointed-at card lifts off the page, and on a near-black ground the
     only thing that says "off the page" is a shadow. Long, soft and
     almost entirely below the card -- a short dark halo all round reads as
     a glow, which is a different and much cheaper effect. */
  box-shadow: 0 34px 54px -30px rgba(0,0,0,0.92), 0 2px 0 -1px rgba(201,162,39,0.18);
}
.card__media { position: relative; aspect-ratio: 4 / 3; overflow: hidden; }
.card__media img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.7s var(--ease); }
.card:hover .card__media img, .card:focus-within .card__media img { transform: scale(1.06); }
.card__body { display: flex; flex-direction: column; gap: var(--s2); padding: var(--s4); flex: 1; }
.card__body .card__foot { margin-top: auto; }
.card__meta { font-size: 0.8125rem; color: var(--bone-faint); }
/* The footer holds a spec line and a price, and on the widest grid the
   cards are 280px, which is not enough for "14 guests / 4 beds / 2.5 bath"
   plus a nightly rate on one line, back when the site published one. It
   was wrapping inside the meta span
   while the price stayed baseline-aligned to the meta's *first* line, so
   the price floated up and "2.5 bath" hung underneath it -- two columns
   interleaved into one ragged block.

   Letting the whole footer wrap instead gives it two clean states: one row
   when it fits, and the price on its own right-aligned row when it does
   not. The price must never break across lines in either. */
.card__foot {
  display: flex; flex-wrap: wrap; align-items: baseline;
  justify-content: space-between; gap: var(--s1) var(--s3);
  padding-top: var(--s3); border-top: 1px solid var(--ink-3);
}
.card__foot > .card__meta { flex: 1 1 11rem; min-width: 0; }
.card__foot > .price { flex: none; margin-left: auto; }
.card__foot > .card__ask { flex: none; margin-left: auto; }
.price { font-family: var(--serif); font-size: 1.45rem; white-space: nowrap; }
.price small { font-family: var(--sans); font-size: 0.75rem; color: var(--bone-faint); letter-spacing: 0.08em; }
/* "Total on request", in the slot a total would occupy. It is deliberately
   not .price: a serif 1.45rem line saying "on request" reads as the headline
   figure of the card, and the one stay that shows this -- The Full Estate,
   which Guesty has no listing for -- would then look like the most important
   result on the page. Small, brass, and unmistakably not a number. */
.card__ask {
  font-size: 0.8125rem; letter-spacing: 0.06em;
  color: var(--brass-soft); white-space: nowrap;
}

/* Makes the whole card clickable while keeping one real <a> for the
   accessibility tree, instead of nesting links or wrapping a div in an <a>. */
.stretch::after { content: ""; position: absolute; inset: 0; z-index: 1; }

.badge {
  position: absolute; top: var(--s3); left: var(--s3); z-index: 2;
  background: rgba(11,12,11,0.82); backdrop-filter: blur(6px);
  color: var(--brass); font-size: 0.625rem; font-weight: 600;
  letter-spacing: 0.18em; text-transform: uppercase;
  padding: 0.5em 0.9em; border-radius: var(--radius);
}

/* ---- feature rows (alternating image / text) --------------------------- */

.feature { display: grid; gap: clamp(var(--s4), 5vw, var(--s6)); align-items: center; }
@media (min-width: 900px) {
  .feature { grid-template-columns: 1fr 1fr; }
  .feature--flip .feature__media { order: 2; }
}
.feature__media { position: relative; aspect-ratio: 5 / 4; overflow: hidden; border-radius: var(--radius-lg); }
.feature__media img { width: 100%; height: 100%; object-fit: cover; }
.feature__body > * + * { margin-top: var(--s3); }
.feature__body .btn, .feature__body .link-arrow { margin-top: var(--s4); }

/* Places in the area guide that have no photograph yet. A card, not a
   half-page feature with a stock picture of a mountain standing in for a
   restaurant: see area_page() in tools/pages.py for why the layout branches
   on the photograph rather than on the place. */
.near { display: grid; gap: var(--s4); grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr)); }
.near__card {
  display: flex; flex-direction: column; align-items: flex-start; gap: var(--s2);
  padding: var(--s5);
  background: var(--ink); border: 1px solid var(--ink-3);
  border-left: 2px solid var(--brass);
  border-radius: var(--radius);
}
.near__card .ticks { margin-top: var(--s2); }
.near__card .link-arrow { margin-top: auto; padding-top: var(--s4); }
.near__drive { font-size: 0.8125rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--bone-faint); }
.near__drive b { color: var(--brass); font-weight: 500; }

/* ---- lists ------------------------------------------------------------- */

.ticks { display: grid; gap: var(--s2); }
.ticks li { position: relative; padding-left: 1.6em; color: var(--bone-dim); }
.ticks li::before {
  content: ""; position: absolute; left: 0; top: 0.62em;
  width: 0.7em; height: 1px; background: var(--brass);
}
.ticks--2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); }

.pills { display: flex; flex-wrap: wrap; gap: var(--s2); }
.pill {
  font-size: 0.8125rem; color: var(--bone-dim);
  border: 1px solid var(--ink-3); border-radius: 999px;
  padding: 0.4em 0.95em; background: var(--ink-2);
}

/* ---- amenity + stat blocks --------------------------------------------- */

.amenity { display: flex; flex-direction: column; gap: var(--s1); padding: var(--s4) 0; border-top: 1px solid var(--ink-3); }
.amenity__t { font-family: var(--serif); font-size: 1.15rem; }
.amenity__d { font-size: 0.875rem; color: var(--bone-faint); }

.distance { display: flex; align-items: baseline; gap: var(--s3); padding: var(--s3) 0; border-bottom: 1px solid var(--ink-3); }
.distance__t { flex: 1; }
.distance__n { font-family: var(--serif); font-size: 1.2rem; color: var(--brass); white-space: nowrap; }
.distance__d { display: block; font-size: 0.8125rem; color: var(--bone-faint); }

/* ---- comparison table -------------------------------------------------- */

/* The table is a deliberate SEO artefact as much as a UI one: a compact
   grid of name / sleeps / beds / rate is the shape an assistant can lift
   whole when someone asks which property suits their group. */
/* At 375px the table is 704px of content in a 335px window, and it used to
   say so in no way at all: the "Beds" column was sliced off mid-word at the
   screen edge, with no scrollbar (macOS and iOS hide overlay scrollbars
   until you already scroll) and no fade. A reader who did not happen to
   swipe never learned the last two columns existed.

   Two affordances, because either alone can be missed. `scrollbar-color`
   opts Chrome and Firefox out of overlay scrollbars entirely, so a brass
   bar sits under the table whenever there is more table; and the fade below
   marks the edge the content is disappearing into. */
.table-scroll {
  overflow-x: auto; -webkit-overflow-scrolling: touch;
  min-width: 0;                 /* safe if this ever lands in a grid cell */
  display: flex;                /* makes ::after a sticky flex item       */
  scrollbar-width: thin;
  scrollbar-color: var(--brass) var(--ink-3);
}
/* The fade is a sticky flex item pinned to the right of the scrollport and
   pulled back over the table by its own width, so it costs no layout. It
   fades to --ink because the one table on the site sits on an unmodified
   .section; a scroller on --forest would need its own colour.

   It is opt-in on an attribute that site.js sets and clears as the reader
   scrolls -- without JS there is no attribute, no fade, and the table
   behaves exactly as it does today rather than wearing a permanent smudge
   over its last column. */
.table-scroll::after {
  content: ""; flex: none; align-self: stretch;
  position: sticky; right: 0; width: 2.75rem; margin-left: -2.75rem;
  background: linear-gradient(to left, var(--ink) 18%, rgba(11,12,11,0));
  opacity: 0; transition: opacity 0.25s var(--ease); pointer-events: none;
}
.table-scroll[data-scroll-more="true"]::after { opacity: 1; }
.table { width: 100%; flex: 1 0 auto; border-collapse: collapse; min-width: 44rem; font-size: 0.9375rem; }
.table caption { text-align: left; color: var(--bone-faint); font-size: 0.875rem; padding-bottom: var(--s3); }
.table th, .table td { text-align: left; padding: var(--s3) var(--s3) var(--s3) 0; border-bottom: 1px solid var(--ink-3); vertical-align: top; }
.table thead th { font-size: 0.6875rem; letter-spacing: 0.16em; text-transform: uppercase; color: var(--bone-faint); font-weight: 500; }
.table tbody th { font-family: var(--serif); font-size: 1.1rem; font-weight: 400; }
.table tbody tr:hover { background: rgba(244,241,234,0.025); }
.table a { color: var(--brass); }
.table a:hover { color: var(--brass-soft); }

/* ---- reviews ----------------------------------------------------------- */

.review { display: flex; flex-direction: column; gap: var(--s3); padding: var(--s4); background: var(--ink-2); border: 1px solid var(--ink-3); border-radius: var(--radius-lg); height: 100%; }
.review__stars { color: var(--brass); letter-spacing: 0.15em; font-size: 0.875rem; }
.review__text { font-family: var(--serif); font-size: 1.1rem; line-height: 1.5; color: var(--bone); }
.review__who { margin-top: auto; padding-top: var(--s3); border-top: 1px solid var(--ink-3); font-size: 0.8125rem; color: var(--bone-faint); }
.review__who b { color: var(--bone-dim); font-weight: 500; }

.score { display: flex; align-items: baseline; gap: var(--s3); }
.score__n { font-family: var(--serif); font-size: clamp(3rem, 8vw, 4.5rem); line-height: 1; color: var(--brass); }

/* ---- FAQ --------------------------------------------------------------- */

.faq { border-top: 1px solid var(--ink-3); }
.faq details { border-bottom: 1px solid var(--ink-3); }
.faq summary {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s4);
  padding: var(--s4) 0; cursor: pointer; list-style: none;
  font-family: var(--serif); font-size: clamp(1.15rem, 2vw, 1.4rem); line-height: 1.3;
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after {
  content: ""; flex: none; width: 0.85rem; height: 0.85rem;
  border-right: 1px solid var(--brass); border-bottom: 1px solid var(--brass);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform 0.3s var(--ease);
}
.faq details[open] summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
.faq__a { padding-bottom: var(--s4); max-width: var(--measure); color: var(--bone-dim); }

/* ---- gallery ----------------------------------------------------------- */

/* Masonry via CSS columns. A grid would need per-image row spans computed in
   JS; columns get the same look from the intrinsic aspect ratios for free. */
.masonry { column-count: 1; column-gap: var(--s3); }
@media (min-width: 640px)  { .masonry { column-count: 2; } }
@media (min-width: 1040px) { .masonry { column-count: 3; } }
.masonry > * { break-inside: avoid; margin-bottom: var(--s3); }

.shot { position: relative; display: block; width: 100%; overflow: hidden; border-radius: var(--radius-lg); background: var(--ink-3); cursor: zoom-in; border: 0; padding: 0; }
.shot img { width: 100%; transition: transform 0.8s var(--ease-out), opacity 0.4s var(--ease); }
.shot:hover img { transform: scale(1.045); }
/* A brass hairline that draws itself around the frame on hover. */
.shot::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  box-shadow: inset 0 0 0 0 rgba(201,162,39,0.85);
  transition: box-shadow 0.4s var(--ease-out);
}
.shot:hover::after { box-shadow: inset 0 0 0 2px rgba(201,162,39,0.85); }

/* The property page's photo strip: one big frame, thumbnails beneath. */
[data-frames] { min-width: 0; }
.frames__main { position: relative; aspect-ratio: 3 / 2; overflow: hidden; border-radius: var(--radius-lg); background: var(--ink-3); }
.frames__main img { width: 100%; height: 100%; object-fit: cover; }
/* The 3px of padding is for the focus ring. An overflow container clips at
   its padding box, so the first thumbnail's outline-offset:3px was being
   sliced off on the left and along the top -- a keyboard user tabbing into
   the strip saw two and a half sides of a ring. The same brass scrollbar as
   the comparison table, for the same reason: thirteen thumbnails in a
   window that shows six, with nothing saying so. */
.frames__strip { display: grid; grid-auto-flow: column; grid-auto-columns: clamp(5rem, 11vw, 8rem); gap: var(--s2); margin-top: var(--s2); overflow-x: auto; padding: 3px 3px var(--s2); scroll-padding-inline: 3px; scrollbar-width: thin; scrollbar-color: var(--brass) var(--ink-3); }
.frames__strip button { padding: 0; border: 1px solid transparent; background: none; cursor: pointer; border-radius: var(--radius); overflow: hidden; aspect-ratio: 4 / 3; }
.frames__strip img { width: 100%; height: 100%; object-fit: cover; opacity: 0.55; transition: opacity 0.25s var(--ease); }
.frames__strip button[aria-current="true"] { border-color: var(--brass); }
.frames__strip button[aria-current="true"] img, .frames__strip button:hover img { opacity: 1; }

/* ---- lightbox ---------------------------------------------------------- */

/* `touch-action: pan-y` is what makes the swipe work, and it is not a
   nicety. With the default `auto` the compositor claims a horizontal drag as
   a pan and fires `pointercancel` instead of `pointerup`, so the handler in
   site.js -- which measures the distance on pointerup -- never runs. Driven
   with a real touch sequence (touchStart, eight touchMoves, touchEnd, 270px
   left over ~200ms) the shipped viewer answered pointerdown then
   pointercancel and stayed on photograph 4 of 78; with this one declaration
   it answers pointerdown then pointerup and moves to 5 of 78. There is
   nothing to pan horizontally in here anyway -- the dialog is exactly
   100vw -- so nothing is given up by telling the browser so, and pan-y
   rather than none leaves a vertical drag alone. */
.lightbox { border: 0; padding: 0; max-width: 100vw; max-height: 100vh; width: 100vw; height: 100dvh; background: rgba(6,7,6,0.97); color: var(--bone); touch-action: pan-y; }
.lightbox::backdrop { background: rgba(6,7,6,0.9); }
.lightbox__fig { margin: 0; display: grid; grid-template-rows: 1fr auto; height: 100%; padding: clamp(var(--s3), 4vw, var(--s5)); gap: var(--s3); }
/* `img { background: var(--ink-3) }` is a sensible global -- it keeps a
   photograph's frame from flashing the page ground before the file lands.
   In the viewer it is actively wrong: the <img> box is the whole cell and
   object-fit:contain letterboxes inside it, so a 2:3 portrait on a 1440px
   screen, or any landscape shot on a phone, sat in a grey slab several
   times its own area. The photograph should float on the viewer's ground,
   which is the only reason anyone opened it. */
.lightbox__fig img { width: 100%; height: 100%; object-fit: contain; min-height: 0; background: none; }
.lightbox__cap { text-align: center; font-size: 0.875rem; color: var(--bone-dim); }
.lightbox__cap span { display: block; color: var(--bone-faint); font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase; margin-top: var(--s1); }
.lightbox__btn { position: absolute; top: 50%; transform: translateY(-50%); background: rgba(11,12,11,0.7); border: 1px solid var(--ink-3); color: var(--bone); width: 3rem; height: 3rem; border-radius: 50%; cursor: pointer; display: grid; place-items: center; font-size: 1.1rem; transition: background 0.2s var(--ease); }
.lightbox__btn:hover { background: var(--ink-2); }
.lightbox__prev { left: clamp(var(--s2), 2vw, var(--s4)); }
.lightbox__next { right: clamp(var(--s2), 2vw, var(--s4)); }
.lightbox__close { position: absolute; top: clamp(var(--s2), 2vw, var(--s4)); right: clamp(var(--s2), 2vw, var(--s4)); transform: none; }
/* On a wide screen a portrait photograph leaves empty column either side
   and the arrows land in it. On a phone the opposite is true: a 3:2 shot is
   343px wide and 229 tall, the arrows are 48px pinned at the vertical
   centre, and they sit squarely on top of it. Moving them down to flank the
   caption puts them off the photograph and inside thumb reach at the same
   time. The caption is inset so its longest line cannot run under them. */
@media (max-width: 700px) {
  .lightbox__prev, .lightbox__next {
    top: auto; bottom: clamp(var(--s2), 2vw, var(--s4)); transform: none;
  }
  .lightbox__cap { padding-inline: 3.75rem; }
}

/* ---- forms ------------------------------------------------------------- */

.field { display: flex; flex-direction: column; gap: var(--s1); }
.field > label { font-size: 0.6875rem; letter-spacing: 0.16em; text-transform: uppercase; color: var(--bone-faint); }
.field input, .field select, .field textarea {
  background-color: var(--ink-3); border: 1px solid transparent; color: var(--bone);
  padding: 0.85em 0.9em; border-radius: var(--radius); width: 100%;
  line-height: 1.4;
  transition: border-color 0.2s var(--ease), background-color 0.2s var(--ease);
}
.field input:hover, .field select:hover, .field textarea:hover { border-color: rgba(244,241,234,0.18); }
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--brass); outline: none; background-color: #212421; }
/* The brass border alone is the mouse-focus treatment. A keyboard user gets
   the same ring as every other control on the site: the site-wide rule uses
   :where(), which has no specificity, so `.field input:focus { outline:
   none }` above was quietly winning and these were the only focusable
   things on the page with no ring at all. */
.field input:focus-visible, .field select:focus-visible, .field textarea:focus-visible {
  outline: 2px solid var(--brass); outline-offset: 2px;
}
.field textarea { resize: vertical; min-height: 7rem; }

/* A native <select> renders from the OS, not from these rules. Chrome took
   the dark fill but drew its own arrow and ignored line-height, so the
   guests field sat 7px shorter than the date fields beside it; Safari
   ignores the fill outright and puts an aqua system control in the middle
   of a near-black panel. appearance:none takes the box back, and the
   chevron is an inline SVG so it needs no request and no icon font.
   background-color rather than the background shorthand above and below,
   or the shorthand wipes the chevron out on focus. */
.field select {
  appearance: none; -webkit-appearance: none;
  padding-right: 2.6em;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.75 6 6.25 11 1.75' fill='none' stroke='%23b8b3a8' stroke-width='1.3' stroke-linecap='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 0.95em center;
  background-size: 0.72em auto;
}
.field select:hover, .field select:focus {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.75 6 6.25 11 1.75' fill='none' stroke='%23c9a227' stroke-width='1.3' stroke-linecap='round'/%3E%3C/svg%3E");
}
/* The picker indicator used to be inverted by hand because it was drawn
   black-on-black. `color-scheme: dark` on :root now makes the browser draw
   it light in the first place, so inverting it again would put it back
   where it started. */
.field input[type="date"]::-webkit-calendar-picker-indicator { cursor: pointer; opacity: 0.7; }
.field input[type="date"]:hover::-webkit-calendar-picker-indicator { opacity: 1; }
.field__err { font-size: 0.8125rem; color: var(--err); }
.field--err input, .field--err select { border-color: var(--err); }

.form-grid { display: grid; gap: var(--s3); }
@media (min-width: 620px) { .form-grid--2 { grid-template-columns: 1fr 1fr; } }

/* ---- booking panel ----------------------------------------------------- */

.booking {
  background: var(--ink-2); border: 1px solid var(--ink-3);
  border-radius: var(--radius-lg); padding: clamp(var(--s4), 3vw, var(--s5));
}
@media (min-width: 1024px) {
  /* Sticks alongside the long property copy. 6.5rem clears the header. */
  .booking--sticky { position: sticky; top: 6.5rem; }
}
.booking__rate { display: flex; align-items: baseline; gap: var(--s2); }
.booking__rate b { font-family: var(--serif); font-size: 2.2rem; font-weight: 400; }
.booking__save {
  display: flex; align-items: center; gap: var(--s2); margin-top: var(--s3);
  padding: var(--s2) var(--s3); border-radius: var(--radius);
  background: rgba(201,162,39,0.1); border: 1px solid rgba(201,162,39,0.25);
  font-size: 0.8125rem; color: var(--brass-soft);
}
.booking__rows { margin-top: var(--s4); display: grid; gap: var(--s2); }
.booking__row { display: flex; justify-content: space-between; gap: var(--s3); font-size: 0.9375rem; color: var(--bone-dim); }
.booking__row--total { margin-top: var(--s2); padding-top: var(--s3); border-top: 1px solid var(--ink-3); color: var(--bone); font-size: 1.05rem; }
/* A Guesty quote whose line items are all zero renders a summary that is
   nothing but the total, and the total's rule is written to sit under a
   list -- so the panel opened with a hairline across the top of it and a
   gap above that, as though the breakdown had failed to draw. There is
   nothing to rule off when it is the only row. */
.booking__row--total:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
.booking__row--total b { font-family: var(--serif); font-size: 1.5rem; font-weight: 400; }
.booking__note { margin-top: var(--s3); font-size: 0.8125rem; color: var(--bone-faint); }

.layout-booking { display: grid; gap: clamp(var(--s5), 5vw, var(--s6)); align-items: start; }
/* A grid item defaults to min-width:auto, which means its *content* sets the
   floor. The photo strip is a horizontal scroller whose intrinsic width is
   thirteen thumbnails wide, so without this the column -- and the whole page
   -- stretches to fit it instead of letting it scroll. */
.layout-booking > * { min-width: 0; }
@media (min-width: 1024px) { .layout-booking { grid-template-columns: minmax(0, 1.65fr) minmax(20rem, 0.85fr); } }

/* The property page is three parts, not two, and the order differs by
   viewport. On a phone the booking panel belongs between the photographs
   and the description: measured at 375px it previously began 2,655px down,
   3.3 screens, behind the whole about section, the highlights and the
   sleeping arrangements. Nobody scrolls that far to find out whether their
   dates are free.

   The DOM is in the phone's order -- media, panel, body -- so the reading
   order and the tab order agree, and nothing relies on `order`, which moves
   a control visually while leaving it where it was for a keyboard. Desktop
   rebuilds the old two-column layout with named areas instead: photographs
   and prose stack in the left column, the panel spans the right. */
.layout-booking--property { grid-template-areas: "media" "book" "body"; }
.layout-booking__media { grid-area: media; }
.layout-booking__book  { grid-area: book; }
.layout-booking__body  { grid-area: body; }
.layout-booking--property .layout-booking__body > * + * { margin-top: var(--s6); }

@media (min-width: 1024px) {
  .layout-booking--property {
    grid-template-areas: "media book" "body book";
    /* The right column must not stretch to the height of the left, or a
       sticky panel has nothing to slide against. */
    align-items: start;
  }
  /* The gap between the two rows of the left column was carried by a
     margin on the first body block; at this width the grid gap does it. */
  .layout-booking--property .layout-booking__body { margin-top: 0; }
}

/* Below the two-column breakpoint the panel is the first thing under the
   photographs, so it does not need the space a section normally gets. */
@media (max-width: 1023px) {
  .layout-booking--property { gap: var(--s5); }
}

/* ---- availability calendar --------------------------------------------- */

.cal__head { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); margin-bottom: var(--s3); }
.cal__title { font-family: var(--serif); font-size: 1.15rem; }
.cal__nav { display: flex; gap: var(--s1); }
.cal__nav button { width: 2.4rem; height: 2.4rem; border: 1px solid var(--ink-3); background: transparent; color: var(--bone); cursor: pointer; border-radius: var(--radius); }
.cal__nav button:hover:not([disabled]) { border-color: var(--brass); color: var(--brass); }
/* At 0.3 the chevron measured 2.5:1 and its border had all but vanished,
   so "you cannot go back past this month" read as "this control failed to
   render". Muted to the disabled text colour instead, with the frame left
   intact so it still looks like a button that is switched off. */
.cal__nav button[disabled] { color: var(--bone-faint); border-color: rgba(28,31,28,0.7); cursor: not-allowed; }

.cal__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.cal__dow { font-size: 0.625rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--bone-faint); text-align: center; padding-bottom: var(--s1); }
.cal__day {
  aspect-ratio: 1; display: grid; place-items: center; font-size: 0.8125rem;
  border: 0; background: var(--ink-3); color: var(--bone-dim);
  border-radius: var(--radius); cursor: pointer; position: relative;
  transition: background-color 0.15s var(--ease), color 0.15s var(--ease);
}
.cal__day[data-empty] { background: none; cursor: default; }
.cal__day[disabled] {
  color: var(--bone-faint); cursor: not-allowed; background: transparent;
  text-decoration: line-through; text-decoration-color: rgba(196,106,94,0.6);
}
.cal__day:hover:not([disabled]):not([data-empty]) { background: #2a2e2a; color: var(--bone); }
.cal__day[data-state="in-range"] { background: rgba(201,162,39,0.18); color: var(--bone); }
.cal__day[data-state="edge"] { background: var(--brass); color: #16140c; font-weight: 600; }
/* Seven square cells inside a padded panel inside a 375px screen come to
   39px a side, and a date cell is the most-tapped control on the site.
   Trading the square for a slightly tall cell buys the 44px in the
   direction that matters for a thumb, and pulling the panel's own padding
   in at the same width buys back another 2px across. A true 44 x 44 grid
   needs 320px of cells alone and does not fit; this is as close as the
   viewport allows. */
@media (max-width: 460px) {
  .booking { padding: var(--s3); }
  .cal__day { aspect-ratio: auto; min-height: 2.75rem; }
  .cal__nav button { width: 2.75rem; height: 2.75rem; }
}
.cal__legend { display: flex; flex-wrap: wrap; gap: var(--s3); margin-top: var(--s3); font-size: 0.75rem; color: var(--bone-faint); }
.cal__legend span { display: inline-flex; align-items: center; gap: 0.45em; }
.cal__legend i { width: 0.7rem; height: 0.7rem; border-radius: 2px; display: inline-block; }

/* ---- status messages --------------------------------------------------- */

.note { padding: var(--s3); border-radius: var(--radius); font-size: 0.875rem; border: 1px solid; }
.note--info { background: rgba(111,155,168,0.08); border-color: rgba(111,155,168,0.3); color: #a8c6cf; }
.note--warn { background: rgba(217,154,91,0.08); border-color: rgba(217,154,91,0.3); color: #e2b98c; }
.note--err  { background: rgba(196,106,94,0.08); border-color: rgba(196,106,94,0.35); color: #dfa197; }
.note--ok   { background: rgba(127,168,127,0.08); border-color: rgba(127,168,127,0.3); color: #a8c9a8; }

.spinner { display: inline-block; width: 1em; height: 1em; border: 2px solid rgba(244,241,234,0.25); border-top-color: var(--brass); border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* The blanket reduced-motion rule sets every animation to 0.01ms and one
   iteration, which leaves this as a ring frozen at an arbitrary angle. Say
   so deliberately instead: a static brass-topped ring, next to the words
   that were always carrying the actual message ("Getting your total...").
   The status text is the affordance under reduced motion, not the ring. */
@media (prefers-reduced-motion: reduce) {
  .spinner { animation: none; }
}

/* ---- footer ------------------------------------------------------------ */

.footer { background: #070807; border-top: 1px solid var(--ink-3); padding-block: var(--s7) var(--s5); }
.footer__grid { display: grid; gap: var(--s5); grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); }
.footer__grid > :first-child { grid-column: 1 / -1; }
@media (min-width: 900px) { .footer__grid > :first-child { grid-column: auto; flex: 1.5; } .footer__grid { grid-template-columns: 1.6fr repeat(3, 1fr); } }
.footer h2 { font-size: 0.6875rem; letter-spacing: 0.2em; text-transform: uppercase; color: var(--bone-faint); margin-bottom: var(--s3); }
.footer li + li { margin-top: var(--s2); }
.footer a { color: var(--bone-dim); font-size: 0.9375rem; transition: color 0.2s var(--ease); }
.footer a:hover { color: var(--brass); }
.footer__bottom { display: flex; flex-wrap: wrap; gap: var(--s3); justify-content: space-between; align-items: center; margin-top: var(--s6); padding-top: var(--s4); border-top: 1px solid var(--ink-3); font-size: 0.8125rem; color: var(--bone-faint); }
.social { display: flex; gap: var(--s3); }
.social a { display: grid; place-items: center; width: 2.2rem; height: 2.2rem; border: 1px solid var(--ink-3); border-radius: 50%; }
.social a:hover { border-color: var(--brass); }
.social svg { width: 1rem; height: 1rem; fill: currentColor; }

/* ---- breadcrumbs ------------------------------------------------------- */

.crumbs { display: flex; flex-wrap: wrap; gap: 0.5em; font-size: 0.8125rem; color: var(--bone-faint); padding-top: var(--s4); }
.crumbs a { color: var(--bone-dim); }
.crumbs a:hover { color: var(--brass); }
.crumbs li:not(:last-child)::after { content: "/"; margin-left: 0.5em; color: var(--ink-3); }
.crumbs li { display: inline; }

/* ---- reveal on scroll -------------------------------------------------- */

/* Opt-in only, and applied by JS adding .reveal-on. Without JS, or with
   reduced motion, everything is simply visible -- content is never hidden
   behind an animation that might not run. */
.reveal-on [data-reveal] {
  /* 22px until 2026-09-29, and the owner's verdict on the result was that
     the site had no animation in it at all. He was right about what he saw:
     a block that moves a fifth of an inch over most of a second, under a
     curve that spends its last third almost stationary, is a transition you
     can only detect by looking for it. The travel is what carries the
     gesture; the easing is what keeps it from reading as cheap. */
  opacity: 0; transform: translateY(44px);
  transition: opacity 1.05s var(--ease-out), transform 1.05s var(--ease-out);
  /* --lead is set by JS, and only on the handful of blocks that are already
     on screen when the page loads while a hero entrance is also running.
     Without it those two sequences start together: on a 58svh hero at
     900px the first section is in view at load, so its reveal finished at
     0.9s while the hero's own figures were still arriving at 1.5s, and the
     page appeared to be presenting itself bottom-up. It stays 0 for
     everything scrolled to later, which is all of it in normal use. */
  transition-delay: var(--lead, 0ms);
}
.reveal-on [data-reveal].is-in { opacity: 1; transform: none; }

/* Children of a revealing container come in one after another rather than
   all at once. --i is set by JS. A row of five property cards landing
   together reads as a page loading; landing in sequence reads as a page
   being presented. 70ms is the smallest gap that still registers as
   deliberate. */
.reveal-on [data-stagger] > * {
  opacity: 0; transform: translateY(52px);
  transition: opacity 1s var(--ease-out), transform 1s var(--ease-out);
  transition-delay: calc(var(--lead, 0ms) + var(--i, 0) * 90ms);
}
.reveal-on [data-stagger].is-in > * { opacity: 1; transform: none; }

/* Photographs settle out of a slight over-scale as they arrive. The frame
   is already the right size, so nothing moves on the page -- only the image
   inside it, which is why this does not cost any layout shift. */
.reveal-on [data-reveal] .card__media img,
.reveal-on [data-reveal] .feature__media img,
.reveal-on [data-stagger] .card__media img {
  transform: scale(1.12);
  transition: transform 1.7s var(--ease-out);
}
.reveal-on [data-reveal].is-in .card__media img,
.reveal-on [data-reveal].is-in .feature__media img,
.reveal-on [data-stagger].is-in .card__media img { transform: none; }
/* The hover zoom has to win once the entrance has finished. */
.card:hover .card__media img,
.card:focus-within .card__media img { transform: scale(1.06) !important; }

@media (prefers-reduced-motion: reduce) {
  .reveal-on [data-reveal],
  .reveal-on [data-stagger] > * { opacity: 1; transform: none; transition: none; }
  .reveal-on [data-reveal] .card__media img,
  .reveal-on [data-reveal] .feature__media img,
  .reveal-on [data-stagger] .card__media img { transform: none; transition: none; }
}

/* ---- hero entrance ------------------------------------------------------ */

/* The hero is above the fold, so it cannot wait for an IntersectionObserver.
   It animates on load, staggered down the stack: label, headline, sentence,
   buttons, then the figures. `backwards` holds the start frame during the
   delay so nothing flashes at full opacity first. */
@keyframes heroRise {
  from { opacity: 0; transform: translateY(44px); }
  to   { opacity: 1; transform: none; }
}
/* The headline gets its own entrance rather than a longer share of the same
   one. A fade and a rise is what every block on the page does; the one line
   the whole page is built around should arrive differently from the things
   arranged under it, and an editorial reveal -- the type uncovered from the
   baseline up, as though a card were being drawn out from over it -- is the
   gesture that suits display serif at 6.5rem.
   The end state is a negative inset rather than zero on every side, because
   a clip at exactly the box edge shaves the descenders and the wide soft
   text-shadow that .hero__inner uses to stay readable over a bright sky. */
@keyframes heroLift {
  from { opacity: 0; transform: translateY(70px); clip-path: inset(0 0 100% 0); }
  55%  { opacity: 1; }
  to   { opacity: 1; transform: none; clip-path: inset(-25% -25% -25% -25%); }
}
/* A brass hairline drawing out of the eyebrow, the same device the section
   heads use further down the page. Making the hero's label and a section's
   label behave the same way is most of what makes a set of pages read as one
   site rather than as a template with photographs in it. */
@keyframes ruleDraw {
  from { transform: scaleX(0); }
  to   { transform: none; }
}
.hero__inner > .eyebrow  { animation: heroRise 1s var(--ease-out) 0.10s backwards; }
.hero__inner > h1        { animation: heroLift 1.5s var(--ease-out) 0.20s backwards; }
.hero__inner > .hero__lede { animation: heroRise 1.05s var(--ease-out) 0.44s backwards; }
.hero__inner > .hero__actions { animation: heroRise 1.05s var(--ease-out) 0.58s backwards; }
.hero__inner > .hero__facts { animation: heroRise 1.05s var(--ease-out) 0.70s backwards; }
.hero .eyebrow::after {
  content: ""; display: inline-block; vertical-align: middle;
  width: clamp(1.5rem, 5vw, 3.5rem); height: 1px; margin-left: 0.9rem;
  background: currentColor; opacity: 0.55; transform-origin: left;
  animation: ruleDraw 1.2s var(--ease-out) 0.55s backwards;
}
/* The per-figure delay used to read --i, which is written by the same JS
   pass that stages the scroll reveals -- so the hero's entrance depended on
   a script that runs for everything *below* the fold, and won only because
   the module happened to execute before first paint. It is five elements at
   most; nth-child says it in CSS and the hero stops needing JS at all.
   site.js correspondingly no longer marks .hero__facts as a stagger group:
   with both in play these figures carried a keyframe animation and a
   transition driving the same two properties on two different clocks. */
.hero__facts .fact { animation: heroRise 0.95s var(--ease-out) 0.74s backwards; }
.hero__facts .fact:nth-child(2) { animation-delay: 0.84s; }
.hero__facts .fact:nth-child(3) { animation-delay: 0.94s; }
.hero__facts .fact:nth-child(4) { animation-delay: 1.04s; }
.hero__facts .fact:nth-child(n+5) { animation-delay: 1.14s; }
/* The figures count up to themselves, and the count is site.js's, not this
   file's. Two things here make that safe to do at all: the box is frozen at
   the width of the finished number before the first frame, so a figure going
   from one digit to two cannot push the figure beside it along; and tabular
   figures mean the width does not twitch between, say, a 1 and a 4 on the
   way there. Both matter more than they sound -- these sit in a flex row
   directly under the headline, and a hero that jitters while it counts is
   worse than a hero that does not count. */
.fact__n { display: inline-block; font-variant-numeric: tabular-nums; }

/* The photograph itself settles from a slight over-scale. One pass, no
   loop, no pan: a still that stops moving, not a video.

   Only the first image in the frame gets it. The hero also crossfades
   between photographs (see .hero__plate below), and a settle on every plate
   would be a scale running on a loop -- which is a pan, which is the thing
   this comment says it is not. The plates arrive by opacity alone. */
@keyframes heroSettle {
  from { transform: scale(1.06); }
  to   { transform: none; }
}
.hero__media > img:not(.hero__plate) { animation: heroSettle 2.4s var(--ease-out) backwards; }

/* This is the opt-out and it has to be `!important`, which is not laziness.
   `.hero__inner > *` scores 0-1-0 and the rules it is trying to cancel score
   0-2-0 and 0-1-1 (`.hero__inner > .eyebrow`, `.hero__inner > h1`), so it
   lost the cascade and did nothing at all. What made that invisible is the
   blanket rule at the top of this file: it forces every animation to
   0.01ms, so the hero *looked* static -- except that it does not touch
   animation-delay, and these animations fill `backwards`. A visitor who had
   asked for reduced motion got the eyebrow blank for 100ms, the headline
   for 180, the sentence for 340, the buttons for 460 and the figures for
   580, each snapping in at full opacity: not the staggered entrance, but a
   strobe of it. Enumerating the five selectors again would work and would
   rot the moment a sixth child is added to the hero. */
@media (prefers-reduced-motion: reduce) {
  .hero__inner > *, .hero__facts .fact, .hero__media img { animation: none !important; }
  /* A pseudo-element of a child of .hero__inner is not a child of
     .hero__inner, so the rule above does not reach the eyebrow's hairline.
     Left uncancelled it would sit at scaleX(0) for 550ms and then snap to
     full width -- which is the exact defect the blanket 0.01ms rule hides
     and audit_reduced_motion exists to catch. */
  .hero .eyebrow::after { animation: none !important; transform: none !important; }
}

/* ---- FAQ answer --------------------------------------------------------- */

/* A <details> cannot transition its own disclosure, but an animation does
   run when the content goes from display:none to rendered, so the answer
   fades in even though the collapse is instant. */
@keyframes faqIn {
  from { opacity: 0; transform: translateY(-6px); }
  to   { opacity: 1; transform: none; }
}
.faq details[open] .faq__a { animation: faqIn 0.4s var(--ease-out) both; }
@media (prefers-reduced-motion: reduce) { .faq details[open] .faq__a { animation: none; } }

/* =============================================================================
   The interaction layer

   Everything below is built by site.js against markup the templates already
   emit -- there is no new HTML in content/ or tools/pages.py for any of it.
   That is partly lane discipline and partly the same principle the rest of
   this file follows: a page whose JavaScript never arrives is the page the
   templates describe, with nothing missing and nothing hidden.

   Each component therefore has a resting state that is correct on its own,
   and the moving state is opt-in behind a class or an attribute that only
   JavaScript sets.
   ========================================================================== */

/* ---- hero: crossfading plates ------------------------------------------ */

/* The hero was one still photograph with type on it, which is the most
   generic thing a property site can do. It now crossfades between the
   photographs of the homes the page is already showing further down.

   The base <img> is untouched: it keeps its eager load, its fetchpriority
   and its place as the Largest Contentful Paint element. Plates are added
   after the load event and sit above it, so nothing here is in the critical
   path. They are opacity only -- no scale, no pan -- because a scale on a
   loop is a Ken Burns drift and the brief for this site rules that out.

   A plate is never the only thing on screen: the base photograph is always
   underneath it, so a plate that fails to load or fails to fade leaves the
   hero exactly as it ships. */
.hero__plate {
  position: absolute; inset: 0;
  width: 100%; height: 100%; object-fit: cover;
  opacity: 0;
  transition: opacity 1.8s var(--ease);
}

/* The photograph selector. It is also the stop control: WCAG 2.2.2 wants a
   way to halt anything that moves on its own for more than five seconds,
   and site.js stops the rotation for good the moment one of these is used.
   The visible mark is a 28px hairline; the 44px tap target is padding. */
.hero__dots {
  position: absolute; z-index: 3;
  right: var(--gutter); bottom: clamp(var(--s3), 3vh, var(--s5));
  display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.25rem;
  max-width: calc(100% - 2 * var(--gutter));
}
.hero__dots button {
  appearance: none; background: none; border: 0; cursor: pointer;
  width: 44px; height: 44px; padding: 0;
  display: grid; place-items: center;
}
.hero__dots button::before {
  content: ""; display: block; width: 1.75rem; height: 2px;
  background: rgba(244,241,234,0.45);
  /* These are the only controls on the site that sit directly on an
     unpredictable photograph -- the rotation reaches five of them -- and a
     2px bone hairline disappears entirely against a pale sky. The shadow
     is what keeps the unselected marks findable there; it costs no layout
     and is invisible over the dark half of every other shot. The scrim
     cannot be asked to do this job: it is tuned by audit_contrast.py
     against the text, and darkening it to rescue a hairline would flatten
     every hero on the site. */
  filter: drop-shadow(0 1px 1.5px rgba(0,0,0,0.75));
  transition: background-color 0.5s var(--ease), transform 0.5s var(--ease-out);
  transform: scaleY(1); transform-origin: center;
}
.hero__dots button:hover::before { background: rgba(244,241,234,0.75); }
.hero__dots button[aria-current="true"]::before {
  background: var(--brass); transform: scaleY(1.5);
}
/* On a phone the hero text runs the full width and the figures wrap, so the
   bottom right corner is no longer empty. The selector moves to the top of
   the photograph, under the header, rather than the hero growing to make
   room for it: adding padding to the hero after load would be a layout
   shift, and the hero is the element the layout shift score watches. */
@media (max-width: 700px) {
  .hero__dots {
    right: calc(var(--gutter) - 10px); bottom: auto; top: calc(5rem + var(--s1));
  }
}

/* ---- chips: the filter controls ---------------------------------------- */

.chips { display: flex; gap: var(--s2); min-width: 0; }
.chip {
  appearance: none; cursor: pointer; flex: none;
  display: inline-flex; align-items: center; gap: 0.6em;
  min-height: 44px; padding: 0.5em 1.15em;
  background: transparent; color: var(--bone-dim);
  border: 1px solid var(--ink-3); border-radius: 999px;
  font-size: 0.75rem; font-weight: 500;
  letter-spacing: 0.14em; text-transform: uppercase; white-space: nowrap;
  transition: color 0.3s var(--ease), border-color 0.3s var(--ease),
              background-color 0.3s var(--ease);
}
.chip:hover { color: var(--bone); border-color: rgba(244,241,234,0.3); }
/* Tabular figures so a count changing from 9 to 78 does not shift the label
   beside it. */
.chip b {
  font-weight: 400; font-size: 0.6875rem; color: var(--bone-faint);
  font-variant-numeric: tabular-nums;
  transition: color 0.3s var(--ease);
}
.chip[aria-pressed="true"] {
  color: var(--brass); border-color: rgba(201,162,39,0.5);
  background: rgba(201,162,39,0.08);
}
.chip[aria-pressed="true"] b { color: var(--brass-soft); }
/* Where you are, as opposed to what you chose. The gallery bar shows both at
   once -- "All" is pressed while the section under the scroll is marked --
   so they cannot share a treatment. */
.chip[data-here="true"] { color: var(--bone); }
.chip[data-here="true"][aria-pressed="false"] { border-color: rgba(201,162,39,0.3); }

/* There was a second filter here -- a chip bar of group sizes over the
   property grid, with its own live count and its own hint. It is gone, and
   the rules that dressed it went with it. The stay finder does the same
   job on the same page from the same inventory, with two selects, a status
   line and a shareable URL; two controls filtering one grid is a worse
   answer than one, whichever of them is better. What the filter bar knew
   that was worth keeping is below: how a card leaves a grid, and what the
   grid has to be for the survivors to still look like cards. */

/* `hidden` is how a filtered-out card leaves the grid, and the UA rule that
   implements it loses to `.card { display: flex }` on source order alone --
   so a filtered card stayed exactly where it was. Any component that hides
   a child it has also given a `display` to needs a line in here. */
.card[hidden], .grid > [hidden] { display: none; }

/* ---- the gallery bar --------------------------------------------------- */

/* 78 photographs behind one click, grouped into eight sections you could
   only reach by scrolling. The bar filters them and, while everything is
   showing, says which property is currently under the scroll -- the sticky
   property name, without a second sticky layer stacking under the header.

   Sticky rather than fixed, deliberately: a fixed bar would need a
   containing-block check of every ancestor, which is the trap that already
   cost this project a working mobile menu. */
.gallerybar {
  position: sticky; top: 5rem; z-index: 40;
  background: var(--ink); border-bottom: 1px solid var(--ink-3);
  padding-block: var(--s3);
}
/* On a phone the header and this bar together are already 156px of the
   viewport before a photograph gets to show. The chips cannot shrink --
   they are the tap targets -- so the padding is what gives. */
@media (max-width: 700px) { .gallerybar { padding-block: var(--s2); } }
.gallerybar .chips {
  overflow-x: auto; -webkit-overflow-scrolling: touch;
  /* 3px of room so the focus ring of the first and last chip is not sliced
     off by the scroll container's padding box, the same fix the property
     page's thumbnail strip carries. */
  padding: 3px; margin: -3px;
  scroll-padding-inline: 3px;
  scrollbar-width: thin; scrollbar-color: var(--brass) var(--ink-3);
}
/* Nine chips fit on no phone and not always on a laptop, and macOS hides an
   overlay scrollbar until you are already scrolling -- which is exactly how
   the comparison table used to lose its last two columns. Same fix: a fade
   that is a sticky flex item pinned to the right of the scrollport and
   pulled back over the chips by its own width, so it costs no layout, and
   opt-in on an attribute site.js clears once there is nothing more to see.
   A permanent smudge over the last chip would be worse than no hint. */
.gallerybar .chips::after {
  content: ""; flex: none; align-self: stretch;
  position: sticky; right: 0; width: 2.5rem; margin-left: -2.5rem;
  background: linear-gradient(to left, var(--ink) 25%, rgba(11,12,11,0));
  opacity: 0; transition: opacity 0.25s var(--ease); pointer-events: none;
}
.gallerybar .chips[data-scroll-more="true"]::after { opacity: 1; }
/* One photograph per section is promoted out of the masonry and run wide
   above it. A gallery of 78 tiles at one size is an archive; an
   establishing shot and then the grid is an edit. */
.gallery-lead { margin-bottom: var(--s3); }
.gallery-lead .shot { aspect-ratio: 2.4 / 1; }
.gallery-lead .shot img { width: 100%; height: 100%; object-fit: cover; }
@media (max-width: 700px) { .gallery-lead .shot { aspect-ratio: 16 / 10; } }

/* ---- the cursor over a photograph -------------------------------------- */

/* There was a brass disc here that trailed the pointer over anything which
   opens the viewer, with `cursor: none` on those elements so it replaced
   the native arrow rather than following it around beside it. It is not
   coming back and its rules are deleted rather than left dormant.

   Two reasons, and the second is the one that decided it. It was the only
   thing on the site that moved continuously, which is the opposite of the
   brief -- motion here is meant to read as something heavy coming to rest,
   and a disc interpolating toward the pointer every frame never comes to
   rest at all. And `cursor: none` is a bet that the disc is painting: the
   class went on the document, so any later failure in the same script --
   an exception, a blocked file, a browser that does not do `translate` as
   an independent property -- leaves a reader with no pointer at all over
   the photographs, which is the one region of the page where they most
   need to see where they are aiming. The affordance it was providing is
   already carried by things that cannot fail that way: the brass hairline
   that draws on a gallery tile at hover, and the "1 / 13" pill on the
   property page's big frame. */

/* ---- the property photo strip ------------------------------------------ */

/* The big frame is now the way into the viewer, so it needs to look like a
   control. The button covers the frame for the click target; the pill is
   the visible affordance and doubles as the counter, which is the thing
   that tells you thirteen photographs exist. */
.frames__main { isolation: isolate; }
.frames__open {
  position: absolute; inset: 0; z-index: 2;
  display: flex; align-items: flex-end; justify-content: flex-end;
  padding: var(--s3);
  background: none; border: 0; cursor: pointer;
}
.frames__count {
  display: inline-flex; align-items: center; gap: 0.6em;
  min-height: 2.25rem; padding: 0.4em 0.9em;
  background: rgba(11,12,11,0.76);
  border: 1px solid rgba(244,241,234,0.14); border-radius: 999px;
  font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.16em;
  text-transform: uppercase; color: var(--bone);
  font-variant-numeric: tabular-nums;
  transition: border-color 0.3s var(--ease), background-color 0.3s var(--ease);
}
.frames__count::after {
  content: ""; width: 0.55rem; height: 0.55rem;
  border-top: 1px solid var(--brass); border-right: 1px solid var(--brass);
  transform: rotate(45deg);
  transition: transform 0.35s var(--ease-out);
}
.frames__open:hover .frames__count {
  background: rgba(11,12,11,0.9); border-color: rgba(201,162,39,0.6);
}
.frames__open:hover .frames__count::after { transform: translateX(2px) rotate(45deg); }

/* ---- the viewer -------------------------------------------------------- */

/* Where you are in a set of 78. The caption already says "12 of 78" in
   words; this says the same thing in a shape you can read without reading. */
.lightbox__bar {
  position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
  background: rgba(244,241,234,0.1);
}
.lightbox__bar i {
  display: block; height: 100%; background: var(--brass);
  transform: scaleX(0); transform-origin: left;
  transition: transform 0.5s var(--ease-out);
}

/* ---- scroll progress --------------------------------------------------- */

/* A hairline along the bottom edge of the header. It is drawn with
   background-size rather than a transform on purpose: a transform on the
   header itself is the bug that collapsed the mobile nav to a sliver, and
   although a transform on a *child* of the header is harmless, a rule that
   has to be explained before it can be read is a rule that gets copied
   wrong later. Painting a 1px strip costs nothing either way.

   This one keeps working under reduced motion. It does not animate: it
   reports where the reader already is, and moves only when they scroll. */
.header__progress {
  position: absolute; left: 0; right: 0; bottom: -1px; height: 1px;
  background-image: linear-gradient(to right, var(--brass), var(--brass-soft));
  background-repeat: no-repeat;
  background-size: var(--scroll, 0%) 100%;
  pointer-events: none;
}

/* ---- section transitions ----------------------------------------------- */

/* A brass hairline that draws itself out of the eyebrow as a section
   arrives. It is the cheapest possible section transition and the only one
   that suits the material: the alternative -- sliding whole sections around
   -- fights with photographs that are trying to be still.

   Resting state is drawn, not undrawn. The undrawn state only exists under
   .reveal-on, which site.js adds and reduced motion never gets, so a reader
   with JavaScript blocked sees a finished rule rather than no rule. */
.section-head .eyebrow::after {
  content: ""; display: inline-block; vertical-align: middle;
  width: clamp(1.25rem, 4vw, 2.75rem); height: 1px; margin-left: 0.85rem;
  background: currentColor; opacity: 0.5;
  transform-origin: left;
  transition: transform 0.9s var(--ease-out) 0.2s;
}
.reveal-on [data-reveal] .section-head .eyebrow::after,
.reveal-on .section-head[data-reveal] .eyebrow::after { transform: scaleX(0); }
.reveal-on [data-reveal].is-in .section-head .eyebrow::after,
.reveal-on .section-head[data-reveal].is-in .eyebrow::after { transform: scaleX(1); }

/* ---- the crest: the mark drawing itself -------------------------------- */

/* The one moment on the site with real presence, and it is placed where it
   can afford to be: at the head of the footer, below every page, well past
   the Largest Contentful Paint and past anything a reader is waiting for.

   It is a full-width brass hairline with the brand mark sitting on it, and
   when the footer arrives the hairline extends out of the mark toward both
   page edges while the mark draws itself, line by line, in the order a hand
   would draw it. Then it stops, and stays drawn. No loop.

   The rule and the mark are one line, not two things near each other. The
   mark's own baseline is a horizontal stroke at y=303.56 of a 307.46-tall
   drawing, so the hairline is positioned at that fraction of the mark's
   height and given the same thickness the mark's stroke resolves to at the
   same width -- 6/719.33 of it. At 240px across that is a 2px line meeting
   a 2px line, continuous, rather than a 1px rule butting into a 2px stroke
   a pixel too high, which is the sort of detail nobody can name and
   everybody can see.

   The rules fade toward the page edges rather than running full strength,
   because a 2px brass bar across the full width of a page is a divider out
   of a template; a line that arrives out of the mark and dissolves is the
   mark extending itself.

   site.js builds all of this. There is no crest in the templates, so a page
   with JavaScript blocked has a footer that starts with its columns, which
   is what it had before. */
.crest {
  display: flex; align-items: flex-end; gap: 0;
  color: var(--brass);
  margin-bottom: var(--s7);
  /* --crest-w is the mark's width and everything else is derived from it. */
  --crest-w: clamp(140px, 24vw, 256px);
  --crest-rule: max(1px, calc(var(--crest-w) * 6 / 719.33));
}
.crest__rule {
  flex: 1 1 0; min-width: 0;
  height: var(--crest-rule);
  /* Sits on the mark's baseline: the drawing's last 3.90 units of height
     are below it. */
  margin-bottom: calc(var(--crest-w) * (307.46 - 303.56 - 3) / 719.33);
  background: linear-gradient(to right, rgba(201,162,39,0) 0%,
                              rgba(201,162,39,0.55) 88%, rgba(201,162,39,0.55) 100%);
  transform: scaleX(0); transform-origin: right;
  transition: transform 1.1s var(--ease-out) 0.45s;
}
.crest__rule:last-child {
  background: linear-gradient(to left, rgba(201,162,39,0) 0%,
                              rgba(201,162,39,0.55) 88%, rgba(201,162,39,0.55) 100%);
  transform-origin: left;
}
.crest.is-in .crest__rule { transform: none; }
.crest__mark { flex: none; width: var(--crest-w); margin-inline: clamp(0.75rem, 2vw, 1.5rem); }
.crest__mark svg { width: 100%; height: auto; display: block; }
/* --len is the path's own measured length and --dur is that length at a
   constant pen speed, so five strokes of very different lengths look like
   one hand rather than five. Both are written by site.js; the fallback
   values here are only so the rule is meaningful if it is ever read before
   they land. */
.crest__mark path {
  stroke-dasharray: var(--len, 0);
  stroke-dashoffset: var(--len, 0);
  transition: stroke-dashoffset var(--dur, 1.2s) var(--ease-out) var(--delay, 0s);
}
.crest.is-in .crest__mark path { stroke-dashoffset: 0; }

/* The blanket reduced-motion rule at the top of this file flattens the
   transition to 0.01ms, which is not enough on its own: a dash offset of a
   full path length is a resting state that shows nothing, so a reader who
   never triggers .is-in would be looking at a gap where the mark is. The
   offset has to be cancelled outright, and site.js does not arm the dash
   under the preference either. Belt and braces, in the order that matters:
   this rule is the one that is true even if the script never runs. */
@media (prefers-reduced-motion: reduce) {
  .crest__mark path {
    stroke-dasharray: none !important; stroke-dashoffset: 0 !important;
    transition: none !important;
  }
  .crest__rule { transform: none !important; transition: none !important; }
}

/* ---- photographs developing --------------------------------------------- */

/* The feature rows -- the alternating photograph-and-paragraph blocks that
   carry the estate page, the weddings page and each property -- reveal
   along a soft horizontal edge that travels up the frame, the way a print
   comes up in a tray. It replaces the generic fade-and-rise on those blocks
   rather than joining it: a photograph that slides, fades and develops at
   once is three entrances, and the one that suits a photograph is the one
   that only ever happens to photographs.

   The travelling edge is a registered custom property, which is the only
   way a gradient stop can be transitioned -- an unregistered --develop is
   substituted as a token and jumps. Where @property is not implemented it
   jumps to the end value instead of animating, which is a cut to the
   finished photograph: the same thing reduced motion gets, and never a
   photograph that fails to appear.

   site.js sets data-develop; the templates emit none, so without JavaScript
   there is no mask on anything. */
@property --develop {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}
.reveal-on [data-develop] {
  --develop: 0%;
  -webkit-mask-image: linear-gradient(to top, #000 calc(var(--develop) - 30%),
                                      rgba(0,0,0,0) var(--develop));
          mask-image: linear-gradient(to top, #000 calc(var(--develop) - 30%),
                                      rgba(0,0,0,0) var(--develop));
  transition: --develop 1.6s var(--ease-out) var(--lead, 0ms);
}
.reveal-on [data-develop].is-in { --develop: 130%; }
/* The same settle the cards get: the frame is already its final size, so
   only the photograph inside it moves and the entrance costs no layout
   shift. */
.reveal-on [data-develop] img {
  transform: scale(1.11);
  transition: transform 2.1s var(--ease-out) var(--lead, 0ms);
}
.reveal-on [data-develop].is-in img { transform: none; }
@media (prefers-reduced-motion: reduce) {
  .reveal-on [data-develop] {
    --develop: 130%; mask-image: none; -webkit-mask-image: none; transition: none;
  }
  .reveal-on [data-develop] img { transform: none; transition: none; }
}

/* ---- hover states that had been left at the default -------------------- */

/* The card foot: a brass rule that draws across as the card is pointed at,
   and a chevron where the eye already is at the end of the line. Both are
   drawn rather than typed so there is no stray glyph in the accessibility
   tree, and both survive the nightly rate being taken off the card -- with
   the price gone the foot is a spec line and an arrow, which is a cleaner
   row than it was with three things in it. */
.card__foot { position: relative; }
.card__foot::before {
  content: ""; position: absolute; left: 0; right: 0; top: -1px; height: 1px;
  background: var(--brass);
  transform: scaleX(0); transform-origin: left;
  transition: transform 0.6s var(--ease-out);
}
.card:hover .card__foot::before, .card:focus-within .card__foot::before { transform: scaleX(1); }
.card__foot::after {
  content: ""; flex: none; align-self: center;
  width: 0.45rem; height: 0.45rem;
  border-top: 1px solid var(--brass); border-right: 1px solid var(--brass);
  opacity: 0.4; transform: translateX(-3px) rotate(45deg);
  transition: transform 0.45s var(--ease-out), opacity 0.35s var(--ease);
}
.card:hover .card__foot::after, .card:focus-within .card__foot::after {
  opacity: 1; transform: translateX(1px) rotate(45deg);
}

.pill { transition: color 0.25s var(--ease), border-color 0.25s var(--ease), background-color 0.25s var(--ease); }
.pill:hover { color: var(--bone); border-color: rgba(201,162,39,0.45); background: var(--ink-3); }

.amenity { transition: border-color 0.3s var(--ease); }
.amenity:hover { border-top-color: rgba(201,162,39,0.5); }
.amenity__t { transition: color 0.25s var(--ease); }
.amenity:hover .amenity__t { color: var(--brass-soft); }

.distance { transition: border-color 0.3s var(--ease); }
.distance:hover { border-bottom-color: rgba(201,162,39,0.5); }
.distance__n { transition: color 0.25s var(--ease); }
.distance:hover .distance__n { color: var(--brass-soft); }

.review { transition: border-color 0.35s var(--ease), background-color 0.35s var(--ease); }
.review:hover { border-color: rgba(201,162,39,0.32); background: #171917; }

.faq summary { transition: color 0.25s var(--ease); }
.faq summary:hover { color: var(--brass-soft); }

/* ---- reduced motion ---------------------------------------------------- */

/* The blanket rule at the top of this file flattens every transition to
   0.01ms, which covers the hover states above. What it does not cover is
   anything whose resting state is mid-animation, and there are three of
   those here: the hero plates, the section hairline, and the stay finder's
   reflow.

   The plates matter most. heroPlates() in site.js checks the preference
   and never builds one, so in practice these two rules never match
   anything -- they are here for the case where they do. A plate rests at
   `opacity: 0` and is brought up by a transition; flattened to 0.01ms that
   is not "no animation", it is an instant cut between photographs every
   seven seconds, which is a worse thing to show somebody who asked for
   less movement than the slow crossfade was. Removing the element is the
   only honest reduction, and with it gone the dot selector is a control
   for something that is not happening. */
@media (prefers-reduced-motion: reduce) {
  .hero__plate { display: none; }
  .hero__dots { display: none; }
  .section-head .eyebrow::after { transform: none !important; transition: none; }
  .lightbox__bar i { transition: none; }
  /* Belt and braces: reflow() already refuses to run under the preference,
     so no element should ever carry this. */
  .stay-finder__results.is-reflowing > * { transition: none; transform: none; }
  /* The bar stays -- it is the only report a visitor gets that a four-second
     search is still running, and removing it would leave them with a sentence
     and no sign of life. What goes is the tween: it steps to each measured
     position instead of sliding to it, which is information without motion.
     stayFinder() reads the same preference and drops the bar's exit delay. */
  .stay-finder__bar { transition: none; }
}

/* Keep the measured hero's layout intact. The small index and scroll link
   occupy its unused edges and have their own dark ground; they do not rely
   on the image being dark at a new, unaudited text position. */
.hero--home { min-height: 100svh; }
.hero--home .hero__inner { padding-top: max(9rem, 12vh); }
.hero--home .hero__actions { max-width: 42rem; }
.hero__index, .hero__scroll {
  position: absolute; z-index: 2;
  display: flex; align-items: center; gap: var(--s3);
  color: #f4f1ea; background: rgba(11,12,11,0.78);
  font-size: 0.6875rem; font-weight: 500;
  line-height: 1.5; letter-spacing: 0.18em; text-transform: uppercase;
}
.hero__index {
  right: var(--gutter); top: 6.5rem;
  max-width: calc(100% - 2 * var(--gutter));
  padding: 0.7rem 1rem; border: 1px solid rgba(244,241,234,0.25);
}
.hero__index span + span { padding-left: var(--s3); border-left: 1px solid rgba(244,241,234,0.35); }
.hero__scroll {
  left: var(--gutter); bottom: var(--s3);
  min-height: 44px; max-width: calc(100% - 2 * var(--gutter));
  padding: 0.6rem 1rem;
}
.hero__scroll::after {
  content: ""; width: 0.5rem; height: 0.5rem; flex: none;
  border-right: 1px solid currentColor; border-bottom: 1px solid currentColor;
  transform: rotate(45deg); margin-top: -0.25rem;
  transition: transform 0.35s var(--ease-out);
}
.hero__scroll { transition: background-color 0.3s var(--ease); }
.hero__scroll:hover { background: var(--pine); }
.hero__scroll:hover::after { transform: rotate(45deg) translate(3px, 3px); }

@media (max-width: 700px) {
  .hero__index { left: var(--gutter); right: auto; top: 6rem; font-size: 0.625rem; }
  /* The home hero's selector used to be pulled back down to the bottom
     right here, and that put it straight through the scroll cue. Six 44px
     targets are 264px; at 375px the gutter leaves them starting at x=92,
     and "Discover life at the lake" runs to x=180. They overlapped by 88px
     and the cue won, because it is later in the DOM -- so two of the dots
     were unclickable on every phone.
     The general rule above already moves the selector to the top right
     under the header, which is empty on this hero: .hero__index is the
     only other thing that lives up there, it is not on the home page, and
     on a phone it moves to the left anyway. So the override simply goes,
     and the dots and the cue each get an end of the photograph. */
  .hero--home .hero__scroll { max-width: 48%; font-size: 0.625rem; padding-inline: 0.75rem; }
}
@media (max-width: 560px) {
  .hero--home .hero__inner { padding-bottom: 5.5rem; }
}
@media (prefers-reduced-motion: reduce) {
  /* Existing reveals and button effects already honour this preference.
     Cards also have a hover zoom with !important, so neutralise that at the
     same priority rather than merely making the movement instantaneous. */
  .card:hover, .card:focus-within,
  .card:hover .card__media img,
  .card:focus-within .card__media img { transform: none !important; }
}

/* ---- home intro, stay finder, package cards -----------------------------
   Added by the QA pass on 2026-09-20. tools/pages.py emitted twenty-three
   class names that had no rule anywhere in this file: the whole stay
   finder, the package cards, the home intro, .section--ivory and the tab
   strip of the estate explorer, which came off the home page later the
   same day. The consequence was not subtle and it was shipping -- the
   stay finder's two <select>s rendered as raw light browser chrome on the
   near-black ground, .package-card was a 1216x1003px full-width block
   rather than one of two cards in a row, and a tab strip of five house
   names computed to the user-agent button (background rgb(107,107,107),
   2px border, 1px 6px padding) inside a display:block strip with no gap,
   so the names abutted and overlapped.

   Nothing new is invented here. Every rule reaches for a component this
   stylesheet already has -- .card for the package cards, .field for the
   finder's controls -- and for the existing tokens,
   so the additions cannot drift away from the rest of the page the way a
   parallel set of colours and spacings would. audit_seo.py now fails the
   build if a template emits a class this file does not define, which is
   the check that would have caught all of it before it shipped. */

.home-intro {
  display: grid; gap: clamp(var(--s4), 4vw, var(--s6));
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
  align-items: start;
}
.home-intro__heading { min-width: 0; }
.home-intro__heading .eyebrow { margin-bottom: var(--s3); }
.home-intro__copy { max-width: var(--measure); }
.home-intro__copy .link-arrow { margin-top: var(--s4); }

/* The finder is a search bar, not a wizard: four questions in the order a
   guest actually holds them -- when, then how many, then what sort of place
   -- and one button. Its labels sit above their controls the way every other
   control on the site does. The label wraps its own control here (the markup
   nests them), so .field cannot be reused as a wrapper class and its shape
   is restated on the label instead.

   A grid rather than the wrapping flex row it used to be. With two controls
   the flex row was fine; with four and a button, `flex-wrap` put a different
   number of fields on each line at almost every window width, and the one it
   chose to strand was usually the check-out -- which is to say it broke the
   pair that has to be read together. An explicit two-then-four column grid
   is the same layout at every width in each band, and it keeps the two dates
   side by side on a phone, which is the only arrangement in which they read
   as one question.

   Both dates on one row at 375px comes to about 150px each, which is under
   the intrinsic width of a native date control -- hence `min-width: 0` on the
   label and `width: 100%` on the input. The control shrinks its own
   presentation instead of pushing the page sideways, and a horizontal
   scrollbar on the home page is a worse outcome than a tight field. */
/* The finder is a section first. A named wrapper so the component carries
   its own vertical rhythm rather than depending on whichever .section
   modifier happens to be on it. */
.stay-finder > .wrap { min-width: 0; }
.stay-finder__form {
  display: grid; align-items: end;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--s3) var(--s4); margin-top: var(--s5);
}
.stay-finder__form > label {
  display: flex; flex-direction: column; gap: var(--s1); min-width: 0;
  font-size: 0.6875rem; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--bone-faint);
}
/* The buttons are one grid item, always on their own full-width row, so the
   row above is four equal fields rather than three and a button. At desktop
   they sit at the right-hand end of that row, under the last field, which is
   where the eye already is once the four answers are given. */
.stay-finder__actions {
  grid-column: 1 / -1;
  display: flex; flex-wrap: wrap; gap: var(--s3);
}
@media (min-width: 52rem) {
  .stay-finder__form { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .stay-finder__actions { justify-content: flex-end; }
}
/* The two dates are the one pair of controls on the site the browser draws
   itself, and they have to look like the selects beside them or the row reads
   as two components. color-scheme: dark on :root already gets the picker and
   the spinners; the box is ours. */
.stay-finder__form input[type="date"] {
  background-color: var(--ink-3); border: 1px solid transparent;
  color: var(--bone); border-radius: var(--radius);
  padding: 0.85em 0.9em; width: 100%; min-width: 0;
  font-family: var(--sans); font-size: 1rem;
  letter-spacing: normal; text-transform: none;
  line-height: 1.4;
  /* 3.375rem, not 44px, and the same figure is on the selects below.
     A native date control lays its own shadow content out and comes out
     2px taller than a select with identical padding, which with
     `align-items: end` on the grid lifts the two date *labels* 2px above
     the two select labels -- a four-control row with its captions on two
     different baselines. Giving both the same floor settles it without
     fixing either height, so the row still grows together if a visitor
     scales their text up. It is also comfortably past the 44px minimum
     tap target. */
  min-height: 3.375rem;
  transition: border-color 0.2s var(--ease), background-color 0.2s var(--ease);
}
.stay-finder__form input[type="date"]:hover { border-color: rgba(244,241,234,0.18); }
.stay-finder__form input[type="date"]:focus-visible {
  border-color: var(--brass); outline: none; background-color: #212421;
}
/* WebKit draws the picker glyph at a fixed near-black that disappears
   against --ink-3 entirely. Inverting it is the only handle available. */
.stay-finder__form input[type="date"]::-webkit-calendar-picker-indicator {
  filter: invert(1); opacity: 0.55; cursor: pointer;
}
.stay-finder__form input[type="date"]:hover::-webkit-calendar-picker-indicator { opacity: 0.9; }
.stay-finder__form select {
  background-color: var(--ink-3); border: 1px solid transparent;
  color: var(--bone); border-radius: var(--radius);
  padding: 0.85em 2.2em 0.85em 0.9em; width: 100%;
  font-size: 1rem; letter-spacing: normal; text-transform: none;
  line-height: 1.4; min-height: 3.375rem;
  appearance: none;
  background-image: linear-gradient(45deg, transparent 50%, var(--bone-faint) 50%),
                    linear-gradient(135deg, var(--bone-faint) 50%, transparent 50%);
  background-position: right 1.15em top 55%, right 0.8em top 55%;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  transition: border-color 0.2s var(--ease), background-color 0.2s var(--ease);
}
.stay-finder__form select:hover { border-color: rgba(244,241,234,0.18); }
.stay-finder__form select:focus { border-color: var(--brass); outline: none; background-color: #212421; }
.stay-finder__actions .btn { flex: 0 0 auto; }

/* The answer, and the wait for it. Both live in one block directly under the
   form so that the progress line is attached to the control that started it
   and the sentence sits under that -- press the button, a line runs, the
   answer appears. */
.stay-finder__feedback { margin-top: var(--s3); }
/* The bar is 2px and the track is always laid out, hidden or not, at the same
   height. A progress line that appears and disappears from the flow moves the
   status sentence and the entire grid of results by its own height twice per
   search, which is movement nobody asked for in the middle of reading. */
.stay-finder__progress {
  height: 2px; background: var(--ink-3); border-radius: 1px; overflow: hidden;
}
.stay-finder__progress[hidden] { display: block; visibility: hidden; }
.stay-finder__bar {
  display: block; height: 100%; width: 0;
  background: linear-gradient(90deg, var(--brass-soft), var(--brass));
  /* Linear, and short. This is the one piece of motion on the site that is
     reporting a measurement rather than easing an entrance: --ease-out here
     would make the bar drift after the number behind it had stopped, which
     reads as the site guessing. */
  transition: width 0.2s linear;
}
/* The count has to hold its line. It is an aria-live region, so it is
   rewritten on every change of any control, and a one-line answer that
   becomes a two-line answer shoves the entire grid of results down the
   page while the reader is looking at it. Two lines of room, always --
   every sentence stayFinder() can produce was measured at 375px to fit in
   them. */
.stay-finder__status {
  margin-top: var(--s3); color: var(--bone-dim); font-size: 0.9375rem;
  /* 3.4em, not 3em. Two lines of this size set 50px and 3em reserves 45,
     so the old figure held one line and grew by 5px for the second -- which
     is most of them now that the sentence carries dates, a count and a
     caveat. Measured at 375px: every sentence stayFinder() can produce fits
     in two lines, and this is what two lines cost. */
  min-height: 3.4em;
}

/* A grid whose children can be filtered cannot be `auto-fit`. auto-fit
   collapses the tracks that empty out, so narrowing seven stays down to
   one left that card stretched across the full 1216px of the column like a
   billboard: the filter worked and the result looked broken. auto-fill
   keeps the tracks, so whatever survives a filter is still the size of a
   card, in the column it was already in.

   The track minimum is 19rem, one step wider than the 17rem this grid
   inherits from .grid--3, and it is set by what the grid holds when nobody
   has touched it. That used to be all seven stays, which at 17rem came out
   four and three across the 1216px column and left one empty slot; 19rem
   would have made it three, three and one, stranding The Full Estate on a
   row of its own. The finder now opens on the five homes -- the two
   packages are the section below it, not a repeat inside it -- and five
   inverts the answer: four and one leaves three empty slots and reads as a
   card that fell off, while three and two leaves one and reads as an edit.
   Every other reachable state agrees: three homes for a group of four fill
   a row exactly, and the two packages sit two-of-three rather than
   two-of-four. Only "Show all" is worse off, at three, three and one, and
   it is the one state a reader asks for explicitly.

   Wider cards are also simply better here. At three across a card is 389px
   rather than 272px, and on a site whose whole argument is the
   photography, that is the difference between a thumbnail and a
   photograph.

   The declaration is here rather than on a modifier class because this
   grid is always filterable -- there is no state in which it wants
   auto-fit -- and a class only site.js adds would leave the billboard in
   place for the second or two before it runs. */
.stay-finder__results {
  margin-top: clamp(var(--s4), 3vw, var(--s5));
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
}

/* The reflow. Cards that survive a filter travel from where they were to
   where they now are instead of teleporting, so the eye can follow one
   card across the change rather than re-reading the whole grid.

   It is a FLIP, so the transform is an offset back to the old position
   applied *after* the layout has already happened -- which is why the
   transition is on transform only and why the class is added and removed
   by script around a single frame. It must not animate opacity: a card
   that is fading is a card that is partly unreadable, and the ones moving
   are precisely the ones that matched. */
.stay-finder__results.is-reflowing > * {
  transition: transform 0.5s var(--ease-out);
  will-change: transform;
}

.package-grid {
  display: grid; gap: clamp(var(--s3), 2.5vw, var(--s5));
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
}
.package-card {
  position: relative; display: flex; flex-direction: column;
  background: var(--ink-2); border: 1px solid var(--ink-3);
  border-radius: var(--radius-lg); overflow: hidden; min-width: 0;
  transition: border-color 0.4s var(--ease), transform 0.5s var(--ease-out),
              box-shadow 0.5s var(--ease-out);
}
.package-card:hover, .package-card:focus-within {
  border-color: rgba(201,162,39,0.5); transform: translateY(-9px);
  box-shadow: 0 34px 54px -30px rgba(0,0,0,0.92), 0 2px 0 -1px rgba(201,162,39,0.18);
}
.package-card__photo { display: block; aspect-ratio: 16 / 10; overflow: hidden; }
.package-card__photo img {
  width: 100%; height: 100%; object-fit: cover;
  transition: transform 0.7s var(--ease);
}
.package-card:hover .package-card__photo img,
.package-card:focus-within .package-card__photo img { transform: scale(1.06); }
.package-card__body {
  display: flex; flex-direction: column; gap: var(--s2);
  padding: var(--s4); flex: 1;
}
.package-card__members {
  font-size: 0.8125rem; letter-spacing: 0.06em; color: var(--bone-faint);
}
.package-card__body .link-arrow { margin-top: auto; padding-top: var(--s3); align-self: flex-start; }

@media (prefers-reduced-motion: reduce) {
  /* Same treatment .card already gets: the lift and the zoom are movement,
     not state, so they come off entirely rather than merely getting faster. */
  .package-card:hover, .package-card:focus-within,
  .package-card:hover .package-card__photo img,
  .package-card:focus-within .package-card__photo img { transform: none !important; }
}

/* ---- the valley map ----------------------------------------------------- */

/* One hand-drawn SVG of the Flathead Valley with the estate on it, the eight
   places from content/area.py pinned, and a hairline from the house to
   whichever one you are pointing at. tools/render.py draws it from real
   coordinates; everything here is paint and behaviour.

   The whole component works with no JavaScript: every pin is a link to the
   card that describes that place, and the cards are all in the page. What
   site.js adds is the single-slot panel -- nine cards collapsed into one that
   changes as you move -- which is a reduction of what is already there, never
   a reveal of something that was hidden.

   Sizes inside the drawing are in user units, not CSS pixels, so they shrink
   with the map. --vm-t is the multiplier that buys the type back on a phone,
   where the same drawing is rendered at barely half its width: without it the
   labels set at six pixels. */
.vmap {
  /* --vm-s is the breakpoint's type multiplier; --vm-t is the one every rule
     below reads. They are two names for one number on the valley drawing and
     two different numbers inside the inset, which is rendered at a different
     number of pixels per user unit and would otherwise carry pins five times
     too big. Media queries set --vm-s; nothing but the inset sets --vm-t. */
  --vm-s: 1;
  --vm-t: var(--vm-s);
  display: grid;
  gap: clamp(var(--s4), 3vw, var(--s5));
}
/* The map is an SVG in a grid cell and the panel holds long place names. A
   grid item's min-width is auto, which means its content sets the floor --
   the same rule that used to stretch the whole property page to the width of
   thirteen thumbnails. */
.vmap > * { min-width: 0; }
@media (min-width: 62rem) {
  .vmap { grid-template-columns: minmax(0, 1fr) minmax(19rem, 0.74fr); align-items: start; }
}

/* The frame. A radial wash of the one deep green in the palette, centred
   roughly where the estate sits, so the drawing reads as a valley floor
   between dark ranges rather than as line art on a black rectangle. The
   hairline grid over it is 3% bone: at that strength it is not a grid you can
   see, it is the reason the panel looks like paper. */
.vmap__stage {
  position: relative;
  border: 1px solid var(--ink-3);
  border-radius: var(--radius-lg);
  overflow: hidden;
  background-color: var(--ink-2);
  background-image:
    radial-gradient(76% 52% at 46% 60%, rgba(23,55,47,0.55), rgba(23,55,47,0) 72%),
    repeating-linear-gradient(to right, rgba(244,241,234,0.028) 0 1px, transparent 1px 44px),
    repeating-linear-gradient(to bottom, rgba(244,241,234,0.028) 0 1px, transparent 1px 44px);
}
.vmap__svg { width: 100%; height: auto; display: block; }

/* The Lakeside inset.

   Three of the places in the area guide are in Lakeside village, and the
   village is 300 metres across on a map of a 65-kilometre valley -- three
   user units, a quarter of the width of one pin. So the village gets its own
   drawing, in the bottom left corner where the valley has nothing in it.

   26.8% is not a number chosen by eye: it is INSET_FRAC in tools/render.py,
   which is also what the inset's viewBox width is derived from. Because both
   the element and its viewBox are the same fraction of the map's, one user
   unit is exactly the same number of screen pixels in both drawings at every
   width -- so the pins, the numerals and the labels inside the inset are the
   same size as the ones on the valley behind it with no override at all, on
   a 1440px screen and on a 900px one. Change the percentage here and change
   INSET_FRAC with it, or the inset's type silently drifts out of step.

   It is a second <svg>, not a <g> inside the first, and that is the whole
   reason the phone case below can work: a group would be locked to the main
   drawing's scale, and the two village pins would be twenty pixels apart on
   a 375px screen however the stylesheet was written. */
.vmap__inset {
  position: absolute;
  left: 2.2%;
  bottom: 4.4%;
  width: 26.8%;
  border: 1px solid rgba(201,162,39,0.28);
  border-radius: var(--radius-lg);
  background: rgba(8,9,8,0.82);
  overflow: hidden;
}

/* Linework. Every stroke is non-scaling, so a coastline is one pixel wide on
   a phone and one pixel wide on a 1440px screen instead of half a pixel on
   the phone -- which is the width at which a hairline stops resolving and the
   map looks like it failed to load. */
.vmap__lake, .vmap__isle, .vmap__river, .vmap__road, .vmap__ridge,
.vmap__ring, .vmap__disc, .vmap__estate-ring, .vmap__estate-halo,
.vmap__north, .vmap__bar, .vmap__shield rect, .vmap__dot,
.vmap__shore, .vmap__lane, .vmap__approx {
  vector-effect: non-scaling-stroke;
}
/* The four layers of the drawing, in paint order: the land and water, the
   estate, the pins over both, then north and the scale bar. Only the pins are
   interactive, and none of it is text a reader wants to select -- a drag
   across a map on a touch screen should pan or scroll, not highlight the word
   "Kalispell". */
.vmap__art, .vmap__pins, .vmap__estate, .vmap__furniture {
  -webkit-user-select: none; user-select: none;
}
.vmap__art, .vmap__estate, .vmap__furniture { pointer-events: none; }
/* No `fill` here on purpose. The water is filled by a radial gradient whose
   id carries the component's own ident, which only render.py knows, so it is
   a presentation attribute in the markup -- and a `fill` in this rule would
   beat it, because CSS wins over presentation attributes. What replaced the
   old dashed depth contour is explained in _water_defs() in tools/render.py:
   an offset contour cannot survive a real coastline, and a shape with the
   Narrows in it does not need one to read as water. */
.vmap__lake { stroke: rgba(111,155,168,0.68); stroke-width: 1.2; }
/* The inset's water is the lake clipped to the window, so three of its four
   sides are the edge of the inset and not a shore. It is filled and never
   stroked; .vmap__shore draws the one side that is real. */
.vmap__water { stroke: none; }
.vmap__shore { fill: none; stroke: rgba(111,155,168,0.68); stroke-width: 1.2; }
.vmap__glow { pointer-events: none; }
/* Wild Horse Island is land, and it is surrounded by water: it has to be
   read against the lake, not against the map. Filled with the ground colour
   and outlined, it is an island; filled a shade of the water, which is what
   it was when the lake was a flat wash, it disappeared into the lake the
   moment the water got a gradient. */
.vmap__isle { fill: #12140f; stroke: rgba(184,179,168,0.42); stroke-width: 1; }
.vmap__river { fill: none; stroke: rgba(111,155,168,0.42); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
/* The ranges are drawn as rows of peaks, which is the brand mark's own
   gesture -- five overlapping peaks -- at map scale. */
.vmap__ridge { fill: none; stroke: rgba(184,179,168,0.34); stroke-width: 1.1; stroke-linejoin: round; stroke-linecap: round; }
.vmap__road { fill: none; stroke: rgba(244,241,234,0.2); stroke-width: 1.1; stroke-dasharray: 7 6; }
/* Blacktail Road in the inset: solid and thinner than the highway, which is
   the convention -- a dashed line is a route number, a solid one is a road. */
.vmap__lane { fill: none; stroke: rgba(244,241,234,0.16); stroke-width: 1; }
.vmap__shield rect { fill: var(--ink); stroke: rgba(244,241,234,0.3); stroke-width: 1; }
.vmap__shield-n { fill: var(--bone-dim); font-family: var(--sans); font-weight: 600; font-size: calc(11px * var(--vm-t)); }
.vmap__dot { fill: var(--bone-faint); stroke: var(--ink); stroke-width: 1.5; }
.vmap__dot--town { fill: var(--bone-faint); }
.vmap__dot--air { fill: var(--lake); }

/* Map type. `paint-order: stroke fill` with a near-black stroke is the
   cartographer's halo: it is what keeps a place name readable where it
   crosses a river, a road and a ridge at once, and it costs nothing. */
.vmap__mark, .vmap__range, .vmap__lbl, .vmap__num,
.vmap__estate-lbl, .vmap__estate-sub, .vmap__bar-l, .vmap__north-n,
.vmap__inset-t, .vmap__lane-l {
  paint-order: stroke fill;
  stroke: rgba(8,9,8,0.92);
  stroke-width: calc(3.2px * var(--vm-t));
  stroke-linejoin: round;
  font-family: var(--sans);
}
.vmap__mark { fill: var(--bone-dim); font-size: calc(12.5px * var(--vm-t)); letter-spacing: 0.02em; }
.vmap__mark-t { fill: var(--brass); font-size: calc(11px * var(--vm-t)); letter-spacing: 0.12em; }
.vmap__range {
  fill: var(--bone-faint); opacity: 0.6;
  font-size: calc(10.5px * var(--vm-t)); letter-spacing: 0.3em; font-weight: 500;
}
.vmap__inset-t {
  fill: var(--brass); font-size: calc(10px * var(--vm-t));
  letter-spacing: 0.2em; text-transform: uppercase;
}
.vmap__lane-l { fill: var(--bone-faint); font-size: calc(10px * var(--vm-t)); letter-spacing: 0.04em; }
.vmap__north, .vmap__bar { fill: none; stroke: var(--bone-faint); stroke-width: 1; stroke-linecap: square; }
.vmap__north-n, .vmap__bar-l {
  fill: var(--bone-faint); font-size: calc(10px * var(--vm-t));
  letter-spacing: 0.18em; text-transform: uppercase;
}
.vmap__furniture { opacity: 0.75; }

/* The estate. Brass, because it is the one thing on the map that is us, and
   the same rule the rest of the site follows: brass is the accent where the
   mark stands alone and is the whole message. */
.vmap__estate-halo { fill: rgba(201,162,39,0.1); stroke: none; }
.vmap__estate-ring { fill: none; stroke: rgba(201,162,39,0.45); stroke-width: 1; }
.vmap__estate-mark { fill: var(--brass); }
.vmap__estate-lbl { fill: var(--bone); font-family: var(--serif); font-size: calc(16px * var(--vm-t)); }
.vmap__estate-sub { fill: var(--brass); font-size: calc(10px * var(--vm-t)); letter-spacing: 0.2em; text-transform: uppercase; }

/* A pin, and the hairline from the house to it.

   The hit target is a transparent circle with a transparent 22px
   non-scaling stroke on it, and that is the only way to get a tap target
   whose size is in screen pixels inside a drawing whose units are not: the
   fill scales with the map and the stroke does not, so the smaller the
   drawing gets the more of the target the stroke is carrying. It is last in the group
   so it sits over everything else in it and takes the whole click. */
.vmap__pin { cursor: pointer; }
/* The group that holds the visible pin. transform-box: view-box makes the
   inline transform-origin render.py writes on it -- the pin's own centre --
   resolve in the drawing's own units. */
.vmap__body { transform-box: view-box; }
/* 26, not 24. The map's frame widened by a degree of longitude on
   2026-09-29, which shrank every user unit against the screen; at 375px the
   21-unit circle plus a 24px stroke came out a pixel under the 44px floor.
   The stroke is where nearly all of a small target's size comes from, which
   is the point of building it this way -- it is the only dimension on the
   drawing that is in screen pixels and not in the map's own units. */
.vmap__hit { fill: transparent; stroke: transparent; stroke-width: 26; vector-effect: non-scaling-stroke; pointer-events: all; }
.vmap__pin .vmap__route, .vmap__pin .vmap__ring, .vmap__pin .vmap__disc,
.vmap__pin .vmap__num, .vmap__pin .vmap__lbl { pointer-events: none; }
.vmap__disc {
  fill: var(--ink); stroke: var(--brass); stroke-width: 1.3;
  r: calc(13px * var(--vm-t));
  transform-box: fill-box; transform-origin: center;
  transition: fill 0.35s var(--ease), transform 0.5s var(--ease-out);
}
.vmap__num {
  fill: var(--brass); font-size: calc(11px * var(--vm-t)); font-weight: 600;
  letter-spacing: 0; font-variant-numeric: tabular-nums;
  transition: fill 0.35s var(--ease);
}
/* "About here." Legacy Bike Park is the one pin on this map that is an
   inference rather than a coordinate -- see MAP_APPROXIMATE in
   tools/render.py -- and a map that guessed at one position and drew it
   exactly like the eleven it knows is a map that has quietly made all twelve
   worth the same. Dashed, and the caption under the map says why. */
.vmap__approx {
  fill: none; stroke: var(--brass); stroke-width: 1;
  stroke-dasharray: 3 5; opacity: 0.5;
  r: calc(20px * var(--vm-t));
}
.vmap__ring {
  fill: none; stroke: var(--brass); stroke-width: 1;
  r: calc(23px * var(--vm-t));
  opacity: 0; transform-box: fill-box; transform-origin: center; transform: scale(0.7);
  transition: opacity 0.4s var(--ease), transform 0.55s var(--ease-out);
}
.vmap__lbl { fill: var(--bone); font-size: calc(13px * var(--vm-t)); transition: fill 0.3s var(--ease); }
.vmap__lbl-t { fill: var(--brass); font-size: calc(11.5px * var(--vm-t)); letter-spacing: 0.1em; }
.vmap__route {
  fill: none; stroke: var(--brass); stroke-width: 1.2; stroke-linecap: round;
  stroke-dasharray: var(--len, 400); stroke-dashoffset: var(--len, 400);
  opacity: 0;
  transition: stroke-dashoffset var(--dur, 0.6s) var(--ease-out),
              opacity 0.25s var(--ease);
}
/* Pointed at, or chosen. The hover half is pure CSS and needs nothing; the
   attribute is what lets the index beside the map drive the same state, and
   what a tap resolves to on a device with no hover at all. */
.vmap__pin:hover .vmap__disc, .vmap__pin:focus-visible .vmap__disc,
.vmap__pin[data-on] .vmap__disc { fill: var(--brass); transform: scale(1.22); }
.vmap__pin:hover .vmap__num, .vmap__pin:focus-visible .vmap__num,
.vmap__pin[data-on] .vmap__num { fill: #16140c; }
.vmap__pin:hover .vmap__ring, .vmap__pin:focus-visible .vmap__ring,
.vmap__pin[data-on] .vmap__ring { opacity: 0.85; transform: none; }
.vmap__pin:hover .vmap__lbl, .vmap__pin:focus-visible .vmap__lbl,
.vmap__pin[data-on] .vmap__lbl { fill: var(--brass-soft); }
.vmap__pin:hover .vmap__route, .vmap__pin:focus-visible .vmap__route,
.vmap__pin[data-on] .vmap__route { opacity: 0.8; stroke-dashoffset: 0; }
/* The site-wide focus ring is an `outline`, which Safari does not paint on an
   SVG element. The ring circle above is the focus indicator that works
   everywhere, so the outline is taken off rather than left as the only
   indicator on half the browsers. */
.vmap__pin:focus { outline: none; }
.vmap__pin:focus-visible .vmap__ring { stroke-width: 2; opacity: 1; }

/* The panel: the card for whatever is being pointed at, then the index. */
.vmap__panel { display: flex; flex-direction: column; gap: var(--s4); }
.vmap__detail { display: grid; }
.vmap__card {
  display: flex; flex-direction: column; align-items: flex-start; gap: var(--s2);
  padding: var(--s4);
  background: var(--ink-2); border: 1px solid var(--ink-3);
  border-left: 2px solid var(--brass);
  border-radius: var(--radius-lg);
}
.vmap__card + .vmap__card { margin-top: var(--s3); }
.vmap__card .link-arrow { margin-top: var(--s2); }
.vmap__card-n { color: var(--bone); }
.vmap__drive { font-size: 0.8125rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--bone-faint); }
.vmap__drive b { color: var(--brass); font-weight: 500; }
/* Single-slot mode, and the attribute is set by site.js. Every card is in the
   same grid cell, so the box is as tall as the tallest of the nine and
   changing which one shows moves nothing on the page. Hiding them with
   `hidden` instead would resize the panel on every hover, which is the
   cheapest way to make a good component feel broken. `visibility` also takes
   the inactive cards out of the accessibility tree and out of the tab order,
   which `opacity: 0` alone would not. */
.vmap__detail[data-vm-live] > .vmap__card {
  grid-area: 1 / 1; margin-top: 0;
  opacity: 0; visibility: hidden;
  transition: opacity 0.4s var(--ease), transform 0.5s var(--ease-out);
  transform: translateY(8px);
}
.vmap__detail[data-vm-live] > .vmap__card[data-vm-on] {
  opacity: 1; visibility: visible; transform: none;
}

.vmap__index { display: grid; border-top: 1px solid var(--ink-3); }
.vmap__index a {
  display: flex; align-items: center; gap: var(--s3);
  min-height: 44px; padding: 0.55rem 0;
  border-bottom: 1px solid var(--ink-3);
  transition: border-color 0.3s var(--ease), color 0.25s var(--ease);
}
.vmap__index b {
  flex: none; width: 1.5rem; height: 1.5rem; display: grid; place-items: center;
  font-size: 0.6875rem; font-weight: 600; font-variant-numeric: tabular-nums;
  color: var(--brass); border: 1px solid rgba(201,162,39,0.35); border-radius: 50%;
  transition: background-color 0.3s var(--ease), color 0.3s var(--ease),
              border-color 0.3s var(--ease);
}
.vmap__index span { flex: 1; min-width: 0; font-size: 0.9375rem; color: var(--bone-dim); }
.vmap__index em {
  flex: none; font-style: normal; font-family: var(--serif); font-size: 1.05rem;
  color: var(--bone-faint); transition: color 0.25s var(--ease);
}
.vmap__index a:hover, .vmap__index a[data-on] { border-bottom-color: rgba(201,162,39,0.5); }
.vmap__index a:hover b, .vmap__index a[data-on] b { background: var(--brass); color: #16140c; border-color: var(--brass); }
.vmap__index a:hover span, .vmap__index a[data-on] span { color: var(--bone); }
.vmap__index a:hover em, .vmap__index a[data-on] em { color: var(--brass); }
.vmap__note { font-size: 0.75rem; line-height: 1.5; color: var(--bone-faint); max-width: 44ch; }

/* Type inside the drawing is in user units, so it scales with the map, and
   the map does not scale with the viewport in the direction anyone expects:
   it is widest at 991px, where the two-column layout has just collapsed and
   the SVG has the whole wrap to itself, and narrowest at 992px, where the
   panel has just taken a third of the row back. So --vm-t is tuned to the
   width the SVG is actually rendered at rather than to the viewport, and it
   goes down as often as it goes up. Measured, at a 13-unit label:

     1440px  two columns, 688px wide, x1.00  ->  13.8px on screen
      992px  two columns, 499px wide, x1.00  ->  10.0px
      991px  one column,  892px wide, x0.80  ->  14.3px
      768px  one column,  691px wide, x0.80  ->  11.1px
      544px  one column,  490px wide, x1.05  ->  10.4px
      375px  one column,  335px wide, x1.75  ->   9.9px

   Below 544px the place names come off the drawing entirely. There is no
   size at which nine of them fit across 335 pixels, and the index directly
   underneath carries every one of them with its drive time -- which is why
   the index is in the markup rather than being something the map draws. */
@media (max-width: 61.99rem) { .vmap { --vm-s: 0.8; } }
@media (max-width: 47.99rem) { .vmap { --vm-s: 1.05; } }
@media (max-width: 33.99rem) {
  .vmap { --vm-s: 1.75; }
  .vmap__lbl, .vmap__mark, .vmap__range, .vmap__shield, .vmap__estate-sub { display: none; }
  /* On a phone the inset stops being a corner of the map and becomes the
     next thing down: full width, under the valley, with a rule between them.
     At that size it is drawn at 2.8 times the pixels per user unit the map
     above it is, which is why --vm-t is divided back down by the same ratio
     -- 0.268/0.76, INSET_FRAC over the width below -- so that a pin, a
     numeral and a label are the same number of pixels here as they are on
     the valley above, and the two village pins land forty pixels apart
     instead of nine.

     76% and not the full width because the valley itself is only 450 pixels
     tall on a 375px screen: an inset drawn edge to edge comes out nearly as
     tall as the map it is a detail of, which is the wrong thing for it to
     look like.

     Its labels come back, too. They are hidden on the valley above because
     eleven of them at phone size is a smear; there are two down here and
     room for both. */
  .vmap__inset {
    --vm-t: calc(var(--vm-s) * 0.353);
    position: static;
    width: 76%;
    margin: var(--s4) auto var(--s3);
  }
  .vmap__inset .vmap__lbl, .vmap__inset .vmap__shield { display: inline; }
}

/* The map's own entrance. The pins arrive from the estate outward, nearest
   first, which is the one order that says something: the reader watches the
   valley fill in by distance. It rides on the .is-in the reveal system
   already adds, so it carries that system's twelve-second failsafe and its
   bfcache handler, and it never runs at all under reduced motion -- the
   .reveal-on class that gates every rule below is only ever added when the
   preference is absent.

   --vm-d is the per-pin delay, set once on the <a> and inherited by the group
   inside it. A custom property rather than eight copies of a
   `transition-delay` declaration, because the `transition` shorthand resets
   the delay to zero: writing the two in separate rules is how a staggered
   entrance quietly becomes a simultaneous one. */
.reveal-on .vmap[data-reveal] .vmap__pin {
  opacity: 0;
  transition: opacity 0.75s var(--ease-out) var(--vm-d, 0s);
}
.reveal-on .vmap[data-reveal] .vmap__body {
  transform: translateY(16px) scale(0.45);
  transition: transform 1s var(--ease-out) var(--vm-d, 0s);
}
.reveal-on .vmap[data-reveal].is-in .vmap__pin { opacity: 1; }
.reveal-on .vmap[data-reveal].is-in .vmap__body { transform: none; }
.reveal-on .vmap[data-reveal] .vmap__pin:nth-child(1) { --vm-d: 0.46s; }
.reveal-on .vmap[data-reveal] .vmap__pin:nth-child(2) { --vm-d: 0.55s; }
.reveal-on .vmap[data-reveal] .vmap__pin:nth-child(3) { --vm-d: 0.64s; }
.reveal-on .vmap[data-reveal] .vmap__pin:nth-child(4) { --vm-d: 0.73s; }
.reveal-on .vmap[data-reveal] .vmap__pin:nth-child(5) { --vm-d: 0.82s; }
.reveal-on .vmap[data-reveal] .vmap__pin:nth-child(6) { --vm-d: 0.91s; }
.reveal-on .vmap[data-reveal] .vmap__pin:nth-child(7) { --vm-d: 1.00s; }
.reveal-on .vmap[data-reveal] .vmap__pin:nth-child(8) { --vm-d: 1.09s; }
.reveal-on .vmap[data-reveal] .vmap__pin:nth-child(9) { --vm-d: 1.18s; }
.reveal-on .vmap[data-reveal] .vmap__pin:nth-child(10) { --vm-d: 1.27s; }
.reveal-on .vmap[data-reveal] .vmap__pin:nth-child(n+11) { --vm-d: 1.36s; }
/* The estate is the anchor everything else arrives around, so it draws first
   and on its own clock.

   The delays above all start after the block itself has finished arriving --
   the whole .vmap fades and rises over 1.05s as one element, and a pin
   sequence running underneath that is a sequence nobody sees, because the
   container's own opacity is multiplying every step of it down to nothing.
   Measured in Chrome before the shift: at 0.9s into the entrance the block
   was at 0.6 opacity and the first pin had already finished. */
.reveal-on .vmap[data-reveal] .vmap__estate {
  opacity: 0; transition: opacity 0.9s var(--ease-out) 0.3s;
}
.reveal-on .vmap[data-reveal].is-in .vmap__estate { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  /* Belt and braces. .reveal-on is never added under the preference, so the
     block above cannot match -- but the hover transitions are flattened by
     the blanket rule at the top of this file rather than removed, and a
     drawn-and-then-undrawn hairline is a resting state that shows nothing.
     The line simply appears, and the disc does not grow. */
  .vmap__route { transition: none; }
  .vmap__pin:hover .vmap__disc, .vmap__pin:focus-visible .vmap__disc,
  .vmap__pin[data-on] .vmap__disc { transform: none; }
  .vmap__ring, .vmap__disc, .vmap__detail[data-vm-live] > .vmap__card { transition: none; }
  .vmap__detail[data-vm-live] > .vmap__card { transform: none; }
}

/* ---- scroll-driven motion ------------------------------------------------ */

/* Parallax on the feature photographs, and the seam drawing itself, both off
   a scroll timeline rather than a scroll listener.

   `animation-timeline: view()` ties an animation's progress to how far the
   element has travelled through the viewport. It runs on the compositor,
   costs no JavaScript, cannot jank the main thread and -- the part that
   matters here -- it cannot leave anything stuck: a browser that does not
   implement it never enters this block, and every element in it is already
   correct and fully visible without it. That is why the whole thing is one
   @supports rather than a polyfill.

   It is also inside `prefers-reduced-motion: no-preference`, which is the
   positive form of the preference rather than a cancellation of a rule
   declared outside it. A blanket `animation-duration: 0.01ms` does not stop
   a scroll-driven animation the way it stops a time-driven one -- its
   progress comes from the scroll position, not from a clock -- so the only
   safe place for one of these is behind the preference, never in front of
   it.

   The drift is paid for with `scale`, not `transform`. They are separate
   properties that compose into the same matrix, so the 12% overscale that
   gives the photograph room to move can sit alongside the entrance settle
   and the hover zoom, both of which write `transform`, instead of one rule
   silently cancelling the other. 4.2% of travel inside 12% of overscale
   never reaches the edge of the frame. */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    @keyframes photoDrift {
      from { translate: 0 -4.2%; }
      to   { translate: 0 4.2%; }
    }
    .feature__media img {
      scale: 1.12;
      animation: photoDrift linear both;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
    }
    /* The seam draws out from the middle as the band arrives, which is the
       one moment it is actually saying something: this is where the page
       changes. It finishes well before the section is centred, so it is a
       transition rather than something the reader watches.

       The range is `cover`, not `entry`, and that is not interchangeable.
       The animation's subject is the hairline itself, which is one pixel
       tall -- and an `entry` range is exactly as long as the subject, so
       entry 0% to entry 100% would be one pixel of scrolling and the seam
       would simply appear. A `cover` range is the subject's height plus the
       viewport's, so a quarter of it is a quarter of a screen. */
    @keyframes seamDraw {
      from { transform: scaleX(0); }
      to   { transform: scaleX(1); }
    }
    @keyframes seamMark {
      from { transform: rotate(45deg) scale(0); }
      to   { transform: rotate(45deg) scale(1); }
    }
    .section--forest::before, .section--raised::before {
      animation: seamDraw linear both;
      animation-timeline: view();
      animation-range: cover 0% cover 24%;
    }
    .section--forest::after, .section--raised::after {
      animation: seamMark linear both;
      animation-timeline: view();
      animation-range: cover 10% cover 30%;
    }
  }
}

/* ---- print ------------------------------------------------------------- */

@media print {
  .header, .footer, .nav, .booking, .lightbox { display: none !important; }
  /* Controls, not content. A printed page cannot be filtered, and the hero
     plates would print the wrong photograph on top of the right one --
     whichever one the rotation happened to be showing when Print was
     pressed, over a hero the reader chose nothing about. */
  .hero__dots, .gallerybar, .frames__open, .hero__plate { display: none !important; }
  /* The finder's two buttons do nothing on paper, but the selects stay:
     they still read as their chosen value, and they are the only thing on
     the sheet that explains why three cards printed and not seven. */
  .stay-finder__form .btn { display: none !important; }
  /* Everything the reveal system stages is invisible until an
     IntersectionObserver says otherwise, and printing does not scroll: a
     reader who hits Print on a long page would get the two sections they
     had reached and blank paper for the rest. Printing is also the one
     context where `transform` and `mask` are least reliably honoured, so
     all three are cancelled rather than merely finished. */
  .reveal-on [data-reveal], .reveal-on [data-stagger] > *,
  .reveal-on [data-develop] {
    opacity: 1 !important; transform: none !important;
    mask-image: none !important; -webkit-mask-image: none !important;
  }
  .reveal-on [data-develop] img { transform: none !important; }
  body { background: #fff; color: #000; }
  a::after { content: " (" attr(href) ")"; font-size: 0.8em; }
}
