/* ==========================================================================
   Mehandi Saga — The Stain Arc
   One stylesheet. Cascade layers keep specificity flat and predictable.

   Palette derives from measured henna chemistry: the paste is dark olive,
   the stain leaves the skin bright orange, oxidises through red and settles
   to maroon over 72 hours. The arc is ordered by lightness, not by hue, so
   it stays readable for red-green colour vision deficiency and in greyscale.

   Two grounds, split by purpose. Gold only reaches contrast on the dark
   ground; the deep stain reds only reach contrast on paper. Orange is the
   one colour that clears 3:1 on both, so it carries every primary action
   and every focus ring.

   Every value below was checked against WCAG 2.x relative luminance before
   it was written here. Ratios are recorded in comments.
   ========================================================================== */

@layer tokens, base, layout, components, utilities;

/* ---------------------------------------------------------------- fonts -- */

@font-face {
  font-family: "Libre Bodoni";
  src: url("../font/bodoni-var.woff2") format("woff2-variations");
  font-weight: 400 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Manrope";
  src: url("../font/manrope-400.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Manrope";
  src: url("../font/manrope-600.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Manrope";
  src: url("../font/manrope-700.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

/* ---------------------------------------------------------------------------
   MOVING BETWEEN PAGES.

   Home, Designs, Rates & booking and Questions & care are four documents, and
   before this the browser simply replaced one with the other -- a white flash
   and a jump, which reads as the page breaking rather than the page changing.

   Cross-document view transitions fix that with no JavaScript at all: both
   documents opt in through this at-rule, the browser snapshots the outgoing
   page, snapshots the incoming one and animates between them. That matters
   here beyond taste -- the content security policy is script-src 'self' with
   no inline handlers and there is no build step, so a router or a page-
   transition library was never available. Where the browser does not support
   it (Firefox, at time of writing) nothing is added and nothing is broken:
   the navigation happens exactly as it did before.

   Deliberately quick and small. A page transition is a join between two
   things a person asked for, not an effect; anything longer than about a
   quarter of a second turns a click into a wait.
   -------------------------------------------------------------------------- */
@view-transition { navigation: auto; }

/* The masthead and the footer are the same objects on every page, so they are
   named and travel as themselves rather than cross-fading with the content
   behind them. That is what makes the site feel like one place rather than
   four documents that happen to look alike. */
.masthead { view-transition-name: masthead; }
.footer { view-transition-name: sitefooter; }

@media (prefers-reduced-motion: no-preference) {
  /* Shorter than it was. With the next page prerendered there is nothing to
     cover any more, so the animation is only marking the change rather than
     filling a wait -- and an animation that outlasts the thing it explains is
     just a delay somebody has to sit through. */
  ::view-transition-old(root) { animation: page-leaves .12s ease-in both; }
  ::view-transition-new(root) { animation: page-arrives .18s cubic-bezier(.2, .7, .3, 1) both; }
}

/* Somebody who has asked for less motion gets the navigation they had before:
   the transition is not softened, it is removed. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}

@keyframes page-leaves {
  to { opacity: 0; transform: translateY(-4px); }
}
@keyframes page-arrives {
  from { opacity: 0; transform: translateY(6px); }
}

@layer tokens {
  :root {
    /* -- grounds ------------------------------------------------------- */
    --brand:        #002f1d;  /* sampled from the supplied logo            */
    --brand-deep:   #001a11;  /* footer                                    */
    --brand-lift:   #0a3f2a;  /* raised panels on the brand ground         */
    /* A mehndi night, as it runs from dusk to the henna itself. Every stop is
       a saturated jewel hue: a dark palette reads as gloomy when it is
       desaturated and monotone, and as festive when the hue keeps moving.
       These average 76% saturation against 69% for the stages they replaced,
       and the hue travels green to teal to indigo to violet to plum rather
       than sitting in one muddy family. Checked against every token first;
       tightest across the journey is the interactive borders at 127% of 3:1. */
    --leaf:          #04321f;  /* the garden at dusk    */
    --dried:         #043029;  /* lanterns going up     */
    --powder:        #0d2438;  /* the night sky         */
    --paste-ground:  #231436;  /* the music starts      */
    --cone:          #33132c;  /* the henna begins      */
    --stain-ground:  #3a1220;  /* petals underfoot      */
    --brand-lit:    #073c28;
    --brand-shade:  #00140d;
    --paper:        #f9faf7;
    --journey-ground: #f6eedd;  /*  warm ground for the cartoon band   */
    --surface:      #ffffff;
    --surface-sunk: #f1f1ec;

    /* -- the mark ------------------------------------------------------ */
    --gold:         #e8b86a;  /* 8.07:1 on brand — dark ground only        */
    --gold-dim:     #c79a4e;  /* borders only, non-text: 3:1 floor         */

    /* -- the stain arc, ordered by lightness ---------------------------- */
    --paste:        #2e3a18;  /* L* 22.6 · 11.56:1 on paper                */
    --stain-fresh:  #d96e27;  /* L* 58.1 · the bridge: 3.23 paper / 4.36 dark */
    --stain-day:    #b54a1f;  /* L* 45.4 ·  5.06:1 on paper                */
    --stain-peak:   #8c2f22;  /* L* 33.4 ·  7.88:1 on paper                */
    --stain-set:    #5c1f1b;  /* L* 21.3 · 12.03:1 on paper                */
    --stain-hover:  #ce6524;  /* 4.69:1 with ink text                      */

    /* -- text ---------------------------------------------------------- */
    --ink:          #1a1712;  /* 17.06:1 on paper                          */
    --muted:        #5f5749;  /*  6.80:1 on paper                          */
    --cream:        #f4efe3;  /* 12.85:1 on brand                          */
    /* Lifted from #b9c6bc. Muted text on a dark ground had only 5.28:1 --
       0.78 above its floor -- which left the design no headroom for ANY
       ornament behind it. The moment the story layer became visible enough to
       be seen, six runs of body copy fell to 3.8:1. Ornament and legibility
       are not really in conflict here; the muted token was simply too close to
       its own limit to absorb anything. Measured at glyph pixels with the
       artwork at 40%, this reads 4.88:1. */
    --cream-soft:   #d3ddd6;

    /* -- lines --------------------------------------------------------- */
    --hairline:     #dcd7cb;  /* decorative rules only                     */
    --hairline-dk:  #174433;
    --edge:         #8a8172;  /* 3.67:1 — interactive borders              */
    --edge-dk:      #79a191;  /* interactive borders on every dark ground.
                                 Lifted when the ground became a lit mehndi
                                 night: 3.75:1 against the brightest measured
                                 point of the field, 4.17:1 on the glass. */

    /* -- status. deliberately outside the arc; never green-vs-red ------- */
    --open:         #1f5145;  /*  8.64:1 on paper                          */
    --held:         #8a5f0d;  /*  5.38:1 on paper                          */
    --closed:       #5f5749;  /*  6.80:1 on paper                          */

    /* -- type ---------------------------------------------------------- */
    --display: "Libre Bodoni", Didot, "Times New Roman", serif;
    --text: "Manrope", "Segoe UI", system-ui, sans-serif;

    --step--1: clamp(0.78rem, 0.76rem + 0.1vw, 0.83rem);
    --step-0:  clamp(1rem, 0.97rem + 0.15vw, 1.06rem);
    --step-1:  clamp(1.2rem, 1.12rem + 0.4vw, 1.45rem);
    --step-2:  clamp(1.5rem, 1.32rem + 0.9vw, 2.1rem);
    --step-3:  clamp(2rem, 1.6rem + 2vw, 3.2rem);
    --step-4:  clamp(2.6rem, 1.8rem + 4vw, 5rem);

    /* -- space --------------------------------------------------------- */
    --gutter: clamp(1.15rem, 4vw, 3.5rem);
    /* How far a paper panel insets its own content. The panel widens by twice
       this so its text lands on the same line as text on the field.
       The floor matches --gutter's floor deliberately: the panel has to bleed
       outward by exactly this much, and it cannot bleed further than the
       gutter without leaving the viewport. Set 1.25rem against a 1.15rem
       gutter, the panel was clamped at the edge on a phone and its text ended
       up 1.6px inside everything else. */
    --paper-pad: clamp(1.15rem, 2.6vw, 2.9rem);
    --band:   clamp(3rem, 5.5vw, 5.5rem);
    --radius: 3px;
    --radius-lg: 6px;

    /* -- targets. WCAG 2.2 SC 2.5.8 floor is 24px; 44px is the comfort
          size for a thumb on a phone, which is the primary context here. */
    --tap: 44px;
    --tap-min: 24px;

    --shadow: 0 1px 2px rgb(26 23 18 / 6%), 0 10px 30px -18px rgb(26 23 18 / 35%);

    color-scheme: light;
  }
}

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

  html { -webkit-text-size-adjust: 100%; }

  body {
    margin: 0;
    background-color: transparent;
    color: var(--cream);
    font-family: var(--text);
    font-size: var(--step-0);
    line-height: 1.6;
    -webkit-font-smoothing: antialiased;
    text-rendering: optimizeLegibility;
  }

  /* ---------------------------------------------------------- the field --
     The ground is a mehndi night.

     Henna is applied at a party. The night runs from the garden at dusk,
     through lanterns going up and the sky turning over, into music and the
     henna itself, and the palette runs with it: emerald, teal, indigo,
     violet, plum, rose. Every stop is a saturated jewel hue, because a dark
     palette reads as gloomy when it is desaturated and monotone, and as
     festive when the hue keeps moving. Depicting the making of the paste
     instead put three stages of dried leaf and mud in the middle, and the
     page went sombre.

     Hanging through all of it, genda phool -- the marigold garlands strung
     at a mehndi. Strands of layered blooms, built on a sine so the tile
     repeats with no seam, with any bloom near an edge drawn again on the far
     side so no flower is sliced at the join. They sit on the body rather
     than a fixed layer, so they scroll with the content.

     Warm lantern light falls across the upper left. Everything ornamental is
     black, because shadow can only darken and so can never pull text below
     its floor; the colour and the warmth come from the ground itself.

     The colour is driven by `animation-timeline: scroll()`, which runs on
     the compositor thread rather than the main thread. Browsers without it
     get the leaf green and lose nothing else. */

  /* An explicit background here stops the body background propagating to the
     canvas, which would detach the vine from the document and stop it
     scrolling. */
  html { background-color: var(--leaf); }

  html::before,
  body::after {
    content: "";
    position: fixed;
    inset: 0;
    pointer-events: none;
  }

  /* the stages */
  html::before {
    z-index: -3;
    background-color: var(--leaf);
    /* Lanterns. Saturated and warm, kept to a low alpha so they add hue
       rather than luminance -- brightness is what costs contrast. */
    background-image:
      radial-gradient(46% 34% at 12% 6%, rgb(245 165 36 / 5%) 0%, transparent 70%),
      radial-gradient(38% 30% at 88% 22%, rgb(226 61 119 / 4%) 0%, transparent 72%),
      radial-gradient(52% 40% at 72% 84%, rgb(240 112 31 / 4%) 0%, transparent 74%),
      radial-gradient(125% 100% at 8% -12%, rgb(255 236 196 / 4%) 0%, transparent 72%);
  }
  @keyframes making-of {
    0%   { background-color: var(--leaf); }
    20%  { background-color: var(--dried); }
    40%  { background-color: var(--powder); }
    60%  { background-color: var(--paste-ground); }
    80%  { background-color: var(--cone); }
    100% { background-color: var(--stain-ground); }
  }
  @supports (animation-timeline: scroll()) {
    html::before {
      animation: making-of linear both;
      animation-timeline: scroll(root block);
    }
  }

  /* Genda phool stays as ambient texture, very faint, under the story. */
  body {
    background-image: url("../img/garland.svg");
    background-repeat: repeat;
    background-size: 1120px 1700px;
    background-position: center top;
  }


  /* ------------------------------------------------- leaves behind the page

     The quiet half of the cartoon. Henna leaves and paste drops, drawn with
     the same wobbling pen as the journey band, drifting at different rates as
     you scroll so the page has some depth without anything to look at.

     This sits under body text, so it is held to the ink value measured safe
     with scripts/contrast_over_art.py. The band is where the drawing gets to
     be loud; here it is texture. */
  .ambient {
    position: fixed;
    inset: 0;
    width: 100%;
    height: 100%;
    z-index: -2;
    pointer-events: none;
    /* 24%, not 34%. The eyebrow labels are gold and so are these leaves, so
       where one passes behind the other the contrast collapses toward 1:1 --
       measured at 3.58:1 against a 4.5 floor, and 7.74:1 with the leaves
       hidden. Same hue is the worst case. The leaves are texture now; the
       drawings carry the artwork, so this can afford to be quiet. */
    color: rgb(232 184 106 / 24%);
    contain: strict;
  }
  .ambient path {
    fill: none;
    stroke: currentColor;
    stroke-linecap: round;
    stroke-linejoin: round;
  }
  @media (max-width: 48rem) {
    .ambient path { stroke-width: 4.4; }
  }

  @supports (animation-timeline: scroll()) {
    @media (prefers-reduced-motion: no-preference) {
      /* Scroll-driven rather than timed: nothing moves while the page is
         still, so an idle tab costs nothing. Six rates, so the leaves
         separate into layers instead of sliding as one sheet. */
      .ambient .drift {
        animation: linear both;
        animation-timeline: scroll(root block);
        transform-box: fill-box;
        transform-origin: center;
      }
      .ambient .d0 { animation-name: drift-0; }
      .ambient .d1 { animation-name: drift-1; }
      .ambient .d2 { animation-name: drift-2; }
      .ambient .d3 { animation-name: drift-3; }
      .ambient .d4 { animation-name: drift-4; }
      .ambient .d5 { animation-name: drift-5; }
    }
  }
  @keyframes drift-0 { from { transform: translate(0, 0) rotate(0) }        to { transform: translate(14px, -120px) rotate(26deg) } }
  @keyframes drift-1 { from { transform: translate(0, 0) rotate(0) }        to { transform: translate(-22px, -78px) rotate(-31deg) } }
  @keyframes drift-2 { from { transform: translate(0, 0) rotate(0) }        to { transform: translate(30px, -168px) rotate(18deg) } }
  @keyframes drift-3 { from { transform: translate(0, 0) rotate(0) }        to { transform: translate(-12px, -52px) rotate(40deg) } }
  @keyframes drift-4 { from { transform: translate(0, 0) rotate(0) }        to { transform: translate(20px, -134px) rotate(-16deg) } }
  @keyframes drift-5 { from { transform: translate(0, 0) rotate(0) }        to { transform: translate(-30px, -96px) rotate(23deg) } }

  /* ---------------------------------------------------- the journey gallery

     Five drawings the reader moves through: swipe, trackpad, drag the bar, or
     tab to it and use the arrow keys. Scroll-snap brings one scene to rest at
     a time.

     This was a pinned band that panned sideways as the page scrolled past it,
     and that is why the page felt like it was hanging. Frame times were never
     the problem -- measured flat at 16.7ms with no frame over 50ms, with every
     layer on -- the page simply stopped going where the reader pushed it, for
     two and a half screens, and did it again on the way back up. Nielsen
     Norman's testing on scrolljacking is blunt about the result: people read a
     page that stops responding to vertical input as broken, and some try to fix
     it by reloading. The old band broke nearly every guideline they give at
     once: it changed direction, it ran long, it carried text, and it did all of
     that on phones, which they say to avoid outright.

     So the drawing stays and the hijacking goes. Horizontal movement happens
     here only when someone asks for it.

     It still gets its own ground: a cartoon needs filled colour to read, and
     filled colour behind paragraphs wrecks their contrast. */
  .journey {
    position: relative;
    padding-block: clamp(2rem, 5vw, 3.5rem);
    /* No ground and no rules. The drawings carry their own paper, so the
       section is just more page: nothing here announces a boundary. Captions
       sit on the field, so they take the field's light text. */
    color: var(--cream);
  }
  .journey-stage {
    scrollbar-gutter: stable;
    display: flex;
    gap: clamp(1rem, 3vw, 2.5rem);
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory;
    align-items: start;
    /* The stage is not inside a .shell, so it works out the shell's own left
       edge itself: whichever of the gutter and the centring margin is larger.
       That is where a section starts, and the drawings start there too. */
    scroll-padding-inline: max(var(--gutter), calc((100% - 82rem) / 2));
    padding-inline: max(var(--gutter), calc((100% - 82rem) / 2));
    padding-block-end: 1rem;
  }
  .journey-stage:focus-visible {
    outline: 3px solid var(--stain-fresh);
    outline-offset: 4px;
  }
  .scene {
    position: relative;
    flex: 0 0 auto;
    margin: 0;
    scroll-snap-align: center;
    width: min(30rem, 82vw);
  }
  /* A patch of paper under each drawing, with no edge. The radial mask makes
     the wash fade out well before it reaches a boundary, so there is nothing
     for the eye to read as the edge of a card. The drawing still sits on
     paper -- flat colour needs a light ground to read -- but the paper stops
     being a rectangle. */
  .scene::before {
    content: "";
    position: absolute;
    /* The wash stays inside its own figure at the top. Bleeding it upward put
       cream behind the gold eyebrow label above the rail, and gold on cream is
       a much worse pairing than gold on the field -- measured 3.27:1 there
       against 8.63:1 where the wash does not reach. */
    inset: 0 -6% 13% -6%;
    z-index: -1;
    background: var(--journey-ground);
    /* farthest-side, so the fade finishes exactly at the box edge rather than
       running out of element while still opaque.

       Centred at 50% 50%, and that is the whole fix. Off-centre at 50% 42%,
       farthest-side took its vertical radius from the FARTHER side -- the 58%
       below -- and then drew that same radius upward too, reaching -16%. The
       element clipped it, and the pool arrived with a flat edge sliced across
       the top of every drawing. On centre the radii are 50% each way, so the
       ellipse closes on all four sides and there is nothing left to cut. */
    -webkit-mask-image: radial-gradient(farthest-side at 50% 50%, #000 30%, rgb(0 0 0 / 55%) 68%, transparent 100%);
    mask-image: radial-gradient(farthest-side at 50% 50%, #000 30%, rgb(0 0 0 / 55%) 68%, transparent 100%);
  }
  .scene-art {
    display: block;
    width: 100%;
    height: auto;
    color: #3a2a1c;
    /* The drawing fades with its paper. Scene three's sea is a filled block
       that runs the width of its box, and without this it ended on a hard
       horizontal edge floating in the middle of a soft wash. Gentler than the
       paper's mask so the drawing stays solid well past where the paper has
       started to go. */
    /* Centred for the same reason as the paper's mask above: off-centre, the
       vertical radius came from the farther side and was clipped at the top. */
    -webkit-mask-image: radial-gradient(farthest-side at 50% 50%, #000 62%, transparent 100%);
    mask-image: radial-gradient(farthest-side at 50% 50%, #000 62%, transparent 100%);
  }
  .scene-art .line,
  .scene-art .route,
  .scene-art .drawn path {
    stroke: currentColor;
    stroke-linecap: round;
    stroke-linejoin: round;
  }
  .scene-art .wash { stroke: none; }
  .scene-art .route { stroke-dasharray: 11 15; }
  .scene-art .drawn path { stroke: #7a2718; }

  .scene figcaption {
    display: flex;
    align-items: baseline;
    gap: .5rem;
    margin-top: .6rem;
    flex-wrap: wrap;
  }
  .scene-n {
    font-weight: 800;
    color: var(--gold);
    font-variant-numeric: tabular-nums;
  }
  .scene figcaption { min-width: 0; }
  .scene figcaption b { font-weight: 800; overflow-wrap: break-word; }
  .scene figcaption small { color: var(--cream-soft); font-size: var(--step--1); }

  /* A horizontal rail for previews. The journey gallery established the
     pattern; this is the same idea for the design tiles on the homepage, where
     a grid orphaned its last row as soon as the studio published more designs
     than the grid had columns. A rail holds any number without a ragged end. */
  .rail {
    scrollbar-gutter: stable;
    display: flex;
    gap: clamp(0.8rem, 1.8vw, 1.4rem);
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory;
    scroll-padding-inline: 0;
    padding-block-end: .9rem;
  }
  .rail > * {
    flex: 0 0 auto;
    width: min(19rem, 78vw);
    scroll-snap-align: start;
  }
  .rail:focus-visible { outline: 3px solid var(--stain-fresh); outline-offset: 4px; }

  /* All five at once, joined, above 60rem. A step that has to be swiped to
     is a step most people never see, and a sequence nobody sees the whole of
     is not telling a sequence. Below that width the rail is the right answer
     and it stays. */
  @media (min-width: 60rem) {
    .journey-stage {
      display: grid;
      grid-template-columns: repeat(5, 1fr);
      overflow: visible;
      scroll-snap-type: none;
      gap: clamp(.75rem, 1.6vw, 1.5rem);
    }
    .journey-stage[tabindex] { outline: none; }
    .scene { width: auto; }
    /* The thread between the steps, on ::after -- ::before is already the
       paper wash, and reusing it silently stripped the paper from every scene
       but the first. */
    .scene + .scene::after {
      content: "";
      position: absolute;
      top: 44%;
      right: calc(100% + 1px);
      width: clamp(.75rem, 1.6vw, 1.5rem);
      border-block-start: 2px dashed rgb(232 184 106 / 55%);
    }
    /* Every caption is two lines, so the row of them shares a baseline
       whatever length the words are. */
    .scene figcaption small { flex-basis: 100%; margin-top: .1rem; }
  }

  /* ------------------------------------------------- the journey, revealed

     Each step arrives as it comes into view, and the drawing inside it does
     the thing it depicts: the shipping route dashes travel, the henna pattern
     draws itself onto the hand, the bead falls from the cone.

     view-timeline rather than scroll-timeline. scroll() ties progress to a
     scroller's position, which is what the pinned band used and why the page
     stopped responding; view() ties it to an element's own passage through the
     viewport, which is the right tool for a reveal and cannot hijack anything.
     `animation-range: entry` confines it to the stretch where the step is
     actually arriving, so nothing is animating off-screen.

     Every base state is the FINISHED state -- drawn, opaque, in place. Without
     scroll timelines, or under reduced motion, the whole thing is simply the
     completed drawing. The animation only ever takes something away and gives
     it back, so there is no way for it to leave content invisible. */
  @supports (animation-timeline: view()) {
    @media (prefers-reduced-motion: no-preference) {
      .scene { view-timeline: --step block; }

      .scene-art,
      .scene figcaption {
        animation: step-in linear both;
        animation-timeline: --step;
        animation-range: entry 8% entry 62%;
      }
      .scene figcaption { animation-range: entry 18% entry 74%; }

      /* Side by side above 60rem the five would arrive together, so each is
         given a slightly later window and they cascade left to right. */
      @media (min-width: 60rem) {
        .scene:nth-child(2) .scene-art { animation-range: entry 13% entry 67%; }
        .scene:nth-child(3) .scene-art { animation-range: entry 18% entry 72%; }
        .scene:nth-child(4) .scene-art { animation-range: entry 23% entry 77%; }
        .scene:nth-child(5) .scene-art { animation-range: entry 28% entry 82%; }
        .scene:nth-child(2) figcaption { animation-range: entry 23% entry 79%; }
        .scene:nth-child(3) figcaption { animation-range: entry 28% entry 84%; }
        .scene:nth-child(4) figcaption { animation-range: entry 33% entry 89%; }
        .scene:nth-child(5) figcaption { animation-range: entry 38% entry 94%; }
      }

      .scene-art .route,
      .scene-art .drawn path,
      .scene-art .bead {
        animation-timeline: --step;
        animation-fill-mode: both;
        animation-timing-function: linear;
      }
      .scene-art .route     { animation-name: route-travel;  animation-range: entry 25% entry 92%; }
      .scene-art .drawn path{ animation-name: pattern-draw; animation-range: entry 30% entry 96%; }
      .scene-art .bead      { animation-name: bead-fall;    animation-range: entry 45% entry 100%; }
    }
  }

  /* Finished states, so an unsupported browser shows the completed drawing. */
  .scene-art .drawn path { stroke-dasharray: 760; stroke-dashoffset: 0; }
  .scene-art .bead { transform-box: fill-box; transform-origin: center; }

  @keyframes step-in {
    from { opacity: 0; transform: translateY(16px); }
    to   { opacity: 1; transform: none; }
  }
  @keyframes route-travel  { from { stroke-dashoffset: 420; } to { stroke-dashoffset: 0; } }
  @keyframes pattern-draw  { from { stroke-dashoffset: 760; } to { stroke-dashoffset: 0; } }
  @keyframes bead-fall {
    0%       { opacity: 0; transform: translateY(-14px); }
    55%      { opacity: 1; }
    100%     { opacity: 1; transform: translateY(4px); }
  }

  .journey-hint {
    display: flex;
    align-items: center;
    gap: .5rem;
    margin: 0 0 .9rem;
    font-size: var(--step--1);
    color: var(--cream-soft);
  }
  /* After the rule it overrides, not before it: same specificity means source
     order decides, and a media query placed first simply loses. The hint
     describes swiping, and above 60rem there is nothing to swipe. */
  @media (min-width: 60rem) {
    .journey-hint { display: none; }
  }

  /* the grain */
  body::after {
    z-index: -1;
    background-image: url("../img/grain.png");
    background-size: 128px 128px;
    opacity: .045;
  }

  h1, h2, h3, h4 {
    font-family: var(--display);
    font-weight: 500;
    line-height: 1.02;
    letter-spacing: -0.02em;
    text-wrap: balance;
    margin: 0;
  }
  h1 { font-size: var(--step-4); }
  h2 { font-size: var(--step-3); }
  h3 { font-size: var(--step-2); }
  h4 { font-size: var(--step-1); }

  h1 em, h2 em, h3 em { font-style: italic; font-weight: 400; }

  p { margin: 0 0 1rem; }
  p:last-child { margin-bottom: 0; }

  a { color: inherit; text-underline-offset: 0.18em; text-decoration-thickness: 1px; }

  img, svg, video { max-width: 100%; height: auto; display: block; }
  /* Nothing the studio types should be able to make the page scroll sideways.

     `anywhere`, not `break-word`. The two behave identically once a line is
     being laid out; the difference is that only `anywhere` lets the break
     count when the browser works out an element's MINIMUM content width. With
     `break-word` a 55-character service name still reports a min-content of 55
     characters, the grid item is sized to that, and the item overflows its
     track -- which is exactly what was still happening after the first pass. */
  h1, h2, h3, h4, h5, h6, p, li, dd, dt, td, th, figcaption, label, legend,
  blockquote, summary { overflow-wrap: anywhere; }

  /* And every layout container's children may be narrower than their contents.
     A grid or flex child defaults to `min-width: auto`, which is min-content,
     so without this the child overflows the track its parent sized for it. */
  .grid-auto > *,
  .tiles > *,
  .split > *,
  .stack > *,
  .stack-lg > *,
  .ledger-rows > * { min-width: 0; }

  ul, ol { margin: 0 0 1rem; padding-left: 1.15rem; }
  li { margin-bottom: 0.35rem; }

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

  /* One focus treatment everywhere. Orange is the only colour that clears
     3:1 against both the paper and the brand ground, so it never has to
     change with the surface. WCAG 2.2 SC 2.4.11. */
  :focus-visible {
    outline: 3px solid var(--stain-fresh);
    outline-offset: 2px;
    border-radius: 2px;
  }
  :focus:not(:focus-visible) { outline: none; }

  ::selection { background: var(--stain-fresh); color: var(--ink); }

  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 0.01ms !important;
      scroll-behavior: auto !important;
    }
  }
}

@layer layout {
  /* One box for every section, and the text inset inside it.

     The cards are back, and the alignment fault they used to have is gone,
     because the box no longer changes size depending on whether it is painted.
     A panel used to be WIDER than the column by its own padding, so its edge
     landed outside where the surrounding text began and appeared to break out
     of the page. Now the box IS the column -- the same for a paper section and
     a field one -- and the padding sits inside it, the way a card's padding is
     supposed to.

     That gives two lines down the page and only two: the section box, which
     paper paints and the field does not, and the text, inset by --paper-pad
     from it. Both are the same on every section. */
  /* The shell carries no inline padding of its own. What starts a section --
     a card's edge on paper, the first words on the field -- lands on the same
     line either way, so the sections read as parallel down the page. A card
     then insets its own text from that edge, which is what a card's padding is
     for and what the eye expects a card to do. */
  .shell { width: min(100% - var(--gutter) * 2, 82rem); margin-inline: auto; }
  .shell-narrow { width: min(100% - var(--gutter) * 2, 48rem); margin-inline: auto; }

  .band { padding-block: var(--band); }
  .band-tight { padding-block: clamp(2rem, 4vw, 3.5rem); }

  /* The two grounds. Which one a section stands on says what it is for:
     dark is the brand speaking, paper is the studio working.

     The dark sections no longer paint a fill. They are openings onto the
     field, which is what stops the page reading as a stack of slabs. */
  .on-brand { background: transparent; color: var(--cream); }
  .on-brand-deep { background: transparent; color: var(--cream); }
  .on-paper { background: var(--paper); color: var(--ink); }

  /* A working surface is a sheet laid on the field, not a band cut across
     it. The green stays visible down both margins the whole way down the
     page, so every section belongs to the same world. */
  .sheet { padding-block: clamp(0.6rem, 1.4vw, 1.4rem); background: transparent; }
  /* Paper floats on the field again. It occupies exactly the section box that
     every other section occupies, so it can carry a corner and a shadow
     without its edge disagreeing with anything above or below it. */
  .sheet > .shell {
    background: var(--paper);
    color: var(--ink);
    border-radius: clamp(10px, 1.4vw, 20px);
    /* Vertical AND horizontal. The card's edge is what starts the section, on
       the same line as the first words of a section that has no card; its own
       text is then inset by this, which is what a card's padding is for. Left
       at padding-block only, the words sat flush on the paper's edge. */
    padding: clamp(2rem, 4.5vw, 4.5rem) var(--paper-pad);
    box-shadow:
      0 1px 0 rgb(255 255 255 / 60%) inset,
      0 2px 6px rgb(0 12 7 / 25%),
      0 40px 80px -50px rgb(0 12 7 / 90%);
    position: relative;
  }
  /* The light catches the top edge of the paper. */
  .sheet > .shell::before {
    content: "";
    position: absolute;
    inset: 0 auto auto 0;
    width: 100%;
    height: 1px;
    border-radius: inherit;
    background: linear-gradient(to right,
      transparent, rgb(232 184 106 / 55%) 18%, rgb(232 184 106 / 22%) 62%, transparent);
    pointer-events: none;
  }
  /* The panel is wider than the column by exactly its own padding on each
     side, so what is written inside it starts on the same line as everything
     written on the field. Headings used to alternate between 64px and 99px all
     the way down the homepage -- seven times -- because the panel was a
     different width from the shell AND inset its text on top of that. The
     panel edge moves outward; the words stay put. */
  .on-brand h1, .on-brand h2, .on-brand h3, .on-brand-deep h2 { color: var(--cream); }

  /* -------------------------------------------------- the ledger + lens --
     The core layout thesis. One side is the work, uncropped and without
     interface chrome. The other is the facts, set like a working document
     and never prettified. Below 62rem the ledger slides under the lens. */
  .split {
    display: grid;
    gap: clamp(1.5rem, 3vw, 3rem);
    align-items: start;
  }
  @media (min-width: 62rem) {
    .split { grid-template-columns: minmax(0, 1.15fr) minmax(20rem, 0.85fr); }
    .split-even { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .split-flip > .lens { order: 2; }
  }

  .lens { position: relative; }
  .lens > img,
  .lens > picture > img {
    width: 100%;
    border-radius: var(--radius-lg);
    background: var(--surface-sunk);
  }
  /* The photograph dissolves into the field instead of stopping on an edge.
     A gradient overlay in the page colour would do the same thing until the
     ground changed underneath it; a mask is a real fade, so it holds on the
     green field, the purple one, or anything else put behind it later. */
  .lens-blend > img,
  .lens-blend > picture > img {
    border-radius: var(--radius-lg) var(--radius-lg) 0 0;
    -webkit-mask-image: linear-gradient(to bottom, #000 58%, transparent 99%);
    mask-image: linear-gradient(to bottom, #000 58%, transparent 99%);
  }
  /* Beside a tall list, the photograph runs the whole height of it and fades
     out at the bottom, rather than stopping halfway and leaving the column
     empty. It accompanies the list from its first day to its last without
     ever pinning, so nothing catches or jumps as you scroll. */
  @media (min-width: 62rem) {
    .lens-blend {
      display: flex;
      flex-direction: column;
      height: 100%;
    }
    .lens-blend > picture {
      display: block;
      flex: 1 1 auto;
      min-height: 0;
    }
    .lens-blend > picture > img {
      height: 100%;
      object-fit: cover;
      /* At this column ratio `cover` shows about 70% of the frame's width, and
         the wordmark in the demo artwork starts around 7% in, so the crop
         origin has to sit below 23% or it slices the first letters off. */
      object-position: 18% 30%;
    }
  }
  /* No sticky lens. The hero was the only place it was used, and there the
     two columns are close to the same height, so it caught at top:90 for about
     220px of scroll and then let go -- the image froze, then jumped. At speed
     that reads as the photo flickering. A sticky that engages briefly is worse
     than none: it buys no empty-column saving and costs a visible stutter. */

  /* A short column beside a much longer one follows the reader rather than
     leaving a screen and a half of empty ground. Only used where the height
     difference is large -- on the FAQ the aside is 238px against 1940px of
     questions. The hero had this once and it was wrong there: its two columns
     were close to the same height, so it caught for 220px and let go, which
     read as the photo flickering. A sticky that engages briefly is worse than
     none; one that holds for most of a long page is what the pattern is for. */
  @media (min-width: 62rem) {
    .stick-aside { position: sticky; top: 96px; align-self: start; }
  }

  .stack { display: flex; flex-direction: column; gap: 1rem; align-items: flex-start; }
  .stack-lg { display: flex; flex-direction: column; gap: 1.75rem; align-items: flex-start; }
  .row { display: flex; flex-wrap: wrap; gap: 0.7rem; align-items: center; }
  .row-between { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; justify-content: space-between; }

  .grid-auto {
    display: grid;
    gap: clamp(0.9rem, 2vw, 1.5rem);
    grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
  }
}

@layer components {

  /* ----------------------------------------------------------- skip link */
  .skip {
    position: absolute;
    left: -9999px;
    top: 0;
    z-index: 100;
    background: var(--stain-fresh);
    color: var(--ink);
    padding: 0.7rem 1.1rem;
    font-weight: 600;
  }
  .skip:focus { left: 0.5rem; top: 0.5rem; }

  /* -------------------------------------------------------------- header */
  /* The header floats on the field rather than capping it with a flat bar.
     The fallback fill is opaque, so contrast never depends on blur support. */
  .masthead {
    background: rgb(10 7 4 / 96%);
    color: var(--cream);
    position: sticky;
    top: 0;
    z-index: 40;
    border-bottom: 1px solid var(--hairline-dk);
  }
  @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
    .masthead {
      background: rgb(10 7 4 / 76%);
      -webkit-backdrop-filter: blur(14px) saturate(140%);
      backdrop-filter: blur(14px) saturate(140%);
    }
  }
  .masthead-inner {
    display: flex;
    align-items: center;
    gap: 1rem;
    min-height: 66px;
    padding-block: 0.5rem;
  }

  .brandmark {
    display: inline-flex;
    align-items: center;
    gap: 0.6rem;
    text-decoration: none;
    /* Vertical padding only. The 0.15rem of horizontal padding put the mark
       at x=66.39 while every other element on the page starts at 64 -- the
       most prominent thing on the page, 2.4px off the column. The focus ring
       has outline-offset, so it does not need padding to breathe. */
    padding-block: 0.3rem;
    flex: 0 0 auto;
  }
  .brandmark img.mark { width: 34px; height: auto; }
  .brandmark img.type { width: clamp(118px, 17vw, 158px); height: auto; }

  .nav-button {
    margin-left: auto;
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    min-height: var(--tap);
    padding: 0 0.9rem;
    background: transparent;
    color: var(--cream);
    border: 1px solid var(--edge-dk);
    border-radius: 999px;
    font-size: var(--step--1);
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    cursor: pointer;
  }
  .nav-button i {
    width: 15px;
    height: 9px;
    border-top: 1.5px solid currentColor;
    border-bottom: 1.5px solid currentColor;
    display: block;
  }
  .nav-button[aria-expanded="true"] i { border-bottom-color: transparent; }
  @media (min-width: 60rem) { .nav-button { display: none; } }

  .nav {
    display: flex;
    align-items: center;
    gap: 0.15rem;
    margin-left: auto;
  }
  .nav a, .nav .as-link {
    display: inline-flex;
    align-items: center;
    min-height: var(--tap);
    padding: 0 0.7rem;
    color: var(--cream);
    text-decoration: none;
    font-size: var(--step--1);
    font-weight: 600;
    letter-spacing: 0.02em;
    border-radius: var(--radius);
    background: none;
    border: 0;
    cursor: pointer;
  }
  .nav a:hover, .nav .as-link:hover { color: var(--gold); }

  /* The nav flattens its links to plain text -- 3px radius, no border, tight
     padding -- which is right for a link and wrong for the one item that is a
     button. The WhatsApp entry takes its shape back, and the gold text hover
     that every other nav link gets would drop white-on-teal to an unreadable
     mix, so it keeps its own. */
  .nav a.wa-solid {
    border-radius: 999px;
    padding-inline: 0.95rem;
    margin-inline-start: 0.4rem;
  }
  .nav a.wa-solid:hover { color: #fff; }

  /* Sign in is an action, not a destination like Designs or Questions, so it
     gets an outline to say so -- quiet, because the gold and the WhatsApp
     green are already spoken for in this bar. */
  .nav a.nav-signin {
    border: 1px solid rgb(244 239 227 / 38%);
    border-radius: 999px;
    padding-inline: 0.9rem;
  }
  .nav a.nav-signin:hover { border-color: var(--gold); color: var(--gold); }

  @media (max-width: 59.99rem) {
    /* In the drawer every link is a full-width row divided by hairlines. A
       pill among them would look like a stray control, so it becomes the
       drawer's own full-width button, sitting under the divider rather than
       wearing one. */
    /* In the drawer every row is full width and hairline-divided, so the
       outline comes off and Sign in goes back to being a row. */
    .nav a.nav-signin {
      border: 0;
      border-bottom: 1px solid var(--hairline-dk);
      border-radius: 0;
      padding-inline: 0.2rem;
    }
    .nav a.wa-solid {
      justify-content: center;
      border-bottom: 0;
      border-radius: var(--radius);
      margin: 0.6rem 0 0.2rem;
      padding-block: 0.7rem;
    }
  }
  .nav a[aria-current="page"] { color: var(--gold); box-shadow: inset 0 -2px 0 var(--gold); }
  .nav .divider { width: 1px; height: 22px; background: var(--hairline-dk); margin-inline: 0.5rem; }

  @media (max-width: 59.99rem) {
    .nav {
      position: absolute;
      inset: 100% 0 auto;
      flex-direction: column;
      align-items: stretch;
      gap: 0;
      background: var(--brand-deep);
      border-bottom: 1px solid var(--hairline-dk);
      padding: 0.5rem var(--gutter) 1rem;
      display: none;
    }
    .nav.open { display: flex; }
    .nav a, .nav .as-link {
      min-height: 48px;
      padding-inline: 0.2rem;
      border-bottom: 1px solid var(--hairline-dk);
      justify-content: flex-start;
    }
    .nav .divider { display: none; }
  }

  /* --------------------------------------------------------------- rules
     A small uppercase label. Used to name a region, never for decoration. */
  .rule {
    display: inline-flex;
    align-items: center;
    gap: 0.55rem;
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.15em;
    text-transform: uppercase;
    color: var(--muted);
    margin: 0;
  }
  .rule::before {
    content: "";
    width: 1.6rem;
    height: 1px;
    background: currentColor;
    flex: 0 0 auto;
  }
  .on-brand .rule, .on-brand-deep .rule, .journey .rule { color: var(--gold); }

  /* ------------------------------------------------------------- buttons */
  .btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    min-height: var(--tap);
    padding: 0.6rem 1.25rem;
    border-radius: 999px;
    border: 1px solid transparent;
    font-size: var(--step--1);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-decoration: none;
    cursor: pointer;
    transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
  }
  /* Gold, because gold is what this page uses to mean "here".

     This was a stain-orange pill and then, in the hero, a frosted pane; both
     were a material borrowed from elsewhere and set on a page that already had
     one. The field is deep green, the line art and rules and masthead are
     gold, the type is cream and ink. The button was the one element speaking
     outside that.

     The frosted attempt is worth recording because it looked like a tuning
     problem and was not. Veil a pale fill over a dark green ground and it
     composites toward sage: the panels measured (126,151,128), a chroma of 25
     on a page whose gold runs at 126. No opacity fixes that, which is why
     three rounds of adjusting it got nowhere.

     One highlight colour doing one job, on both grounds. Gold carries dark
     text either way -- 8.07:1 against the deep green -- so the same button
     works on the dark field and on a paper card without becoming two
     different things, which is what the orange was on its way to being. It is
     also already this site's own engaged state, since it is exactly what
     .btn-quiet turns into on hover: the language existed and the button was
     not speaking it.

     Hover brightens rather than deepens, so contrast only climbs. */
  .btn-primary {
    background-color: var(--gold);
    border-color: var(--gold);
    color: var(--brand);
    box-shadow: 0 8px 18px -10px rgb(0 12 7 / 70%);
  }
  .btn-primary:hover {
    background-color: #eec079;
    border-color: #eec079;
    color: var(--brand);
    box-shadow: 0 12px 24px -10px rgb(0 12 7 / 78%);
  }
  /* Pressed: it settles onto the page, so the lift goes. */
  .btn-primary:active {
    background-color: #dcab5c;
    border-color: #dcab5c;
    box-shadow: inset 0 2px 4px rgb(60 40 10 / 30%);
  }

  /* A chevron for an action that takes you somewhere, drawn from two borders
     so it needs no asset and inherits the button's own colour. It leans
     forward on hover; anyone who has asked for less motion just gets the
     chevron. */
  .btn-go::after {
    content: "";
    width: .58em;
    height: .58em;
    flex: 0 0 auto;
    border-block-start: 2px solid currentColor;
    border-inline-end: 2px solid currentColor;
    transform: rotate(45deg) translate(-.06em, .06em);
    transition: transform .18s ease;
  }
  @media (prefers-reduced-motion: no-preference) {
    .btn-go:hover::after,
    .btn-go:focus-visible::after { transform: rotate(45deg) translate(.06em, -.06em); }
  }

  .btn-quiet { background: transparent; color: var(--ink); border-color: var(--edge); }
  .btn-quiet:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }

  .on-brand .btn-quiet, .on-brand-deep .btn-quiet { color: var(--cream); border-color: var(--edge-dk); }
  .on-brand .btn-quiet:hover, .on-brand-deep .btn-quiet:hover { background: var(--gold); color: var(--brand); border-color: var(--gold); }

  .btn-small { min-height: 34px; padding: 0.3rem 0.85rem; font-size: 0.75rem; }
  .btn-wide { width: 100%; }

  .btn:disabled, .btn[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }

  .linkish {
    background: none;
    border: 0;
    padding: 0.4rem 0;
    min-height: var(--tap-min);
    font-size: var(--step--1);
    font-weight: 600;
    color: var(--stain-peak);
    text-decoration: underline;
    cursor: pointer;
  }
  .linkish-danger { color: var(--stain-peak); }

  /* Switching between sign-in and register is a primary route, not an aside,
     so it gets a real 24px target rather than sentence-height. */
  .switch-link {
    display: inline-flex;
    align-items: center;
    min-height: var(--tap-min);
    padding: 0.2rem 0;
    font-weight: 700;
    color: var(--stain-peak);
    text-decoration: underline;
  }
  .on-brand .linkish, .on-brand-deep .linkish { color: var(--gold); }

  /* ------------------------------------------------------------ WhatsApp
     58% of Singapore consumers prefer WhatsApp for business queries, so
     this is a primary rail rather than a corner bubble. The link carries
     the visitor's actual context. */
  .wa {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    min-height: var(--tap);
    padding: 0.5rem 1rem;
    border-radius: 999px;
    border: 1px solid var(--edge);
    color: var(--ink);
    text-decoration: none;
    font-size: var(--step--1);
    font-weight: 600;
  }
  .wa:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
  /* The WhatsApp mark itself.

     Meta's rule is that the logo is used as supplied and unaltered, in one of
     its own colourways -- green on light, white on green. The shape here is
     the official glyph, drawn as a mask so the two approved colours can be
     selected per ground without the artwork being touched: nothing is
     redrawn, recomposed or restyled, only filled.

     Kept as a mask on a span rather than an inline <svg> in nine templates,
     so there is exactly one copy of the mark and one place to change it. */
  .wa-dot {
    width: 18px;
    height: 18px;
    flex: 0 0 auto;
    background-color: #25d366;
    /* One copy of the artwork, referenced twice. The prefixed property is
       still needed and the URI is 1.5KB; a custom property keeps a single
       definition to keep correct. */
    --wa-mark: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20d%3D%22M17.472%2014.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94%201.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198%200-.52.074-.792.372-.272.297-1.04%201.016-1.04%202.479%200%201.462%201.065%202.875%201.213%203.074.149.198%202.096%203.2%205.077%204.487.709.306%201.262.489%201.694.625.712.227%201.36.195%201.871.118.571-.085%201.758-.719%202.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421%207.403h-.004a9.87%209.87%200%2001-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86%209.86%200%2001-1.51-5.26c.001-5.45%204.436-9.884%209.888-9.884%202.64%200%205.122%201.03%206.988%202.898a9.825%209.825%200%20012.893%206.994c-.003%205.45-4.437%209.884-9.885%209.884m8.413-18.297A11.815%2011.815%200%200012.05%200C5.495%200%20.16%205.335.157%2011.892c0%202.096.547%204.142%201.588%205.945L.057%2024l6.305-1.654a11.882%2011.882%200%20005.683%201.448h.005c6.554%200%2011.89-5.335%2011.893-11.893a11.821%2011.821%200%2000-3.48-8.413Z%22%2F%3E%3C%2Fsvg%3E") center / contain no-repeat;
    -webkit-mask: var(--wa-mark);
    mask: var(--wa-mark);
  }

  /* Paper is the default ground, so the dark treatment is the override —
     and it stops at any light surface nested inside a dark section. */
  .on-brand .wa, .on-brand-deep .wa, .footer .wa {
    color: var(--cream);
    border-color: var(--edge-dk);
  }
  /* The gold hover belongs to the plain pill. The two WhatsApp-liveried
     variants below carry their own, and opt out here rather than winning a
     specificity argument further down the file. */
  .on-brand .wa:not(.wa-outline):not(.wa-solid):hover,
  .on-brand-deep .wa:not(.wa-outline):not(.wa-solid):hover,
  .footer .wa:not(.wa-outline):not(.wa-solid):hover {
    background: var(--gold); color: var(--brand); border-color: var(--gold);
  }
  /* On the gold hover fill, the dark version of the mark. */
  .on-brand .wa:not(.wa-outline):not(.wa-solid):hover .wa-dot,
  .on-brand-deep .wa:not(.wa-outline):not(.wa-solid):hover .wa-dot,
  .footer .wa:not(.wa-outline):not(.wa-solid):hover .wa-dot { background-color: var(--brand); }
  /* White on a dark ground: the other approved colourway, and the quiet one.
     (An earlier note here said the green mark measured about 2:1 on the field.
     It does not -- it is 6.76:1, and passes. Cream stays because a passing
     contextual link should not fly the brand colour, not because green was
     illegible. Where the button IS the WhatsApp channel, .wa-outline uses
     the green. */
  .on-brand .wa-dot, .on-brand-deep .wa-dot, .footer .wa-dot,
  .journey .wa-dot, .masthead .wa-dot { background-color: var(--cream); }

  /* ------------------------------------------------ the two WhatsApp forms

     The nav carries a solid WhatsApp button -- the standing way in, looking
     the way people expect that button to look. In the page body the same
     channel appears as glass, so a contextual "ask about this" sits beside the
     main action instead of shouting over it.

     The solid one is #075E54, WhatsApp's own teal, and not the familiar
     #25D366. That is a legibility decision with only one answer. On #25D366,
     white measures 1.98:1 and cream 1.73:1 -- the button the whole web ships
     fails outright -- and the only text that passes on it is near-black, which
     is not one of the two colourways Meta approves. #075E54 is a WhatsApp
     brand colour in its own right, and on it white text and the white mark
     both measure 7.67:1: an approved colourway AND legible, rather than a
     choice between the two. */
  .wa-solid, .wa-outline { transition: background-color .18s ease, border-color .18s ease, color .18s ease; }

  .wa.wa-solid { background-color: #075e54; border-color: #075e54; color: #fff; }
  .wa.wa-solid .wa-dot { background-color: #fff; }
  /* Hover brightens, but stops inside the passing range: #128C7E, the next
     brand green up, drops white to 4.14:1. This holds 5.23:1. */
  .wa.wa-solid:hover { background-color: #0c7a6c; border-color: #0c7a6c; color: #fff; }
  .wa.wa-solid:hover .wa-dot { background-color: #fff; }

  /* No fill at all. Beside a solid gold action it only has to be the quieter
     of the two, and an outline is the same material as the leaves and mandalas
     drawn behind it -- line on green, which is what this whole page is made
     of.

     The mark is the only green on it, deliberately. A WhatsApp-green border
     would name the channel a fraction faster, at the cost of a second bright
     hue beside the gold, and one highlight colour is what holds this palette
     together. The official glyph identifies the channel and the label says
     what it is for, so the button can stay in the page's voice.

     Cream at 55% for the edge rather than the muted --edge-dk: next to a solid
     gold button, the quiet one still has to look pressable. */
  .wa.wa-outline {
    background: none;
    border-color: rgb(26 23 18 / 40%);
    color: var(--ink);
  }
  .wa.wa-outline .wa-dot { background-color: #25d366; }
  .wa.wa-outline:hover {
    background: rgb(26 23 18 / 6%);
    border-color: var(--ink);
    color: var(--ink);
  }
  /* Paper is the default ground here as everywhere else, so the dark treatment
     is the override. The mark stays green on both: green-on-light and
     green-on-dark are both approved, and it is the one green on the button. */
  .on-brand .wa.wa-outline, .on-brand-deep .wa.wa-outline, .footer .wa.wa-outline {
    border-color: rgb(244 239 227 / 55%);
    color: var(--cream);
  }
  .on-brand .wa.wa-outline:hover, .on-brand-deep .wa.wa-outline:hover,
  .footer .wa.wa-outline:hover {
    background: rgb(244 239 227 / 10%);
    border-color: var(--cream);
    color: var(--cream);
  }

  /* Sized to sit beside a .btn-small in a tile foot without towering over it. */
  .wa-small { min-height: 34px; padding: 0.3rem 0.8rem; font-size: 0.75rem; gap: 0.4rem; }
  .wa-small .wa-dot { width: 15px; height: 15px; }

  /* --------------------------------------------------------------- flash */
  .flashes { display: flex; flex-direction: column; gap: 0.5rem; padding-top: 0.9rem; }
  .flash {
    display: flex;
    gap: 0.6rem;
    align-items: baseline;
    padding: 0.8rem 1rem;
    border-radius: var(--radius);
    background: var(--surface);
    color: var(--ink);
    border: 1px solid var(--hairline);
    border-left: 4px solid var(--closed);
    font-size: var(--step--1);
  }
  .flash::before { font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; font-size: 0.66rem; }
  .flash-success { border-left-color: var(--open); }
  .flash-success::before { content: "Done"; color: var(--open); }
  .flash-error { border-left-color: var(--stain-peak); }
  .flash-error::before { content: "Check"; color: var(--stain-peak); }

  /* ---------------------------------------------------------- fact table
     The ledger half. Numbers line up, nothing is decorated. */
  .facts { width: 100%; min-width: 0; }
  .facts table {
    width: 100%;
    /* fixed, so the columns come from the table's own width rather than from
       the longest word in them. A booking note or an event location is typed
       by a guest and can be any length; with the default auto layout one long
       value pushed this table to 2625px inside a 1440px window and took the
       whole page with it. */
    table-layout: fixed;
    border-collapse: collapse;
    font-variant-numeric: tabular-nums;
    font-size: var(--step--1);
  }
  /* `anywhere`, matching the base rule rather than weakening it. Written as
     break-word here, this override reinstated the very problem it was meant to
     solve: the cell still reported a min-content of the whole unbroken value
     and its text spilled 2100px past the table. */
  .facts td, .facts th { overflow-wrap: anywhere; }
  .facts th {
    text-align: left;
    font-size: 0.66rem;
    font-weight: 700;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--muted);
    padding: 0 0.75rem 0.5rem 0;
    border-bottom: 1px solid var(--ink);
  }
  .facts td {
    padding: 0.7rem 0.75rem 0.7rem 0;
    border-bottom: 1px solid var(--hairline);
    vertical-align: top;
  }
  .facts td:last-child, .facts th:last-child { padding-right: 0; }
  .facts .num { text-align: right; white-space: nowrap; font-weight: 600; }
  /* nowrap is right for a price or a duration -- "S$1,234" should never break
     across two lines. It is wrong for a cell holding something a person typed:
     a service name or an event location can be any length, and nowrap means it
     can never wrap at all, which is what pushed a booking's table 2100px past
     its own edge. */
  .facts .num-text { white-space: normal; overflow-wrap: anywhere; }
  .facts tr:last-child td { border-bottom: 0; }
  .table-scroll { overflow-x: auto; }

  .on-brand .facts th { color: var(--gold); border-bottom-color: var(--gold-dim); }
  .on-brand .facts td { border-bottom-color: var(--hairline-dk); }

  /* -------------------------------------------------------- price line
     A price is a fact, so it is set like one: the number large and
     tabular, the qualifier small and honest beside it. */
  .price { display: flex; align-items: baseline; gap: 0.4rem; font-variant-numeric: tabular-nums; }
  .price b {
    font-family: var(--display);
    font-size: var(--step-2);
    font-weight: 500;
    letter-spacing: -0.02em;
  }
  .price small { font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
  .on-brand .price small { color: var(--cream-soft); }

  /* ---------------------------------------------------------- status pill
     Colour is never the only carrier. Each state also has a distinct
     glyph, because red and green collapse to one colour for roughly
     1 in 12 men. */
  .pill {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    min-height: var(--tap-min);
    padding: 0.15rem 0.6rem;
    border: 1px solid currentColor;
    border-radius: 999px;
    font-size: 0.68rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
  }
  .pill::before { font-size: 0.8em; line-height: 1; }
  .pill-confirmed, .pill-completed { color: var(--open); }
  .pill-confirmed::before, .pill-completed::before { content: "\25CF"; }   /* filled */
  .pill-pending { color: var(--held); }
  .pill-pending::before { content: "\25D0"; }                              /* half   */
  .pill-cancelled, .pill-refunded, .pill-no_show { color: var(--closed); }
  .pill-cancelled::before, .pill-refunded::before, .pill-no_show::before { content: "\25CB"; }  /* empty */

  /* ------------------------------------------------------------- reviews */
  .stars {
    display: flex;
    align-items: baseline;
    gap: .5rem;
    margin: 0;
    letter-spacing: .18em;
    color: var(--stain-day);
    /* a rating is five glyphs; it never needs to wrap or to stretch a card */
    flex-wrap: wrap;
    min-width: 0;
  }
  .stars span { overflow-wrap: anywhere; min-width: 0; }
  .stars small { letter-spacing: normal; color: var(--muted); font-size: var(--step--1); }
  .rating-choice { position: relative; }
  .rating-choice input {
    position: absolute; inset: 0; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer;
  }
  .rating-choice span {
    display: inline-flex; align-items: center; justify-content: center;
    min-width: var(--tap); min-height: var(--tap);
    border: 1px solid var(--edge); border-radius: 999px;
    font-weight: 700; font-variant-numeric: tabular-nums;
  }
  .rating-choice input:checked + span { background: var(--stain-fresh); border-color: var(--stain-fresh); color: var(--ink); }
  .rating-choice input:focus-visible + span { outline: 3px solid var(--stain-fresh); outline-offset: 2px; }

  /* A panel's children may be narrower than their contents. Without this a
     review body with one very long word in it stretches the panel, and in a
     grid of panels that stretches the row. */
  .panel > * { min-width: 0; }

  /* ---------------------------------------------------------- panel/card */
  .panel {
    background: var(--surface);
    color: var(--ink);
    border: 1px solid var(--hairline);
    border-radius: var(--radius-lg);
    padding: clamp(1.1rem, 2.2vw, 1.75rem);
  }
  .panel > :last-child { margin-bottom: 0; }
  /* A raised surface on the field is glass, not a lighter rectangle. The
     opaque fill is the fallback, so contrast never depends on blur support. */
  .on-brand .panel {
    background: var(--brand-lift);
    border-color: var(--hairline-dk);
    color: var(--cream);
  }
  @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
    .on-brand .panel {
      background: rgb(255 255 255 / 5%);
      border-color: rgb(255 255 255 / 12%);
      -webkit-backdrop-filter: blur(18px) saturate(130%);
      backdrop-filter: blur(18px) saturate(130%);
      box-shadow: 0 1px 0 rgb(255 255 255 / 7%) inset, 0 30px 60px -40px rgb(0 10 6 / 90%);
    }
  }
  /* A form is a working surface, so it stays on paper even when the section
     around it is on the brand ground. Without this the field labels inherit
     --muted, which is a paper colour, and land at 1.68:1 on dark green. */
  .on-brand .panel-paper, .on-brand-deep .panel-paper {
    background: var(--surface);
    border-color: var(--hairline);
    color: var(--ink);
  }
  .panel-paper .rule { color: var(--muted); }
  .panel-paper h1, .panel-paper h2, .panel-paper h3 { color: var(--ink); }

  .note {
    padding: 0.85rem 1rem;
    border-left: 3px solid var(--held);
    background: var(--surface-sunk);
    border-radius: 0 var(--radius) var(--radius) 0;
    font-size: var(--step--1);
    color: var(--muted);
  }
  .on-brand .note { background: var(--brand-lift); color: var(--cream-soft); }

  /* ------------------------------------------------------- opening board
     Availability leads. Real openings, priced, straight from the diary.
     Booked time reads as unavailable without exposing who booked it. */
  .board { display: grid; gap: 0.4rem; }
  .board-day {
    display: grid;
    grid-template-columns: 5.5rem 1fr;
    gap: 0.75rem;
    align-items: center;
    padding: 0.7rem 0;
    border-bottom: 1px solid var(--hairline-dk);
  }
  .board-day:last-child { border-bottom: 0; }
  .board-date { font-variant-numeric: tabular-nums; line-height: 1.25; }
  .board-date b { display: block; font-size: var(--step--1); font-weight: 700; letter-spacing: 0.04em; }
  .board-date small { font-size: 0.7rem; color: var(--cream-soft); letter-spacing: 0.06em; text-transform: uppercase; }
  .board-slots { display: flex; flex-wrap: wrap; gap: 0.35rem; }
  .board-closed { font-size: var(--step--1); color: var(--cream-soft); }
  .board-rest {
    display: inline-flex;
    align-items: center;
    min-height: var(--tap);
    padding-top: 0.6rem;
    font-size: var(--step--1);
    font-weight: 600;
    color: var(--gold);
  }

  /* --------------------------------------------------------------- slots */
  /* Quick days. Same chip language as the times below them, one step up. */
  .day-quick { margin-bottom: 1rem; }
  .day-quick-label {
    font-size: 0.66rem;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--muted);
    margin: 0 0 0.5rem;
  }
  .day-chips { display: flex; flex-wrap: wrap; gap: 0.5rem; }
  .day-chip {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    min-height: var(--tap);
    padding: 0.45rem 0.75rem;
    border: 1px solid var(--edge);
    border-radius: var(--radius);
    background: var(--surface);
    color: var(--ink);
    font: inherit;
    line-height: 1.2;
    cursor: pointer;
    text-align: left;
  }
  .day-chip b { font-size: var(--step--1); font-weight: 700; }
  .day-chip span { font-size: var(--step--1); font-variant-numeric: tabular-nums; }
  .day-chip small { font-size: 0.66rem; color: var(--muted); }
  .day-chip:hover { border-color: var(--ink); }
  .day-chip[aria-pressed="true"] {
    background: var(--stain-fresh);
    border-color: var(--stain-fresh);
    color: var(--ink);
  }
  .day-chip[aria-pressed="true"] small { color: rgb(26 23 18 / 72%); }

  .slots {
    display: grid;
    gap: 0.5rem;
    grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
  }
  /* Anything that is not a time takes the whole row. The placeholder and the
     "no times" message are paragraphs, and as grid items they were sized to a
     single 6.5rem column -- "Choose a service and a date to see the open
     times." came out five lines tall, one or two words each. */
  .slots > p { grid-column: 1 / -1; margin: 0; }
  .slot {
    min-height: var(--tap);
    padding: 0.5rem 0.6rem;
    border: 1px solid var(--edge);
    border-radius: var(--radius);
    background: var(--surface);
    color: var(--ink);
    font-size: var(--step--1);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    cursor: pointer;
    text-decoration: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  .slot:hover { border-color: var(--ink); }
  .slot[aria-pressed="true"], .slot.is-on {
    background: var(--stain-fresh);
    border-color: var(--stain-fresh);
    color: var(--ink);
  }
  .slot[aria-pressed="true"]::after, .slot.is-on::after { content: "\00a0\2713"; }

  .on-brand .slot, .board .slot {
    background: transparent;
    color: var(--cream);
    border-color: var(--edge-dk);
    min-height: 36px;
    padding: 0.25rem 0.6rem;
  }
  .on-brand .slot:hover, .board .slot:hover { background: var(--gold); border-color: var(--gold); color: var(--brand); }

  /* Now a link rather than a caption, so it needs a target worth hitting and
     a hover that says it does something. */
  .slot-more {
    align-self: center;
    display: inline-flex;
    align-items: center;
    min-height: var(--tap);
    padding-inline: 0.4rem;
    font-size: 0.72rem;
    color: var(--cream-soft);
    letter-spacing: 0.06em;
    text-decoration: none;
  }
  .slot-more:hover, .slot-more:focus-visible { color: var(--gold); text-decoration: underline; }

  /* A crest for the top of a sheet: the words centred and flanked by a
     hairline that fades out, with a small lozenge where each line meets the
     text.

     The display face is already a Didone, which is the whole reason fashion
     and luxury houses reach for Bodoni and Didot -- high stroke contrast,
     narrow unbracketed serifs, almost no ornament. So the regal part is set
     by using it at size and giving it room, not by importing a flourished
     display font on top of a face that already does the job. The one
     ornament follows the editorial convention of a fleuron centred on a thin
     rule, kept to a lozenge because a Didone next to a curly fleuron reads
     as a wedding invitation rather than a studio.

     Gold, because gold is this page's highlight everywhere else. The rules
     are decoration and carry no meaning, so they are free to be faint; the
     words are ink at 17:1. */
  .crest {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: clamp(0.65rem, 2vw, 1.15rem);
    margin: 0 0 clamp(1.25rem, 3vw, 2rem);
    font-family: var(--display);
    font-size: var(--step-2);
    font-weight: 500;
    line-height: 1.15;
    letter-spacing: 0.01em;
    color: var(--ink);
    text-align: center;
    text-wrap: balance;
  }
  .crest-flourish {
    position: relative;
    height: 1px;
    /* shrinks before the words do, and disappears entirely on a narrow
       screen rather than squeezing the line onto three rows */
    flex: 0 1 clamp(1.5rem, 10vw, 6rem);
    background: linear-gradient(90deg, transparent, var(--gold-dim));
  }
  .crest-flourish + .crest-flourish,
  .crest-flourish:last-child {
    background: linear-gradient(270deg, transparent, var(--gold-dim));
  }
  .crest-flourish::after {
    content: "";
    position: absolute;
    inset-block-start: 50%;
    inset-inline-end: -0.28rem;
    width: 0.34rem;
    height: 0.34rem;
    background: var(--gold-dim);
    transform: translateY(-50%) rotate(45deg);
  }
  .crest-flourish:last-child::after {
    inset-inline-end: auto;
    inset-inline-start: -0.28rem;
  }
  @media (max-width: 26rem) {
    .crest-flourish { display: none; }
  }

  /* One line of explanation under a crest, centred with it. Not a second
     heading -- the card gets exactly one, the same as the gallery -- but the
     sentence it replaces was doing real work explaining how the rates are
     arrived at, and dropping it to gain a centred title would have been a
     poor trade. */
  .crest-note {
    max-width: 46ch;
    margin-inline: auto;
    margin-block: 0 clamp(1.25rem, 3vw, 2rem);
    text-align: center;
    text-wrap: balance;
    color: var(--muted);
    font-size: var(--step--1);
  }
  .crest:has(+ .crest-note) { margin-block-end: 0.5rem; }

  /* Gallery paging. Quiet: it is a way back to the grid, not a feature. */
  .pager {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: .6rem clamp(.9rem, 3vw, 2rem);
    margin-top: clamp(1.5rem, 4vw, 2.5rem);
    padding-top: clamp(1rem, 2.5vw, 1.5rem);
    border-block-start: 1px solid var(--hairline);
    font-size: var(--step--1);
  }
  .pager a, .pager-off {
    display: inline-flex;
    align-items: center;
    gap: .4rem;
    min-height: var(--tap);
    padding-inline: .75rem;
    border-radius: 999px;
    font-weight: 600;
    text-decoration: none;
  }
  .pager a { color: var(--ink); border: 1px solid var(--edge); }
  .pager a:hover { border-color: var(--ink); background: rgb(26 23 18 / 5%); }
  /* Present but plainly unavailable, rather than removed -- the row keeps its
     shape from the first page to the last. */
  .pager-off { color: var(--muted); opacity: .55; }
  .pager-count { color: var(--muted); font-variant-numeric: tabular-nums; }

  /* The studio's own view of the library: smaller tiles than the public
     gallery, because this is an inventory to scan rather than work to admire,
     and the same three declarations were repeated inline on every row of it. */
  .library { grid-template-columns: repeat(auto-fill, minmax(min(11rem, 100%), 1fr)); }
  .library .tile-body { padding: .6rem .7rem .8rem; }
  .library .tile-body small { font-size: .72rem; }
  .library .pill { align-self: flex-start; }

  /* Nine positions, laid out as the picture they describe. A list of radio
     buttons would say the same thing and mean nothing; a 3x3 grid is the
     frame, and the one you pick is where the photograph stays. */
  .focus-form { margin-top: .55rem; }
  .focus-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 2px;
    border: 1px solid var(--edge);
    border-radius: var(--radius);
    padding: 2px;
    margin: 0 0 .5rem;
  }
  .focus-grid legend {
    font-size: .62rem; font-weight: 700; letter-spacing: .1em;
    text-transform: uppercase; color: var(--muted); padding-inline: .3rem;
  }
  .focus-cell { position: relative; display: block; aspect-ratio: 4 / 3; cursor: pointer; }
  /* The label is the target, so the input only has to be reachable and
     focusable -- not visible, and never removed from the accessibility tree
     with display:none. */
  .focus-cell input {
    position: absolute; inset: 0; width: 100%; height: 100%;
    margin: 0; opacity: 0; cursor: pointer;
  }
  .focus-cell span {
    display: block; height: 100%; border-radius: 3px;
    background: var(--surface-sunk);
    /* The name is for a screen reader; sighted operators read the grid. */
    overflow: hidden; text-indent: -999em;
  }
  .focus-cell:hover span { background: var(--edge); }
  .focus-cell input:checked + span { background: var(--stain-fresh); }
  .focus-cell input:focus-visible + span { outline: 2px solid var(--ink); outline-offset: 1px; }

  /* A real sequence, so it is numbered -- each step is only true after the one
     before it. The counter is drawn rather than left to the browser's marker so
     the number can take the accent and sit level with the first line. */
  .process-list { list-style: none; counter-reset: step; margin: 1rem 0 0; padding: 0; }
  .process-list li {
    counter-increment: step;
    position: relative;
    padding-inline-start: 2.4rem;
    margin-bottom: .85rem;
    font-size: var(--step--1);
  }
  .process-list li::before {
    content: counter(step);
    position: absolute;
    inset-inline-start: 0;
    inset-block-start: 0;
    width: 1.7rem;
    text-align: center;
    font-family: var(--display);
    font-weight: 500;
    color: var(--stain-peak);
    font-variant-numeric: tabular-nums;
  }
  .process-list b { font-weight: 700; }

  /* ------------------------------------------------------- design tile */
  .tiles {
    display: grid;
    gap: clamp(0.8rem, 1.8vw, 1.4rem);
    grid-template-columns: repeat(auto-fill, minmax(min(17rem, 100%), 1fr));
  }
  .tile {
    display: flex;
    flex-direction: column;
    background: var(--surface);
    border: 1px solid var(--hairline);
    border-radius: var(--radius-lg);
    overflow: hidden;
    color: var(--ink);
  }
  /* On a dark section the tile is a dark object. Painting a white card there
     and inheriting the section's cream text is how you get 1.15:1 text. */
  .on-brand .tile, .on-brand-deep .tile {
    background: var(--brand-lift);
    border-color: var(--hairline-dk);
    color: var(--cream);
  }
  @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
    .on-brand .tile, .on-brand-deep .tile {
      background: rgb(255 255 255 / 5%);
      border-color: rgb(255 255 255 / 12%);
      -webkit-backdrop-filter: blur(18px) saturate(130%);
      backdrop-filter: blur(18px) saturate(130%);
    }
  }
  .on-brand .tile .tile-body p, .on-brand-deep .tile .tile-body p { color: var(--cream-soft); }
  /* Category chips sit on the dark tile body, so a 30% scrim is enough for
     them. .tag-demo does NOT -- it is pinned over the photograph, where the
     real composited ground is whatever the image happens to be (measured
     rgb(160,151,139) on the gallery shots, putting the label at 1.6:1). This
     selector is (0,3,0) and was silently beating .tag-demo's own opaque
     background at (0,1,0). Excluding it lets the demo label keep its ground.
     The DOM-walking audit could never see this: it skips any background under
     0.85 alpha and resolves to the section colour, which is not what is behind
     the glyphs. PRODUCT.md requires demo artwork to stay labelled, and a label
     at 1.6:1 is not labelled. */
  .on-brand .tile .tag:not(.tag-demo),
  .on-brand-deep .tile .tag:not(.tag-demo) { background: rgb(0 0 0 / 30%); color: var(--cream-soft); }
  .on-brand .tile .tile-shot, .on-brand-deep .tile .tile-shot { background: rgb(0 0 0 / 22%); }
  /* The frame holds the shape, not the photograph. With the ratio on the img,
     an image that fails to load falls back to its alt text and the box grows
     to fit the words -- measured, one card in a row came out 63px taller than
     its neighbours purely because its alt text was long. On the container the
     shape holds whatever the image does, and overflow keeps the alt text
     inside it. */
  .tile-shot {
    position: relative;
    background: var(--surface-sunk);
    aspect-ratio: 4 / 3;
    overflow: hidden;
  }
  .tile-shot img { width: 100%; height: 100%; object-fit: cover; }
  .tile-body { padding: 0.9rem 1rem 1.1rem; display: flex; flex-direction: column; gap: 0.35rem; flex: 1; }
  .tile-body > * { min-width: 0; }
  .tile-body h3 { font-size: var(--step-1); overflow-wrap: break-word; }
  .tile-body p {
    font-size: var(--step--1);
    color: var(--muted);
    margin: 0;
    overflow-wrap: break-word;
    /* Descriptions are written by the studio and will not all be the same
       length. Three lines keeps a row of cards the same height without
       truncating anything the reader needs -- the full text is on the design's
       own page. */
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 3;
    line-clamp: 3;
    overflow: hidden;
  }
  .tile-foot { margin-top: auto; padding-top: 0.7rem; display: flex; gap: 0.6rem; align-items: center; justify-content: space-between; }

  .tag {
    display: inline-block;
    /* A chip hugs its text. As a flex child it would otherwise stretch to
       the full width of the tile body and read as a bar. */
    align-self: flex-start;
    padding: 0.15rem 0.5rem;
    border-radius: var(--radius);
    background: var(--surface-sunk);
    font-size: 0.66rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--muted);
    overflow-wrap: break-word;
    max-width: 100%;
  }
  .tag-demo {
    position: absolute;
    left: 0.6rem;
    bottom: 0.6rem;
    background: var(--ink);
    color: var(--paper);
  }

  /* The longest service sat in the ledger looking exactly like the shortest
     one. It is a four-hour sitting at six times the price, and the row should
     say so before anyone reads the numbers. Warmth and a rule, not a border
     box -- the ledger's whole character is hairlines rather than cards. */
  /* The wash sits on the row itself and on the column: it starts where the
     words start and ends where every other row ends.

     Two earlier attempts were wrong in opposite directions. A negative inline
     margin bled the row 1rem past the column and took the hairline under every
     other row with it. Moving the paint to an absolutely positioned ::after at
     z-index -1 put it behind the paper panel and it vanished -- position
     relative with z-index auto does not create a stacking context, so the
     negative index escapes all the way to the root.

     No rail either. A 3px inset shadow at the row's left edge would sit under
     the first character now the row starts on the column, and the label plus
     the larger name carry the emphasis without it. */
  .ledger-row-lead {
    background: linear-gradient(90deg, rgb(217 110 39 / 10%), rgb(217 110 39 / 0%) 68%);
  }
  .ledger-row-lead h3 { font-size: var(--step-2); }
  .tag-lead {
    background: rgb(217 110 39 / 14%);
    color: var(--stain-peak);
    margin-bottom: .35rem;
  }

  /* The event date is optional for most services and required for the ones
     agreed with the studio first. The field is always in the markup -- the
     server enforces it either way -- and this only shows which state it is in,
     so nobody fills the form and gets bounced. */
  .opt { color: var(--muted); font-weight: 400; text-transform: none; letter-spacing: 0; }
  [data-event-date].is-required .opt { display: none; }
  [data-event-date].is-required label::after {
    content: " • required";
    color: var(--stain-peak);
    font-weight: 700;
  }

  /* --------------------------------------------------------- filter row */
  .filters { display: flex; flex-wrap: wrap; gap: 0.4rem; }
  .filters a {
    display: inline-flex;
    align-items: center;
    min-height: 36px;
    padding: 0.25rem 0.85rem;
    border: 1px solid var(--edge);
    border-radius: 999px;
    text-decoration: none;
    font-size: var(--step--1);
    font-weight: 600;
  }
  .filters a:hover { border-color: var(--ink); }
  .filters a[aria-current="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }

  /* ------------------------------------------------------- service rows */
  .ledger-rows { display: flex; flex-direction: column; }
  .ledger-row {
    display: grid;
    gap: 0.5rem 1.5rem;
    /* minmax(0, 1fr), not 1fr. A grid track sized 1fr still refuses to go
       below its content's minimum, so one service name with no spaces in it --
       a real thing a studio will type -- pushed this row to 533px inside a
       390px phone and took 180px of the whole page with it. */
    grid-template-columns: minmax(0, 1fr);
    /* The row box stays on the column. Giving every row a negative inline
       margin did keep the columns aligned with each other, but it dragged the
       hairline under each one 1rem past the text on both sides -- visible the
       moment a guide is drawn down the page. The emphasised row paints its
       background with a pseudo-element instead, so only the paint bleeds. */
    position: relative;
    padding: 1.4rem 0;
    border-bottom: 1px solid var(--hairline);
    align-items: start;
  }
  .ledger-row:first-child { border-top: 1px solid var(--ink); }
  @media (min-width: 48rem) {
    /* The action column is fixed, not auto. With auto it sized to its own
       button, so "Request a date" being wider than "Choose" pushed the price
       and duration columns left on that row alone. */
    .ledger-row { grid-template-columns: minmax(0, 1fr) 7rem 8rem 10.5rem; align-items: center; }
    .ledger-row > :last-child { justify-self: end; }
  }
  /* Grid and flex children default to a minimum size of their content, which
     is what lets one long word push a whole row wider than the screen. These
     are told they may be narrower than what is in them, and the text is told
     it may break mid-word rather than insist on a line nothing can hold. */
  .ledger-row > * { min-width: 0; }
  .ledger-row h2,
  .ledger-row h3,
  .ledger-row p { overflow-wrap: break-word; }
  .ledger-row h3 { font-size: var(--step-1); }
  .ledger-row p { font-size: var(--step--1); color: var(--muted); margin: 0.2rem 0 0; }
  /* Numbers in a column are for comparing down, so they share a right edge.
     Left-aligned they were ragged -- 45 min ended at 994 and 120 min at 1009,
     S$45 at 1114 and S$280 at 1129 -- which is the whole point of tabular-nums
     undone by the alignment. The label sits on the same edge as its value so
     each cell reads as one block. */
  /* A four-figure price is wider than a two-figure one, so the cell states a
     floor rather than trusting the track to be enough. */
  .ledger-cell { font-variant-numeric: tabular-nums; text-align: right; min-width: 0; }
  .ledger-cell b { white-space: nowrap; }
  .ledger-cell small {
    display: block;
    font-size: 0.66rem;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--muted);
  }
  .ledger-cell b { font-size: var(--step-1); font-weight: 600; }

  /* A promise, set beside the thing it is a promise about.

     These were a block of four at the foot of the page. Trust signals work
     where the doubt they answer arises -- a pricing guarantee next to the
     prices, a privacy line next to the calendar -- and lose most of their
     effect consolidated into a "what we stand for" section, which is read, if
     at all, after the decision is made. */
  .promise {
    margin: 1.25rem 0 0;
    padding-inline-start: .9rem;
    border-inline-start: 2px solid var(--stain-fresh);
    font-size: var(--step--1);
    color: var(--muted);
    max-width: 62ch;
  }
  .promise b { color: var(--ink); }
  .promise-tight { margin-top: .9rem; font-size: .75rem; }
  .on-brand .promise, .on-brand-deep .promise, .journey .promise { color: var(--cream-soft); }
  .on-brand .promise b, .on-brand-deep .promise b, .journey .promise b { color: var(--cream); }

  /* ----------------------------------------------------------- the arc
     A real aftercare guide that also explains the palette. Ordered by
     lightness so the sequence survives colour vision deficiency. */
  .arc { display: grid; gap: 0; grid-template-columns: repeat(2, 1fr); border-radius: var(--radius-lg); overflow: hidden; }
  @media (min-width: 48rem) { .arc { grid-template-columns: repeat(4, 1fr); } }
  .arc-step { padding: 1.1rem 1rem 1.3rem; display: flex; flex-direction: column; gap: 0.3rem; min-height: 9.5rem; }
  .arc-step b { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
  .arc-step strong { font-family: var(--display); font-size: var(--step-1); font-weight: 500; }
  .arc-step span { font-size: 0.8rem; line-height: 1.45; }
  .arc-1 { background: #d96e27; color: #1a1712; }
  .arc-2 { background: #b54a1f; color: #fff; }
  .arc-3 { background: #8c2f22; color: #fff; }
  .arc-4 { background: #5c1f1b; color: #fff; }

  /* ------------------------------------------------------- the order card
     The signature element. A booking builds visibly as you browse, the way
     a cart does on a shop. It is a summary, not a control panel: one line
     per decision, the running total always visible, one action. */
  .order {
    position: sticky;
    top: 82px;
    background: var(--surface);
    color: var(--ink);
    border: 1px solid var(--ink);
    border-radius: var(--radius-lg);
    padding: 1.1rem 1.2rem 1.2rem;
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
  }
  .order-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 0.5rem;
    padding-bottom: 0.6rem;
    border-bottom: 1px solid var(--ink);
  }
  .order-head b { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
  .order-head small { font-size: 0.7rem; color: var(--muted); }
  .order-lines { display: flex; flex-direction: column; gap: 0; margin: 0; }
  .order-line {
    display: grid;
    grid-template-columns: 1fr auto;
    gap: 0.5rem;
    padding: 0.55rem 0;
    border-bottom: 1px dashed var(--hairline);
    font-size: var(--step--1);
    font-variant-numeric: tabular-nums;
  }
  .order-line dt { color: var(--muted); font-size: 0.68rem; letter-spacing: 0.1em; text-transform: uppercase; }
  .order-line dd { margin: 0; font-weight: 600; text-align: right; }
  .order-line-empty dd { font-weight: 400; color: var(--muted); }
  .order-total {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 0.5rem;
    padding-top: 0.5rem;
  }
  .order-total span { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
  .order-note { font-size: 0.74rem; color: var(--muted); line-height: 1.45; margin: 0; }

  @media (max-width: 61.99rem) {
    /* On a phone the card is a bar, not a panel: one line, always in reach. */
    .order {
      position: fixed;
      inset: auto 0 0;
      top: auto;
      border-radius: var(--radius-lg) var(--radius-lg) 0 0;
      border-width: 1px 0 0;
      padding: 0.7rem var(--gutter) max(0.7rem, env(safe-area-inset-bottom));
      box-shadow: 0 -8px 30px -20px rgb(26 23 18 / 60%);
      z-index: 30;
      gap: 0.5rem;
      max-height: 72vh;
    }
    .order-collapsible { display: none; }
    .order.is-open .order-collapsible { display: block; overflow-y: auto; }
    .order-head { border-bottom: 0; padding-bottom: 0; }

    /* A fixed bar sits on top of whatever is at the bottom of the page.
       Reserve the space it occupies so the footer stays reachable. */
    .has-order-bar .footer { padding-bottom: 9rem; }
  }
  .order-toggle { display: none; }
  @media (max-width: 61.99rem) {
    .order-toggle {
      display: inline-flex;
      align-items: center;
      gap: 0.3rem;
      background: none;
      border: 0;
      padding: 0.3rem 0;
      min-height: var(--tap-min);
      font-size: 0.7rem;
      font-weight: 700;
      letter-spacing: 0.1em;
      text-transform: uppercase;
      color: var(--stain-peak);
      cursor: pointer;
    }
  }

  /* -------------------------------------------------------------- forms */
  .field { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 1.05rem; }
  .field > span, .field > label {
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--muted);
  }
  .field input, .field select, .field textarea, .input {
    width: 100%;
    min-height: var(--tap);
    padding: 0.6rem 0.75rem;
    background: var(--surface);
    color: var(--ink);
    border: 1px solid var(--edge);
    border-radius: var(--radius);
    font-size: var(--step-0);
  }
  .field textarea { min-height: 6rem; resize: vertical; line-height: 1.5; }
  .field input:hover, .field select:hover, .field textarea:hover { border-color: var(--ink); }
  .field small { font-size: 0.75rem; color: var(--muted); }

  .field-grid { display: grid; gap: 0 1rem; grid-template-columns: 1fr; }
  @media (min-width: 40rem) { .field-grid { grid-template-columns: repeat(2, 1fr); } .field-grid .wide { grid-column: 1 / -1; } }

  .check {
    display: flex;
    gap: 0.65rem;
    align-items: flex-start;
    padding: 0.5rem 0;
    min-height: var(--tap);
    font-size: var(--step--1);
    line-height: 1.5;
    cursor: pointer;
  }
  .check input {
    width: 24px;
    height: 24px;
    margin: 0.1rem 0 0;
    accent-color: var(--stain-day);
    flex: 0 0 auto;
  }

  fieldset { border: 0; padding: 0; margin: 0 0 1.05rem; min-width: 0; }
  legend {
    padding: 0;
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--muted);
  }

  .status-line { font-size: var(--step--1); font-weight: 600; margin: 0; min-height: 1.4em; }
  .status-line.is-good { color: var(--open); }
  .status-line.is-bad { color: var(--stain-peak); }

  /* ------------------------------------------------------------- steps
     Numbered because this genuinely is a sequence: each step depends on
     the answer to the one before it. */
  .steps { display: flex; flex-direction: column; gap: 0; }
  .step { display: grid; grid-template-columns: 2.4rem 1fr; gap: 0.9rem; padding: 1.4rem 0; border-bottom: 1px solid var(--hairline); }
  .step:first-child { border-top: 1px solid var(--ink); }
  /* A <legend> is the right element for a group of choices, but it inherits
     the small-caps field label look, so step three's title was a fraction of
     the size of every other step's. It keeps the semantics and takes the
     heading's appearance. */
  .step legend {
    font-family: var(--display);
    font-size: var(--step-1);
    font-weight: 500;
    letter-spacing: normal;
    text-transform: none;
    color: inherit;
    padding: 0;
  }
  .step-n {
    font-variant-numeric: tabular-nums;
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    color: var(--muted);
    padding-top: 0.35rem;
  }
  .step h3 { font-size: var(--step-1); margin-bottom: 0.15rem; }
  .step > div > p { font-size: var(--step--1); color: var(--muted); }

  /* ------------------------------------------------------------- footer */
  /* The footer settles into the field rather than starting a new block:
     the darkening fades in from transparent instead of cutting. */
  .footer {
    background: linear-gradient(to bottom, transparent 0%, rgb(8 5 3 / 55%) 30%, rgb(8 5 3 / 88%) 100%);
    color: var(--cream);
    padding-block: var(--band) 2rem;
  }
  .footer a { color: var(--cream); text-decoration: none; }
  .footer a:hover { color: var(--gold); text-decoration: underline; }
  .footer-grid {
    display: grid;
    gap: clamp(1.5rem, 3vw, 2.5rem);
    grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr));
  }
  .footer-col { display: flex; flex-direction: column; gap: 0.45rem; align-items: flex-start; }
  .footer-col b { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--gold); margin-bottom: 0.2rem; }
  .footer-col a { min-height: var(--tap-min); display: inline-flex; align-items: center; font-size: var(--step--1); }
  .footer-fine {
    margin-top: clamp(2rem, 4vw, 3rem);
    padding-top: 1.2rem;
    border-top: 1px solid var(--hairline-dk);
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem 1.5rem;
    font-size: 0.75rem;
    color: var(--cream-soft);
  }

  /* -------------------------------------------------------------- admin
     Same tokens, higher density. The studio surface is the public surface
     with the air taken out, not a different product. */
  .admin { --band: clamp(2rem, 3vw, 3rem); }
  .admin .panel { padding: 1rem 1.1rem; }
  .admin h1 { font-size: var(--step-3); }
  .admin h2 { font-size: var(--step-1); }
  .metrics {
    display: grid;
    gap: 0.6rem;
    grid-template-columns: repeat(auto-fit, minmax(min(10rem, 100%), 1fr));
  }
  .metric {
    background: var(--surface);
    color: var(--ink);
    border: 1px solid var(--hairline);
    border-radius: var(--radius-lg);
    padding: 0.85rem 1rem;
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
  }
  .metric span { font-size: 0.66rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); }
  .metric strong {
    font-family: var(--display);
    font-size: var(--step-2);
    font-weight: 500;
    font-variant-numeric: tabular-nums;
    line-height: 1;
  }
  .metric small { font-size: 0.72rem; color: var(--muted); }

  .admin-cols { display: grid; gap: 1rem; grid-template-columns: 1fr; }
  @media (min-width: 62rem) {
    .admin-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .admin-cols .full { grid-column: 1 / -1; }
    /* Each panel is as tall as its own content. By default a grid row is as
       tall as its tallest item and every other panel stretches to match, which
       put roughly 200px of empty white under the WhatsApp form because the
       address form beside it has one more field. */
    .admin-cols { align-items: start; }
  }

  /* ------------------------------------------------ the dashboard's shape

     Three groups, because the questions are different and so is how often
     they are asked. "Needs you" is read every morning; "the last 30 days" is
     glanced at; "studio settings" is opened twice a year. Previously it was
     nine panels in a row with nothing to say which was which, and the address
     form sat between the appointment list and the review queue. */
  .admin-group + .admin-group { margin-top: clamp(1.75rem, 4vw, 2.75rem); }
  .admin-group > .rule { margin-bottom: .9rem; }

  /* A metric that reads zero should not compete with one that does not, and a
     cancellation is not the same kind of fact as a booking. The stripe and the
     weight say which is which before the number is read -- which is the only
     job a row of six identical boxes was failing at. */
  .metric {
    border-inline-start: 3px solid transparent;
    transition: border-color .18s ease;
  }
  .metric[data-tone="good"] { border-inline-start-color: var(--stain-day); }
  .metric[data-tone="watch"] { border-inline-start-color: var(--stain-fresh); }
  .metric[data-tone="idle"] strong { color: var(--muted); font-weight: 500; }
  .metric[data-tone="idle"] { border-inline-start-color: var(--hairline); }

  /* Settings save one field each; a full-width gold bar for that is a shout
     where a word will do. */
  .panel-actions { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .9rem; }

  /* The small print under a panel heading, and the "what is on file now" line
     above its form. Both were an inline font-size repeated nineteen times. */
  .panel-note { font-size: var(--step--1); color: var(--muted); margin-bottom: .5rem; }
  .panel-state { font-size: var(--step--1); margin: .2rem 0 .8rem; }
  .panel h2 { margin-bottom: .6rem; }
  .panel .row-between { margin-bottom: .6rem; }

  .mini { display: flex; flex-direction: column; }
  .mini > div {
    display: grid;
    grid-template-columns: 1fr auto auto;
    gap: 0.75rem;
    align-items: baseline;
    padding: 0.5rem 0;
    border-bottom: 1px solid var(--hairline);
    font-size: var(--step--1);
    font-variant-numeric: tabular-nums;
  }
  .mini > div:last-child { border-bottom: 0; }
  .mini strong { font-weight: 700; }
  .mini small { color: var(--muted); }

  details.disclosure > summary {
    cursor: pointer;
    min-height: var(--tap);
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-size: var(--step--1);
    font-weight: 700;
    letter-spacing: 0.06em;
  }
  /* display:flex on a summary suppresses the default disclosure triangle,
     which left this control looking like plain bold text. Draw our own. */
  details.disclosure > summary::marker,
  details.disclosure > summary::-webkit-details-marker { display: none; content: ""; }
  details.disclosure > summary::before {
    content: "";
    width: 0.55rem;
    height: 0.55rem;
    border-right: 2px solid var(--stain-day);
    border-bottom: 2px solid var(--stain-day);
    transform: rotate(-45deg);
    transition: transform 0.15s ease;
    flex: 0 0 auto;
    margin-right: 0.15rem;
  }
  details.disclosure[open] > summary::before { transform: rotate(45deg); }
  details.disclosure > summary:hover { color: var(--stain-day); }

  /* ---------------------------------------------------------- empty state */
  .empty {
    text-align: center;
    padding: clamp(2.5rem, 6vw, 5rem) 1rem;
    border: 1px dashed var(--edge);
    border-radius: var(--radius-lg);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.75rem;
  }

  /* ------------------------------------------------------------ legal nav */
  .legal-layout { display: grid; gap: clamp(1.5rem, 3vw, 3rem); }
  @media (min-width: 56rem) { .legal-layout { grid-template-columns: 14rem minmax(0, 1fr); } }
  .legal-nav { display: flex; flex-direction: column; gap: 0.1rem; position: sticky; top: 90px; align-self: start; }
  .legal-nav b { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); margin-bottom: 0.4rem; }
  .legal-nav a { min-height: var(--tap-min); display: flex; align-items: center; padding: 0.35rem 0; font-size: var(--step--1); }
  .legal-body section { padding: 1.5rem 0; border-top: 1px solid var(--hairline); }
  .legal-body section:first-of-type { border-top: 1px solid var(--ink); }
  .legal-body h2 { font-size: var(--step-1); margin-bottom: 0.4rem; }
  .legal-body .lead { font-size: var(--step-1); line-height: 1.45; color: var(--muted); }
  .legal-body a { color: var(--stain-peak); }
}

@layer utilities {
  /* ------------------------------------------------------------ utilities

     These exist so that no template needs a style attribute. That is not
     tidiness: `style-src 'unsafe-inline'` cannot be removed from the content
     security policy while one remains, and a nonce does not help -- it permits
     a <style> element, never an attribute. Fifty-two declarations scattered
     through the markup were the only thing keeping the directive open. */
  .t-xl { font-size: var(--step-3); }
  .t-lg { font-size: var(--step-2); }
  .t-md { font-size: var(--step-1); }
  .t-sm { font-size: var(--step--1); }
  .t-xs { font-size: .8rem; }
  .t-xxs { font-size: .73rem; }

  .m-0 { margin: 0; }
  .mt-1 { margin-top: .4rem; }
  .mt-2 { margin-top: .6rem; }
  .mt-3 { margin-top: 1rem; }
  .mt-4 { margin-top: 1.25rem; }
  .mt-5 { margin-top: 1.5rem; }
  .mb-1 { margin-bottom: .6rem; }
  .mb-2 { margin-bottom: 1rem; }
  .mb-3 { margin-bottom: 1.25rem; }
  .mb-4 { margin-bottom: 1.5rem; }
  .mb-5 { margin-bottom: 1.75rem; }
  .mb-6 { margin-bottom: 2rem; }
  .mb-flow { margin-bottom: clamp(1.5rem, 3vw, 2.5rem); }
  .mb-flow-lg { margin-bottom: clamp(2rem, 4vw, 3.5rem); }
  .my-2 { margin-block: .3rem .8rem; }
  .my-3 { margin-block: 1rem; }

  .w-full { width: 100%; }
  .p-0 { padding: 0; }
  .no-rule { border-bottom: 0; }
  .items-center { align-items: center; }
  .items-end { align-items: flex-end; }
  .justify-center { justify-content: center; }
  .gap-1 { gap: .4rem; }
  .gap-2 { gap: .6rem; }
  .gap-3 { gap: 1rem; }
  .span-all { grid-column: 1 / -1; }
  .cols-1 { grid-template-columns: 1fr; }
  .cols-auto-1-auto { grid-template-columns: auto 1fr auto; }
  .pos-static { position: static; }
  .inline-block { display: inline-block; }
  .min-w-9 { min-width: 9rem; }

  /* The nine framings a photograph can be given. Nine classes rather than a
     computed style attribute, because the set is fixed and a fixed set does
     not need the CSP held open to express it. */
  .focus-0-0 { object-position: 0% 0%; }
  .focus-50-0 { object-position: 50% 0%; }
  .focus-100-0 { object-position: 100% 0%; }
  .focus-0-50 { object-position: 0% 50%; }
  .focus-50-50 { object-position: 50% 50%; }
  .focus-100-50 { object-position: 100% 50%; }
  .focus-0-100 { object-position: 0% 100%; }
  .focus-50-100 { object-position: 50% 100%; }
  .focus-100-100 { object-position: 100% 100%; }

  .sr-only {
    position: absolute;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
  .muted { color: var(--muted); }
  /* The footer is a dark ground too. This override must live in the same
     layer as .muted — in @layer components it loses to the utility. */
  /* .journey belongs here too: it sits on the field, so its muted text needs
     the field's colour. Putting this in @layer components -- where the section
     is styled -- loses to the plain .muted in @layer utilities every time. An
     override has to live in the same layer as the thing it overrides. */
  .on-brand .muted, .on-brand-deep .muted, .footer .muted, .journey .muted { color: var(--cream-soft); }
  /* A paper panel inside a dark section is still paper. Higher specificity
     than the rule above, and in the same layer so it can win at all. */
  .on-brand .panel-paper .muted,
  .on-brand-deep .panel-paper .muted { color: var(--muted); }
  .measure { max-width: 34rem; }
  .measure-wide { max-width: 48rem; }
  .lead { font-size: var(--step-1); line-height: 1.5; }
  .nowrap { white-space: nowrap; }
  .tnum { font-variant-numeric: tabular-nums; }
  .mt-0 { margin-top: 0; }
  .mb-0 { margin-bottom: 0; }
  .hide { display: none !important; }
  @media (max-width: 47.99rem) { .hide-sm { display: none !important; } }
}

/* ---------------------------------------------------------------- rates */
/* `.stack` and `.stack-lg` set align-items: flex-start. That is right for a
   column of prose -- a heading should not stretch to the width of its
   container -- and wrong for anything whose job is to lay out a row.

   Inside those stacks a grid shrank to its own content and then had no width
   left to divide, so the dashboard's six metric cards came out as one narrow
   column with two thirds of the row empty, and the rates page gave every
   editor the width of its own longest line: six panels sharing a left edge at
   101 and right edges at 466, 475, 478, 503, 542 and 806. Close enough to look
   like a mistake rather than a choice, which is what it was.

   Scoped to the studio rather than listed container by container. Naming them
   individually meant missing one: `.admin-cols` was stretched, but it sits
   inside `.admin-group`, which was not, so the two selling panels stayed 250px
   wide in a 1237px row. Every direct child of a stack in here is a block whose
   job is to hold a row, and stretching a heading or a paragraph does nothing a
   block element was not already doing. The public pages keep the old
   behaviour, which is what they were written against. */
.admin .stack > *,
.admin .stack-lg > * { align-self: stretch; }

/* A service that has been taken off the public site. Still editable, still
   attached to whatever bookings already reference it -- just not on offer. */
.is-hidden-service { opacity: .62; }
.is-hidden-service h3 { text-decoration: line-through; text-decoration-thickness: 1px; }

/* A form that sits on the same line as the heading it belongs to. */
form.inline { display: inline; margin: 0; }

/* ------------------------------------------------------------- studio */
/* Destructive actions look destructive. Not red-on-red shouting -- a quiet
   outline that turns solid on hover, so the button reads as different from
   "save" without dominating a page full of forms. */
.btn-danger {
  background: transparent;
  color: var(--danger, #8d2f26);
  border: 1px solid currentColor;
}
.btn-danger:hover, .btn-danger:focus-visible {
  background: var(--danger, #8d2f26);
  color: #fff;
}
.linkish.is-danger { color: var(--danger, #8d2f26); }

/* An edit form revealed underneath the row it belongs to. */
.panel-inset {
  margin: 0.75rem 0 0;
  padding: 0.9rem;
  border: 1px dashed var(--hairline);
  border-radius: 8px;
}

/* History. One line per change: when, what kind, what happened, who did it. */
.history { list-style: none; margin: 0; padding: 0; }
.history-row {
  display: grid;
  grid-template-columns: auto auto 1fr auto;
  gap: 0.75rem;
  align-items: baseline;
  padding: 0.5rem 0;
  border-top: 1px solid var(--hairline);
}
.history-row:first-child { border-top: none; }
.history-row time {
  font-variant-numeric: tabular-nums;
  font-size: 0.85rem;
  color: var(--muted-ink, inherit);
  opacity: 0.75;
}
.history-what { min-width: 0; }
.history-who { font-size: 0.85rem; white-space: nowrap; }
@media (max-width: 40rem) {
  .history-row { grid-template-columns: auto 1fr; }
  .history-what { grid-column: 1 / -1; }
  .history-who { grid-column: 1 / -1; }
}

/* ------------------------------------------------------------- reach */
/* A shape, drawn small. There is deliberately no y-axis: the question this
   answers is "is this week like the others", not "exactly how many". */
.spark {
  /* Capped rather than full-bleed. Stretched across the whole panel, a 480-wide
     viewBox is squashed to about a quarter of its intended aspect and every
     week looks identical -- the chart stops saying anything at exactly the
     moment it is supposed to show a shape. */
  width: 100%;
  max-width: 34rem;
  height: 110px;
  display: block;
  margin: 0.25rem 0 0.9rem;
  overflow: visible;
}
/* The band of ordinary variation. A single flat tint rather than a traffic
   light -- inside it means nothing happened, and nothing happening is not a
   warning. */
.spark-band { fill: color-mix(in srgb, var(--ink) 7%, transparent); }
.spark-mean {
  stroke: color-mix(in srgb, var(--ink) 28%, transparent);
  stroke-width: 1;
  stroke-dasharray: 3 3;
}
.spark-line {
  fill: none;
  stroke: var(--ink);
  stroke-width: 2;
  stroke-linejoin: round;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}
/* The week in progress. Dashed because it is incomplete, not because it is
   bad -- borrowed from Plausible, who mark partial periods the same way. */
.spark-partial {
  fill: none;
  stroke: color-mix(in srgb, var(--ink) 45%, transparent);
  stroke-width: 2;
  stroke-dasharray: 4 4;
  vector-effect: non-scaling-stroke;
}
.spark-now { fill: var(--ink); }

.reach-verdict {
  font-size: 1.02rem;
  margin: 0 0 0.35rem;
  text-wrap: balance;
}
