:root {
  --ink: #000000;
  /* Softened from #f5f1e8 (owner's call -- that read as too close to white).
     Cooler and a touch darker: relative luminance 0.824 vs 0.882. Every tint
     below is mixed from these same three channels, so they move with it. */
  --ivory: #ECEAE6;
  /* 0.8, not the 0.62 used over flat black: this copy now sits over moving
     footage that peaks near 100 luminance, and 0.62 fell under 4.5:1 there. */
  --ivory-soft: rgba(236, 234, 230, 0.8);
  --ivory-faint: rgba(236, 234, 230, 0.4);
  --hairline: rgba(236, 234, 230, 0.28);
  /* The one chromatic note in an otherwise two-tone system, reserved for the two
     booking CTAs and nothing else. Named for its job, not its hue: this slot has
     held brass and then oxblood, and a variable named after a colour it no longer
     contains is how `--brass` ended up holding a red.

     ⚠️ It separates from the footage by *chroma*, not luminance. At 0.107 relative
     luminance it is almost exactly as bright as the lit cape it crosses on desktop
     (~1.5:1 there, and ~1.0:1 against the hottest patches) -- what makes the pill
     read at all is that monochrome film cannot occupy a red axis. So do not judge
     this swatch by a contrast number against the video, and do not shift it to
     chase one. The ratio that actually governs is the label inside the pill:
     #ECEAE6 on #b22222 is 5.6:1. That is also what caps the hover lift -- past
     roughly #c42d2d the label drops under 4.5:1. */
  --accent: #b22222;
  --accent-lift: #bf2a2a;
  --font-display: "Playfair Display", serif;
  --font-ui: "Archivo", -apple-system, BlinkMacSystemFont, sans-serif;
  --side: clamp(24px, 7vw, 84px);
}

* { margin: 0; padding: 0; box-sizing: border-box; }

html { color-scheme: dark; }

body {
  background: var(--ink);
  color: var(--ivory);
  font-family: var(--font-ui);
  -webkit-font-smoothing: antialiased;
}

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

.hero {
  position: relative;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  background: var(--ink);
  overflow: hidden;
}

.hero__video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  background: var(--ink);
  opacity: 0;
  will-change: opacity;
  transform: translateZ(0);
  backface-visibility: hidden;
  z-index: 0;
}

.nav,
.hero__content,
.footer {
  position: relative;
  z-index: 1;
}

/* Above .hero__content, not level with it: z-index on .nav creates a stacking
   context, so the mobile dropdown cannot escape it no matter how high its own
   z-index goes. At equal z-index the later sibling (.hero__content) won, and the
   headline swallowed taps meant for the menu. */
.nav {
  z-index: 3;
}

/* ---------- Nav ---------- */

.nav {
  display: flex;
  align-items: center;
  gap: clamp(20px, 4vw, 56px);
  padding: 28px var(--side);
}

.nav__logo {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 1.5rem;
  letter-spacing: 0.01em;
  margin-right: auto;
}

.nav__menu {
  position: relative;
}

/* Chained with .btn deliberately: .btn sets `display: inline-flex` further down
   the file at equal specificity, so an unchained `.nav__toggle` loses to it. */
.btn.nav__toggle {
  display: none;
  font-family: inherit;
  cursor: pointer;
  /* Ghost, deliberately NOT the booking colour it sits next to. This is
     navigation; the accent means "book". When the toggle carried the accent too,
     the accent stopped meaning anything -- two different jobs wearing the same
     signal. Outline keeps the nav pill subordinate to the hero CTA at the one
     breakpoint where both are on screen. Only ever visible on mobile. */
  background: transparent;
  border-color: var(--hairline);
  color: var(--ivory);
}

/* Fills on hover rather than lifting: the base state is transparent here, so
   there is no fill to lift. Mirrors .btn--outline. */
.btn.nav__toggle:focus-visible {
  background: var(--ivory);
  border-color: var(--ivory);
  color: var(--ink);
}

.nav__chev {
  transition: transform 0.25s ease;
}

.nav__toggle[aria-expanded="true"] .nav__chev {
  transform: rotate(180deg);
}

.nav__links {
  display: flex;
  gap: clamp(20px, 3vw, 40px);
}

.nav__links a {
  font-size: 0.78rem;
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ivory-soft);
  transition: color 0.25s ease;
}

.nav__links a:focus-visible {
  color: var(--ivory);
}

/* Under 760px the link row becomes a dropdown under a Menu button, which takes
   the CTA's slot. Booking is not lost: the hero's VARAA AIKASI pill sits in the
   first viewport on every phone size, so the primary action stays one tap away. */
@media (max-width: 760px) {
  .btn.nav__cta { display: none; }
  .btn.nav__toggle { display: inline-flex; }

  /* Closed state is driven straight off aria-expanded, so the attribute the
     screen reader reads and the attribute that hides the panel are the same one
     -- they cannot drift apart. */
  .nav__toggle[aria-expanded="false"] + .nav__links {
    display: none;
  }

  .nav__links {
    position: absolute;
    top: calc(100% + 8px);
    /* Negative, so the panel overhangs the MENU button toward the screen edge
       and takes back some of the headline it covers. The containing block is
       .nav__menu, whose right edge sits at the nav gutter (--side, 25-30px on
       phones), so -14px leaves a constant ~11-16px margin off the viewport --
       tight enough to buy room, wide enough that the rounded corner doesn't
       look clipped or land in a phone's edge-gesture strip.
       This reduces the headline overlap, it does not clear it: at 390px the
       overlap is 71px and only 27px of shift exists. Clearing it fully needs a
       narrower panel than "YHTEYSTIEDOT" can set in. */
    right: -14px;
    z-index: 2;
    flex-direction: column;
    gap: 0;
    /* No min-width: it was set to 190px while the longest label ("YHTEYSTIEDOT")
       measures 118px of ink, so 72px of the panel was slack -- which is what made
       it read oversized. The column now hugs its widest row and lands near 150px. */
    padding: 5px;
    /* Solid black, not a translucent panel: this sits over moving footage, and a
       see-through surface would put the same contrast problem behind the links
       that the hero body copy already has. Hairline border instead of a shadow,
       per the no-shadow rule. */
    background: var(--ink);
    border: 1px solid var(--hairline);
    border-radius: 14px;
    animation: menu-in 0.18s ease-out;
  }

  .nav__links a {
    display: flex;
    align-items: center;
    /* 40px, down from 44. 44 is Apple's HIG figure; the binding standard here is
       WCAG 2.2 SC 2.5.8 (AA), which requires 24x24 -- so this clears the actual
       requirement by 16px and only gives up the AAA-level comfort margin. Do not
       take it below 24 or the panel stops being conformant. */
    min-height: 40px;
    padding: 0 14px;
    border-radius: 10px;
    font-size: 0.75rem;
    transition: background-color 0.2s ease, color 0.2s ease;
  }

  .nav__links a:focus-visible {
    background: rgba(236, 234, 230, 0.08);
    color: var(--ivory);
  }
}

@keyframes menu-in {
  from { opacity: 0; transform: translateY(-6px); }
  to   { opacity: 1; transform: translateY(0); }
}

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

/* ---------- Buttons ---------- */

.btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  border-radius: 999px;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  word-spacing: 0.22em;
  text-transform: uppercase;
  white-space: nowrap;
  transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease, transform 0.2s ease;
}

.btn--solid {
  padding: 11px 23px;
  background: var(--ivory);
  color: var(--ink);
  border: 1px solid var(--ivory);
}

.btn--solid:focus-visible {
  background: transparent;
  color: var(--ivory);
}

/* The accent is booking-only -- this pill and the hero CTA, nothing else.
   The MENU toggle shares .btn--solid but goes ghost (see above): an accent that
   appears on two different jobs stops meaning "this is the thing to click".
   Chained with .btn so it wins regardless of source order. */
.btn.nav__cta {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--ivory);
}

/* Hover lifts the fill instead of inverting to transparent: over the bright
   passages a see-through button is exactly the failure this treatment fixed. */
.btn.nav__cta:focus-visible {
  background: var(--accent-lift);
  border-color: var(--accent-lift);
  color: var(--ivory);
}

.btn--outline {
  padding: 14px 28px;
  border: 1px solid var(--hairline);
  color: var(--ivory);
}

.btn--outline .btn__icon {
  transition: transform 0.25s ease;
}

.btn--outline:focus-visible {
  background: var(--ivory);
  color: var(--ink);
  border-color: var(--ivory);
}

.btn--outline:focus-visible .btn__icon {
  transform: translate(2px, -2px);
}

.btn:active { transform: scale(0.98); }

/* ---------- Hero content ---------- */

.hero__content {
  padding: clamp(48px, 12vh, 140px) var(--side) 0;
  max-width: 46rem;
}

.hero__headline {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: clamp(2.75rem, 7.5vw, 5.5rem);
  line-height: 1.02;
  letter-spacing: -0.01em;
  color: var(--ivory);
}

/* Clip mask for the entrance. Present even without JS so the two spans still
   stack as lines where the <br> used to be. */
.hero__headline .line {
  display: block;
  overflow: hidden;
  /* ⚠️ Both lines contain a `p`, and line-height 1.02 puts the descender BELOW
     the line box -- measured at 0.0923em over (4.06px at the 44px phone size,
     7.62px at 88px desktop). Plain overflow:hidden shaves it. So grow the mask
     box downward past the ink, then pull the same amount back out of layout
     with the negative margin: the descender is inside the mask, but line
     spacing and every recorded element position are untouched.
     0.12em is the measured 0.0923 plus ~30% headroom for other rasterisers.
     These two values MUST stay equal and opposite. */
  padding-bottom: 0.12em;
  margin-bottom: -0.12em;
}

.hero__headline .line__in {
  display: block;
}

.hero__eyebrow {
  margin-top: clamp(20px, 3vh, 32px);
  /* 0.72rem, down from 0.85: at the old size the 0.28em tracking pushed this
     onto two lines below 375px, which read as a wrapped sentence rather than a
     single tracked label. It now holds one line down to 360px. */
  font-size: 0.72rem;
  font-weight: 600;
  /* Held in a custom property because the entrance keyframes animate INTO this
     exact value. Hard-coding 0.28em in both places is how the two drift apart,
     and the wrap note above is why that would matter. */
  --track: 0.28em;
  letter-spacing: var(--track);
  text-transform: uppercase;
  color: var(--ivory-soft);
}

.hero__body {
  margin-top: 22px;
  font-size: 1rem;
  line-height: 1.6;
  color: var(--ivory-soft);
  max-width: 52ch;
  /* The copy is one long sentence, so the wrap point is doing typographic work
     rather than just fitting. At 40ch it left a 15-character last line under two
     full ones; `balance` evens the rag instead (68px at desktop, and it kills the
     orphan on phones too). The 52ch cap is wider than balance needs at desktop --
     it's headroom so the rule keeps working if the copy grows. */
  text-wrap: balance;
}

/* Solid at every width, not just mobile: the footage runs bright enough in
   places (bg luminance up to ~143) that no text colour clears 4.5:1 against it,
   and neither an overlay nor a shadow is available to buy contrast back.
   An opaque pill carries its own background, which sidesteps that entirely: the
   ivory label sits at 5.6:1 against the accent no matter what is behind it. */
.hero__content .btn--outline {
  margin-top: clamp(28px, 4vh, 40px);
  background: var(--accent);
  color: var(--ivory);
  border-color: var(--accent);
}

/* Needed because .btn--outline:hover inverts to an ivory fill -- correct for a
   ghost button, but on this one it would flip the primary CTA from the accent to
   white on hover. Lifts within the accent instead, so both booking pills behave
   identically. */
.hero__content .btn--outline:focus-visible {
  background: var(--accent-lift);
  border-color: var(--accent-lift);
  color: var(--ivory);
}

/* ---------- Hero content, portrait ---------- */

/* Portrait splits the copy into two groups so the paragraph clears the face.
   Headline + eyebrow keep their default placement -- same nav padding and same
   clamp() top padding as every other breakpoint, deliberately untouched here --
   and the paragraph + CTA drop away to the bottom of the hero instead of stacking
   under them. The headline is allowed to run over the hair; the paragraph is not,
   which is the whole reason the two groups are decoupled.

   The split is done with `margin-top: auto` on the paragraph rather than a fixed
   offset, so it is the *free space* that gets distributed -- the bottom group stays
   pinned to the footer at any viewport height instead of drifting off a magic
   number tuned for one phone. Moving the top group therefore does not move it.

   Measured down the text column on the graded clip (not the full row -- the cape
   sits centre-right and a full-width mean misreads what is behind the copy).
   Luminance peaks at 100 around y 425-475 where the cape catches the light, then
   falls away steadily: 70 at y 575, 53 at y 625, 43 at y 650. Pushed to the
   bottom, the paragraph sits at y 573-650 on that falling edge -- worst-case
   background 57, so 7.2:1. The short-phone case is the tight one: 360x640 puts it
   at y 369-446 where the cape is still bright (74), and that still clears at
   5.8:1. Verified 5.8 / 6.0 / 7.2 / 7.9 / 8.4 across 360x640, 375x667, 390x844,
   430x932, 768x1024.

   Only the paragraph is constrained here -- the CTA is an opaque accent pill and
   carries its own background, so it can sit anywhere.

   MUST stay after the .hero__body / .hero__content .btn--outline rules above:
   a media query adds no specificity, so at equal weight the later rule wins. This
   block sat before them once and only its .hero__content lines applied -- the same
   trap `.btn.nav__toggle` documents further up. */
@media (max-aspect-ratio: 1 / 1) {
  /* flex + `flex: 1` is what makes `margin-top: auto` below have space to work
     with: the content block has to actually fill the gap between nav and footer,
     otherwise it shrink-wraps the copy and there is no free space to absorb.
     Note it does NOT touch padding-top -- the top group keeps the same nav gap
     and clamp() top padding as every other breakpoint. */
  .hero__content {
    flex: 1;
    display: flex;
    flex-direction: column;
    /* Lifts the top group ~42px at 390x844 vs the base clamp(48px,12vh,140px),
       to open headroom above the subject. The vh term is scaled rather than a
       fixed nudge subtracted, so short phones lift proportionally instead of
       over-lifting into the wordmark.

       The floor matters more than the ceiling here: it is what keeps the gap
       between nav and headline at 45px on a 360x640, and an earlier revision
       that closed that gap to 22px read as a mistake rather than a decision.
       Don't take the vh term below ~7 without re-checking that gap. */
    padding-top: clamp(34px, 7vh, 92px);
    padding-bottom: 24px;
  }

  .hero__body { margin-top: auto; }

  /* `align-self` because the flex parent above would otherwise stretch the pill
     to the full column width -- inline-flex only hugs its content while the
     container is a block. Keeps it the same shape as the nav pill. */
  .hero__content .btn--outline {
    margin-top: 26px;
    align-self: flex-start;
  }
}

/* ---------- Footer ---------- */

.footer {
  margin-top: auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 28px var(--side) 32px;
}

.footer__est,
.footer__tagline {
  font-size: 0.7rem;
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ivory-soft);
}

.footer__tagline {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
}

.footer__social {
  display: flex;
  gap: 16px;
  margin-left: auto;
}

.footer__social a {
  color: var(--ivory-soft);
  display: inline-flex;
  transition: color 0.25s ease, transform 0.2s ease;
}

.footer__social a:focus-visible {
  color: var(--ivory);
  transform: translateY(-1px);
}

.footer {
  position: relative;
}

@media (max-width: 640px) {
  .footer {
    flex-wrap: wrap;
    justify-content: center;
    text-align: center;
    gap: 12px;
  }
  .footer__tagline {
    position: static;
    transform: none;
    order: 3;
    width: 100%;
  }
  .footer__social { margin-left: 0; }
}

/* ---------- Sub-page shell ---------- */

/* Everything below serves the four sub-pages. They deliberately reuse .nav and
   .footer verbatim rather than getting their own variants -- those two rules are
   already written against no particular parent, so the only thing a sub-page has
   to supply is the flex column that lets .footer's `margin-top: auto` reach the
   bottom. .hero does the same job on the index; .page is its plain-black twin. */
.page {
  min-height: 100svh;
  display: flex;
  flex-direction: column;
}

/* ⚠️ No max-width and no `margin: 0 auto` here, deliberately. The whole site
   hangs off ONE left spine -- the wordmark, the hero headline, the eyebrow, the
   body and the footer all start at the `--side` gutter. Centring this column
   inside a max-width put the page title 128px right of the wordmark at 1440px,
   which broke that spine and read as a different site. Same gutter as .nav and
   .footer means all three align on both edges at every width.
   Line length is held back on the blocks that need it (.page__intro, .prose,
   .services) rather than by squeezing the whole column, so the gallery grid can
   still use the full measure. */
.page__main {
  flex: 1;
  width: 100%;
  padding: clamp(36px, 7vh, 76px) var(--side) clamp(56px, 9vh, 104px);
}

.page__head {
  margin-bottom: clamp(36px, 6vh, 64px);
}

/* Same tracked micro-label as the hero eyebrow, and it carries --track for the
   same reason: the entrance keyframe animates into this exact value. */
.page__eyebrow {
  font-size: 0.72rem;
  font-weight: 600;
  --track: 0.28em;
  letter-spacing: var(--track);
  text-transform: uppercase;
  /* --ivory-soft, not --ivory-faint. At 0.4 alpha over black the faint token
     measures ~3.2:1, which fails WCAG AA for anything under 24px -- and every
     label on these pages is 11px. --ivory-faint was defined but unused before
     this page shell existed; it is a decorative tone, not a text tone. */
  color: var(--ivory-soft);
}

.page__title {
  margin-top: 14px;
  font-family: var(--font-display);
  font-weight: 400;
  /* Caps below the hero's 5.5rem on purpose. The hero headline is the loudest
     type on the site and nothing should tie with it. */
  font-size: clamp(2.25rem, 5.5vw, 3.75rem);
  line-height: 1.05;
  letter-spacing: -0.01em;
}

/* Matches the hero headline's descender fix -- .page__title runs a tight
   line-height too, and "Palvelut" / "Yhteystiedot" both carry a descender. No
   mask here, so this is only about not colliding with the paragraph below. */
.page__intro {
  margin-top: 20px;
  max-width: 52ch;
  font-size: 1rem;
  line-height: 1.6;
  color: var(--ivory-soft);
  text-wrap: balance;
}

.page__section + .page__section {
  margin-top: clamp(48px, 8vh, 88px);
}

.page__section-title {
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--ivory-soft);
  margin-bottom: 24px;
}

/* Body prose on Meistä. Narrower than .page__main so the measure stays readable
   -- Finnish sets long, and 74rem of running text would be unreadable. */
.prose {
  max-width: 58ch;
}

.prose p + p {
  margin-top: 1.1em;
}

.prose p {
  font-size: 1rem;
  line-height: 1.7;
  color: var(--ivory-soft);
}

/* ---------- Services list ---------- */

/* A ruled list, not cards: cards would need fills or borders on four sides and
   start competing with the hairline pills. One rule per row keeps the page as
   quiet as the hero. */
.services {
  border-top: 1px solid var(--hairline);
  /* Capped while the page column is not: a price sitting 1272px from the service
     it belongs to is unscannable, however tidy the rule line looks. */
  max-width: 60rem;
}

.service {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: clamp(16px, 4vw, 48px);
  padding: 22px 0;
  border-bottom: 1px solid var(--hairline);
}

.service__name {
  font-size: 1.05rem;
  font-weight: 500;
}

.service__desc {
  margin-top: 6px;
  font-size: 0.85rem;
  line-height: 1.5;
  color: var(--ivory-soft);
  max-width: 46ch;
}

/* Playfair for the figure so price reads as a considered value rather than a
   spreadsheet cell. Tabular so the column stays aligned once real numbers land. */
.service__price {
  flex: none;
  font-family: var(--font-display);
  font-size: 1.05rem;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

@media (max-width: 560px) {
  .service {
    flex-direction: column;
    align-items: flex-start;
    gap: 8px;
  }
}

/* ---------- Gallery ---------- */

/* Two columns on a phone, auto-fill above it.
   ⚠️ Do not collapse this back to a single `auto-fill` rule. A 230px minimum
   fits only ONE column at 390px, which stacks the eight 4:5 tiles into a
   ~7800px page -- nine screens of scrolling. That reads as a broken page rather
   than a gallery, and it is worst in exactly the empty-placeholder state the
   owners see first. Two columns keeps it legible as a grid at every width. */
.gallery {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: clamp(10px, 1.6vw, 18px);
}

@media (min-width: 700px) {
  .gallery {
    grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
  }
}

.gallery figure {
  margin: 0;
}

/* 4:5 portrait. Set on the figure rather than the image so the row heights are
   already correct before a single photo has loaded -- swapping a placeholder for
   a real <img> then causes no layout shift at all. */
.gallery__frame {
  position: relative;
  aspect-ratio: 4 / 5;
  overflow: hidden;
  background: #0a0a0a;
}

.gallery__frame img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* The empty state. Deliberately reads as a labelled, waiting slot rather than a
   broken image: a hairline box with its intended subject named inside it, so the
   page is presentable to the owners before any photography exists.
   ⚠️ No border on .gallery__frame itself -- the no-frames-on-imagery rule applies
   once real photos land, so the outline belongs to the placeholder, not the box. */
.gallery__slot {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  border: 1px solid var(--hairline);
  color: var(--ivory-soft);
  font-size: 0.68rem;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  text-align: center;
  padding: 16px;
}

/* ---------- Contact ---------- */

.contact {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: clamp(28px, 4vw, 56px);
  border-top: 1px solid var(--hairline);
  padding-top: 32px;
}

.contact__label {
  font-size: 0.68rem;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--ivory-soft);
  margin-bottom: 12px;
}

.contact__value {
  font-size: 1rem;
  line-height: 1.6;
  color: var(--ivory);
  font-style: normal;
}

/* ⚠️ The vertical padding is a TAP TARGET, not spacing -- don't "tidy" it away.
   The phone number is the shop's only booking channel until the real booking
   flow exists, so it is the primary conversion action on mobile, and at the bare
   line height it was a 93x19 target. Padding on an INLINE box grows the hit area
   and moves the border without touching the line box, so this buys ~25px of
   target for zero layout shift.
   (For the record it already passed SC 2.5.8 before this: the nearest other
   target is 310px away, so the spacing exception applied. This is a conversion
   decision, not a compliance one.) */
.contact__value a {
  border-bottom: 1px solid var(--hairline);
  padding: 3px 0;
  transition: border-color 0.25s ease, color 0.25s ease;
}

/* Hover is gated so a touch device doesn't latch the state after a tap; the
   phone number is the one link on this page and it is tapped far more often
   than it is hovered. :focus-visible stays OUTSIDE the query -- keyboard focus
   must be visible on every device, hover-capable or not. */
@media (hover: hover) {
  .contact__value a:hover {
    border-color: var(--ivory);
  }
}

.contact__value a:focus-visible {
  border-color: var(--ivory);
}

/* Opening hours. A two-column grid rather than a table -- there is no header row
   to justify one, and the grid collapses cleanly without colspan gymnastics. */
.hours {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 8px 20px;
  font-size: 0.95rem;
  line-height: 1.5;
}

.hours dt {
  color: var(--ivory-soft);
}

.hours dd {
  font-variant-numeric: tabular-nums;
  /* Shrink to the value instead of filling the 1fr track. Without this a `.todo`
     dd stretches the full column and its dotted underline draws a ~320px rule,
     which reads as an empty form field rather than a missing value. Correct for
     real hours too -- they are left-aligned against the day label either way. */
  justify-self: start;
}

/* ---------- Placeholder marker ---------- */

/* ⚠️ Every value the owners still have to supply wears this class. It is
   deliberately VISIBLE -- a dotted underline -- so unfilled prices and hours
   cannot be mistaken for real ones and shipped by accident. Inventing plausible
   prices for a real business is worse than showing a blank. Removing the class
   is what marks a value as confirmed.

   ⚠️ NOTHING CARRIES THIS CLASS RIGHT NOW (as of 2026-07-28 every price, the
   address, the phone and the hours are owner-confirmed) and it is kept anyway,
   overriding this comment's own earlier instruction to delete it when unused.
   Reason: owner data arrives piecemeal on this project, and the next service
   added without a price needs somewhere honest to sit. Deleting the mechanism
   is what makes typing a plausible fake the path of least resistance. It is six
   lines; the failure it prevents is quoting a real customer a made-up price. */
.todo {
  /* The dotted rule is what marks this as unfilled, NOT a dimmed colour. Dimming
     it below --ivory-soft would push an 11px label under 4.5:1, and "this value
     is missing" is not a reason to make it unreadable. */
  color: var(--ivory-soft);
  border-bottom: 1px dotted var(--hairline);
}

/* ---------- Focus ---------- */

a:focus-visible,
.btn:focus-visible {
  outline: 1px solid var(--ivory);
  outline-offset: 3px;
}

/* The link for the page you are already on. Same treatment as :hover so the nav
   reads as "you are here" without adding a new visual idea. */
.nav__links a[aria-current="page"] {
  color: var(--ivory);
}

/* ---------- Hover (pointer devices only) ---------- */

/* Every hover state lives here and nowhere else. On a touchscreen `:hover`
   latches on tap and stays latched until you tap something else -- so tapping
   MENU left the toggle stuck in its filled ivory state. `hover: hover` matches
   only pointers that can truly hover, so touch never enters these rules.
   ⚠️ `:focus-visible` deliberately stays OUTSIDE this block, up with each
   component. A tablet driven by a Bluetooth keyboard reports `hover: none`;
   wrapping focus here would take the focus ring away from exactly the people
   who need it. The two states are visually identical but must be scoped apart.
   Last in the file on purpose: several of these tie on specificity with the
   :focus-visible rule they mirror, so source order is what makes hover win. */
@media (hover: hover) {
  .nav__links a:hover,
  .footer__social a:hover {
    color: var(--ivory);
  }

  .footer__social a:hover {
    transform: translateY(-1px);
  }

  /* Inverts to a solid ivory fill: correct for the ghost pills. */
  .btn.nav__toggle:hover,
  .btn--outline:hover {
    background: var(--ivory);
    border-color: var(--ivory);
    color: var(--ink);
  }

  .btn--solid:hover {
    background: transparent;
    color: var(--ivory);
  }

  /* Both booking pills lift within the accent rather than inverting -- over the
     bright passages a see-through button is the failure this treatment fixed,
     and inverting would flip the primary CTA from the accent to white. */
  .btn.nav__cta:hover,
  .hero__content .btn--outline:hover {
    background: var(--accent-lift);
    border-color: var(--accent-lift);
    color: var(--ivory);
  }

  .btn--outline:hover .btn__icon {
    transform: translate(2px, -2px);
  }

  @media (max-width: 760px) {
    .nav__links a:hover {
      background: rgba(236, 234, 230, 0.08);
    }
  }
}

/* ---------- Hero entrance ---------- */

/* One curve for the whole entrance. Strong ease-out, settles without overshoot:
   the house vocabulary elsewhere in this file is 0.18-0.35s ease, and while an
   entrance legitimately runs longer than a state transition, it must not pick up
   a different personality on the way. Nothing bounces, nothing travels far. */
:root { --ease-entrance: cubic-bezier(0.22, 1, 0.36, 1); }

/* Pre-animation state. Scoped to :not(.anim-in) so it stops applying the instant
   the entrance is triggered -- which lets every animation below use `backwards`
   fill instead of `forwards`. That distinction matters: `forwards` would leave
   transform permanently locked at the keyframe's end value, and .btn:active's
   scale(0.98) press would silently stop working for the rest of the session. */
.js-anim:not(.anim-in) .hero__headline .line__in { transform: translateY(110%); }
.js-anim:not(.anim-in) .hero__eyebrow { opacity: 0; letter-spacing: 0.18em; }
.js-anim:not(.anim-in) .hero__body,
.js-anim:not(.anim-in) .hero__content .btn--outline { opacity: 0; transform: translateY(10px); }
.js-anim:not(.anim-in) .nav,
.js-anim:not(.anim-in) .footer { opacity: 0; }

@keyframes line-rise {
  from { transform: translateY(110%); }
  to   { transform: translateY(0); }
}

@keyframes rise-fade {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Tight -> wide, and never the reverse. 0.28em at 0.72rem only just holds one
   line at 360px, so starting NARROWER guarantees the label is never wider than
   its settled state and cannot wrap mid-animation. `letter-spacing` relayouts
   every frame rather than compositing; fine for one short line, but if it ever
   janks, fall back to opacity-only -- do not fix it by flipping the direction. */
@keyframes track-in {
  from { opacity: 0; letter-spacing: 0.18em; }
  to   { opacity: 1; letter-spacing: var(--track); }
}

@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.anim-in .nav { animation: fade-in 0.4s var(--ease-entrance) 0ms backwards; }

/* The letters themselves never fade -- they are revealed purely by the mask edge
   travelling up. Adding opacity here collapses the effect into an ordinary
   crossfade and loses the whole point of the clip. */
.anim-in .hero__headline .line:nth-child(1) .line__in {
  animation: line-rise 0.62s var(--ease-entrance) 40ms backwards;
}

.anim-in .hero__headline .line:nth-child(2) .line__in {
  animation: line-rise 0.62s var(--ease-entrance) 130ms backwards;
}

.anim-in .hero__eyebrow { animation: track-in 0.5s var(--ease-entrance) 260ms backwards; }
.anim-in .hero__body { animation: rise-fade 0.5s var(--ease-entrance) 380ms backwards; }
.anim-in .hero__content .btn--outline { animation: rise-fade 0.5s var(--ease-entrance) 470ms backwards; }
.anim-in .footer { animation: fade-in 0.4s var(--ease-entrance) 560ms backwards; }

/* Sub-pages get a much shorter version of the same gesture -- two elements, same
   curve, same fade-up. The hero's staged 1.0s reveal is a front-door moment and
   would be tiresome on a page the visitor navigated to deliberately.
   The nav and footer rules above already cover these pages, which is why both
   scripts have to be present on every sub-page: `.js-anim` hides the nav, and
   without the trigger that adds `.anim-in` it would never come back. */
.js-anim:not(.anim-in) .page__head,
.js-anim:not(.anim-in) .page__section { opacity: 0; transform: translateY(10px); }

.anim-in .page__head { animation: rise-fade 0.5s var(--ease-entrance) 60ms backwards; }
.anim-in .page__section { animation: rise-fade 0.5s var(--ease-entrance) 160ms backwards; }

/* Everything lands finished, instantly. The pre-animation state above must be
   OVERRIDDEN, not merely un-animated -- neutralising the animation alone would
   leave .js-anim's opacity:0 in force and hand reduced-motion users a blank
   hero. !important is deliberate here: the pre-state selectors carry a :not()
   that outweighs these, and an accessibility override should not be settled by
   who happens to sit lower in the file. */
@media (prefers-reduced-motion: reduce) {
  .js-anim .hero__headline .line__in,
  .js-anim .hero__eyebrow,
  .js-anim .hero__body,
  .js-anim .hero__content .btn--outline,
  .js-anim .page__head,
  .js-anim .page__section,
  .js-anim .nav,
  .js-anim .footer {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }

  .js-anim .hero__eyebrow { letter-spacing: var(--track) !important; }
}
