/*
 * FasterChef â€” marketing site.
 *
 * Written mobile-first because that is where the traffic comes from: people
 * tapping a link inside the Instagram or TikTok browser on a phone. The desktop
 * layout is the enhancement, not the other way round.
 *
 * The tokens mirror ios/FasterChef/DesignSystem/Tokens.swift so the site and
 * the app look like the same product. If the two ever disagree, the app is
 * right â€” it is the thing people actually use.
 *
 * The design thesis, from docs/02-DESIGN-SYSTEM.md, governs everything below:
 * a cool, quiet, disciplined shell with heat only where you act. Orange is
 * never decoration. Orange means "press this" or "this is ready". Plum belongs
 * to the meal plan and nothing else. Green means checked off. If a colour here
 * is doing none of those jobs, it should be ink.
 *
 * No frameworks, no third-party anything, one self-hosted font file.
 */

/* ------------------------------------------------------------------- font */

/* Fraunces, the display face the app bundles. Variable across weight and
   optical size, so one file covers every heading on the site. `swap` means a
   slow connection reads the page in Georgia and upgrades in place rather than
   staring at nothing. */
@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-weight: 600 700;
  font-display: swap;
  src: url('/fonts/fraunces-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-weight: 600 700;
  font-display: swap;
  src: url('/fonts/fraunces-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* ----------------------------------------------------------------- tokens */

:root {
  --orange: #fe8b05;
  --orange-deep: #fd7605;
  --orange-tint: #fff1e0;
  /* Orange text sitting ON the tint, which `--orange-deep` cannot do: #fd7605
     on #fff1e0 is 2.43:1, well under the 4.5:1 that body-sized text needs.
     Darkened only as far as it takes to pass — 5.11:1 — so it still reads as
     the same colour. In dark mode the pairing already passes at 6.62:1, so
     there the two tokens are the same value. */
  --orange-on-tint: #ad4800;
  /* Text on the success green. White passes on the light green (5.05:1) and
     fails badly on the lighter one dark mode uses (2.71:1), so it flips. */
  --on-success: #ffffff;
  --plum: #5b2c4a;
  --plum-tint: #f3eaf0;

  --ink-900: #1a1512;
  --ink-600: #5b534c;
  --ink-400: #918980;
  --ink-200: #ddd8d1;

  --canvas: #f6f5f2;
  --surface: #ffffff;
  --success: #2e7d4f;
  --danger: #c0392b;

  --radius-card: 16px;
  --radius-button: 14px;
  --radius-sheet: 24px;

  --space-xs: 8px;
  --space-sm: 12px;
  --space-md: 16px;
  --space-lg: 24px;
  --space-xl: 32px;
  --space-2xl: 48px;
  --space-3xl: 72px;
  --space-4xl: 104px;

  --font-display: 'Fraunces', Georgia, 'Times New Roman', serif;
  --font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;

  --measure: 34rem;
  --page: 72rem;

  /*
   * The frame inside every phone on this page: what the video somebody shared
   * looks like when you pause it.
   *
   * It used to be drawn in CSS, and it took three tries to learn why that could
   * not work. The first two drew the food -- a pan, then a bowl of rigatoni with
   * tube openings and a stoneware rim -- and both failed the same way. Gradients
   * have no edges, and a picture of a specific object with no edges does not read
   * as that object slightly blurred; it reads as a different object, because the
   * eye picks the nearest thing it knows. Pale ovals became eggs. Give them dark
   * centres and they became cells under a microscope.
   *
   * The third drew the lens instead of the food: two dozen bokeh discs, round and
   * rim-lit and overlapping, nothing claiming to be anything in particular so
   * nothing able to be the wrong thing. That one worked, and it was still a
   * drawing of a photograph standing in the place a photograph goes.
   *
   * So now it is a photograph, and the whole argument above is settled by the
   * only thing that was ever going to settle it. A macro frame with the sauce in
   * focus and the back of the pan gone soft -- shot close, wide open, indoors,
   * which is how every one of these videos is shot.
   *
   * Two crops, not one. The portrait one is cut to 9:19.5, which is the phone
   * screen's own shape, so the largest frame on the page shows the whole of it
   * and every smaller frame takes a band out of the middle — the sauce meeting
   * the pasta, which is the part worth keeping. The wide one exists because
   * covering a 2.3:1 strip from a 9:19.5 picture keeps about a twelfth of it,
   * and a twelfth of a macro shot is a stripe of one colour.
   *
   * Penne all'arrabbiata by Leonhard Lenz, released CC0 on Wikimedia Commons --
   * public domain, no attribution owed. Recorded here so the next person knows
   * where it came from and that it is ours to use, and so that swapping in our
   * own photography later is a two-line change.
   */
  --still: url('/assets/reel-still.jpg');
  --still-wide: url('/assets/reel-still-wide.jpg');

  /* What sits under the photograph, and what is left if it never arrives. A
     picture that has not loaded yet should look like a dim kitchen rather than
     like a hole in the phone, so the ground is the photograph's own average
     brought up a little. Deep tomato rather than brown: brown is what a warm
     gradient turns into when it runs out of saturation, and it looks like gravy. */
  --dish-ground: radial-gradient(122% 96% at 46% 40%, #d4611d 0 8%, #a8420f 32%, #5e2409 66%, #2a1108 100%);

  /* The corners fall away, and the grain lands on everything. Every lens does
     the first and every sensor does the second, and between them they are most
     of why a rectangle reads as a photograph rather than as a panel of colour.
     Both belong above the picture, which is why they live on the frame's ::after
     rather than in the layer below. */
  --dish-vignette: radial-gradient(124% 92% at 50% 44%, transparent 30%, rgb(0 0 0 / 0.3) 72%, rgb(0 0 0 / 0.62) 100%);

  /* Grain. A photograph scaled down to 300px wide has had its own noise
     averaged away, and perfectly clean at that size is the one thing no phone
     screen has ever shown you — a reel is shot in a kitchen at night by a
     sensor working hard. Three pixels of dither over the top puts back what the
     downscale took out, and costs one gradient. */
  --grain: repeating-conic-gradient(
    from 22deg at 50% 50%,
    rgb(255 255 255 / 0.028) 0deg 90deg,
    rgb(0 0 0 / 0.032) 90deg 180deg
  );

  /* One shadow, used sparingly. Depth comes from canvas/surface contrast. */
  --shadow-card: 0 2px 12px rgb(26 21 18 / 0.06);
  --shadow-lift: 0 12px 32px rgb(26 21 18 / 0.10);
  --shadow-phone: 0 32px 80px rgb(26 21 18 / 0.20);
  /* What separates the handset from the page it sits on. On paper the drop
     shadow does that job and the edge is only a highlight; on a near-black page
     a black shadow does nothing at all, so the edge has to carry it alone. */
  --phone-edge: inset 0 0 0 1px rgb(255 255 255 / 0.07);

  /* Motion tokens, ported from the app's spring specs. Springs, not eases:
     each of these overshoots very slightly and settles, which is what makes a
     press feel physical rather than animated. */
  --snap: 280ms cubic-bezier(0.22, 1.2, 0.36, 1);
  --settle: 420ms cubic-bezier(0.16, 1, 0.3, 1);
  --flow: 550ms cubic-bezier(0.16, 1, 0.3, 1);

  /* The same two curves again, without a duration. `animation` needs its easing
     as a separate value, so the shorthands above cannot be used there. */
  --ease-spring: cubic-bezier(0.22, 1.2, 0.36, 1);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);

  /* A press should register the instant the finger lands. Easing *into* a press
     reads as lag; only the release is allowed to take its time. A bare duration
     rather than a shorthand, because it is applied as `transition-duration`. */
  --press: 70ms;
}

/* The page has two palettes and three ways to be in one of them: what the
   operating system says, and what the reader said with the button in the
   header, which wins over it.

   The list below is written out twice. A media query cannot be part of a
   selector, so there is no way to say "dark, unless the reader said light" in
   one rule. The two copies must stay identical - if a colour is changed in one
   of them and not the other, the page will look correct until somebody presses
   the button. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    --orange: #ff9a29;
    --orange-deep: #ff8a12;
    --orange-tint: #2e2113;
    --orange-on-tint: #ff8a12;
    --on-success: #10281c;
    --plum: #d9a8c6;
    --plum-tint: #2a1a24;

    --ink-900: #f5f2ee;
    --ink-600: #a79e95;
    --ink-400: #877e75;
    --ink-200: #3a342e;

    --canvas: #141210;
    --surface: #1f1c19;
    --success: #4caf7d;

    --shadow-card: 0 2px 12px rgb(0 0 0 / 0.36);
    --shadow-lift: 0 12px 32px rgb(0 0 0 / 0.45);
    --shadow-phone: 0 32px 80px rgb(0 0 0 / 0.6);
    --phone-edge: inset 0 0 0 1px rgb(255 255 255 / 0.17), 0 0 0 1px rgb(255 255 255 / 0.07);
  }
}
:root[data-theme='dark'] {
    --orange: #ff9a29;
  --orange-deep: #ff8a12;
  --orange-tint: #2e2113;
  --orange-on-tint: #ff8a12;
  --on-success: #10281c;
  --plum: #d9a8c6;
  --plum-tint: #2a1a24;

  --ink-900: #f5f2ee;
  --ink-600: #a79e95;
  --ink-400: #877e75;
  --ink-200: #3a342e;

  --canvas: #141210;
  --surface: #1f1c19;
  --success: #4caf7d;

  --shadow-card: 0 2px 12px rgb(0 0 0 / 0.36);
  --shadow-lift: 0 12px 32px rgb(0 0 0 / 0.45);
  --shadow-phone: 0 32px 80px rgb(0 0 0 / 0.6);
  --phone-edge: inset 0 0 0 1px rgb(255 255 255 / 0.17), 0 0 0 1px rgb(255 255 255 / 0.07);
}

/* Form controls, scrollbars and the rest of what the browser draws itself.
   Without this they stay light on a dark page, which is the one part of a dark
   theme nobody can restyle from here. */
:root { color-scheme: light dark; }
:root[data-theme='dark'] { color-scheme: dark; }
:root[data-theme='light'] { color-scheme: light; }

/* ------------------------------------------------------------------- base */

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

html {
  /* The header is fixed, so an anchor that scrolls to the top of a section
     puts that section's heading underneath it. This is how far short to
     stop: the header's height and a little air. */
  scroll-padding-top: 5.5rem;
  scroll-behavior: smooth;
  -webkit-text-size-adjust: 100%;
  /* Nothing clips here, and that is deliberate. The page had a real overflow
     problem on phones, and clipping the root looked like the tidy one-line fix
     for it - but every `position: sticky` on the site measures itself against
     the nearest ancestor that is not overflow-visible, and clipping the root
     makes that the root. The header stopped sticking and the scrolly stage
     stopped pinning, which is most of what this page does. The overflow is cut
     off where it is actually made instead; see `.hero`. */
}

body {
  margin: 0;
  background: var(--canvas);
  color: var(--ink-900);
  font-family: var(--font-body);
  font-size: 17px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

/* -------------------------------------------------------------- the ground */

/* The page has weather.
 *
 * Two pools of colour behind everything, fixed to the viewport, that move as
 * you scroll: the warm one drifts down and out and gives way, the plum one
 * comes up and takes over. By the pricing table you are somewhere different
 * from where you started, and nothing on the page told you so in words.
 *
 * Tied to the scroll position rather than to a clock, which is the whole
 * point. A wash on a timer moves while you sit still, and a background that
 * moves while you read is a background that is asking to be looked at. This
 * one only moves because you moved it, so it reads as depth — the far layer of
 * a parallax — rather than as animation.
 *
 * On body's own pseudo-elements rather than a div, so it costs no markup and
 * every page gets it — privacy, terms, the blog, the 404 — without four more
 * edits and a change to the generator. Behind the content at z-index -1, above
 * the canvas colour, and the dark scrolly section is opaque, so it simply
 * covers this up while it is on screen.
 *
 * Both are fainter than a section's own .glow. This is the room; that is the
 * lamp.
 */
body::before,
body::after {
  content: '';
  position: fixed;
  /* Oversized so the box's own edge is always off screen. A radial gradient
     fades to nothing on its own, but only if it is given room to. */
  inset: -30%;
  z-index: -1;
  pointer-events: none;
}
body::before {
  background: radial-gradient(38rem 32rem at 72% 24%, rgb(254 139 5 / 0.15), transparent 64%);
}
body::after {
  background: radial-gradient(34rem 30rem at 24% 76%, rgb(91 44 74 / 0.13), transparent 66%);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) body::before {
    background: radial-gradient(38rem 32rem at 72% 24%, rgb(255 154 41 / 0.10), transparent 64%);
  }
  :root:not([data-theme='light']) body::after {
    background: radial-gradient(34rem 30rem at 24% 76%, rgb(217 168 198 / 0.08), transparent 66%);
  }
}
:root[data-theme='dark'] body::before {
  background: radial-gradient(38rem 32rem at 72% 24%, rgb(255 154 41 / 0.10), transparent 64%);
}
:root[data-theme='dark'] body::after {
  background: radial-gradient(34rem 30rem at 24% 76%, rgb(217 168 198 / 0.08), transparent 66%);
}
@supports (animation-timeline: scroll(root)) {
  @media (prefers-reduced-motion: no-preference) {
    body::before {
      animation: ground-warm linear both;
      animation-timeline: scroll(root);
    }
    body::after {
      animation: ground-plum linear both;
      animation-timeline: scroll(root);
    }
  }
}
@keyframes ground-warm {
  from { transform: translate3d(5%, -7%, 0) scale(1); opacity: 1; }
  to { transform: translate3d(-11%, 13%, 0) scale(1.24); opacity: 0.4; }
}
@keyframes ground-plum {
  from { transform: translate3d(-9%, 11%, 0) scale(0.92); opacity: 0.35; }
  to { transform: translate3d(7%, -9%, 0) scale(1.28); opacity: 1; }
}

/* ------------------------------------------------------- page transitions */

/* Moving between pages used to be a white flash. It is a cross-fade now, and
   the header is held still across it, because the header genuinely is the same
   object on both pages — fading it out and back in claims there were two of them.
   The progress line is held for the same reason.

   Four declarations do all of it, and a browser that has not shipped this
   ignores every one of them and navigates the way it always did. That is the
   whole argument for having it: no script, no fallback to write, and no way for
   it to fail into something broken.

   The old page leaves faster than the new one arrives, and they overlap. Equal
   times read as a dissolve, which is a slideshow; a short exit under a longer
   entrance reads as one page being replaced by another. */
@view-transition { navigation: auto; }

.site-header { view-transition-name: site-header; }
.progress { view-transition-name: progress-line; }

::view-transition-old(root) { animation: page-out 170ms var(--ease-out) both; }
::view-transition-new(root) { animation: page-in 340ms var(--ease-out) both; }
@keyframes page-out {
  to {
    opacity: 0;
    transform: translateY(-10px);
  }
}
@keyframes page-in {
  from {
    opacity: 0;
    transform: translateY(16px);
  }
}
/* The killswitch at the end of this file works on elements, and these are not
   elements — the view-transition pseudos live outside the document tree, so they
   have to be turned off by name. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation: none;
  }
}

/* The same machinery, borrowed for the light and dark switch.
 *
 * Nothing navigates - the swap is one attribute on the html element - but a
 * view transition does not care why the page changed, only that it did. It
 * photographs the page before, photographs it after, and hands both to CSS as
 * two layers. So the new page is laid over the old one and a circle is opened
 * in it from the middle of the button that was pressed, out to whichever corner
 * of the window is furthest away. The old palette is still under it the whole
 * time, which is what makes it read as the light being changed in a room rather
 * than as a page reloading.
 *
 * app.js writes the centre and the radius. It sets `is-theming` before it starts
 * the transition, which matters: the snapshot is taken the moment the transition
 * begins, so the header and the progress line have to give up their own names
 * before that or they would be lifted out and cross-faded separately, floating
 * over a page that is being wiped underneath them.
 *
 * A browser without view transitions never enters any of this. The attribute
 * still changes and the palette still swaps; it simply happens between one frame
 * and the next, which is what a theme switch did everywhere until recently.
 */
:root.is-theming .site-header,
:root.is-theming .progress { view-transition-name: none; }

:root.is-theming::view-transition-old(root) {
  animation: none;
  z-index: 0;
}
:root.is-theming::view-transition-new(root) {
  animation: theme-open 620ms var(--ease-out) both;
  z-index: 1;
}
@keyframes theme-open {
  from { clip-path: circle(0 at var(--tv-x, 50%) var(--tv-y, 0)); }
  to { clip-path: circle(var(--tv-r, 150vmax) at var(--tv-x, 50%) var(--tv-y, 0)); }
}

h1, h2, h3 {
  font-family: var(--font-display);
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.015em;
  margin: 0 0 var(--space-sm);
  text-wrap: balance;
  font-variation-settings: 'SOFT' 40, 'WONK' 0;
}

h1 { font-size: clamp(2.4rem, 7.4vw, 4.2rem); }
h2 { font-size: clamp(1.75rem, 4.6vw, 2.7rem); }
h3 { font-size: 1.2rem; font-weight: 600; }

p { margin: 0 0 var(--space-md); }
a { color: var(--orange-deep); }
img { max-width: 100%; height: auto; }

/* Several things here are display:flex or display:grid, which beat the browser
   default for [hidden] and would leave a "hidden" element on screen. */
[hidden] { display: none !important; }

/* A visible focus ring everywhere. Removing it is the single most common
   accessibility mistake on a landing page. */
:focus-visible {
  outline: 3px solid var(--orange);
  outline-offset: 2px;
  border-radius: 4px;
}

/* And it closes in rather than appearing. A ring that is simply there on the
   next frame is easy to miss when you are three tabs deep and moving fast; one
   that contracts onto the control draws the eye to the control it landed on.
   Named elements rather than every :focus-visible, because an `animation` here
   would replace whatever animation the focused element was already running —
   and on this page a lot of things are mid-reveal. */
@media (prefers-reduced-motion: no-preference) {
  a:focus-visible,
  button:focus-visible,
  summary:focus-visible,
  input:focus-visible,
  select:focus-visible,
  textarea:focus-visible,
  [tabindex]:focus-visible {
    animation: focus-ring 240ms var(--ease-out);
  }
}
@keyframes focus-ring {
  from { outline-offset: 10px; outline-color: transparent; }
}

/* Selected text keeps the brand instead of the browser's blue. Only the
   background is set: the page has light copy on dark sections and dark copy
   everywhere else, and forcing a single text colour would make one of the two
   unreadable the moment somebody dragged across it. */
::selection { background: color-mix(in srgb, var(--orange) 30%, transparent); }

.skip-link {
  position: absolute;
  left: -9999px;
  top: var(--space-xs);
  background: var(--surface);
  padding: var(--space-xs) var(--space-md);
  border-radius: var(--radius-button);
  z-index: 100;
}
.skip-link:focus { left: var(--space-md); }

/* Read aloud, never drawn. The prices animate as loose digits, which is fine to
   look at and useless to listen to, so the spoken version is a plain sentence
   sitting next to them. */
.vh {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.eyebrow {
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--orange-deep);
  margin-bottom: var(--space-sm);
}

/* The hero's eyebrow is a status rather than a label â€” the app is not out yet â€”
   so it gets the dot that every status light on every dashboard has. It is the
   only thing on the page that moves before you have done anything, which is
   the whole reason it can carry that meaning: if everything pulsed, nothing
   would be pulsing. The other eyebrow on the page says "The difference", which
   is a heading, and gets no dot. */
.hero .eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}
.hero .eyebrow::before {
  content: '';
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--orange);
  animation: live 2.8s var(--ease-out) infinite;
}
@keyframes live {
  0% { box-shadow: 0 0 0 0 rgb(254 139 5 / 0.55); }
  45% { box-shadow: 0 0 0 7px rgb(254 139 5 / 0); }
  /* Spread returns to nothing while it is already invisible, so the ring does
     not appear to shrink back into the dot before the next pulse. */
  100% { box-shadow: 0 0 0 0 rgb(254 139 5 / 0); }
}

.lede {
  font-size: 1.15rem;
  color: var(--ink-600);
  max-width: var(--measure);
}

/* ---------------------------------------------------------------- reveals */

/* Everything marked data-reveal starts slightly low and transparent, and is
   released by an IntersectionObserver. `--d` staggers siblings so a row of
   cards arrives as a sequence rather than a block.

   Gated behind `.js`, which app.js adds to the document. That gate is the
   whole safety net: if the script is blocked â€” and inside an in-app browser it
   sometimes is â€” nothing is ever hidden in the first place. */
.js [data-reveal] {
  opacity: 0;
  transform: translateY(20px);
  transition:
    opacity var(--flow) calc(var(--d, 0) * 80ms),
    transform var(--flow) calc(var(--d, 0) * 80ms);
}
.js [data-reveal].is-in {
  opacity: 1;
  transform: none;
}

/* -------------------------------------------------------- headline reveal */

/* The headings arrive a word at a time, each one rising out of a box that clips
   it â€” so what you see is type coming up from under a rule rather than a
   paragraph fading in. It is the one flourish that is allowed to be purely a
   flourish, and it is spent on the lines that carry the argument.
   app.js walks each heading to split it, so a line break survives as a line
   break and the underlined phrase in the headline rises in one piece with its
   rule intact. The headline's own timing is further down, with the hero. */
.js [data-reveal].split {
  opacity: 1;
  transform: none;
  transition: none;
}
.js .split .w {
  display: inline-block;
  /* Descenders sit below the baseline and would be cropped by the clip. Pad
     the box, then pull the padding back out of the line so the heading's
     leading is exactly what it was before it was split. */
  padding-bottom: 0.14em;
  margin-bottom: -0.14em;
  overflow: hidden;
  vertical-align: bottom;
}
.js .split .w > i {
  display: inline-block;
  font-style: normal;
  transform: translateY(112%);
  transition: transform var(--flow) calc(var(--w, 0) * 40ms);
}
.js .split.is-in .w > i { transform: none; }

/* ------------------------------------------- reveals, tied to the scroll */

/* The same reveals again, driven by scroll position instead of by a timer.
   
   An IntersectionObserver can only say "now". Everything above fires a fixed
   half-second animation the moment it is asked to, which means the page moves
   at the same speed whether you are easing down it or throwing it. Scroll-driven
   animation ties the same movement to the scrollbar: the block rises as you
   pull it up, at your speed, and it goes back down if you scroll back. That
   single property — the page moving under your finger rather than near it — is
   most of the difference between a site that feels made and one that feels
   assembled.

   It is layered on top rather than replacing what is there. The observer keeps
   running and keeps adding `is-in`; browsers without scroll timelines never
   enter this block and get exactly the page they had. And where both apply, the
   animation wins on its own — animations outrank ordinary declarations — so
   there is nothing to co-ordinate.

   The hero is deliberately left out. It is already on screen when the page
   loads, so a scroll-driven version of it would resolve to "finished" and the
   opening would simply not happen. Its arrival is an event, not a position. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .js [data-reveal] {
      animation: reveal-rise linear both;
      animation-timeline: view();
      /* Starts once the block is a little way in and finishes well before it
         is fully up, so it is settled by the time it is worth reading. --d
         shifts the whole window later for each sibling, which is the same
         stagger the timed version does with a delay — a scroll timeline has no
         delay to give, because there is no time in it. */
      animation-range: entry calc(4% + var(--d, 0) * 7%) entry calc(58% + var(--d, 0) * 7%);
    }
    /* Headings arrive a word at a time, and the words scrub too. Each one is on
       roughly the same line, so the view range they share is the same range —
       the stagger is entirely --w pushing each word's window later than the one
       before it. */
    .js .split .w > i {
      animation: word-rise linear both;
      animation-timeline: view();
      animation-range: entry calc(6% + var(--w, 0) * 2.4%) entry calc(52% + var(--w, 0) * 2.4%);
    }
    /* A split heading is not a block that rises; it is a row of words that do.
       Without this it would be both, and the words would climb inside a box
       that was climbing with them. */
    .js [data-reveal].split { animation: none; }
    .js .hero [data-reveal],
    .js .hero .split .w > i {
      animation: none;
    }
  }
}
@keyframes reveal-rise {
  from {
    opacity: 0;
    transform: translateY(22px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}
@keyframes word-rise {
  from { transform: translateY(112%); }
  to { transform: none; }
}

/* ------------------------------------------------------------ scroll shear */

/* app.js measures how hard the page is being scrolled and writes it here as an
   angle. These blocks lean into it and come back level when it stops.

   The elements marked are grid containers, never the cards inside them, for two
   reasons. The cards carry data-reveal, and where the browser supports scroll
   timelines that is an animation on transform - an animation and a declaration
   cannot share a property, and the animation wins. And a container leans as one
   piece, which is the whole effect: six cards each leaning on their own is a
   mess, six cards leaning together is a page with some mass to it. */
.js [data-skew] {
  transform: skewY(calc(var(--shear, 0deg) * var(--shear-mul, 1)));
}
/* Only while it is actually moving. A standing will-change is a layer the
   compositor holds for the whole visit in return for nothing. */
.js.is-shearing [data-skew] { will-change: transform; }

@media (max-width: 46rem) {
  /* Half on a phone. A flick crosses far more page per pixel of thumb, so the
     same angle reads as a wobble rather than as weight. */
  .js [data-skew] { --shear-mul: 0.5; }
}

/* The killswitch further down only shortens durations, and this is neither an
   animation nor a transition. It has to be turned off by hand. app.js does not
   start the loop either, so this is the belt to that pair of braces. */
@media (prefers-reduced-motion: reduce) {
  .js [data-skew] { transform: none; }
}

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

/* A hairline that fills as you move through the page. It is the only piece of
   permanent chrome that moves, and it is orange because it is reporting
   progress â€” the one thing orange is for. */
.progress {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  z-index: 60;
  background: linear-gradient(90deg, var(--orange), var(--orange-deep));
  transform: scaleX(0);
  transform-origin: 0 50%;
  pointer-events: none;
}


/* ------------------------------------------------------------ section rail */

/*
 * The beads down the right-hand edge.
 *
 * The hairline above reports a fraction and nothing else. This says what the
 * fraction is made of: thirteen sections, named, with the line between them
 * filling as you go and the one you are in lit. It is a `nav` and its beads
 * are links, because the third thing it does is take you there.
 *
 * It lives in the gutter the sections already leave empty. Twenty-two pixels
 * is narrower than their twenty-four of padding, so at any window where the
 * rail is shown at all it is standing on nothing - no card edge, no table
 * border, no line of text. It moves out into real gutter only once the page
 * has stopped growing and there is real gutter to move into.
 */
.rail {
  display: none;
  position: fixed;
  right: 1px;
  top: 50%;
  z-index: 45;
  flex-direction: column;
  align-items: center;
  padding: 10px 0;
  transform: translateY(-50%);
}
/* Below this there is no gutter to stand in, the thumb bar is already the way
   back to the form, and thirteen beads in a column is thirteen targets under
   one thumb, which is none. */
@media (min-width: 62rem) {
  .rail { display: flex; }
}
@media (min-width: 78rem) {
  .rail { right: 14px; }
}

.rail-track {
  position: absolute;
  top: 10px;
  bottom: 10px;
  left: 50%;
  width: 2px;
  margin-left: -1px;
  border-radius: 2px;
  background: var(--ink-200);
  overflow: hidden;
}
/* Scaled from the top by app.js on the same frame as the hairline, off the
   same ratio, so the two never disagree about where you are. */
.rail-fill {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(180deg, var(--orange), var(--orange-deep));
  transform: scaleY(0);
  transform-origin: 50% 0;
}

.rail-dot {
  position: relative;
  display: block;
  width: 22px;
  height: 22px;
  border-radius: 999px;
  -webkit-tap-highlight-color: transparent;
}
/* The bead is opaque and four times the width of the line it sits on, so it
   hides the line by being there. A ring of page colour would have done the
   same job and would have been wrong: this rail floats over the walkthrough
   and the closing card as well as over the page, and a ring painted in
   `--canvas` announces itself the moment the thing behind it is not. */
.rail-dot::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 8px;
  height: 8px;
  transform: translate(-50%, -50%);
  border-radius: 999px;
  background: var(--ink-400);
  transition: background var(--snap), width var(--settle), height var(--settle);
}
.rail-dot:hover::after { background: var(--ink-600); }
.rail-dot[aria-current='true']::after {
  width: 12px;
  height: 12px;
  background: var(--orange);
}
.rail-dot:focus-visible {
  outline: 2px solid var(--orange);
  outline-offset: 1px;
}

/* The name, in a chip of its own. It has a background and a border because it
   is the one part of this that is allowed over the text, and a bare word
   floating on top of a paragraph is unreadable in both directions. */
.rail-say {
  position: absolute;
  right: calc(100% + 2px);
  top: 50%;
  padding: 4px 10px;
  border: 1px solid var(--ink-200);
  border-radius: 999px;
  background: var(--surface);
  box-shadow: 0 8px 20px rgb(0 0 0 / 0.12);
  font-size: 0.72rem;
  font-weight: 600;
  line-height: 1.2;
  white-space: nowrap;
  color: var(--ink-900);
  opacity: 0;
  pointer-events: none;
  transform: translate(6px, -50%);
  transition: opacity var(--snap), transform var(--snap);
}
/* All thirteen at once, on the rail rather than on the bead. Reading a rail
   one label at a time, by hovering each bead in turn, is not reading a rail. */
.rail:hover .rail-say,
.rail-dot:focus-visible .rail-say {
  opacity: 1;
  transform: translate(0, -50%);
}

/* And on arrival, the section you have just entered says its own name and then
   gets out of the way. This is the whole reason the rail is worth having over
   the hairline: the hairline can tell you that you are two thirds through, and
   only this can tell you that two thirds through is the pricing.

   No `forwards`. The animation is allowed to end and hand the label back to
   the rule above, which is what lets a hover still work afterwards; and while
   it is running, a hover suppresses it outright rather than losing to it. */
.rail-dot[aria-current='true'] .rail-say {
  animation: rail-say 1900ms var(--ease-out);
}
.rail:hover .rail-dot[aria-current='true'] .rail-say { animation: none; }

@keyframes rail-say {
  0% { opacity: 0; transform: translate(6px, -50%); }
  9% { opacity: 1; transform: translate(0, -50%); }
  64% { opacity: 1; transform: translate(0, -50%); }
  100% { opacity: 0; transform: translate(6px, -50%); }
}

/* --------------------------------------------------------------- ambience */

/* Two soft brand-coloured washes, well below the text. This is the one place
   orange is allowed to be atmosphere rather than an instruction â€” it is behind
   everything, it never sits next to a control, and it is what stops the page
   reading as a white document. */
/* Painted as gradients rather than blurred circles. A blurred element still
   has a box, and clipping that box leaves a visible straight edge across the
   page â€” a radial gradient fades to nothing on its own and cannot. */
.glow {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 0;
  background:
    radial-gradient(42rem 34rem at 76% 8%, rgb(254 139 5 / 0.26), transparent 62%),
    radial-gradient(32rem 28rem at 6% 96%, rgb(91 44 74 / 0.14), transparent 66%);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .glow {
    background:
      radial-gradient(42rem 34rem at 76% 8%, rgb(255 154 41 / 0.16), transparent 62%),
      radial-gradient(32rem 28rem at 6% 96%, rgb(217 168 198 / 0.10), transparent 66%);
  }
}
:root[data-theme='dark'] .glow {
  background:
    radial-gradient(42rem 34rem at 76% 8%, rgb(255 154 41 / 0.16), transparent 62%),
    radial-gradient(32rem 28rem at 6% 96%, rgb(217 168 198 / 0.10), transparent 66%);
}

/* The hero has no panel edge to hide behind, so its wash is oversized and
   faded out from below. A gradient that is still coloured where its box ends
   draws a straight line across the page â€” which is exactly what a soft
   background wash must never do. */
.hero .glow {
  inset: -24% -18% -8%;
  -webkit-mask-image: linear-gradient(to bottom, #000 52%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 52%, transparent 100%);
}

/* The clip on `.hero` catches this in any browser that has `overflow: clip`;
 * pulling the wash in as well means a browser that does not still gets a page
 * the honest width of its own screen. The wash reads the same either way - on
 * a screen this narrow the radial has faded out well before the box ends. */
@media (max-width: 46rem) {
  .hero .glow {
    left: 0;
    right: 0;
  }
}

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

.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-md);
  padding: var(--space-md) var(--space-lg);
  /* The solid value first, for anything without color-mix. A transparent
     header over scrolling text is worse than an opaque one. */
  background: var(--canvas);
  background: color-mix(in srgb, var(--canvas) 82%, transparent);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  border-bottom: 1px solid transparent;
  transition: border-color var(--settle), padding var(--settle);
}
.site-header.is-stuck {
  border-bottom-color: var(--ink-200);
  padding-block: var(--space-sm);
}
.site-header .brand,
.site-header .header-cta { position: relative; z-index: 1; }

.brand {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.25rem;
  color: var(--ink-900);
  text-decoration: none;
}
/* The mark answers when you point at it. Nothing more than that â€” it is a home
   link on a one-page site, so it must not look like it does something bigger. */
.brand img {
  border-radius: 50%;
  transition: transform var(--snap);
}
.brand:hover img,
.brand:focus-visible img { transform: rotate(-8deg) scale(1.07); }
.brand:active img { transform: scale(0.94); transition-duration: var(--press); }

/* The right-hand end of the header. Two controls that come and go at different
   times - the call to action is held back while a form is on screen, and on a
   phone it stands down altogether for the thumb bar - so they are wrapped
   rather than laid out by the header itself, which would otherwise spread its
   three children apart the moment one of them left. */
.header-tools {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: var(--space-sm);
}

.theme-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 38px;
  height: 38px;
  padding: 0;
  border: 1px solid var(--ink-200);
  border-radius: 999px;
  /* The bite that turns the sun into a moon is a disc the colour of the button,
     parked outside the frame until it is wanted. This is what keeps it there. */
  overflow: hidden;
  background: var(--surface);
  color: var(--ink-600);
  cursor: pointer;
  transition: color var(--snap), border-color var(--snap), transform var(--snap);
}
.theme-toggle:hover {
  color: var(--orange-deep);
  border-color: var(--orange);
}
.theme-toggle:focus-visible { outline: 2px solid var(--ink-900); outline-offset: 2px; }
.theme-toggle:active { transform: scale(0.92); transition-duration: var(--press); }

/* One shape does both states. The disc is the sun; a second disc the colour of
   the button slides in from the corner and takes a bite out of it, and what is
   left is a moon. The rays fold into the middle while it happens, so it reads as
   one thing changing rather than two icons being swapped. */
.tt-orb {
  position: relative;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  background: currentColor;
  transition: transform var(--settle);
}
.tt-orb::before,
.tt-orb::after {
  content: '';
  position: absolute;
  border-radius: 50%;
}
/* Eight rays as eight shadows of one two-pixel dot, which is the cheapest way
   to draw them and the only one that can be folded away with a single scale. */
.tt-orb::before {
  left: 50%;
  top: 50%;
  width: 2px;
  height: 2px;
  margin: -1px 0 0 -1px;
  background: currentColor;
  box-shadow: 0 -11px, 0 11px, -11px 0, 11px 0, -8px -8px, 8px -8px, -8px 8px, 8px 8px;
  transition: transform var(--settle), opacity var(--snap);
}
.tt-orb::after {
  inset: 0;
  background: var(--surface);
  transform: translate(115%, -115%);
  transition: transform var(--settle);
}

.theme-toggle[aria-pressed='true'] .tt-orb { transform: rotate(-22deg); }
.theme-toggle[aria-pressed='true'] .tt-orb::before {
  transform: scale(0.2) rotate(60deg);
  opacity: 0;
}
.theme-toggle[aria-pressed='true'] .tt-orb::after { transform: translate(32%, -32%); }

.header-cta {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--ink-900);
  text-decoration: none;
  padding: var(--space-xs) var(--space-md);
  border: 1px solid var(--ink-200);
  border-radius: 999px;
  background: var(--surface);
  white-space: nowrap;
  transition:
    border-color var(--snap), color var(--snap), transform var(--snap),
    opacity var(--snap), visibility 0s;
}
/* Held back until the form it points at has actually left the screen. Hidden
   from the stylesheet rather than from a class the script adds, so it is out
   before the first frame instead of blinking once on every slow load; the
   `noscript` block in the page head puts it back for a browser that will never
   run the script. The visibility delay is what stops a hidden button from
   taking a tab stop while it is still fading.

   Above the pointer states rather than below them, because both rules weigh the
   same now and the later one would win. Arriving and being pointed at are not
   the same thing, and the second has to be able to answer.

   `[data-cta]` and not `.header-cta`, because this rule is the homepage's
   arrangement and it was being applied to every page. Privacy, terms and every
   blog post carry the same button with no form anywhere on them to yield to and
   no script to release it, so on all of those it was hidden permanently: the
   one control that gets a reader from a written page back into the product,
   painted out. The attribute is the honest gate — it means "a script decides
   when this appears". A button nobody manages is simply on. */
.header-cta[data-cta] {
  opacity: 0;
  visibility: hidden;
  transform: translateY(-8px) scale(0.94);
  pointer-events: none;
  transition-delay: 0s, 0s, 0s, 0s, 280ms;
}
/* The lift is a variable rather than a transform because this rule outweighs
   the pointer rule below it — class, attribute, class against class, pseudo —
   so on the homepage, where the button is managed, `transform: none` won and
   the button did not answer the pointer at all. Custom properties do not have
   that fight: the pointer rule sets --lift, this rule spends it. */
.header-cta[data-cta].is-shown {
  opacity: 1;
  visibility: visible;
  transform: translateY(var(--lift, 0px));
  pointer-events: auto;
  transition-delay: 0s;
}

/* The thumb bar.
   Off on anything wider than a phone, where the header is a perfectly good
   place for a button and a bar across the bottom of the window would only be
   in the way. */
.thumb-cta { display: none; }
/* Both of these are flex, which outranks the browser's own rule for [hidden].
   The script hides them for good once someone has joined, and without this the
   attribute would go on and nothing would happen. */
.header-cta[hidden],
.thumb-cta[hidden] { display: none; }

@media (max-width: 46rem) {
  /* One control at a time. The header keeps its button when there is no script
     to manage either of them — hence the .js gate — but when the script is
     running, the reachable one wins and the other stands down rather than the
     page carrying two buttons to the same place. */
  .js .site-header .header-cta[data-cta] { display: none; }

  .thumb-cta {
    position: fixed;
    z-index: 45;
    left: max(var(--space-md), env(safe-area-inset-left));
    right: max(var(--space-md), env(safe-area-inset-right));
    bottom: calc(var(--space-md) + env(safe-area-inset-bottom));
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    padding: 15px var(--space-lg);
    border-radius: 999px;
    background: linear-gradient(180deg, var(--orange), var(--orange-deep));
    color: #fffaf3;
    font-size: 1rem;
    font-weight: 700;
    letter-spacing: -0.005em;
    text-decoration: none;
    /* A long shadow and a lit top edge, because this one floats over whatever
       the reader is in the middle of and has to read as sitting above the page
       rather than printed on it. */
    box-shadow:
      0 12px 30px rgb(20 18 16 / 0.26),
      0 3px 8px rgb(20 18 16 / 0.16),
      inset 0 1px 0 rgb(255 255 255 / 0.24);
    opacity: 0;
    visibility: hidden;
    /* Out of the window entirely, not merely faded — it arrives by sliding up
       from the edge it lives on, which is where a bar like this comes from. */
    transform: translateY(calc(100% + var(--space-lg)));
    pointer-events: none;
    transition: opacity var(--snap), transform var(--settle), visibility 0s;
    transition-delay: 0s, 0s, 280ms;
  }
  .thumb-cta.is-shown {
    opacity: 1;
    visibility: visible;
    transform: none;
    pointer-events: auto;
    transition-delay: 0s;
  }
  /* There is no hover on a phone, so the press is the only state worth having.
     The arrow leans the way it is about to take you. */
  .thumb-cta:active {
    transform: scale(0.975);
    transition-duration: var(--press);
  }
  .thumb-cta .cta-arrow { transition: transform var(--snap); }
  .thumb-cta:active .cta-arrow { transform: translateX(3px); }

  /* Room under the last line of the page, so the bar never covers it. */
  .js .site-footer {
    padding-bottom: calc(var(--space-3xl) + env(safe-area-inset-bottom));
  }
}

.header-cta:hover,
.header-cta:focus-visible {
  --lift: -1px;
  border-color: var(--orange);
  color: var(--orange-deep);
  transform: translateY(var(--lift));
}
.header-cta:active { transform: scale(0.97); transition-duration: var(--press); }

/* The arrow reports the direction of travel, and leans that way when you point
   at it. It is 10px because it is a hint, not an icon. */
.cta-arrow {
  width: 13px;
  height: 13px;
  flex: none;
  transition: transform var(--snap);
}
.header-cta:hover .cta-arrow,
.header-cta:focus-visible .cta-arrow { transform: translateY(-2px); }

/* Over the two key changes, a pale header reads as a grey band laid across the
   scene. app.js watches a 1px line just under the header and flips this on, so
   the bar joins the section it is sitting over instead of interrupting it.

   Dark scheme only. In the light one those two sections are lit as well, and a
   header that inverted over them would be exactly the grey band it exists to
   avoid. app.js still adds the class either way; in the light scheme it simply
   finds nothing to do. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .site-header.is-inverted { background: rgb(20 18 16 / 0.72); }
  :root:not([data-theme='light']) .site-header.is-inverted.is-stuck { border-bottom-color: rgb(255 255 255 / 0.1); }
  :root:not([data-theme='light']) .site-header.is-inverted .brand { color: #f7f4f0; }
  :root:not([data-theme='light']) .site-header.is-inverted .header-cta {
    color: #f7f4f0;
    background: rgb(255 255 255 / 0.08);
    border-color: rgb(255 255 255 / 0.18);
  }
  :root:not([data-theme='light']) .site-header.is-inverted .header-cta:hover {
    border-color: #ff9a29;
    color: #ff9a29;
  }
}
:root[data-theme='dark'] .site-header.is-inverted { background: rgb(20 18 16 / 0.72); }
:root[data-theme='dark'] .site-header.is-inverted.is-stuck { border-bottom-color: rgb(255 255 255 / 0.1); }
:root[data-theme='dark'] .site-header.is-inverted .brand { color: #f7f4f0; }
:root[data-theme='dark'] .site-header.is-inverted .header-cta {
  color: #f7f4f0;
  background: rgb(255 255 255 / 0.08);
  border-color: rgb(255 255 255 / 0.18);
}
:root[data-theme='dark'] .site-header.is-inverted .header-cta:hover {
  border-color: #ff9a29;
  color: #ff9a29;
}

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

.hero {
  position: relative;
  max-width: var(--page);
  margin: 0 auto;
  padding: var(--space-xl) var(--space-lg) var(--space-3xl);
  display: grid;
  gap: var(--space-3xl);
  /* The wash below is drawn wider than this section on purpose, so that its own
     edge is never on screen. On a desktop window that is a soft margin nobody
     notices; on a 390-pixel phone it is seventy pixels of page hanging off the
     side, and a phone does not read that as decoration. It measures the widest
     thing on the page, decides the page is 461 wide, and zooms the whole site
     out to about 85% to make it fit - every word, every button and every mockup
     drawn smaller than it was designed, on a page that now slides sideways onto
     a blank strip.
     Clipped here rather than at the root, because the root is an ancestor of
     both sticky elements on the site and clipping it stops them sticking. The
     x axis only: the wash still needs to bleed above and below, and `clip` is
     the one value that will hold one axis while leaving the other alone.
     The clip margin is why this is `clip` and not `hidden`. This section is
     capped at 72rem and centred, so on a wide monitor there is a gutter either
     side of it, and the wash is meant to carry on across that gutter to the
     window edge. A plain clip would cut it off at the section and draw the
     straight line the wash exists to avoid. `overflow-clip-margin` lets it
     paint half a window further out while still contributing nothing at all to
     how wide the page thinks it is - which was the entire problem. */
  overflow-x: clip;
  overflow-clip-margin: 50vw;
}
/* Named, not `.hero > *`. A universal child selector here would also match the
   glow and override its `position: absolute`, which would drop it into the
   grid as a real column and push the layout sideways. */
.hero-copy,
.hero-art { position: relative; z-index: 1; }

.hero h1 { margin-bottom: var(--space-md); }

/* The hero introduces itself once, on load, in reading order â€” eyebrow, then
   headline, then the promise, then the thing to do about it.
   Deliberately not gated on `.js` like the scroll reveals are. This runs at
   first paint from the stylesheet alone, so there is no window in which the
   text is painted, then hidden by a late script, then animated back in. */
.hero-copy > * { animation: hero-rise 700ms var(--ease-out) both; }
.hero-copy > .eyebrow { animation-delay: 40ms; }
.hero-copy > h1 { animation-delay: 120ms; }
.hero-copy > .lede { animation-delay: 220ms; }
.hero-copy > .waitlist { animation-delay: 320ms; }
.hero-copy > .reassurance { animation-delay: 400ms; }
@keyframes hero-rise {
  from { opacity: 0; transform: translateY(18px); }
  to { opacity: 1; transform: none; }
}

/* The headline is split like the other headings, and that changes what its
   share of `hero-rise` is for: a block sliding up with its own words sliding up
   inside it is two motions on one object, and reads as neither. So the block
   move is dropped here and the words do all of it. Same 120ms start, so the
   headline still takes its turn between the eyebrow and the promise. */
.js .hero-copy > h1.split {
  animation: none;
  opacity: 1;
  transform: none;
}
/* The underline overhangs its phrase slightly at both ends, and the clip that
   makes the rise possible would shear those ends off. Widen the box past them
   and pull the width back out of the layout, so the line is whole and the words
   still sit exactly where they were spaced to sit. */
.js .hero-copy > h1.split .w {
  padding-inline: 0.08em;
  margin-inline: -0.08em;
}
.js .hero-copy > h1.split .w > i {
  /* An animation rather than the transition the other headings use. Those wait
     to be told they are on screen; this one is on screen already. */
  transition: none;
  /* --flow carries its own easing, so naming one here would put two timing
     functions in the shorthand and throw the whole declaration away. */
  animation: word-rise var(--flow) calc(120ms + var(--w, 0) * 44ms) both;
}
@keyframes word-rise {
  from { transform: translateY(112%); }
  to { transform: none; }
}

/* One underlined phrase in the headline, drawn on rather than printed. It is
   the only decoration in the hero, and it waits for two things: the phone has
   finished arriving, and the phrase it belongs to has landed. Drawing a rule
   under words that are still moving underlines nothing. */
.mark {
  position: relative;
  white-space: nowrap;
}
.mark::after {
  content: '';
  position: absolute;
  left: -0.04em;
  right: -0.04em;
  bottom: 0.02em;
  height: 0.085em;
  border-radius: 999px;
  background: var(--orange);
  transform-origin: 0 50%;
  animation: underline-draw 620ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes underline-draw {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

/* -------------------------------------------------------------------- form */

.waitlist {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  max-width: 30rem;
  margin-top: var(--space-lg);
}

.field { position: relative; }

/* The label warms up with the field. On a form with exactly one input this is
   the cheapest possible way to say "you are here" without a second element. */
.field label {
  display: block;
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--ink-600);
  margin-bottom: 6px;
  transition: color var(--snap);
}
.field:focus-within label { color: var(--orange-deep); }

.field input {
  width: 100%;
  /* 16px minimum: anything smaller makes iOS Safari zoom the whole page when
     the field is focused, which feels broken. */
  font-size: 16px;
  font-family: inherit;
  /* Right side is wider to leave the confirmation tick somewhere to sit that
     is not on top of what you are typing. */
  padding: 15px 44px 15px var(--space-md);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-button);
  background: var(--surface);
  color: var(--ink-900);
  /* The one blinking thing on the page, so it may as well be the brand
     colour rather than the operating system's. */
  caret-color: var(--orange);
  box-shadow: var(--shadow-card);
  transition: border-color var(--snap), box-shadow var(--snap);
}
.field input::placeholder {
  color: var(--ink-400);
  transition: opacity var(--snap), transform var(--snap);
}
/* The prompt steps aside once you start. Leaving it under the caret while you
   type is the only job a placeholder can actively get wrong. */
.field input:focus::placeholder { opacity: 0.5; transform: translateX(4px); }
.field input:focus {
  border-color: var(--orange);
  box-shadow: 0 0 0 4px rgb(254 139 5 / 0.14);
}
.field input[aria-invalid='true'] { border-color: var(--danger); }
.field input[aria-invalid='true']:focus { box-shadow: 0 0 0 4px rgb(192 57 43 / 0.16); }

/* Drawn, not printed. The two strokes of a tick arriving in order is the same
   gesture as a pen, and it takes 260ms â€” long enough to be seen, short enough
   that it is over before you look up at the button. */
.field-ok {
  position: absolute;
  right: var(--space-md);
  bottom: 17px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--success) 14%, transparent);
  opacity: 0;
  transform: scale(0.5);
  transition: opacity var(--snap), transform var(--snap);
  pointer-events: none;
}
.field-ok::after {
  content: '';
  position: absolute;
  left: 6px;
  top: 3px;
  width: 4px;
  height: 8px;
  border: solid var(--success);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) scale(0.4);
  transition: transform var(--settle) 60ms;
}
.field.is-valid .field-ok { opacity: 1; transform: none; }
.field.is-valid .field-ok::after { transform: rotate(45deg) scale(1); }

/* One ring off the edge when a call to action has just sent you here.
   Every "join the waitlist" on the page ends at this field, and after a scroll
   of several thousand pixels the reader arrives looking at a form, not at the
   cursor sitting in it. The ring runs as the scroll lands and is gone by the
   time they start typing. */
.field.is-called input { animation: field-called 800ms var(--ease-out); }
@keyframes field-called {
  from { box-shadow: 0 0 0 0 rgb(254 139 5 / 0.55); }
  to { box-shadow: 0 0 0 16px rgb(254 139 5 / 0); }
}

/* A rejected address gets a headshake, in place, before anything is said in
   words. It points at the one field that is wrong faster than the status line
   underneath can be read. */
.field.is-shake input { animation: headshake 380ms var(--ease-out) both; }
@keyframes headshake {
  0% { transform: none; }
  18% { transform: translateX(-7px); }
  38% { transform: translateX(6px); }
  58% { transform: translateX(-4px); }
  78% { transform: translateX(2px); }
  100% { transform: none; }
}

/* The honeypot. Hidden from sight and from screen readers, but still a real
   focusable field for anything filling the form programmatically. */
.gotcha {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.button-primary {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-xs);
  min-height: 54px;
  padding: 0 var(--space-lg);
  font-family: inherit;
  font-size: 1.05rem;
  font-weight: 600;
  color: #fff;
  background: linear-gradient(180deg, var(--orange), var(--orange-deep));
  border: none;
  border-radius: var(--radius-button);
  cursor: pointer;
  box-shadow: 0 6px 18px rgb(253 118 5 / 0.28);
  transition: transform var(--snap), box-shadow var(--snap), filter var(--snap);
}
/* The button leans towards you.
 *
 * Not far — seven pixels at the very corner, and most of the time two or
 * three. Far enough that pointing at it feels like it noticed, not far enough
 * that the thing you are aiming at moves out from under the cursor, which is
 * the failure mode of every magnetic button that overdoes it. app.js writes
 * --lean-x/--lean-y while the pointer is over it; the -2px lift is still here
 * underneath, so a browser that never runs the script gets what it had.
 */
.button-primary:hover {
  transform: translate3d(var(--lean-x, 0px), calc(-2px + var(--lean-y, 0px)), 0);
  box-shadow: 0 12px 26px rgb(253 118 5 / 0.34);
}
/* Leaning has to keep up with a pointer, and --snap is a spring meant for
   arriving somewhere. :not(:active) so the press below keeps its own timing. */
.button-primary.is-leaning:not(:active) {
  transition: transform 110ms linear, box-shadow var(--snap), filter var(--snap);
}
/* Pressed, the button loses its shadow as well as its height. Scaling alone
   leaves it floating; dropping the shadow is what puts it against the page. */
.button-primary:active {
  transform: translateY(0) scale(0.975);
  box-shadow: 0 2px 8px rgb(253 118 5 / 0.3);
  transition-duration: var(--press);
}
.button-primary[disabled] { opacity: 0.75; cursor: progress; box-shadow: none; }
.button-primary[disabled]:hover { transform: none; }

/* One pass of light across the face on hover. It runs once per hover rather
   than looping â€” a button that shimmers on its own is an advert; a button that
   answers when you reach for it is a control. */
.button-primary::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(105deg, transparent 32%, rgb(255 255 255 / 0.32) 50%, transparent 68%);
  transform: translateX(-120%);
  pointer-events: none;
}
@media (hover: hover) {
  .button-primary:hover::before { animation: sheen 760ms var(--ease-out); }
}
@keyframes sheen { to { transform: translateX(120%); } }

/* The press answers where the press actually landed.
   On a touchscreen there is no hover, so the sheen above never runs and the
   only feedback would be the button shrinking a little. This starts at the
   finger and spreads, which is the difference between a control that reacted
   and a control that happened to move. app.js writes --px/--py on pointerdown;
   a keyboard press has no coordinates, so it falls back to the middle. */
.button-primary::after,
.button-secondary::after {
  content: '';
  position: absolute;
  left: var(--px, 50%);
  top: var(--py, 50%);
  width: 10px;
  height: 10px;
  margin: -5px 0 0 -5px;
  border-radius: 50%;
  /* Under the label, over the button's own face. Both buttons isolate, so this
     stays inside them rather than dropping behind the page. Without it the
     wash would wipe across the words on its way out â€” the ink is not what was
     pressed. */
  z-index: -1;
  opacity: 0;
  pointer-events: none;
}
.button-primary::after { background: rgb(255 255 255 / 0.5); }
.button-secondary::after { background: color-mix(in srgb, var(--orange) 60%, transparent); }
.button-primary.is-pressed::after,
.button-secondary.is-pressed::after { animation: ripple 540ms var(--ease-out); }
@keyframes ripple {
  from { opacity: 1; transform: scale(1); }
  to { opacity: 0; transform: scale(30); }
}

.button-primary {
  /* Keeps the sheen inside the corners, and the label and spinner from ever
     spilling out of a button that is mid-swap. */
  overflow: hidden;
  isolation: isolate;
}
.button-primary .button-label,
.button-primary .cta-arrow { position: relative; z-index: 1; }

.button-primary.as-link {
  text-decoration: none;
  max-width: 22rem;
  margin: 0 auto;
}
.button-primary.as-link .cta-arrow { width: 15px; height: 15px; }
.button-primary.as-link:hover .cta-arrow,
.button-primary.as-link:focus-visible .cta-arrow { transform: translateY(-3px); }

.button-secondary {
  position: relative;
  /* Same reason as the primary: the ripple has to stop at the corners. */
  overflow: hidden;
  isolation: isolate;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-xs);
  min-height: 44px;
  padding: 0 var(--space-md);
  font-family: inherit;
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--ink-900);
  background: var(--surface);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-button);
  cursor: pointer;
  transition: border-color var(--snap), transform var(--snap), color var(--snap);
}
/* The icon does the thing the button does. Half a turn on hover is a preview
   of it; a full turn on the press is the act itself, landing as the new nights
   land, so the two read as one event.

   Both are the same declaration rather than a hover rule and a separate
   animation. `--turn` is a running total that app.js adds a revolution to on
   every press, and the hover simply offsets it â€” so pressing the button while
   pointing at it makes the icon keep going, instead of jumping back to catch
   up with whichever rule won. */
.button-secondary svg {
  width: 17px;
  height: 17px;
  transform: rotate(var(--turn, 0deg));
  transition: transform var(--settle);
}
/* Reaching a control with the keyboard is reaching it. Every hover state on
   this page that says something â€” the icon preview here, the padlock on a
   plan night, the arrow on a call to action â€” is repeated for :focus-visible,
   so tabbing through the page tells you the same things pointing at it does. */
.button-secondary:hover,
.button-secondary:focus-visible {
  border-color: var(--orange);
  color: var(--orange-deep);
  background: color-mix(in srgb, var(--orange) 7%, var(--surface));
}
.button-secondary:hover svg,
.button-secondary:focus-visible svg { transform: rotate(calc(var(--turn, 0deg) - 180deg)); }
.button-secondary:active { transform: scale(0.97); transition-duration: var(--press); }

/* The spinner takes the label's place rather than standing beside it, so the
   button keeps exactly the width it had. A control that changes size at the
   moment you press it is the one thing a button must never do. */
.spinner {
  position: absolute;
  left: 50%;
  top: 50%;
  margin: -9px 0 0 -9px;
  width: 18px;
  height: 18px;
  border: 2px solid rgb(255 255 255 / 0.4);
  border-top-color: #fff;
  border-radius: 50%;
  opacity: 0;
  transition: opacity var(--snap);
  /* Parked rather than absent. An infinite animation that is paused costs
     nothing, and starting it is one property change instead of a relayout.
     No transform of its own â€” the spin owns that property outright. */
  animation: spin 0.7s linear infinite;
  animation-play-state: paused;
  z-index: 1;
}
.button-primary .button-label {
  transition: opacity var(--snap), transform var(--snap);
}
.is-busy .button-label { opacity: 0; transform: translateY(-8px); }

/* The button finishing.
   The spinner says the request is in flight; nothing said it landed. The tick
   draws itself over about a third of a second and the button holds it for
   another third before the panel takes over, which is the whole reason the
   success state now feels like something happened rather than like the page
   quietly swapping one block for another.

   The button also stops being a button here — it narrows a little and stops
   answering the pointer, because there is nothing left to press. */
.button-tick {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  opacity: 0;
  transition: opacity var(--press);
  z-index: 2;
}
.button-tick svg {
  width: 24px;
  height: 24px;
  stroke-dasharray: 26;
  stroke-dashoffset: 26;
}
.button-primary.is-done {
  cursor: default;
  transform: none;
  filter: saturate(1.1);
  box-shadow: 0 4px 14px rgb(253 118 5 / 0.24);
}
.button-primary.is-done .button-label { opacity: 0; transform: translateY(-8px); }
.button-primary.is-done .spinner { opacity: 0; animation-play-state: paused; }
.button-primary.is-done .button-tick { opacity: 1; }
.button-primary.is-done .button-tick svg {
  animation: tick-write 340ms 60ms var(--ease-out) forwards;
}
@keyframes tick-write {
  to { stroke-dashoffset: 0; }
}
.is-busy .spinner {
  opacity: 1;
  animation-play-state: running;
}
@keyframes spin { to { transform: rotate(360deg); } }

.status {
  margin: 0;
  min-height: 1.4em;
  font-size: 0.95rem;
  color: var(--ink-600);
}
.status.is-error { color: var(--danger); }
.status.is-success { color: var(--success); font-weight: 600; }
/* Every new message drops in, including a replacement for one already there.
   Text that changes in place under a form is easy to miss entirely â€” and this
   line is where the form says whether it worked. */
.status.is-said { animation: status-in 320ms var(--ease-out) both; }
@keyframes status-in {
  from { opacity: 0; transform: translateY(-5px); }
  to { opacity: 1; transform: none; }
}

/* The signed-up state replaces the form outright, and lands with the same
   spring the app uses when a card settles into place. */
.waitlist-done {
  background: var(--surface);
  border: 1px solid var(--ink-200);
  border-left: 3px solid var(--success);
  border-radius: var(--radius-card);
  padding: var(--space-lg);
  box-shadow: var(--shadow-card);
  animation: settle-in var(--settle) both;
}
.waitlist-done h3 {
  margin-bottom: var(--space-xs);
  animation: done-line 420ms 120ms var(--ease-out) both;
}
.waitlist-done .done-place { animation: done-line 420ms 200ms var(--ease-out) both; }
.waitlist-done ul {
  margin: var(--space-sm) 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 7px;
  font-size: 0.95rem;
  color: var(--ink-600);
}
.waitlist-done li { padding-left: 24px; position: relative; }
.waitlist-done li::before {
  content: '';
  position: absolute;
  left: 3px;
  top: 8px;
  width: 5px;
  height: 9px;
  border: solid var(--success);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}
/* The three promises arrive one at a time, each behind its own tick, the way
   the price tiers fill in. This is the only success state on the page and the
   last thing most people will read here â€” it is worth the extra half second. */
/* The seal at the top of the panel. Two strokes, drawn in order: the ring
   closes, then the tick is written inside it. Drawing it costs nothing and is
   the difference between a badge that was always there and one that was just
   awarded. */
.done-mark {
  display: block;
  width: 38px;
  height: 38px;
  margin-bottom: var(--space-sm);
  color: var(--success);
}
.done-mark svg { display: block; width: 100%; height: 100%; }
.done-mark circle {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-dasharray: 88;
  stroke-dashoffset: 88;
  transform: rotate(-90deg);
  transform-origin: 50% 50%;
  animation: mark-ring 520ms 40ms var(--ease-out) forwards;
}
.done-mark path {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 22;
  stroke-dashoffset: 22;
  animation: mark-tick 300ms 380ms var(--ease-out) forwards;
}
@keyframes mark-ring { to { stroke-dashoffset: 0; } }
@keyframes mark-tick { to { stroke-dashoffset: 0; } }

/* Your place in the queue. The one fact in this panel that is about you rather
   than about us, so it gets the size and it gets counted up to — a number that
   lands rather than one that was simply printed.

   It only appears once the queue is long enough for a position to be worth
   knowing. The same rule governs the counter above the form: "you are 4th"
   invites the reader to work out how few people that is. */
.done-place {
  margin: 0 0 var(--space-sm);
  color: var(--ink-600);
  font-size: 0.95rem;
}
.done-place b {
  font-family: var(--font-display);
  font-size: 1.55rem;
  font-weight: 700;
  color: var(--ink-900);
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
}
.closer .done-place { color: rgb(var(--room-ink) / 0.72); }
.closer .done-place b { color: rgb(var(--room-ink)); }

.waitlist-done li { animation: done-line 420ms var(--ease-out) both; }
.waitlist-done li:nth-child(1) { animation-delay: 220ms; }
.waitlist-done li:nth-child(2) { animation-delay: 310ms; }
.waitlist-done li:nth-child(3) { animation-delay: 400ms; }
.waitlist-done li::before { animation: tick-pop 300ms var(--ease-spring) both; animation-delay: inherit; }
@keyframes done-line {
  from { opacity: 0; transform: translateX(-8px); }
  to { opacity: 1; transform: none; }
}
@keyframes tick-pop {
  from { transform: rotate(45deg) scale(0.2); }
  to { transform: rotate(45deg) scale(1); }
}
@keyframes settle-in {
  from { opacity: 0; transform: translateY(10px) scale(0.98); }
  to { opacity: 1; transform: none; }
}

.reassurance {
  font-size: 0.85rem;
  color: var(--ink-400);
  max-width: 30rem;
  margin: var(--space-sm) 0 0;
}

/* The counter is only rendered once it is a number worth showing, so it can
   afford to look like a real badge rather than a line of grey text. */
.counter {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  margin: var(--space-md) 0 0;
  padding: 7px var(--space-md) 7px var(--space-sm);
  background: var(--surface);
  border: 1px solid var(--ink-200);
  border-radius: 999px;
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--ink-600);
  box-shadow: var(--shadow-card);
  animation: settle-in var(--settle) both;
}
.counter::before {
  content: '';
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--success);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--success) 22%, transparent);
}

/* ------------------------------------------------------------------ phone */

.hero-art {
  display: flex;
  justify-content: center;
  perspective: 1400px;
}

/*
 * The device.
 *
 * The page says "coming soon to iPhone" in its first line, so the thing being
 * held up had better be one. Every dimension below is the real handset's,
 * expressed as a fraction of the phone's width â€” screen 19.5:9, corner radius
 * 55pt, Dynamic Island 125 Ã— 36.7pt eleven points down, home indicator
 * 139 Ã— 5pt eight points up, and the four side buttons where your fingers
 * expect them. One variable drives all of it, so a smaller phone in the pinned
 * scene is the same handset seen from further away rather than a different
 * one with the details rounded off.
 */
.phone {
  --phone-w: min(300px, 80vw);
  --frame-pad: calc(var(--phone-w) * 0.034);
  --frame-radius: calc(var(--phone-w) * 0.158);
  position: relative;
  width: var(--phone-w);
  border-radius: var(--frame-radius);
  padding: var(--frame-pad);
  /* Four layers: a titanium rail catching the light all the way round, over the
     darker body. The bright edges are most of what makes a rounded black
     rectangle read as a phone rather than as a rounded black rectangle, and the
     top and bottom have to be in it too â€” with only the long edges lit, the
     band reads as two stripes painted on rather than as one rail going round.
     The radius clips them into arcs at the corners, which is the shape the real
     thing makes. */
  background:
    linear-gradient(180deg, #6d6259, transparent calc(var(--phone-w) * 0.009)),
    linear-gradient(0deg, #6d6259, transparent calc(var(--phone-w) * 0.009)),
    linear-gradient(100deg, #5a5148 0%, #241f1b 5%, #14100e 50%, #241f1b 95%, #5a5148 100%),
    linear-gradient(160deg, #2b2521, #14100e 60%);
  box-shadow: var(--shadow-phone), var(--phone-edge);
}

/* The hairline where the glass meets the frame, and the sheen lying across it.
   A screen with no reflection on it is a picture of a screen; this is the
   cheapest thing on the page that says the surface is glass. Kept to a corner
   band and under a tenth of an alpha, because a reflection that competes with
   what is on the display defeats the purpose of drawing the display. Above the
   status bar in the stack â€” real glare falls on the chrome as well. */
.phone::after {
  content: '';
  position: absolute;
  z-index: 7;
  inset: var(--frame-pad);
  pointer-events: none;
  border-radius: calc(var(--frame-radius) - var(--frame-pad));
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.07);
  background:
    linear-gradient(
      104deg,
      rgb(255 255 255 / 0.075) 0%,
      rgb(255 255 255 / 0.03) 11%,
      rgb(255 255 255 / 0) 23%
    );
}

/* The four buttons, as background layers on one overlay rather than four
   elements: each needs its own length â€” the action button is barely half a
   volume key â€” and layers can differ where box-shadow copies cannot.
   Most of each button's width sits on the frame and only a sliver clears it:
   a button that stands too far out stops reading as part of the phone and
   starts reading as a mark floating beside it. Pointer-transparent, so the
   hero phone underneath is still pressable. */
.phone-rail {
  position: absolute;
  inset: 0 calc(var(--phone-w) * -0.0037);
  pointer-events: none;
  --rail-l: linear-gradient(90deg, #7d7166, #3b342e);
  --rail-r: linear-gradient(90deg, #3b342e, #7d7166);
  --rail-w: calc(var(--phone-w) * 0.014);
  background:
    /* action button */
    var(--rail-l) left calc(var(--phone-w) * 0.386) / var(--rail-w) calc(var(--phone-w) * 0.092) no-repeat,
    /* volume up */
    var(--rail-l) left calc(var(--phone-w) * 0.574) / var(--rail-w) calc(var(--phone-w) * 0.157) no-repeat,
    /* volume down */
    var(--rail-l) left calc(var(--phone-w) * 0.749) / var(--rail-w) calc(var(--phone-w) * 0.157) no-repeat,
    /* side button */
    var(--rail-r) right calc(var(--phone-w) * 0.615) / var(--rail-w) calc(var(--phone-w) * 0.313) no-repeat;
}
.hero-art .phone {
  transform: rotateY(-9deg) rotateX(3deg) translateZ(0);
  animation: phone-in 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* Added by app.js, and only when pressing it will actually replay the import.
   A pointer cursor over something inert is a promise the page cannot keep. */
.phone.is-tappable { cursor: pointer; transition: box-shadow var(--settle); }
/* A warm halo rather than a lift. The device is held in place by `phone-in`,
   whose fill keeps its own transform on the element for good, so any hover
   transform here would need !important to win â€” and a hover state that has to
   shout at the entrance animation is a hover state that will lose an argument
   later. The shadow is untouched by that animation and free to say it. */
.phone.is-tappable:hover {
  box-shadow:
    var(--shadow-phone),
    0 0 0 4px color-mix(in srgb, var(--orange) 17%, transparent),
    var(--phone-edge);
}
/* And it says so in words, once the run it would replay has finished.
 *
 * The halo above is a hover state, and the first thing anybody does with a
 * picture of a phone is not hover over it. This is the whole illustration —
 * the biggest thing on the screen after the headline — and there was nothing
 * on it to suggest it answered at all.
 *
 * Below the frame rather than over the glass, because the glass is the thing
 * it is advertising and a label across it would be covering the demo to tell
 * you the demo exists. It comes in only after the import has played out: while
 * that is still running, "tap to replay" is an offer to interrupt something
 * you have not seen yet. app.js retires it the first time you press.
 */
.phone-hint {
  position: absolute;
  left: 50%;
  bottom: calc(var(--phone-w) * -0.14);
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 12px 5px 10px;
  border-radius: 999px;
  border: 1px solid var(--ink-200);
  background: var(--surface);
  box-shadow: 0 6px 18px rgb(26 21 18 / 0.14);
  color: var(--ink-600);
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  white-space: nowrap;
  /* It is a label on the thing behind it, not a second target. Clicking it has
     to be clicking the phone. */
  pointer-events: none;
  transform: translateX(-50%);
  animation: hint-in 460ms 3600ms var(--ease-out) both;
}
.phone-hint svg {
  width: 13px;
  height: 13px;
  color: var(--orange-deep);
}
@keyframes hint-in {
  from { opacity: 0; transform: translate(-50%, 7px) scale(0.94); }
  to { opacity: 1; transform: translateX(-50%); }
}
/* Pressed once, it is knowledge rather than a hint. The cursor and the halo
   are still there for anybody who wants a third run. */
.phone.is-used .phone-hint {
  animation: none;
  opacity: 0;
  transform: translate(-50%, 5px);
  transition: opacity var(--snap), transform var(--snap);
}

@keyframes phone-in {
  from { opacity: 0; transform: rotateY(-16deg) rotateX(6deg) translateY(26px) scale(0.96); }
  to { opacity: 1; transform: rotateY(-9deg) rotateX(3deg); }
}

.phone-screen {
  position: relative;
  background: var(--canvas);
  color: var(--ink-900);
  /* The glass follows the frame in: one radius, one inset, concentric corners.
     Getting this wrong is the single most common tell in a drawn handset. */
  border-radius: calc(var(--frame-radius) - var(--frame-pad));
  overflow: hidden;
  aspect-ratio: 9 / 19.5;
}

/* ------------------------------------------------------- the screen's own UI */

.phone-ui {
  position: absolute;
  inset: 0;
  z-index: 6;
  pointer-events: none;
  color: var(--ink-900);
  /* The status bar is the operating system talking, not the page, so it uses
     the system's own face â€” SF on an Apple device, its nearest neighbour
     everywhere else. */
  font-family: -apple-system, 'Segoe UI', system-ui, sans-serif;
  font-size: calc(var(--phone-w) * 0.039);
  font-weight: 600;
  line-height: 1;
}

/* Two of the three scenes put a frame behind the status bar — the first is a
   whole reel, the third runs the recipe photo up under it the way the app does
   — and dark text on a photograph is what a real phone never does. The chrome
   is the last thing in the screen so any scene can reach forward and repaint it.
   The third scene needed this and did not have it: while the frame was a drawn
   gradient the clock was merely hard to read, and against an actual photograph
   it disappeared. */
.scene[data-scene='0'].is-active ~ .phone-ui,
.scene[data-scene='2'].is-active ~ .phone-ui,
.scrolly:not(.js-scenes) .phone-ui { color: var(--ink-900); }

.pu-time {
  position: absolute;
  top: calc(var(--phone-w) * 0.048);
  left: 0;
  /* The left ear runs to the island; the clock sits in the middle of it. */
  width: 34.5%;
  text-align: center;
}
.pu-icons {
  position: absolute;
  top: calc(var(--phone-w) * 0.0453);
  right: 5.5%;
  width: 26%;
  height: auto;
}

.pu-island {
  position: absolute;
  top: calc(var(--phone-w) * 0.0254);
  left: 50%;
  width: 31%;
  height: calc(var(--phone-w) * 0.0848);
  transform: translateX(-50%);
  border-radius: 999px;
  /* Not var(--ink-900): this is a hole in the display, and a hole stays black
     when the page turns dark. The slot along the top edge is the earpiece,
     which on a real device is a shade lighter than the cutout rather than
     another hole in it. */
  background:
    linear-gradient(#191a1d, #191a1d) center calc(var(--phone-w) * 0.0115) /
      24% calc(var(--phone-w) * 0.0055) no-repeat,
    #050403;
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.05);
}
/* The TrueDepth sensor on the far side of the cutout from the lens. Barely
   visible on the real thing, and barely visible here for the same reason: it
   is the second dark circle that tells you the first one is a camera. */
.pu-island::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 13%;
  width: calc(var(--phone-w) * 0.018);
  height: calc(var(--phone-w) * 0.018);
  margin-top: calc(var(--phone-w) * -0.009);
  border-radius: 50%;
  background: #131620;
}
/* The lens, with the faint blue cast every camera glass has. */
.pu-island::after {
  content: '';
  position: absolute;
  top: 50%;
  right: 11%;
  width: calc(var(--phone-w) * 0.036);
  height: calc(var(--phone-w) * 0.036);
  margin-top: calc(var(--phone-w) * -0.018);
  border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, #33405e, #07070a 72%);
}

/*
 * The app's own tab bar.
 *
 * Every scene but the first is a screen inside FasterChef, and the bottom fifth
 * of a 19.5:9 display is where iPhone users look for one. Without it the taller
 * glass just ended in a band of empty paper. With it the four scenes stop being
 * four unrelated panels and become one app â€” and it doubles as the only diagram
 * on the page of what the product actually contains.
 */
.pu-tabs {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: calc(var(--phone-w) * 0.196);
  /* Room underneath for the home indicator, which iOS never lets a control
     share space with. */
  padding-bottom: calc(var(--phone-w) * 0.052);
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: center;
  background: color-mix(in srgb, var(--surface) 86%, transparent);
  backdrop-filter: blur(14px);
  border-top: 1px solid color-mix(in srgb, var(--ink-900) 9%, transparent);
  transition: opacity var(--flow);
}
.pu-tab {
  display: grid;
  justify-items: center;
  gap: calc(var(--phone-w) * 0.012);
  font-size: calc(var(--phone-w) * 0.029);
  font-weight: 500;
  color: var(--ink-400);
  transition: color var(--settle);
}
.pu-tab svg {
  width: calc(var(--phone-w) * 0.076);
  height: calc(var(--phone-w) * 0.076);
}

/* Scene one is Instagram, not us. A FasterChef tab bar under someone else's
   app would be the one dishonest thing in the whole mockup. */
.scene[data-scene='0'].is-active ~ .phone-ui .pu-tabs,
.scrolly:not(.js-scenes) .pu-tabs { opacity: 0; }

/* Which tab is lit follows the scene, so the bar is reporting where you are
   rather than sitting there looking like furniture. */
.hero-art .pu-tab:nth-child(1),
.scene[data-scene='1'].is-active ~ .phone-ui .pu-tab:nth-child(1),
.scene[data-scene='2'].is-active ~ .phone-ui .pu-tab:nth-child(1),
.scene[data-scene='3'].is-active ~ .phone-ui .pu-tab:nth-child(2),
.scene[data-scene='4'].is-active ~ .phone-ui .pu-tab:nth-child(3) { color: var(--orange-deep); }

.pu-home {
  position: absolute;
  bottom: calc(var(--phone-w) * 0.0185);
  left: 50%;
  width: 34.6%;
  height: calc(var(--phone-w) * 0.0115);
  transform: translateX(-50%);
  border-radius: 999px;
  /* Deliberately not currentColor. The status bar turns white over a dark reel;
     the indicator sits over the share sheet or the tab bar, both of which are
     paper, and a white bar on white paper is no bar at all. */
  background: var(--ink-900);
  opacity: 0.8;
}

/* --------------------------------------------------- the recipe card snap */

/*
 * The app's signature moment, rebuilt in CSS.
 *
 * A shared reel lands slightly rotated, a hairline sweeps it while the caption
 * is read, then the tile straightens and expands into a recipe card whose
 * ingredients strike in one at a time â€” the beat that makes the number of
 * ingredients feel earned. It runs once on arrival and rests on the recipe.
 * Timings mirror docs/02-DESIGN-SYSTEM.md.
 */
/* Used for one frame to restart the sequence when the hero scrolls back into
   view. Killing the animations and putting them straight back is the only
   reliable way to make a browser replay a CSS keyframe from the start. */
.phone.is-replay .snap-stage,
.phone.is-replay .snap-stage *,
.phone.is-replay .snap-stage *::before,
.phone.is-replay .snap-stage *::after { animation: none !important; }

/* iOS calls this the safe area: the band under the island and the band above
   the home indicator, which no app is allowed to draw content into. Honouring
   it is most of the difference between a screenshot and a picture of a phone. */
.snap-stage {
  display: grid;
  height: 100%;
  padding: calc(var(--phone-w) * 0.136) var(--space-md) calc(var(--phone-w) * 0.215);
  place-items: center;
}
.snap-stage > * { grid-area: 1 / 1; width: 100%; }

/* The finished recipe fills the glass instead of floating in the middle of it.
   A card centred in a 19.5:9 screen reads as a card photographed against a
   phone; a card that reaches the safe area on both sides reads as the app.
   Only here â€” the same card appears again inside the pinned scenes, where it
   is deliberately one panel among several and keeps its own height. */
.snap-stage > .recipe-card { align-self: stretch; }
.snap-stage > .recipe-card .rc-hero { height: 20%; }
/* The method follows the last ingredient rather than arriving with it: 2900ms
   started the ingredients, 70ms apart, six of them. */
.snap-stage .rc-steps li {
  animation: strike-in 320ms both;
  animation-delay: calc(2680ms + var(--i) * 110ms);
}

.reel-tile {
  position: relative;
  background: var(--surface);
  border-radius: var(--radius-card);
  padding: var(--space-sm);
  box-shadow: var(--shadow-lift);
  overflow: hidden;
  /* The tile leaves at 1.68s rather than 2.4s, and the time it is up is spent
     showing the caption being read rather than showing a wait. The page is
     selling speed; a demonstration that takes its time arguing for speed is
     arguing against itself, and every frame here is spent before the payoff on
     a page people leave in seconds. */
  animation:
    tile-drop var(--settle) 120ms both,
    tile-open 480ms 1680ms both;
}

@keyframes tile-drop {
  from { opacity: 0; transform: translateY(-18px) rotate(-6deg) scale(0.92); }
  to { opacity: 1; transform: rotate(-2deg) scale(1); }
}
/* On success the tile straightens to 0Â° and grows out of the way. It never
   flashes or shakes â€” failure in this product is a fork in the road, not an
   error, and the success path should not be melodramatic either. */
@keyframes tile-open {
  from { opacity: 1; transform: rotate(-2deg) scale(1); }
  to { opacity: 0; transform: rotate(0deg) scale(1.06); }
}

/* Every frame on the page that stands in for the video somebody shared: the
   thumbnail on the hero tile, the photo on a recipe card, the post above the
   caption, the post above the paste box, and the whole screen in the share-sheet
   scene. One photograph, cropped five ways, in three layers.

   The element itself is the ground, and its only job is to be what you see if
   the picture is slow or missing. The ::before is the picture. The ::after is
   the vignette and the grain, above everything, because that is where a lens
   and a sensor put them.

   The picture is a pseudo-element rather than the element's own background for
   one reason, and it is the reason at the bottom of this block: a background
   cannot be transformed, and the one frame big enough to be watched needs to
   move. */
.reel-thumb,
.rc-hero,
.cap-media,
.pst-thumb,
.sheet-reel {
  position: relative;
  overflow: hidden;
  background: var(--dish-ground);
}
.reel-thumb::before,
.rc-hero::before,
.cap-media::before,
.pst-thumb::before,
.sheet-reel::before {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--still) 50% 46% / cover no-repeat;
  pointer-events: none;
}
/* The two short wide strips take the wide crop instead. Covering a 2.3:1 box
   from a 3:5 picture keeps about a twentieth of it, and a twentieth of a macro
   shot is a stripe of one colour with nothing in it to recognise. */
.rc-hero::before,
.cap-media::before {
  background-image: var(--still-wide);
  background-position: 50% 50%;
}
.reel-thumb::after,
.rc-hero::after,
.cap-media::after,
.pst-thumb::after,
.sheet-reel::after {
  content: '';
  position: absolute;
  inset: 0;
  background-image: var(--dish-vignette), var(--grain);
  background-size: auto, 3px 3px;
  pointer-events: none;
}

.reel-thumb {
  aspect-ratio: 4 / 5;
  border-radius: 11px;
  display: grid;
  place-items: center;
}
/* Glass rather than a white disc. Nine tenths opaque white in the middle of a
   dark frame is the heaviest object on the screen, and it was reading as a
   button somebody had left there rather than as the overlay a player draws on
   top of a paused video. Dark and translucent with a bright ring, it sits on
   the still instead of on top of it, and the frame behind stays the subject. */
.reel-play {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: rgb(18 13 9 / 0.34);
  box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.88);
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);
  /* Above the frame's own ::after, which carries the vignette and the grain.
     Left below it the badge picked up both, and a play button with film grain
     on it is a play button printed on the picture rather than laid over it. */
  position: relative;
  z-index: 1;
}
.reel-play::after {
  content: '';
  position: absolute;
  left: 13px;
  top: 10px;
  border-left: 11px solid #fff;
  border-top: 7px solid transparent;
  border-bottom: 7px solid transparent;
}

.reel-handle {
  margin: var(--space-xs) 2px 2px;
  font-size: 0.72rem;
  font-weight: 600;
  color: var(--ink-600);
}

/*
 * The caption, with the ingredients lighting up as they are found.
 *
 * This beat used to be a grey card, an abstract thumbnail and the word
 * "Readingâ€¦", held for two and a half seconds. That is a poor use of the one
 * frame every visitor is guaranteed to see: it showed the product waiting
 * rather than the product working, and anyone who left early left having
 * watched a spinner.
 *
 * The caption is now the real thing â€” someone else's post, written the way
 * those posts are written â€” and three quantities pick themselves out of it
 * while you watch. That is the entire claim of the product, made in the first
 * second, on material the reader recognises.
 */
.reel-cap {
  margin: 6px 2px 8px;
  font-size: 0.67rem;
  line-height: 1.45;
  color: var(--ink-600);
}
.reel-cap mark {
  /* mark arrives yellow and black from the browser. It is used here for what
     it means â€” a run of text picked out of a longer one â€” not for its paint. */
  /* Pulled back by most of what it pads, so the highlight has room to breathe
     without opening a gap in front of the comma that follows it. */
  padding: 1px 3px;
  margin: 0 -2px;
  border-radius: 4px;
  background: transparent;
  color: inherit;
  /* After "Reading the caption…" has gone and while "Finding ingredients…" is
     up, because that is the order the two things actually happen in. */
  animation: cap-find 360ms var(--ease-out) both;
  animation-delay: calc(780ms + var(--i) * 170ms);
}
@keyframes cap-find {
  from { background: transparent; color: var(--ink-600); }
  to { background: var(--orange-tint); color: var(--orange-on-tint); }
}

/* The progress hairline. Orange, because it means "this is happening" â€” the
   only kind of thing orange is allowed to mean. */
.hairline {
  position: absolute;
  top: 0;
  left: 0;
  height: 3px;
  width: 40%;
  border-radius: 0 3px 3px 0;
  background: linear-gradient(90deg, transparent, var(--orange), transparent);
  z-index: 2;
  animation: hairline-sweep 700ms 300ms 2 both;
}
@keyframes hairline-sweep {
  from { transform: translateX(-100%); }
  to { transform: translateX(280%); }
}

.snap-status {
  position: relative;
  margin: 0 2px;
  min-height: 1.2em;
  font-size: 0.72rem;
  color: var(--ink-400);
}
.snap-status span {
  position: absolute;
  inset: 0;
}
/* The two lines share one line of space, so the first has to be all the way
   out before the second starts arriving. Overlapping them prints one sentence
   on top of the other, which reads as a rendering fault rather than as
   progress. status-1 is clear by 822ms; status-2 begins at 820ms. */
.snap-status .s1 { animation: status-1 900ms 300ms both; }
.snap-status .s2 { animation: status-2 620ms 820ms both; }
@keyframes status-1 {
  0%, 4% { opacity: 0; }
  10%, 52% { opacity: 1; }
  58%, 100% { opacity: 0; }
}
@keyframes status-2 {
  from { opacity: 0; }
  20%, 100% { opacity: 1; }
}

.recipe-card {
  background: var(--surface);
  border-radius: var(--radius-card);
  overflow: hidden;
  box-shadow: var(--shadow-lift);
  animation: card-in 520ms 1730ms both;
}
.recipe-card.is-static { animation: none; }
@keyframes card-in {
  from { opacity: 0; transform: scale(0.94) translateY(10px); }
  to { opacity: 1; transform: none; }
}

.rc-hero {
  height: 74px;
}
.rc-body { padding: var(--space-sm) var(--space-sm) var(--space-md); }
.rc-source {
  margin: 0 0 4px;
  font-size: 0.64rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--orange-deep);
}
.rc-title {
  font-size: 0.98rem;
  line-height: 1.2;
  margin-bottom: 4px;
}
.rc-meta {
  margin: 0 0 var(--space-sm);
  font-size: 0.7rem;
  color: var(--ink-400);
}
.rc-meta b { font-variant-numeric: tabular-nums; color: var(--ink-600); }

.rc-ings {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 1px;
  border-top: 1px solid var(--ink-200);
  padding-top: var(--space-xs);
}
.rc-ings li {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 4px 0;
  font-size: 0.74rem;
}
/* The receipt-printing beat: 70ms apart, top to bottom. */
.snap-stage .rc-ings li {
  animation: strike-in 300ms both;
  animation-delay: calc(2180ms + var(--i) * 70ms);
}
@keyframes strike-in {
  from { opacity: 0; transform: translateX(-8px); }
  to { opacity: 1; transform: none; }
}
.rc-ings b {
  font-variant-numeric: tabular-nums;
  min-width: 42px;
  font-weight: 600;
  font-size: 0.72rem;
}
.rc-ings span { flex: 1; color: var(--ink-600); }

/* ------------------------------------------------------------- tick shape */

.tick {
  width: 16px;
  height: 16px;
  border: 1.5px solid var(--ink-200);
  border-radius: 5px;
  flex: none;
  position: relative;
}
.tick.on { border-color: var(--success); background: color-mix(in srgb, var(--success) 12%, transparent); }
.tick.on::after {
  content: '';
  position: absolute;
  left: 5px;
  top: 1px;
  width: 4px;
  height: 8px;
  border: solid var(--success);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}

/* --------------------------------------------------------------- sections */

.section {
  position: relative;
  max-width: var(--page);
  margin: 0 auto;
  padding: var(--space-3xl) var(--space-lg);
}
.section > h2 { max-width: var(--measure); }
.section > .lede { margin-bottom: var(--space-xl); }

.cards {
  display: grid;
  gap: var(--space-md);
  margin-top: var(--space-xl);
}

.card {
  background: var(--surface);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-card);
  padding: var(--space-lg);
  box-shadow: var(--shadow-card);
  transition: transform var(--settle), box-shadow var(--settle), border-color var(--settle);
}
/* The panel turns to face you, by about five degrees at the corners. It is the
   same idea as the wash that follows the pointer across it — the light and the
   plane agree about where you are standing — and it is what stops three boxes
   in a row reading as a table. Held to five degrees because past that the text
   starts to look printed on a surface instead of set on a page. */
.card:hover {
  transform: perspective(900px) translateY(-3px)
    rotateX(var(--tilt-x, 0deg)) rotateY(var(--tilt-y, 0deg));
  box-shadow: var(--shadow-lift);
  border-color: color-mix(in srgb, var(--orange) 40%, var(--ink-200));
}
/* While it is being turned, transform follows the pointer at pointer speed.
   Everything else keeps the settle it arrived with, and letting go drops the
   class so the panel eases back rather than snapping flat. */
.card.is-tilting,
.tier.is-tilting {
  transition: transform 90ms linear, box-shadow var(--settle), border-color var(--settle);
}
.card p { margin: 0; color: var(--ink-600); }

.card-icon {
  display: grid;
  place-items: center;
  width: 42px;
  height: 42px;
  margin-bottom: var(--space-md);
  border-radius: 12px;
  background: var(--orange-tint);
  color: var(--orange-deep);
  transition: transform var(--snap), background var(--snap);
}
.card-icon svg {
  width: 21px;
  height: 21px;
  transition: transform var(--settle), opacity var(--settle);
}
.card:hover .card-icon {
  transform: scale(1.06);
  background: color-mix(in srgb, var(--orange) 22%, var(--orange-tint));
}

/* The tile warms identically on all three. The drawing inside does not: each
   glyph acts out the sentence printed under it, which is the difference
   between three cards that move and three cards that mean something.
   The tile itself no longer rotates â€” it was fighting the glyph for the same
   few degrees, and the glyph has more to say. */

/* Saved isn't cooked. The bookmark sinks and dims, the way the post does once
   forty more land on top of it. */
.card:nth-child(1):hover .card-icon svg {
  transform: translateY(5px);
  opacity: 0.55;
}

/* Planning is a chore you skip. The calendar leans away from you â€” the shrug
   that ends with ordering in. */
.card:nth-child(2):hover .card-icon svg { transform: rotate(11deg) translateY(1px); }

/* The list is always wrong. The trolley rolls off and comes back short of
   where it started, because that is what a list you cannot trust does: you
   go out with it and return without half of it. */
.card:nth-child(3):hover .card-icon svg { animation: cart-roll 720ms var(--ease-out); }
@keyframes cart-roll {
  0% { transform: translateX(0); }
  40% { transform: translateX(5px); }
  70% { transform: translateX(-3px); }
  100% { transform: translateX(0); }
}

/* On a touchscreen none of the above ever happens, and this audience arrives
   from Instagram and TikTok, which is to say on a phone. These three are not
   decoration that can be dropped there: they are the three sentences of the
   problem, acted out. So each performs itself once as its card scrolls in.

   Two beats where hover has a held state â€” sunk, leaning â€” because a hover
   holds for as long as you point at it and a one-shot that only passes through
   the pose reads as a twitch. The trolley already came back on its own.

   The tile's own warming is deliberately not brought across. That one says
   "you are pointing at this", and without a pointer it says nothing.

   Delayed past the card's entrance, so the tile has arrived before the drawing
   inside it starts moving. */
@media (hover: none) {
  .card.is-in .card-icon svg {
    animation-duration: 900ms;
    animation-timing-function: var(--ease-out);
    animation-delay: calc(var(--d, 0) * 80ms + 520ms);
  }
  .card.is-in:nth-child(1) .card-icon svg { animation-name: bookmark-sink; }
  .card.is-in:nth-child(2) .card-icon svg { animation-name: calendar-shrug; }
  .card.is-in:nth-child(3) .card-icon svg {
    animation-name: cart-roll;
    animation-duration: 720ms;
  }
}
@keyframes bookmark-sink {
  0% { transform: none; opacity: 1; }
  36% { transform: translateY(5px); opacity: 0.55; }
  64% { transform: translateY(5px); opacity: 0.55; }
  100% { transform: none; opacity: 1; }
}
@keyframes calendar-shrug {
  0% { transform: none; }
  34% { transform: rotate(11deg) translateY(1px); }
  62% { transform: rotate(11deg) translateY(1px); }
  100% { transform: none; }
}

/* --------------------------------------------------------- panel spotlight */

/* A brand-coloured wash that follows the pointer across the big panels. app.js
   writes --mx/--my on the panel you are over; the gradient only reads them.
   It is the one piece of motion on this half of the page that answers the
   pointer rather than the scroll, which is what stops a row of boxes reading
   as a table of specifications.
   Pointer devices only. On a touchscreen there is no hover to follow, and the
   wash would either never appear or stick to whatever was last tapped. */
@media (hover: hover) {
  .card,
  .trust-item,
  .tier {
    position: relative;
    isolation: isolate;
  }
  .card::before,
  .trust-item::before,
  .tier::before {
    content: '';
    position: absolute;
    inset: 0;
    /* Behind the text but in front of the panel's own background, which is
       exactly what the isolate above buys. */
    z-index: -1;
    border-radius: inherit;
    background: radial-gradient(
      15rem circle at var(--mx, 50%) var(--my, 0%),
      color-mix(in srgb, var(--orange) 18%, transparent),
      transparent 62%
    );
    opacity: 0;
    transition: opacity var(--settle);
    pointer-events: none;
  }
  .card:hover::before,
  .trust-item:hover::before,
  .tier:hover::before { opacity: 1; }
}

/* ---------------------------------------------------------------- scrolly */

/*
 * The pinned sequence.
 *
 * The phone sticks to the middle of the viewport while the four steps scroll
 * past it, and each step swaps the scene inside the screen. The scroll is the
 * timeline â€” go back up and it plays backwards, because it is not an animation
 * running on a clock, it is a position.
 *
 * Everything here is opt-in: `.js-scrolly` is added by app.js only when the
 * viewport is tall enough and the reader has not asked for reduced motion.
 * Without it the section is four ordinary panels and one static phone.
 */
/*
 * The two acts where the page changes key: this walkthrough, and the closing
 * card at the end.
 *
 * Both of them used to be dark whatever the reader had chosen. That was right
 * while the page had exactly one palette and the walkthrough was doing what a
 * cinema does when the lights go down. It stopped being right the moment there
 * was a switch in the header: a reader who asks for the light page and then
 * scrolls into two black slabs has not been given the light page. So the two
 * still change key - a room of their own, set apart from the ordinary run of
 * sections - but the key follows the palette now instead of overruling it.
 *
 * Two tokens carry the twenty-odd colours inside them. `--room-ink` is the ink
 * as bare channels, so each of the many places that wants it at some opacity
 * can write `rgb(var(--room-ink) / 0.7)` rather than earn a token of its own,
 * and one declaration flips all of them at once. `--room-lift` is what a raised
 * surface mixes toward: white when the room is dark, black when it is lit. Both
 * `color-mix` calls below are therefore still written the way they always were.
 */
.scrolly,
.closer {
  --room-ink: 26 21 18;
  --room-lift: #000000;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .scrolly,
  :root:not([data-theme='light']) .closer {
    --room-ink: 247 244 239;
    --room-lift: #ffffff;
  }
}
:root[data-theme='dark'] .scrolly,
:root[data-theme='dark'] .closer {
  --room-ink: 247 244 239;
  --room-lift: #ffffff;
}

.scrolly {
  --room: var(--surface);

  position: relative;
  /* Contains the grain's blend mode. Deliberately not `overflow: hidden` â€”
     that would turn this into a scroll container and silently kill the sticky
     pin inside it, which is the entire point of the section. */
  isolation: isolate;
  background: var(--room);
  padding: var(--space-4xl) var(--space-lg);
  margin-top: var(--space-2xl);
}

/* Only ever the copy is inverted, never the section. The phone keeps the page's
   own tokens, so the screen stays lit against the room, which is the whole shot,
   and the app looks like the same app it is in the hero. Invert the tokens on
   `.scrolly` instead and the phone goes dark along with the room, which makes
   the product look like two different products.

   All of it is dark-scheme only. In the light scheme there is nothing to invert:
   the room is lit, and the copy standing in it is the page's own ink at the
   page's own weights. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .scrolly {
    --room: #141210;
    /* A dark room wants a rim light on the handset that a lit one does not. */
    --phone-edge: inset 0 0 0 1px rgb(255 255 255 / 0.17), 0 0 0 1px rgb(255 255 255 / 0.16);
  }
  :root:not([data-theme='light']) .scrolly-intro,
  :root:not([data-theme='light']) .scrolly-steps {
    --orange: #ff9a29;
    --orange-deep: #ff8a12;
    --orange-tint: #2e2113;
    --ink-900: #f5f2ee;
    --ink-600: #a79e95;
    --ink-400: #877e75;
    --ink-200: #3a342e;
    color: var(--ink-900);
  }
}
:root[data-theme='dark'] .scrolly {
  --room: #141210;
  --phone-edge: inset 0 0 0 1px rgb(255 255 255 / 0.17), 0 0 0 1px rgb(255 255 255 / 0.16);
}
:root[data-theme='dark'] .scrolly-intro,
:root[data-theme='dark'] .scrolly-steps {
  --orange: #ff9a29;
  --orange-deep: #ff8a12;
  --orange-tint: #2e2113;
  --ink-900: #f5f2ee;
  --ink-600: #a79e95;
  --ink-400: #877e75;
  --ink-200: #3a342e;
  color: var(--ink-900);
}

/* Lit, not just light. The bloom is what sells a screen in a dark room.
   --phone-edge last and not spelled out again: writing the hairline by hand
   here dropped the inset half of it, and the handset on this stage came out
   flatter than the same handset in the hero — a black shape rather than a
   milled edge catching the room. */
.scrolly .phone {
  box-shadow:
    0 40px 90px rgb(0 0 0 / 0.55),
    0 0 140px -30px rgb(254 139 5 / 0.45),
    var(--phone-edge);
}

/* A very fine film grain over both key changes. On the dark card it is what
   stops a large flat dark panel reading as a hole in the page on an OLED phone;
   on the lit one it keeps the same surface from going to plain flat paper. It
   costs one generated tile and no network request either way.

   Lighter on the lit card, because `overlay` works from the surface underneath
   it: the same grain that is barely a texture over near-black is visible
   speckle over near-white. */
.scrolly::after,
.closer::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  opacity: 0.16;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .scrolly::after,
  :root:not([data-theme='light']) .closer::after { opacity: 0.4; }
}
:root[data-theme='dark'] .scrolly::after,
:root[data-theme='dark'] .closer::after { opacity: 0.4; }

.scrolly-intro,
.scrolly-inner { position: relative; z-index: 1; max-width: var(--page); margin-inline: auto; }
.scrolly-intro { max-width: var(--measure); margin-bottom: var(--space-2xl); }
.scrolly-inner { display: grid; gap: var(--space-2xl); }

/* A column, so the stopwatch can sit under the phone without taking a place
   beside it. The phone still holds the middle of the stage. */
.scrolly-stage {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-md);
}

/* ------------------------------------------------------------ the stopwatch */

/* Runs while the share sheet is open and stops when it closes, then sits
   unchanged through the reading, the recipe, the week and the list. The point
   is made by the number that will not move, so nothing here may look like it
   is still working: the dial goes solid orange the moment it stops, which is
   the only place on this dark stage that colour is spent. */
/* The caption above the phone. Apple names the gesture next to the device
   doing it, and the reason is that a screen changing on its own is a video,
   while a screen changing under a named press is a thing you can picture
   yourself doing. Four of these five say what you tap. The fifth says that you
   do not have to, which is the only one of the five that is a selling point.

   One fixed row, five pills stacked in it, so the caption swapping never
   changes the height of the stage and never moves the phone. */
.gesture-track {
  position: relative;
  width: 100%;
  height: 26px;
}
.scrolly:not(.js-scenes) .gesture-track { display: none; }
.gesture {
  position: absolute;
  left: 50%;
  top: 0;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 4px 13px 4px 10px;
  border: 1px solid rgb(var(--room-ink) / 0.14);
  border-radius: 999px;
  background: color-mix(in srgb, var(--room) 88%, var(--room-lift));
  color: rgb(var(--room-ink) / 0.86);
  font-size: 0.76rem;
  font-weight: 600;
  line-height: 1.2;
  white-space: nowrap;
  opacity: 0;
  transform: translate(-50%, 7px);
  transition: opacity var(--snap), transform var(--snap);
}
.gesture svg {
  flex: none;
  width: 14px;
  height: 14px;
  color: var(--orange);
}
.gesture.is-quiet svg { color: rgb(var(--room-ink) / 0.42); }
.gesture.is-on {
  opacity: 1;
  transform: translateX(-50%);
}

.clock {
  display: inline-flex;
  align-items: center;
  gap: var(--space-sm);
  padding: 7px var(--space-md) 7px 7px;
  border: 1px solid rgb(var(--room-ink) / 0.13);
  border-radius: 999px;
  /* Opaque, not a tint of white over the room. On a phone the stage is pinned
     and the step text scrolls underneath it, and a see-through pill would have
     a sentence running through the middle of the number. */
  background: color-mix(in srgb, var(--room) 92%, var(--room-lift));
  color: rgb(var(--room-ink) / 0.72);
  font-size: 0.84rem;
  line-height: 1.2;
  transition: border-color var(--settle), background var(--settle), color var(--settle);
}

.clock-dial {
  display: inline-flex;
  align-items: baseline;
  justify-content: center;
  gap: 1px;
  /* Fixed width and lining figures. A dial that resizes as it counts drags the
     sentence next to it back and forth, and the eye follows the movement
     instead of the number. */
  min-width: 3.6em;
  padding: 4px 10px;
  border-radius: 999px;
  background: rgb(var(--room-ink) / 0.07);
  color: rgb(var(--room-ink));
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
  transition: background var(--settle), color var(--settle);
}
.clock-dial i { font-style: normal; font-size: 0.78em; opacity: 0.6; }

.clock.is-done {
  border-color: color-mix(in srgb, var(--orange) 42%, transparent);
  background: color-mix(in srgb, var(--room) 88%, var(--orange));
  color: rgb(var(--room-ink) / 0.92);
}
.clock.is-done .clock-dial {
  background: var(--orange);
  color: #1d1206;
  /* The stop is an event, not the absence of one. Without this the dial simply
     goes quiet, and quiet is exactly what a reader mid-scroll does not notice. */
  animation: clock-stop 520ms var(--ease-spring) both;
}
@keyframes clock-stop {
  0% { transform: scale(1); }
  42% { transform: scale(1.1); }
  100% { transform: scale(1); }
}

/* Wide enough for the longer of the two sentences it will hold, so the pill is
   one size for the whole section. Otherwise it snaps to a new width at the
   exact moment the number stops, and the reader watches the box move instead of
   the number stop. */
.clock-label { min-width: 9.8em; }

/* Replayed from app.js with the usual class-restart, so the new sentence
   arrives rather than swapping under you. */
.clock-label.is-said { animation: clock-said var(--settle) both; }
@keyframes clock-said {
  from { opacity: 0; transform: translateY(4px); }
  to { opacity: 1; transform: none; }
}

/* Without JavaScript the scenes never change, so the stopwatch would sit at
   zero for ever â€” which claims the opposite of what it is there to say. Same
   reasoning as the scenes it reports on. */
.scrolly:not(.js-scenes) .clock { display: none; }

.scene {
  position: absolute;
  inset: 0;
  opacity: 0;
  transform: scale(0.97);
  transition: opacity var(--flow), transform var(--flow);
  pointer-events: none;
}
.scene.is-active { opacity: 1; transform: none; }
/* Without JavaScript the scenes cannot be swapped, so the phone shows the one
   gesture the whole product depends on and the four steps carry the rest. */
.scrolly:not(.js-scenes) .scene { position: relative; opacity: 1; transform: none; }
.scrolly:not(.js-scenes) .scene + .scene { display: none; }

/* Top: under the island. Bottom: clear of the tab bar, which content is allowed
   to scroll behind but never to end behind. */
.scene-pad {
  padding: calc(var(--phone-w) * 0.136) var(--space-sm) calc(var(--phone-w) * 0.215);
  height: 100%;
  display: flex;
  flex-direction: column;
}
/* The closing line sits on the floor of the screen instead of trailing the
   content, so short scenes end in a footnote above the tab bar rather than in
   a hand's width of empty paper. */
.scene-pad > .scene-note { margin-top: auto; }
.scene-head {
  font-family: var(--font-display);
  font-size: 1.25rem;
  font-weight: 700;
  margin: 0 4px var(--space-sm);
}
.scene-note {
  margin: var(--space-md) 4px 0;
  font-size: 0.68rem;
  line-height: 1.45;
  color: var(--ink-400);
}
.scene-note.is-plum {
  color: var(--plum);
  background: var(--plum-tint);
  border-radius: 10px;
  padding: var(--space-xs) var(--space-sm);
  font-weight: 600;
}

/* Scene 1 â€” the iOS share sheet, which is the one gesture the whole product
   depends on being understood. */
/* The post fills the display, as it does on a phone. It used to sit inside a
   16px black margin, which turned the one screen on the page that is meant to
   be somebody else's app into a video embedded in a box. */
.sheet-bg {
  position: absolute;
  inset: 0;
  background: #0d0b0a;
}
.sheet-reel {
  height: 100%;
}
/* This is the only frame on the page that is meant to be playing rather than
   paused: no play badge on it, a full screen of somebody else's app, and the
   scene around it is already in motion. A photograph that does not move inside
   all that reads as a screenshot of a video rather than as a video. So it
   drifts — twenty-six seconds to cross a frame it is already larger than, which
   is slow enough that you never catch it moving and never quite believe it is
   still. One composited transform, no repaint, and the scale floor keeps an
   edge from ever swinging into view. */
.sheet-reel::before {
  transform-origin: 52% 44%;
  animation: reel-drift 26s ease-in-out infinite alternate;
}
@keyframes reel-drift {
  from { transform: scale(1.05) translate3d(0.6%, 1.2%, 0); }
  to { transform: scale(1.12) translate3d(-0.8%, -1.4%, 0); }
}
/* On top of the grain every frame gets, the two scrims this one needs so the
   status bar above it and the rail on it stay legible. */
.sheet-reel::after {
  background-image:
    linear-gradient(rgb(0 0 0 / 0.38), transparent 16%),
    linear-gradient(transparent 52%, rgb(0 0 0 / 0.42)),
    var(--dish-vignette),
    var(--grain);
  background-size: auto, auto, auto, 3px 3px;
}
/* The rail of actions every short-video app puts down its right edge, stopping
   well above the line the sheet will cover. The send action is lit, because it
   is the one this scene is about to use. */
.reel-rail {
  position: absolute;
  right: 10px;
  top: 42%;
  z-index: 1;
  display: grid;
  gap: 14px;
  justify-items: center;
  font-size: 0.55rem;
  font-weight: 600;
  color: rgb(255 255 255 / 0.94);
  text-shadow: 0 1px 3px rgb(0 0 0 / 0.55);
}
.rr {
  display: grid;
  justify-items: center;
  gap: 3px;
}
.rr svg {
  width: 21px;
  height: 21px;
  filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.5));
}
.rr.is-lit { color: var(--orange); }

/* Somebody else likes the post while you are watching it.
 *
 * It is the smallest thing on this screen and it does more than the rest of the
 * rail put together, because a number that changes is the one thing a
 * screenshot cannot do. Everything else here — the drift, the sheet, the tap —
 * is choreography we wrote. This is the post being live.
 *
 * Two numbers in a box the height of one, and the box rolls up. Once, at 1.6
 * seconds, after the sheet has settled and while the rail is still the only
 * bright thing above it. Not on a loop: a counter that ticks every few seconds
 * forever is a slot machine, and it would still be going when the reader has
 * moved on to the next scene.
 */
/* The window has to be exactly as tall as one number, and a number is only as
   tall as its line box — which inherits 1.5 from the page, so a 1em window
   showed the bottom of one figure and the top of the next. Both heights are
   the same variable now, and so is the distance the box travels. */
.rr-n {
  --rr-line: 1.25em;
  display: block;
  height: var(--rr-line);
  line-height: var(--rr-line);
  overflow: hidden;
}
.rr-n b {
  display: block;
  font-weight: inherit;
}
.rr-n i {
  display: block;
  font-style: normal;
}
.is-seen .scene[data-scene='0'].is-active .rr-n b {
  animation: rr-roll 620ms 1600ms var(--ease-spring) both;
}
@keyframes rr-roll {
  from { transform: none; }
  to { transform: translateY(calc(var(--rr-line) * -1)); }
}
/* The heart beats on the same count, and keeps the colour it earns. A number
   that moves on its own is a data feed; a number that moves while the thing it
   counts flinches is somebody's thumb. */
.is-seen .scene[data-scene='0'].is-active .rr-like svg {
  animation: rr-beat 700ms 1560ms var(--ease-spring) both;
}
@keyframes rr-beat {
  0% { transform: none; }
  34% { transform: scale(1.34); color: #ff4d6d; }
  62% { transform: scale(0.96); color: #ff4d6d; }
  100% { transform: none; color: #ff4d6d; }
}

/* Dimming the post behind the sheet is a scrim, not `opacity` on the post.
   Opacity fades everything drawn on the frame towards the black underneath,
   so the white of the play badge and the rail came out grey and the screen
   read as switched off. A scrim only takes the light down. */
.sheet-dim {
  position: absolute;
  inset: 0;
  background: linear-gradient(rgb(0 0 0 / 0.3), rgb(0 0 0 / 0.55));
}
.is-active .sheet-dim { animation: dim-in var(--flow) 120ms var(--ease-out) both; }
@keyframes dim-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

.share-sheet {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--surface);
  border-radius: var(--radius-sheet) var(--radius-sheet) 0 0;
  padding: 7px var(--space-sm) var(--space-lg);
  box-shadow: 0 -12px 34px rgb(0 0 0 / 0.34);
}
.is-active .share-sheet { animation: sheet-up var(--flow) 120ms both; }
@keyframes sheet-up {
  from { transform: translateY(60%); opacity: 0; }
  to { transform: none; opacity: 1; }
}
/* The grabber. It is the one mark that says "this slid up and can be pushed
   back down", and a sheet without it is only a panel. */
.sheet-grab {
  display: block;
  width: 36px;
  height: 5px;
  margin: 0 auto var(--space-sm);
  border-radius: 3px;
  background: var(--ink-200);
}
/* What is being shared, named. The label above the icons used to read
   "SHARE TO", which says what the row does but not what is in your hand. iOS
   shows you the item instead, and so does this. */
.sheet-item {
  display: flex;
  align-items: center;
  gap: 9px;
  padding-bottom: var(--space-sm);
  margin-bottom: var(--space-sm);
  border-bottom: 1px solid var(--ink-200);
}
.sheet-thumb {
  flex: none;
  width: 34px;
  height: 34px;
  border-radius: 8px;
  /* The same photograph, but as a plain background rather than as a frame: at
     thirty-four pixels the vignette has nothing to fall off across and the grain
     is a checkerboard. A chip this size only has to be recognisably the post
     above it, so it is cropped higher than the frames are: the sauce carries at
     thirty-four pixels and the pasta, which is pale, does not. */
  background: var(--still) 50% 30% / cover no-repeat, var(--dish-ground);
}
.sheet-meta {
  display: grid;
  gap: 1px;
  min-width: 0;
}
.sheet-meta b {
  font-size: 0.68rem;
  color: var(--ink-900);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.sheet-meta em {
  font-style: normal;
  font-size: 0.61rem;
  color: var(--ink-400);
}
.sheet-apps {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--space-xs);
  text-align: center;
}
.app {
  position: relative;
  font-size: 0.58rem;
  color: var(--ink-600);
  display: grid;
  justify-items: center;
  gap: 5px;
}
/* Sized off the grid cell, not fixed, so four icons still fit when the phone
   shrinks to 214px on a handset. The cap keeps them from ballooning on the
   wider hero phone.
   The radius is a proportion for the same reason, and it is the phone's
   proportion: a fixed 12px on a 44px icon is a rounded square, 22.4% is the
   shape iOS actually draws. */
.app i {
  width: 100%;
  max-width: 44px;
  aspect-ratio: 1;
  border-radius: 22.4%;
  display: grid;
  place-items: center;
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.22), 0 1px 3px rgb(0 0 0 / 0.16);
}
.app i svg {
  width: 56%;
  height: 56%;
}
/* Three flat colour chips with nothing drawn on them read as icons that failed
   to load. A glyph apiece is the difference between a share sheet and a set of
   swatches. */
.app-msg {
  background: linear-gradient(#5ef07a, #10c93a);
  color: #fff;
}
.app-note {
  background: linear-gradient(#ffd95e, #f7bd1a);
  color: #6b4e00;
}
.app-more {
  background: linear-gradient(#adadb4, #8a8a90);
  color: #fff;
}
.app-fc { overflow: hidden; }
.app-fc img { width: 100%; height: 100%; border-radius: inherit; }
.app.is-target {
  color: var(--ink-900);
  font-weight: 700;
}
.app.is-target i {
  box-shadow: 0 0 0 3px var(--orange), 0 6px 16px rgb(253 118 5 / 0.35);
}
/* A finger arriving on the right icon. One press, then it holds â€” a tap
   indicator that keeps tapping reads as a broken loop. It now lives inside the
   icon it is pressing rather than being measured up from the corner of the
   sheet, which is both how it stays centred on the target and how it survived
   the sheet growing a preview row above the icons. */
.touch {
  position: absolute;
  left: var(--tx, 50%);
  top: var(--ty, 50%);
  z-index: 5;
  width: 28px;
  height: 28px;
  margin: -14px 0 0 -14px;
  border-radius: 50%;
  background: radial-gradient(
    circle at 36% 32%,
    rgb(255 255 255 / 0.96),
    rgb(255 255 255 / 0.66) 54%,
    rgb(255 255 255 / 0.2) 78%
  );
  box-shadow:
    0 3px 10px rgb(20 18 16 / 0.34),
    inset 0 0 0 1px rgb(255 255 255 / 0.62);
  opacity: 0;
  pointer-events: none;
}
/* The ripple leaves from under the fingertip on the frame it presses, not on
   the frame it arrives — which is the difference between a tap and a hover. */
.touch::after {
  content: '';
  position: absolute;
  inset: -6px;
  border-radius: 50%;
  border: 2px solid var(--orange);
  opacity: 0;
}
.is-seen .scene.is-active .touch {
  animation: touch-press 1250ms var(--td, 700ms) var(--ease-out) both;
}
.is-seen .scene.is-active .touch::after {
  animation: touch-ripple 820ms calc(var(--td, 700ms) + 250ms) var(--ease-out) both;
}
@keyframes touch-press {
  0% { opacity: 0; transform: translate3d(7px, 30px, 0) scale(1.45); }
  20% { opacity: 1; transform: none; }
  30% { opacity: 1; transform: scale(0.72); }
  42% { opacity: 1; transform: scale(1); }
  74% { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: translate3d(3px, -14px, 0) scale(1.1); }
}
@keyframes touch-ripple {
  0% { opacity: 0.85; transform: scale(0.45); }
  100% { opacity: 0; transform: scale(2.1); }
}

/* Where the hand lands on each screen. The offsets are inside the thing being
   pressed rather than measured from the screen, so a shorter handset moves the
   target and the finger with it. */
.rc-link,
.week li,
.row {
  position: relative;
}
.app.is-target .touch { --ty: 22px; }
.rc-link .touch { --tx: 30px; }
/* On the row, not on the padlock. A fingertip parked on top of the icon hides
   the one thing the press is meant to cause. */
.week li .touch { --tx: 72%; }
.row .touch { --tx: 11px; }
/* The two lines that tick themselves off already carry their running order in
   --t. The finger borrows it, so the press lands a beat before the box fills
   rather than at the same instant, which is what makes the box look caused. */
.row[data-tick] .touch { --td: calc(730ms + var(--t, 0) * 950ms); }

/* Scene 2 â€” reading the caption.
   The highlighter is the whole point. A phrase that lights up where it was
   written is a claim you can check against the words either side of it; a
   progress spinner is only a picture of waiting. The caption is written the
   way people write them â€” prose, an emoji, an aside, hashtags at the end â€”
   because extracting from a tidy list would be proving the easy case. */
.cap {
  position: relative;
  flex: 1;
  min-height: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border-radius: var(--radius-card);
  padding: var(--space-sm);
  box-shadow: var(--shadow-lift);
}
/* The reading pass. Twice across, then gone â€” long enough to read as work,
   short enough that the highlighting starts before you tire of it. */
.cap::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  height: 3px;
  width: 40%;
  border-radius: 0 3px 3px 0;
  background: linear-gradient(90deg, transparent, var(--orange), transparent);
  transform: translateX(-100%);
}
.scene.is-active .cap::before { animation: hairline-sweep 1150ms 150ms 2 both; }

/* The post itself, above its caption. What gets shared is a video; a screen
   showing only the words would be showing half of what arrived. It bleeds to
   the card's edges, so the negative margin cancels the card's padding. */
.cap-media {
  flex: none;
  height: 27%;
  margin: calc(var(--space-sm) * -1) calc(var(--space-sm) * -1) var(--space-sm);
  display: grid;
  place-items: center;
}
/* The one mark that says video rather than photograph, and it costs a line of
   type. Bottom right, where every player has always put it. */
.cap-dur {
  position: absolute;
  right: 7px;
  bottom: 7px;
  z-index: 1;
  padding: 1px 5px;
  border-radius: 999px;
  background: rgb(0 0 0 / 0.5);
  color: rgb(255 255 255 / 0.92);
  font-size: 0.54rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.cap-media .reel-play { width: 26px; height: 26px; }
.cap-media .reel-play::after {
  left: 10px;
  top: 8px;
  border-left: 8px solid #fff;
  border-top: 5px solid transparent;
  border-bottom: 5px solid transparent;
}

.cap-head {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 0 0 var(--space-xs);
  font-size: 0.7rem;
  font-weight: 600;
  color: var(--ink-900);
}
.cap-avatar {
  width: 18px;
  height: 18px;
  flex: none;
  border-radius: 50%;
  /* Cropped high, like the chip in the sheet and for the same reason. */
  background: var(--still) 50% 30% / cover no-repeat, var(--dish-ground);
}
/* The window; the paragraph inside it is what moves. */
.cap-scroll {
  position: relative;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}
/* The soft top edge arrives with the movement rather than being there from the
   start. On the first frame the caption begins at the top of its window and
   there is nothing to fade â€” a veil over a first line that is not going
   anywhere just looks like the type failed to render. */
.cap-scroll::before,
.cap-scroll::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  z-index: 1;
  height: 28px;
  pointer-events: none;
}
.cap-scroll::before {
  top: 0;
  opacity: 0;
  background: linear-gradient(var(--surface), transparent);
}
/* The bottom edge is cut from the first frame at every size, so its fade is
   simply always there. */
.cap-scroll::after {
  bottom: 0;
  background: linear-gradient(transparent, var(--surface));
}
.scene.is-active .cap-scroll::before { animation: cap-veil 400ms 700ms var(--ease-out) both; }
@keyframes cap-veil {
  to { opacity: 1; }
}
.cap-text {
  position: relative;
  margin: 0;
  font-size: 0.65rem;
  line-height: 1.6;
  color: var(--ink-600);
}
.cap-tags { display: block; margin-top: 5px; color: var(--plum); }

/* The read. `top` is a percentage of the window and `translateY` a percentage
   of the paragraph, so the pair travels exactly the difference between the two
   â€” the overflow â€” without anything having to be measured in advance. Matching
   them at 30% therefore moves the text 30% of however far it happens to
   overhang, which is why one rule holds on a 268px handset where the caption
   is twice the height of its window and on a 300px one where it is barely
   taller. The caption is written long enough to overflow even there, so this
   is always a scroll and never a drift downwards.

   It stops at 30% rather than running to the end because a pinned scene is
   held, not glimpsed, and where it comes to rest is what most people will
   actually look at: the six phrases, all lit, all still on screen. Scrolling
   the hashtags into view would mean scrolling the recipe out of it.

   Linear on purpose. Eased, it would read as a swipe somebody performed;
   steady, it reads as a machine going through the text at a constant rate,
   which is what is happening. The phrases light as they arrive under it. */
.scene.is-active .cap-text { animation: cap-read 1800ms 600ms linear both; }
@keyframes cap-read {
  from { top: 0; transform: none; }
  to { top: 30%; transform: translateY(-30%); }
}

/* Drawn left to right at reading speed, in the order the phrases appear.
   Sized rather than scaled, so nothing underneath ever moves â€” a highlighter
   that shifted the text would be a different, worse gesture. */
.cap mark {
  border-radius: 3px;
  padding: 1px 2px;
  margin: 0 -2px;
  color: var(--ink-600);
  background:
    linear-gradient(
        color-mix(in srgb, var(--orange) 30%, transparent),
        color-mix(in srgb, var(--orange) 30%, transparent)
      )
      0 0 / 0 100% no-repeat;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}
.scene.is-active .cap mark {
  animation: cap-mark 300ms calc(700ms + var(--i) * 280ms) var(--ease-out) both;
}
@keyframes cap-mark {
  from { background-size: 0 100%; color: var(--ink-600); }
  to { background-size: 100% 100%; color: var(--ink-900); }
}

/* What the reading produced, after the last phrase is lit â€” so the numbers
   read as a result of the pass rather than as a second caption. Ruled off at
   the foot of the same panel: this is the caption's total, and a total belongs
   under the thing it totals. */
.cap-found {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  margin-top: auto;
  padding-top: var(--space-sm);
  border-top: 1px solid var(--ink-200);
}
.found {
  border-radius: 999px;
  padding: 4px 9px;
  font-size: 0.62rem;
  font-weight: 600;
  color: var(--orange-deep);
  background: var(--orange-tint);
}
.found b { font-variant-numeric: tabular-nums; }
.scene.is-active .found {
  animation: card-in 320ms calc(2450ms + var(--i) * 90ms) var(--ease-spring) both;
}

/* Scene 3 â€” the recipe. Here the card is not a card resting on a screen: it is
   the screen, edge to edge and full height, the way the app itself shows it.
   Left at its natural height it left the bottom third of a 19.5:9 display
   empty, which is the one thing a real recipe view never looks like. */
.scene[data-scene='2'] .recipe-card {
  height: 100%;
  border-radius: 0;
  box-shadow: none;
  display: flex;
  flex-direction: column;
}
/* The photo runs up behind the status bar, as it does in the app, so the safe
   area is bought back at the bottom of the body rather than above the hero. */
.scene[data-scene='2'] .rc-hero { flex: none; height: 20%; }
.scene[data-scene='2'] .rc-body {
  flex: 1;
  min-height: 0;
  padding-bottom: calc(var(--phone-w) * 0.215);
}
/* Six ingredients, three steps and the link back have to fit above the tab bar
   without a scroll, because a screenshot that ends mid-sentence reads as a bug
   in the app rather than as a page you could have scrolled. */
.scene[data-scene='2'] .rc-ings li { padding: 2px 0; }
.scene[data-scene='2'] .rc-steps { margin-top: var(--space-xs); }

.rc-steps {
  list-style: none;
  margin: var(--space-sm) 0 0;
  padding: var(--space-xs) 0 0;
  border-top: 1px solid var(--ink-200);
  display: grid;
  gap: var(--space-xs);
  counter-reset: rc-step;
}
.rc-steps li {
  display: flex;
  gap: 7px;
  font-size: 0.71rem;
  line-height: 1.35;
  color: var(--ink-600);
}
/* Numbered by CSS rather than by the list marker, so the number can be a
   filled disc without the marker box getting a say in where it sits. */
.rc-steps li::before {
  counter-increment: rc-step;
  content: counter(rc-step);
  flex: none;
  width: 15px;
  height: 15px;
  margin-top: 1px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: var(--orange-tint);
  color: var(--orange-deep);
  font-size: 0.57rem;
  font-weight: 700;
}
.rc-steps span { flex: 1; }
.rc-steps em {
  flex: none;
  font-style: normal;
  font-size: 0.62rem;
  font-variant-numeric: tabular-nums;
  color: var(--ink-400);
}

/* The way back to the person who made it. Orange, because it is the one thing
   on this screen you are meant to press. */
.rc-link {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: var(--space-sm) 0 0;
  font-size: 0.68rem;
  font-weight: 600;
  color: var(--orange-deep);
}
.rc-link svg {
  flex: none;
  width: 13px;
  height: 13px;
}

/* Scene 4 â€” the week. Plum, because in this product plum means "planned" and
   orange would collide with "press this". */
/* The constraints the week was built under. They land before the nights do,
   because they are the input and the nights are the output â€” and they are
   plum, not orange: this is a report of what was already decided, not a row
   of things to press. */
.week-rules {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin: 0 0 var(--space-sm);
}
.rule {
  border-radius: 999px;
  padding: 3px 8px;
  font-size: 0.58rem;
  font-weight: 600;
  color: var(--plum);
  background: var(--plum-tint);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--plum) 22%, transparent);
}
.is-active .rule { animation: card-in 300ms calc(var(--i) * 70ms) var(--ease-spring) both; }

.week {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 6px;
}
.week li {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  padding: 8px var(--space-xs);
  border-radius: 10px;
  background: var(--plum-tint);
  font-size: 0.72rem;
}
.is-active .week li {
  animation: strike-in 320ms both;
  animation-delay: calc(180ms + var(--i) * 110ms);
}
.week .day {
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--plum);
  min-width: 30px;
}
.week .meal { color: var(--ink-900); }
/* A night the reader has already decided. Deeper plum and an outline, because
   "locked" has to be legible at a glance from across a scrolling page. */
.week li.is-locked {
  background: color-mix(in srgb, var(--plum) 15%, var(--plum-tint));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--plum) 32%, transparent);
}
.week .lock {
  flex: none;
  margin-left: auto;
  width: 11px;
  height: 11px;
  color: var(--plum);
}
/* Saturday is not locked when the week arrives — it is locked because a finger
   pressed it, half a second later. A night that was already spoken for before
   you got there is a fact about the screenshot; a night that latches while you
   watch is the feature. */
.is-seen .scene[data-scene='3'].is-active .week li.is-locked .lock {
  animation: lock-latch 480ms 1560ms var(--ease-spring) both;
}
@keyframes lock-latch {
  0% { opacity: 0; transform: scale(0.35) rotate(-32deg); }
  56% { opacity: 1; transform: scale(1.22) rotate(5deg); }
  100% { opacity: 1; transform: none; }
}

/* Scene 5 â€” the list */
.aisle {
  margin: var(--space-sm) 4px 4px;
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-400);
}
.row {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  padding: 7px 4px;
  font-size: 0.74rem;
}
.row b {
  font-variant-numeric: tabular-nums;
  min-width: 44px;
  font-weight: 600;
}
.row span { flex: 1; }
.row em {
  font-style: normal;
  font-size: 0.62rem;
  font-weight: 700;
  color: var(--ink-600);
  background: var(--ink-200);
  border-radius: 999px;
  padding: 1px 7px;
}
.row.is-done span { text-decoration: line-through; color: var(--ink-400); }

/* An opened merged line, and underneath it the recipes it was added up from.
   Indented past the checkbox so it reads as belonging to the row above rather
   than as another thing to buy. */
.row.is-open {
  border-radius: 8px;
  background: color-mix(in srgb, var(--ink-200) 55%, transparent);
}
.row-from {
  margin: 3px 4px var(--space-xs) 28px;
  font-size: 0.6rem;
  line-height: 1.4;
  color: var(--ink-400);
}

/* Two lines get shopped while the scene is on screen. It is the only scene
   whose subject is a thing you do rather than a thing the app does, so it is
   the only one that acts on itself.
   Keyed off `.is-active`, which the scroll adds and removes â€” scroll back up
   and the list is unticked again, because the scene is a position on the page
   rather than a clip that has already played. */
.row[data-tick] .tick::after {
  content: '';
  position: absolute;
  left: 5px;
  top: 1px;
  width: 4px;
  height: 8px;
  border: solid var(--success);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) scale(0.3);
  opacity: 0;
}
.row[data-tick] span { position: relative; }
.row[data-tick] span::after {
  content: '';
  position: absolute;
  left: -1px;
  right: -1px;
  top: 52%;
  height: 1px;
  background: var(--ink-400);
  transform: scaleX(0);
  transform-origin: 0 50%;
}
.scene.is-active .row[data-tick] .tick {
  animation: tick-box 280ms calc(1000ms + var(--t, 0) * 950ms) var(--ease-spring) both;
}
.scene.is-active .row[data-tick] .tick::after {
  animation: tick-draw 260ms calc(1080ms + var(--t, 0) * 950ms) var(--ease-spring) both;
}
.scene.is-active .row[data-tick] span::after {
  animation: tick-strike 300ms calc(1140ms + var(--t, 0) * 950ms) var(--ease-out) both;
}
@keyframes tick-box {
  from { border-color: var(--ink-200); background: transparent; transform: scale(1); }
  55% { transform: scale(0.86); }
  to {
    border-color: var(--success);
    background: color-mix(in srgb, var(--success) 12%, transparent);
    transform: scale(1);
  }
}
@keyframes tick-draw {
  from { opacity: 0; transform: rotate(45deg) scale(0.3); }
  to { opacity: 1; transform: rotate(45deg) scale(1); }
}
@keyframes tick-strike {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

/* The steps beside the phone */
.scrolly-steps {
  list-style: none;
  counter-reset: step;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-xl);
}
.step { position: relative; padding-left: 58px; }
.step p { color: var(--ink-600); margin: 0; }

/* The second line of each step: the detail that makes the claim above it
   believable. Set quieter and behind a rule, so it reads as a footnote you can
   skip rather than a second paragraph you have to get through. Selector is
   `.step p.step-detail` and not `.step-detail` so it outranks `.step p` above
   without reaching for !important. */
.step p.step-detail {
  margin-top: var(--space-md);
  padding-left: var(--space-md);
  border-left: 2px solid var(--orange);
  font-size: 0.95rem;
  color: var(--ink-400);
}

.step-number {
  position: absolute;
  left: 0;
  top: -2px;
  width: 42px;
  height: 42px;
  display: grid;
  place-items: center;
  background: var(--orange-tint);
  color: var(--orange-deep);
  border-radius: 50%;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.1rem;
  transition: background var(--settle), color var(--settle), transform var(--settle);
}
.js-scenes .step {
  opacity: 0.42;
  transition: opacity var(--flow);
}
.js-scenes .step.is-active { opacity: 1; }
/* Forty-two per cent is a fade, and a fade only works while something is
   fading. With motion off the step ahead of you does not brighten as you
   arrive, it simply sits there at 2:1 against the page, which is not a
   de-emphasised paragraph, it is an unreadable one. Eighty-five clears 4.5:1
   in both schemes and still reads as quieter than the step you are on; the
   numbered bead, which goes from outline to solid orange, is doing the real
   work of saying which one that is. */
@media (prefers-reduced-motion: reduce) {
  .js-scenes .step { opacity: 0.85; }
}
.js-scenes .step.is-active .step-number {
  background: var(--orange);
  color: #fff;
  transform: scale(1.06);
  box-shadow: 0 6px 18px rgb(253 118 5 / 0.3);
}

/* ---------------------------------------------------------------- library */

.lib { margin-top: var(--space-xl); display: grid; gap: var(--space-2xl); }

.lib-copy h3 {
  margin-bottom: var(--space-xs);
  font-size: clamp(1.15rem, 2.4vw, 1.4rem);
  font-weight: 700;
}
.lib-copy h3 + p { margin-bottom: var(--space-lg); }
.lib-copy p { color: var(--ink-600); }
.lib-copy > h3:first-child { margin-top: 0; }

/* The three suggestions only mean anything if pressing one types it, so
   without the script they are not offered. */
.lib-try { display: none; }
.js .lib-try {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-xs);
  margin-bottom: 6px;
}
.js .lib-copy .lib-try + .lib-note { margin-bottom: var(--space-lg); }
.lib-note { display: none; }
.js .lib-note {
  display: block;
  font-size: 0.85rem;
  color: var(--ink-400);
}

/* A word, not a button â€” it belongs to the sentence it sits in, and dressing
   it as a control would make the paragraph look like a toolbar. The underline
   is what says it can be pressed, and it thickens rather than appears, so the
   line does not have to jump to make room for it. */
.tryword {
  border: 0;
  padding: 0 0 2px;
  background: none;
  font: inherit;
  font-weight: 600;
  color: var(--orange-deep);
  cursor: pointer;
  border-bottom: 1px solid color-mix(in srgb, var(--orange) 45%, transparent);
  transition: border-color var(--snap), color var(--snap), transform var(--press);
}
.tryword:hover,
.tryword:focus-visible {
  color: var(--ink-900);
  border-bottom-color: var(--orange);
}
.tryword:active { transform: translateY(1px); }

.lib-art { display: flex; justify-content: center; }
.lib-art .phone { --phone-w: min(320px, 82vw); }
/* This screen is the Library tab, so the Library tab is the one that is lit.
   The bar has named it first since the hero and this is the first time the
   page opens it. */
.lib-art .pu-tab:nth-child(1) { color: var(--orange-deep); }

/*
 * The Library screen.
 *
 * Laid out against the real handset's safe areas â€” the status bar's band at the
 * top, the tab bar's at the bottom â€” so the list runs under neither. Everything
 * the reader has to read is sized in rem and only the chrome scales with the
 * phone, because a search field that shrinks with the mockup is a search field
 * nobody can type into.
 */
.lib-screen {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  padding: calc(var(--phone-w) * 0.136) 0 calc(var(--phone-w) * 0.196);
  background: var(--surface);
}

.lib-heading {
  margin: 0 0 10px;
  padding: 0 14px;
  font-family: var(--font-display);
  font-size: 1.55rem;
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--ink-900);
}

/* Without the script none of these do anything, and a dead search field is
   worse than no search field: it invites the one interaction the page cannot
   honour. The list underneath is real content and stays either way. */
.lib-field,
.lib-filters,
.lib-sort { display: none; }
.js .lib-field { display: flex; }
.js .lib-filters { display: flex; }
.js .lib-sort { display: inline-flex; }

.lib-field {
  align-items: center;
  gap: 7px;
  margin: 0 14px 10px;
  padding: 0 10px;
  height: 34px;
  border-radius: 10px;
  background: color-mix(in srgb, var(--ink-900) 6%, transparent);
  transition: background-color var(--settle), box-shadow var(--settle);
}
.lib-field:focus-within {
  background: var(--surface);
  box-shadow: 0 0 0 2px var(--orange);
}
.lib-mag {
  flex: none;
  width: 14px;
  height: 14px;
  color: var(--ink-400);
  transition: color var(--settle);
}
.lib-field:focus-within .lib-mag { color: var(--orange-deep); }

/* 16px, and not a pixel less. Anything smaller and iOS zooms the whole page in
   the moment this is focused, which on a page built around a scroll is the
   single most destructive thing a text field can do. */
.lib-input {
  flex: 1;
  min-width: 0;
  border: 0;
  padding: 0;
  background: none;
  font: inherit;
  font-size: 16px;
  color: var(--ink-900);
  outline: none;
}
.lib-input::placeholder { color: var(--ink-400); }
/* Safari draws its own clear button here. Ours is the one that matches the
   rest of the screen and the one the script knows about. */
.lib-input::-webkit-search-cancel-button { display: none; }

.lib-clear {
  flex: none;
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--ink-400);
  color: var(--surface);
  cursor: pointer;
  animation: clear-in 200ms var(--ease-spring) both;
  transition: background-color var(--snap), transform var(--press);
}
.lib-clear svg { width: 10px; height: 10px; }
.lib-clear:hover { background: var(--ink-600); }
.lib-clear:active { transform: scale(0.86); }
@keyframes clear-in {
  from { opacity: 0; transform: scale(0.4); }
  to { opacity: 1; transform: none; }
}

.lib-filters {
  gap: 6px;
  margin: 0 14px 10px;
  padding-bottom: 1px;
  overflow-x: auto;
  scrollbar-width: none;
}
.lib-filters::-webkit-scrollbar { display: none; }

/* Deliberately not the plum chip from the preference section. That one sets a
   rule that outlives the visit; this one narrows a list until you clear it,
   and the two should not look like the same promise. Orange is the app's own
   accent, which is what a filter inside the app would use. */
.lib-chip {
  flex: none;
  border: 1px solid var(--ink-200);
  border-radius: 999px;
  padding: 4px 10px;
  background: var(--surface);
  font: inherit;
  font-size: 0.72rem;
  font-weight: 600;
  white-space: nowrap;
  color: var(--ink-600);
  cursor: pointer;
  transition:
    background-color var(--snap),
    border-color var(--snap),
    color var(--snap),
    transform var(--press);
}
.lib-chip:hover { border-color: color-mix(in srgb, var(--orange) 55%, var(--ink-200)); }
.lib-chip:active { transform: scale(0.94); }
.lib-chip[aria-pressed='true'] {
  background: var(--orange);
  border-color: var(--orange);
  color: #fff;
}

.lib-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-xs);
  margin: 0 14px 4px;
}
.lib-count {
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-400);
}
.lib-sort {
  align-items: center;
  gap: 4px;
  border: 0;
  padding: 2px 0;
  background: none;
  font: inherit;
  font-size: 0.72rem;
  font-weight: 600;
  color: var(--orange-deep);
  cursor: pointer;
  transition: transform var(--press), opacity var(--snap);
}
.lib-sort svg { width: 11px; height: 11px; }
.lib-sort:hover { opacity: 0.75; }
.lib-sort:active { transform: scale(0.94); }

.lib-list {
  flex: 1;
  min-height: 0;
  list-style: none;
  margin: 0;
  padding: 0;
  overflow-y: auto;
  /* iOS overlay scrollbars are invisible at rest, and a permanent grey bar down
     the inside of a drawn handset is the sort of detail that gives the whole
     mockup away. The half-row cut off at the bottom edge is the affordance. */
  scrollbar-width: none;
}
.lib-list::-webkit-scrollbar { display: none; }

.lrow {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 7px 14px;
  border-top: 1px solid color-mix(in srgb, var(--ink-900) 7%, transparent);
  /* The one thing that moves. A row that survives a filter or a re-sort is the
     same row it was a moment ago, so it travels to its new place rather than
     being redrawn there â€” which is the difference between a list that sorted
     itself and a list that was replaced. */
  transition: transform var(--settle);
}
.lrow:first-child { border-top: 0; }

/*
 * Stand-ins for the hero image every recipe carries.
 *
 * These were flat gradient swatches, and twelve of them stacked down a list
 * read as a colour test rather than as a library — the one thing a recipe list
 * is supposed to look like is a list of food.
 *
 * So each one is a bowl on a dark worktop, seen from above, with the dish in
 * it. Thirty-eight pixels is not enough to draw a dish, but it is enough to
 * draw the shape of a plated one: a pale rim, something coloured inside it,
 * and light landing up and to the left. That silhouette is what the eye
 * recognises at this size, and it is the same silhouette in every row, which
 * is what makes the twelve colours read as twelve dinners rather than twelve
 * swatches.
 *
 * The colour is the dish's own, set on the row: --h turns it away from the
 * page orange, --s says how much of a colour it really has (a bean taco is
 * brown, not orange), and --l lifts the pale ones. Amatriciana and roast
 * tomato soup landing near each other is not a fault; they are both tomato.
 */
.lrow-thumb,
.ptile-thumb {
  --food: hsl(calc(28 + var(--h, 0)) calc(var(--s, 58) * 1%) calc(var(--l, 52) * 1%));
  --food-deep: hsl(calc(28 + var(--h, 0)) calc(var(--s, 58) * 0.88%) calc(var(--l, 52) * 0.6%));
  flex: none;
  width: var(--dish, 38px);
  height: var(--dish, 38px);
  border-radius: 8px;
  background:
    /* The bits in it, scattered rather than spaced. Three evenly placed dots
       inside a ring is a shirt button, which is what the first attempt looked
       like; three uneven ones is food. */
    radial-gradient(circle at 63% 33%, rgb(255 255 255 / 0.42) 0 1.4px, transparent 1.6px),
    radial-gradient(circle at 37% 51%, rgb(0 0 0 / 0.26) 0 1.7px, transparent 1.9px),
    radial-gradient(circle at 54% 67%, rgb(0 0 0 / 0.18) 0 1px, transparent 1.2px),
    /* Light on the near side, shade under the far wall. Without the pair the
       food is a flat circle lying on the rim rather than sitting down in it. */
    radial-gradient(circle at 37% 34%, rgb(255 255 255 / 0.26), transparent 30%),
    radial-gradient(circle at 68% 70%, rgb(0 0 0 / 0.28), transparent 34%),
    /* Two overlapping masses, not one circle. Nothing anybody serves has a
       compass-drawn edge, and the edge is most of what reads at 38px. */
    radial-gradient(32% 30% at 57% 45%, var(--food) 0 74%, transparent 82%),
    radial-gradient(33% 31% at 43% 58%, var(--food-deep) 0 72%, transparent 82%),
    radial-gradient(circle at 50% 50%, #ece5da 0 57%, #b6ab9c 59%, transparent 62%),
    /* The board it is standing on. Near-black turned these into app icons —
       a dark rounded square with a white disc in it — where a warm mid brown
       reads as the wood in the photograph it is standing in for. */
    radial-gradient(circle at 50% 54%, rgb(0 0 0 / 0.3), transparent 70%),
    linear-gradient(150deg, #8d7458, #4a3b2d);
}
.lrow-main {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.lrow-title {
  font-size: 0.84rem;
  font-weight: 600;
  line-height: 1.25;
  color: var(--ink-900);
}
/* Why this row is here. On an exact match it lands under what you typed; on a
   near one it lands under the word we decided you meant, which is the only
   honest way to show a search that guessed. */
.lrow-title mark {
  padding: 0 1px;
  border-radius: 3px;
  background: color-mix(in srgb, var(--orange) 30%, transparent);
  color: inherit;
}
.lrow-meta {
  font-size: 0.66rem;
  line-height: 1.3;
  color: var(--ink-400);
}
.lrow-fav {
  flex: none;
  display: block;
  width: 13px;
  height: 13px;
  color: var(--orange);
}
.lrow-fav svg { width: 100%; height: 100%; display: block; }

/* Centred, unlike everything else on this page, because this is the app's own
   empty state and that is where iOS puts one â€” in the middle of the space the
   content would have filled, rather than flush against the last thing that
   worked. */
.lib-empty {
  margin: var(--space-lg) auto 0;
  max-width: 22em;
  padding: 0 18px;
  font-size: 0.78rem;
  line-height: 1.5;
  text-align: center;
  text-wrap: balance;
  color: var(--ink-600);
  animation: card-in 300ms var(--ease-out) both;
}


/* ------------------------------------------------- the library, filling up */

/*
 * The strip of dishes under the demo.
 *
 * The heading of this section says two hundred saved. Everything under it
 * shows twelve, because twelve is what fits on a phone screen, and the gap
 * between the two is where a reader quietly stops believing the heading. This
 * strip closes it without a sentence: a shelf running past in both directions
 * with no end visible at either side of the frame.
 *
 * It is scenery, not content. Nothing here is meant to be read; it is meant to
 * be recognised out of the corner of an eye as more food than you could count,
 * which is the feeling the section is actually selling.
 */
.foodmq {
  /* Cancels the section's own gutter and nothing else, so the strip reaches
     the edge of the page without ever being wider than it. `100vw` is the
     usual way to do this and is a bug on any desktop with a classic
     scrollbar, where it is wider than the page by exactly the scrollbar. */
  margin: var(--space-2xl) calc(var(--space-lg) * -1) 0;
  display: grid;
  gap: 10px;
  /* Both ends dissolve. A hard cut says there is a box here whose contents are
     too long for it; a fade says this carries on past the frame, which is the
     whole claim being made. */
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}

.fmq-row { overflow: hidden; }

.fmq-track {
  display: flex;
  gap: 10px;
  width: max-content;
  /* Slow enough to be furniture. Under about a minute a lap it starts asking
     for attention, and a thing that asks for attention has to be worth
     reading; this is not, deliberately. */
  animation: fmq-drift 84s linear infinite;
}
/* The other way, and on a different clock. Two rows at one speed are a single
   thick row; the mismatch is what makes them read as a heap. */
.fmq-row.is-back .fmq-track {
  animation-duration: 67s;
  animation-direction: reverse;
}

/* Half the track is exactly one copy of the list, and the list is written out
   twice, so the frame this ends on is the frame it started from. */
@keyframes fmq-drift {
  from { transform: translate3d(0, 0, 0); }
  to { transform: translate3d(-50%, 0, 0); }
}

/* Rest a pointer on it and it waits. Anyone who stops the strip has stopped it
   to read a name off it, and a name sliding out from under the cursor
   mid-word is the small rudeness that makes a page feel cheap. */
.foodmq:hover .fmq-track { animation-play-state: paused; }

.fmq {
  flex: none;
  display: grid;
  grid-template-columns: auto 1fr;
  grid-template-rows: auto auto;
  align-items: center;
  column-gap: 10px;
  padding: 8px 18px 8px 8px;
  border: 1px solid var(--ink-200);
  border-radius: 14px;
  background: var(--surface);
}
.fmq-dish {
  grid-row: 1 / 3;
  width: 54px;
  height: 54px;
  display: grid;
  place-items: center;
}
/* The dish is the list's own 38px tile, scaled rather than resized. Every
   highlight, shadow and speck in that tile is written in pixels: give it a
   bigger width and you get a dinner plate with the same three crumbs on it,
   seen from across the room. A transform takes the crumbs with it. */
.fmq-dish .lrow-thumb { transform: scale(1.42); }
.fmq-t {
  align-self: end;
  font-size: 0.82rem;
  font-weight: 600;
  line-height: 1.2;
  color: var(--ink-900);
  white-space: nowrap;
}
.fmq-m {
  align-self: start;
  font-size: 0.68rem;
  line-height: 1.35;
  color: var(--ink-400);
  white-space: nowrap;
}

.foodmq-note {
  margin-top: var(--space-md);
  font-size: 0.85rem;
  color: var(--ink-400);
}

/* Stopped, not slowed. A strip whose entire job is to drift is precisely what
   this preference is asking for less of, and the blanket 0.01ms further down
   would leave the track parked at minus fifty percent, which reads as a
   layout fault rather than as stillness. What it becomes instead is something
   you can push along with a finger, so the same dishes are still all there. */
@media (prefers-reduced-motion: reduce) {
  .fmq-track { animation: none; }
  .fmq-row {
    overflow-x: auto;
    scrollbar-width: none;
  }
  .fmq-row::-webkit-scrollbar { display: none; }
}

/* On a handset the strip is only a phone wide, so a card built for a desktop
   row shows one and a half of itself and reads as a broken carousel rather
   than as a shelf. Everything comes in a size, and the fade at the ends comes
   in with it: eight percent of a 390px screen is a third of a card gone. */
@media (max-width: 46rem) {
  .foodmq {
    -webkit-mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
    mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
  }
  .fmq { padding: 6px 14px 6px 6px; column-gap: 8px; border-radius: 12px; }
  .fmq-dish { width: 42px; height: 42px; }
  .fmq-dish .lrow-thumb { transform: scale(1.1); }
  .fmq-t { font-size: 0.78rem; }
  .fmq-m { font-size: 0.64rem; }
}


/* ------------------------------------------------------------- difference */

.difference .feature {
  display: grid;
  gap: var(--space-xl);
  padding-top: var(--space-2xl);
  margin-top: var(--space-2xl);
  border-top: 1px solid var(--ink-200);
}
.feature-copy p { color: var(--ink-600); }
.feature-copy h3 {
  font-size: clamp(1.35rem, 3vw, 1.8rem);
  font-weight: 700;
  margin-bottom: var(--space-md);
}

.demo {
  margin: 0;
  background: var(--surface);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-card);
  padding: var(--space-lg);
  box-shadow: var(--shadow-card);
}
.demo figcaption {
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-400);
  margin-bottom: var(--space-md);
}

/* The merge demo. Three differently-worded lines converge into one, on a loop,
   because the claim "we merge duplicates properly" is worth three seconds of
   proof and nothing else on the page can prove it as quickly. */
.merge-sources { display: grid; gap: 6px; }
.src {
  display: block;
  padding: 9px var(--space-sm);
  border: 1px dashed var(--ink-200);
  border-radius: 10px;
  font-size: 0.9rem;
  color: var(--ink-600);
  background: var(--canvas);
}
.is-playing .src {
  animation: src-converge 5200ms infinite both;
  animation-delay: calc(var(--i) * 140ms);
}
@keyframes src-converge {
  0%, 12% { opacity: 1; transform: none; }
  34% { opacity: 0.15; transform: translateY(14px) scale(0.97); }
  74% { opacity: 0.15; transform: translateY(14px) scale(0.97); }
  92%, 100% { opacity: 1; transform: none; }
}

.merge-arrow {
  display: grid;
  place-items: center;
  height: 34px;
  color: var(--ink-400);
}
.merge-arrow svg { width: 20px; height: 20px; }
.is-playing .merge-arrow { animation: arrow-pulse 5200ms infinite both; }
@keyframes arrow-pulse {
  0%, 20% { opacity: 0.35; transform: translateY(-4px); }
  32%, 72% { opacity: 1; color: var(--orange-deep); transform: none; }
  86%, 100% { opacity: 0.35; transform: translateY(-4px); }
}

.merge-result {
  border: 1px solid var(--ink-200);
  border-radius: 12px;
  padding: var(--space-xs) var(--space-sm) var(--space-sm);
  background: var(--canvas);
}
.merge-result .row { font-size: 0.95rem; }
.merge-result .row b { min-width: 54px; }
.is-playing .merge-result {
  animation: result-in 5200ms infinite both;
}
@keyframes result-in {
  0%, 22% { opacity: 0.25; transform: scale(0.98); }
  38%, 74% { opacity: 1; transform: none; }
  90%, 100% { opacity: 0.25; transform: scale(0.98); }
}

/* ------------------------------------------------------------- the cue ring */

/* Every demo on this page can be operated, and until now the page said so only
 * in words above them. People do not read the label on a thing that looks like
 * a picture; they scroll past it. So when a working demo arrives on screen its
 * first control puts out a ring, twice, and then stops.
 *
 * Drawn with `outline` on purpose. An outline takes no space, so nothing
 * shifts; it sits outside the border, so it clears a control's own edge; and
 * it is the one ring property none of these demos already spend on something
 * else — .plan-day's ::after is its latch, .chip has a shadow, the phone has
 * four background layers. Nothing to fight with.
 *
 * Twice and then done. A control that keeps pulsing is not inviting you, it is
 * nagging you, and it is still nagging long after you have decided. app.js
 * takes the class off for good the first time you touch anything in that demo,
 * because the one certain thing about somebody who has just used it is that
 * they no longer need telling.
 */
.is-cued {
  outline: 2px solid transparent;
  outline-offset: 0;
  animation: cue-ring 1700ms var(--ease-out) 2;
}
@keyframes cue-ring {
  0% { outline-color: rgb(254 139 5 / 0); outline-offset: 0; }
  18% { outline-color: rgb(254 139 5 / 0.75); outline-offset: 0; }
  58%, 100% { outline-color: rgb(254 139 5 / 0); outline-offset: 11px; }
}
/* Once you are actually on it, the ring has done its job and the focus ring
   needs the outline back.

   Cancelling the animation is not enough to give it back. `.is-cued` declares
   `outline: 2px solid transparent`, and it is the same specificity as the
   global `:focus-visible` rule while being declared several thousand lines
   later — so it wins, and a keyboard user tabbing onto a cued control gets a
   transparent outline and no visible focus at all. The outline has to be
   restated here, where `.is-cued:focus-visible` outranks both. */
.is-cued:hover { animation: none; }
.is-cued:focus-visible {
  animation: none;
  outline: 3px solid var(--orange);
  outline-offset: 2px;
}

/* The plan demo is real: the nights are generated in JS, you can lock one, and
   regenerating respects the lock. It is the differentiator, so it is worth
   letting people actually operate it rather than watch it. */
.plan-week {
  list-style: none;
  margin: 0 0 var(--space-md);
  padding: 0;
  display: grid;
  gap: 6px;
}
.plan-day {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  width: 100%;
  padding: 11px var(--space-sm);
  border: 1px solid var(--ink-200);
  border-radius: 12px;
  background: var(--canvas);
  font: inherit;
  font-size: 0.92rem;
  color: var(--ink-900);
  text-align: left;
  cursor: pointer;
  transition: border-color var(--snap), background var(--snap), transform var(--snap);
}
.plan-day:hover,
.plan-day:focus-visible {
  border-color: color-mix(in srgb, var(--plum) 45%, var(--ink-200));
  transform: translateX(2px);
}
.plan-day:active { transform: scale(0.99); transition-duration: var(--press); }
.plan-day .day {
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-400);
  min-width: 34px;
  transition: color var(--snap);
}
.plan-day .meal { flex: 1; }

/* The padlock turns as it closes. It starts small and tilted, is previewed at
   a third of its opacity while you are only pointing at the night, and snaps
   upright when the night is actually locked â€” so the button shows you what it
   is about to do before you commit to it. */
/* And it is faintly there at rest as well. Invisible until pointed at, five
   nights in a row read as a list of what is for dinner, and a list is a thing
   you read; the mark on the end is what makes each one a switch you could
   throw. Nobody hovers over a paragraph to find out whether it is a button. */
.plan-day .lock {
  width: 15px;
  height: 15px;
  flex: none;
  opacity: 0.18;
  color: var(--plum);
  transform: scale(0.72) rotate(-22deg);
  transition: opacity var(--snap), transform var(--snap);
}
.plan-day:hover .lock,
.plan-day:focus-visible .lock { opacity: 0.34; transform: scale(0.86) rotate(-12deg); }
.plan-day.is-locked {
  background: var(--plum-tint);
  border-color: color-mix(in srgb, var(--plum) 35%, transparent);
}
.plan-day.is-locked .day { color: var(--plum); }
.plan-day.is-locked .lock,
.plan-day.is-locked:hover .lock,
.plan-day.is-locked:focus-visible .lock { opacity: 1; transform: none; }

/* One ring off the edge at the moment it latches. Added by the click handler
   alone, so regenerating a week never makes every locked night flash again. */
.plan-day::after {
  content: '';
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  border: 1.5px solid var(--plum);
  opacity: 0;
  pointer-events: none;
}
.plan-day.is-just-locked::after { animation: lock-ring 560ms var(--ease-out); }
@keyframes lock-ring {
  from { opacity: 0.7; transform: scale(1); }
  to { opacity: 0; transform: scale(1.05); }
}
/* Regenerated nights land top to bottom rather than all at once, so you can
   see which ones actually changed and which ones you had locked. */
.plan-day.is-fresh {
  animation: strike-in 340ms both;
  animation-delay: calc(var(--i, 0) * 55ms);
}

.plan-note {
  margin: 0 0 var(--space-md);
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--plum);
  background: var(--plum-tint);
  border-radius: 10px;
  padding: var(--space-xs) var(--space-sm);
}
/* This number is the whole argument â€” a smaller shop because the week was
   chosen, not assembled. When it moves, it says so, otherwise the one line on
   the page that just changed is the one line nobody notices. */
.plan-note.is-changed { animation: note-flash 760ms var(--ease-out); }
@keyframes note-flash {
  0% { background-color: color-mix(in srgb, var(--plum) 28%, var(--plum-tint)); }
  100% { background-color: var(--plum-tint); }
}
.plan-actions { display: flex; gap: var(--space-xs); }

/* ------------------------------------------------------------ preferences */

.prefs { margin-top: var(--space-xl); }

/* Without the script the chips cannot filter anything, so they are not shown
   and neither is the running count they change. The library below them is real
   content either way, and stays. */
.prefs-controls,
.prefs-count { display: none; }
.js .prefs-count { display: block; }
.js .prefs-controls {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs);
}

/* Off: an outline, and the lift on hover that means "this is a control".
   On: filled plum, because it has stopped being a button you could press and
   become part of the plan â€” which is what plum means everywhere else here.
   The tick is not decoration. Colour alone would leave the state invisible to
   a reader who cannot separate these two, and the chip widening to admit it is
   the accept gesture: pressing it adds something. */
.chip {
  display: inline-flex;
  align-items: center;
  border: 1px solid var(--ink-200);
  border-radius: 999px;
  padding: 9px 17px;
  background: var(--surface);
  color: var(--ink-600);
  font: inherit;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  transition:
    transform var(--snap),
    background-color var(--settle),
    border-color var(--settle),
    color var(--settle),
    box-shadow var(--settle);
}
.chip::before {
  content: '';
  width: 0;
  height: 9px;
  margin: 0 0 3px;
  border: solid currentColor;
  border-width: 0 2px 2px 0;
  opacity: 0;
  transform: rotate(45deg) scale(0.5);
  transition:
    width var(--settle),
    margin var(--settle),
    opacity var(--snap),
    transform var(--snap);
}
.chip:hover {
  transform: translateY(-2px);
  border-color: color-mix(in srgb, var(--orange) 55%, var(--ink-200));
  box-shadow: var(--shadow-lift);
}
.chip:active { transform: translateY(0) scale(0.97); transition-duration: var(--press); }
.chip[aria-pressed='true'] {
  background: var(--plum);
  border-color: var(--plum);
  color: #fff;
  box-shadow: 0 8px 22px color-mix(in srgb, var(--plum) 32%, transparent);
}
.chip[aria-pressed='true']::before {
  width: 5px;
  margin-right: 10px;
  opacity: 1;
  transform: rotate(45deg) scale(1);
}

.prefs-count {
  margin: var(--space-lg) 0 var(--space-md);
  font-size: 1.05rem;
  color: var(--ink-600);
}
.prefs-count b {
  display: inline-block;
  font-family: var(--font-display);
  font-size: 1.6rem;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}
/* The number is the answer to the question the chip asked, and it is two
   characters in the middle of a paragraph â€” exactly the sort of thing that
   changes without anybody seeing it. */
.prefs-count b.is-bump { animation: count-bump 380ms var(--ease-spring); }
@keyframes count-bump {
  0% { transform: translateY(-7px) scale(1.2); color: var(--plum); }
  100% { transform: none; }
}

.prefs-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  /* Sized so twelve tiles come out as whole rows rather than a ragged last
     line â€” four across on a desktop, three on a tablet, two on a phone. */
  grid-template-columns: repeat(auto-fill, minmax(238px, 1fr));
  gap: var(--space-sm);
}
/* Same twelve recipes as the library list two sections up, so the same bowls.
   A grid of names with no pictures reads as a spreadsheet of dinners, and the
   thing being demonstrated here is somebody choosing between them. */
.ptile {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  column-gap: 11px;
  row-gap: 3px;
  padding: var(--space-sm) var(--space-md);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-card);
  background: var(--surface);
  transition:
    opacity var(--settle),
    transform var(--settle),
    border-color var(--settle),
    background-color var(--settle);
}
.ptile-thumb {
  --dish: 36px;
  grid-row: 1 / 3;
  align-self: center;
  /* Ruled out is ruled out all the way through. The colour draining out of the
     picture says the same thing the struck-through name says, at a glance and
     from further away than the reason underneath can be read. */
  transition: filter var(--settle);
}
.ptile-name { grid-column: 2; font-weight: 600; line-height: 1.3; color: var(--ink-900); transition: color var(--settle); }
.ptile-meta { grid-column: 2; font-size: 0.82rem; color: var(--ink-400); }
/* The reason arrives with the dimming rather than after it, so the tile never
   spends a moment simply looking broken. */
.ptile-why {
  grid-column: 1 / -1;
  max-height: 0;
  opacity: 0;
  overflow: hidden;
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--plum);
  transition: max-height var(--settle), opacity var(--settle), margin var(--settle);
}
/* Ruled out, not deleted. It dims, says why, and stays exactly where it was â€”
   a tile that vanished would hide the cost of the rule you just set, and the
   cost is the honest half of the answer. */
.ptile.is-out {
  opacity: 0.6;
  transform: scale(0.975);
  background: transparent;
  border-style: dashed;
}
.ptile.is-out .ptile-thumb { filter: grayscale(0.85) brightness(0.9); }
.ptile.is-out .ptile-name {
  color: var(--ink-600);
  text-decoration: line-through;
  text-decoration-color: var(--ink-400);
}
.ptile.is-out .ptile-why { max-height: 3em; opacity: 1; margin-top: 5px; }

/* The week the filter produces. Five columns rather than five rows, so it
   cannot be mistaken for the planner demo two sections up â€” that one is about
   locking and regenerating, this one is about what happens when there is not
   enough left to plan with. */
.pweek { margin-top: var(--space-xl); }
.pweek-head {
  margin: 0 0 var(--space-sm);
  font-family: var(--font-display);
  font-size: 1.15rem;
  font-weight: 600;
  color: var(--ink-900);
}
.wk {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
  gap: var(--space-xs);
}
.pnight {
  display: flex;
  flex-direction: column;
  gap: 5px;
  padding: var(--space-sm) var(--space-md);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-card);
  background: var(--surface);
  transition: border-color var(--settle), background-color var(--settle);
}
.pnight-day {
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-400);
}
.pnight-meal {
  font-weight: 600;
  line-height: 1.3;
  color: var(--ink-900);
}
/* An empty night is drawn as an empty night. Not an error state and not an
   apology â€” a dashed box with nothing in it, which is what the app shows you
   and what the note underneath is about. */
.pnight.is-empty {
  border-style: dashed;
  background: transparent;
}
.pnight.is-empty .pnight-meal {
  font-weight: 500;
  color: var(--ink-400);
}
.wk.is-fresh .pnight { animation: card-in 340ms calc(var(--i) * 55ms) var(--ease-spring) both; }

.pweek-note {
  margin: var(--space-md) 0 0;
  max-width: var(--measure);
  color: var(--ink-600);
}

.prefs-foot {
  margin: var(--space-lg) 0 0;
  max-width: var(--measure);
  font-size: 0.92rem;
  color: var(--ink-400);
}

/* Two across rather than one: a phone-width column of twelve tiles would be a
   scroll of its own, and the point of the grid is seeing several go dim at
   once. */
@media (max-width: 560px) {
  .prefs-grid { grid-template-columns: repeat(2, 1fr); gap: var(--space-xs); }
  .ptile { padding: var(--space-xs) var(--space-sm); }
  .ptile-name { font-size: 0.92rem; }
  .ptile-meta,
  .ptile-why { font-size: 0.76rem; }
  .chip { padding: 8px 14px; font-size: 0.86rem; }

  /* The week goes to rows here. Five days across a phone leaves each meal a
     column two words wide, and a plan you have to read sideways is not a
     plan. */
  .wk { grid-template-columns: 1fr; }
  .pnight {
    flex-direction: row;
    align-items: baseline;
    gap: var(--space-sm);
    padding: var(--space-xs) var(--space-sm);
  }
  .pnight-day { min-width: 2.4em; }
  .pnight-meal { font-size: 0.94rem; }
}

@media (prefers-reduced-motion: reduce) {
  .ptile,
  .chip,
  .pnight { transition-duration: 1ms; }
}

/* ----------------------------------------------------------------- credit */

.credit-inner {
  max-width: 46rem;
  margin: 0 auto;
  text-align: center;
  background: var(--surface);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-sheet);
  padding: var(--space-2xl) var(--space-lg);
  box-shadow: var(--shadow-card);
}
.credit-inner p { color: var(--ink-600); margin-inline: auto; max-width: 36rem; }
.credit-sub { font-size: 0.92rem; color: var(--ink-400) !important; margin-bottom: 0; }
.credit-mark {
  display: grid;
  place-items: center;
  width: 52px;
  height: 52px;
  margin: 0 auto var(--space-md);
  border-radius: 50%;
  background: var(--orange-tint);
  color: var(--orange-deep);
}
.credit-mark svg { width: 26px; height: 26px; }

/* It beats twice as the panel lands, and then stops. This section is the one
   ethical promise on the page â€” the person who filmed it keeps their name on
   it â€” and a heart that never moves is a sticker. Twice, not forever: a pulse
   that never ends stops being a gesture and becomes wallpaper. */
.js .credit-inner.is-in .credit-mark { animation: beat 1.2s var(--ease-out) 320ms both; }
@keyframes beat {
  0% { transform: scale(1); }
  12% { transform: scale(1.15); }
  24% { transform: scale(1); }
  36% { transform: scale(1.1); }
  50%, 100% { transform: scale(1); }
}

/* ------------------------------------------------------------------ trust */

.trust-grid {
  display: grid;
  gap: var(--space-md);
  margin-top: var(--space-xl);
}
.trust-item {
  position: relative;
  padding: var(--space-lg);
  border-radius: var(--radius-card);
  border: 1px solid var(--ink-200);
  background: var(--surface);
  box-shadow: var(--shadow-card);
  transition: transform var(--settle), box-shadow var(--settle), border-color var(--settle);
}
.trust-item:hover {
  transform: translateY(-3px);
  box-shadow: var(--shadow-lift);
  border-color: color-mix(in srgb, var(--orange) 40%, var(--ink-200));
}
.trust-item h3 { margin-bottom: 6px; }

/* Each promise draws its own rail as it lands, top to bottom, in the order you
   read them. A rail already means one thing on this page â€” the open question in
   the FAQ has one, and so does the panel that tells you you're on the list â€” so
   these four read as settled rather than decorated.
   Drawn, not faded: it is the direction that makes it read as a mark being made
   rather than a border that was always there and merely appeared. */
.trust-item::after {
  content: '';
  position: absolute;
  left: 0;
  top: 14px;
  bottom: 14px;
  width: 3px;
  border-radius: 0 3px 3px 0;
  background: var(--orange);
  transform-origin: top;
  transition: transform var(--flow);
}
/* Full height by default, so a page whose script never ran shows four finished
   rails instead of four invisible ones. */
.js .trust-item::after { transform: scaleY(0); }
.js .trust-item.is-in::after {
  transform: scaleY(1);
  transition-delay: calc(var(--d, 0) * 90ms + 220ms);
}
.trust-item p { margin: 0; color: var(--ink-600); font-size: 0.95rem; }

/*
 * Four small proofs.
 *
 * This section was four paragraphs asking to be believed, sitting directly
 * above the price. Each of the four claims is the kind you can only really
 * settle by watching it: reception dies and the recipe still opens, the tiles
 * come back, the counters land on nothing, the correction is still there the
 * second time. So each one is drawn and each one runs once, as its card lands.
 *
 * They run once and stop. A loop in a text section is not a demonstration, it
 * is a thing flickering next to something you are trying to read.
 *
 * Everything is timed off --t0, which trails the card's own entrance and
 * inherits the grid's stagger, so the four never perform on top of each other.
 */
.tz {
  --t0: calc(var(--d, 0) * 90ms + 460ms);
  margin-top: var(--space-md);
  padding: var(--space-sm) var(--space-md);
  border: 1px solid var(--ink-200);
  border-radius: 12px;
  /* Mixed from the ink rather than named, so it stays a shade inset from the
     card in both schemes instead of inverting into a light panel at night. */
  background: color-mix(in srgb, var(--ink-900) 4%, var(--surface));
  font-size: 0.82rem;
  line-height: 1.25;
  color: var(--ink-600);
}
@keyframes tz-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: none; }
}

/* ---- no signal. The bars go out; the line underneath does not move at all. */

.tz-signal { display: grid; gap: 10px; }
.tz-head { display: flex; align-items: center; gap: var(--space-xs); }
.tz-bars { display: flex; align-items: flex-end; gap: 2px; height: 13px; }
.tz-bars i {
  width: 3px;
  height: calc(4px + var(--b) * 3px);
  border-radius: 1px;
  background: var(--ink-900);
  opacity: 0.72;
}
/* Tallest first. Reception drops from the top bar down, and a phone that lost
   its shortest bar first would be a phone nobody has ever owned. */
.js .trust-item.is-in .tz-bars i {
  animation: tz-bar-out 240ms var(--ease-out) both;
  animation-delay: calc(var(--t0) + (3 - var(--b)) * 170ms);
}
@keyframes tz-bar-out {
  to { opacity: 0.15; transform: scaleY(0.3); transform-origin: bottom; }
}
.tz-net { color: var(--ink-400); font-weight: 600; }
.js .trust-item.is-in .tz-net {
  animation: tz-in 300ms var(--ease-out) both;
  animation-delay: calc(var(--t0) + 620ms);
}
.tz-line {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  padding: 7px 10px;
  border: 1px solid var(--ink-200);
  border-radius: 9px;
  background: var(--surface);
  color: var(--ink-900);
  font-weight: 500;
}
.tz-line em { margin-left: auto; font-style: normal; font-weight: 600; color: var(--orange-deep); }
.tz-tick { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--success); }
/* One pulse, at the exact moment the last bar dies. Staying put is the claim,
   and a thing that stays put by doing nothing is indistinguishable from a
   picture â€” this is the smallest mark that says it is still live. */
.js .trust-item.is-in .tz-line {
  animation: tz-hold 1000ms var(--ease-out) both;
  animation-delay: calc(var(--t0) + 560ms);
}
@keyframes tz-hold {
  0% { box-shadow: 0 0 0 0 transparent; }
  24% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--orange) 24%, transparent); }
  100% { box-shadow: 0 0 0 0 transparent; }
}

/* ---- nothing gets lost. Twelve tiles, and the same twelve on the other side. */

.tz-restore {
  display: grid;
  /* Sized to the contents and centred, rather than three equal columns. On
     fractions the two slabs drift to the outside edges of the card and the
     line between them reads as a gap they are falling into. */
  grid-template-columns: auto auto auto;
  justify-content: center;
  align-items: center;
  gap: var(--space-sm);
  padding-block: 2px;
}
.tz-dev { display: grid; justify-items: center; gap: 7px; }
.tz-dev b { font-size: 0.72rem; font-weight: 600; color: var(--ink-400); }
/* Framed, so each side reads as a device holding a library rather than as a
   loose block of squares. */
.tz-tiles {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 3px;
  width: 84px;
  padding: 5px;
  border: 1px solid var(--ink-200);
  border-radius: 8px;
  background: var(--surface);
}
.tz-tiles i { aspect-ratio: 1; border-radius: 3px; background: var(--ink-200); }
.tz-tiles.is-full i { background: color-mix(in srgb, var(--orange) 62%, transparent); }
.js .trust-item.is-in .tz-tiles:not(.is-full) i {
  animation: tz-fill 300ms var(--ease-out) both;
  animation-delay: calc(var(--t0) + 300ms + var(--n) * 62ms);
}
@keyframes tz-fill {
  from { background: var(--ink-200); transform: scale(0.5); }
  to { background: color-mix(in srgb, var(--orange) 62%, transparent); transform: none; }
}
.tz-flow {
  position: relative;
  width: 46px;
  height: 2px;
  border-radius: 2px;
  background: var(--ink-200);
  overflow: hidden;
}
.tz-flow i {
  position: absolute;
  inset: 0 auto 0 0;
  width: 42%;
  border-radius: 2px;
  background: var(--orange);
}
.js .trust-item .tz-flow i { transform: translateX(-120%); }
.js .trust-item.is-in .tz-flow i {
  animation: tz-travel 780ms var(--ease-out) 3 both;
  animation-delay: calc(var(--t0) + 120ms);
}
@keyframes tz-travel {
  from { transform: translateX(-120%); }
  to { transform: translateX(260%); }
}

/* ---- no tracking. Three counts, and the number is what arrives last. */

.tz-zero { display: grid; gap: 6px; }
.tz-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); }
.tz-row b {
  min-width: 26px;
  padding: 2px 0;
  border-radius: 6px;
  background: var(--orange-tint);
  color: var(--orange-deep);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  text-align: center;
}
.js .trust-item.is-in .tz-row {
  animation: tz-in 300ms var(--ease-out) both;
  animation-delay: calc(var(--t0) + var(--n) * 130ms);
}
.js .trust-item.is-in .tz-row b {
  animation: tz-land 420ms var(--ease-spring) both;
  animation-delay: calc(var(--t0) + var(--n) * 130ms + 170ms);
}
@keyframes tz-land {
  from { opacity: 0; transform: scale(0.4); }
  to { opacity: 1; transform: none; }
}

/* ---- corrections stick. It moves once, and it is still there the next time. */

.tz-aisle { position: relative; display: grid; gap: var(--space-xs); }
.tz-aisle-row { display: flex; align-items: center; gap: 10px; }
.tz-aisle-row b {
  min-width: 44px;
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-400);
}
.tz-slot {
  flex: 1;
  height: 26px;
  border: 1px dashed var(--ink-200);
  border-radius: 8px;
  transition: border-color var(--snap), background-color var(--snap);
}
/* The slot answers a chip held over it. Without this the drag is a guess about
   where letting go will put things. */
.tz-aisle.is-over [data-drop] {
  border-color: var(--orange);
  border-style: solid;
  background: var(--orange-tint);
}
.tz-chip {
  /* Out of the flow so it can cross a row boundary. Offsets are measured from
     the padding box, which is why the padding token appears in them. */
  position: absolute;
  top: var(--space-sm);
  left: calc(var(--space-md) + 54px);
  display: inline-flex;
  align-items: center;
  height: 26px;
  padding: 0 10px;
  border: 0;
  border-radius: 8px;
  background: var(--orange);
  color: #1d1206;
  font: inherit;
  font-size: 0.78rem;
  font-weight: 600;
  white-space: nowrap;
  /* It is a button now, and the cursor is the only thing on the card that can
     say so before it is touched. */
  cursor: grab;
  /* `none` would hand us every touch gesture that starts here, including the
     ones meant for the page — and this chip sits in the middle of a card, so it
     became a 110x26px patch you could not scroll off. `pan-y` gives scrolling
     back to the browser. A touch drag is then cancelled the moment it becomes a
     scroll, which `pointercancel` already resets cleanly, and tapping the chip
     files it exactly the same way — so nothing is lost on a phone, where
     dragging a 26px target was never the gesture anybody reached for. */
  touch-action: pan-y;
  z-index: 2;
}
/* Two short rules, the way a handle is drawn on anything meant to be picked up.
   They go once it has been put down. */
.tz-chip::after {
  content: '';
  width: 9px;
  height: 7px;
  margin-left: 8px;
  border-top: 1.5px solid rgb(29 18 6 / 0.45);
  border-bottom: 1.5px solid rgb(29 18 6 / 0.45);
  transition: opacity var(--snap);
}
.tz-chip:hover { filter: brightness(1.06); }
.tz-chip:focus-visible { outline: 2px solid var(--ink-900); outline-offset: 2px; }

/* Under the hand. Lifted off the card and given a shadow, so it reads as being
   carried rather than sliding along the surface. */
.js .trust-item .tz-aisle.is-held .tz-chip {
  cursor: grabbing;
  box-shadow: 0 8px 18px rgb(20 18 16 / 0.28);
  transform: translateY(var(--drag, 0px)) scale(1.04);
}
/* The hand overrules the demonstration. Whichever gets there first is the one
   that happens, and both end in the same place. */
/* Four classes deep on purpose. The card's own rule that drives the
   demonstration is three, and it carries a filled transform that would sit on
   top of anything lighter for the whole two seconds it is waiting to start. */
.js .trust-item .tz-aisle.is-held .tz-chip,
.js .trust-item .tz-aisle.is-done .tz-chip { animation: none; }
/* Filed, by whichever road. The handle and the grab cursor were an invitation,
   and there is nothing left to accept. */
.js .trust-item .tz-aisle.is-filed .tz-chip { cursor: default; }
.js .trust-item .tz-aisle.is-filed .tz-chip::after { opacity: 0; }

.js .trust-item .tz-aisle.is-done .tz-chip {
  transform: translateY(34px);
  transition: transform var(--settle), box-shadow var(--settle);
}
/* 26px row plus the 8px gap. It lands in oils and the animation keeps it there
   â€” the point of the card is the frame after the movement, not the movement. */
/* Two seconds rather than half of one, which is the window the reader gets to
   do it themselves. It is spent reading the paragraph above, so nothing waits
   on it, and a reader who never touches the card sees exactly what they saw
   before, one beat later. */
.js .trust-item.is-in .tz-chip {
  animation: tz-move 620ms var(--ease-spring) both;
  animation-delay: calc(var(--t0) + 2200ms);
}
@keyframes tz-move {
  from { transform: none; }
  to { transform: translateY(34px); }
}
.tz-kept {
  position: absolute;
  top: calc(var(--space-sm) + 40px);
  right: calc(var(--space-md) + 10px);
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--success);
}
/* Arrives well after the chip has settled. Said at the same moment as the move
   it would read as a caption on the move; said later it reads as the next shop
   finding it already there, which is the claim. */
.js .trust-item.is-in .tz-kept {
  animation: tz-in 320ms var(--ease-out) both;
  animation-delay: calc(var(--t0) + 3020ms);
}
/* Moved by hand, so it is said off the hand's timing rather than the card's. */
.js .trust-item .tz-aisle.is-held .tz-kept { animation: none; opacity: 0; }
.js .trust-item .tz-aisle.is-done .tz-kept {
  animation: tz-in 320ms 620ms var(--ease-out) both;
}

/* --------------------------------------------------------------- compare */

/* A real table, kept as one. The row header is the step of the week, the two
   columns are the two ways it can go, and every cell is genuinely the crossing
   of the two - which is what makes it readable out loud as well as on screen.

   Table cells cannot be flex or grid containers without stopping being cells
   and taking the column alignment down with them, so the mark hangs off the
   text with a negative indent instead. Sixteen pixels of mark plus ten of gap
   is the twenty-six that comes back out of the padding. */

.cmp-wrap { margin-top: var(--space-xl); }

.cmp {
  width: 100%;
  border-collapse: separate;
  border-spacing: 0;
  text-align: left;
}

.cmp thead th {
  padding: 14px var(--space-md) 12px;
  vertical-align: bottom;
  color: var(--ink-400);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.cmp thead .cmp-head-us {
  color: var(--orange-deep);
  border-radius: 16px 16px 0 0;
}

.cmp tbody th[scope='row'] {
  width: 26%;
  padding: 16px var(--space-lg) 16px 0;
  border-top: 1px solid var(--ink-200);
  color: var(--ink-900);
  font-size: 0.95rem;
  font-weight: 650;
  line-height: 1.35;
}

.cmp tbody td {
  width: 37%;
  padding: 16px var(--space-md) 16px calc(var(--space-md) + 26px);
  border-top: 1px solid var(--ink-200);
  text-indent: -26px;
  font-size: 0.95rem;
  line-height: 1.45;
}
.cmp-them { color: var(--ink-600); }
.cmp-ours { color: var(--ink-900); }

/* The tint runs the height of the column rather than sitting behind each cell
   separately, which is what a column of cells with the same background does. */
.cmp-head-us,
.cmp-ours { background: var(--orange-tint); }
.cmp tbody .cmp-ours { border-top-color: rgb(253 118 5 / 0.18); }
.cmp tbody tr:first-child .cmp-ours { border-top-color: transparent; }
.cmp tbody tr:last-child .cmp-ours { border-radius: 0 0 16px 16px; }

.cmp-mark,
.cmp-tick {
  display: inline-block;
  flex: none;
  width: 16px;
  height: 16px;
  margin-right: 10px;
  vertical-align: -3px;
  text-indent: 0;
}

/* Not a cross. A cross is an accusation, and the left-hand column is describing
   what people put up with rather than something anyone did wrong. A ring with a
   bar through it is the quieter version of the same word. */
.cmp-mark {
  position: relative;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1.5px var(--ink-200);
}
.cmp-mark::before {
  content: '';
  position: absolute;
  left: 4px;
  right: 4px;
  top: 50%;
  height: 1.5px;
  margin-top: -0.75px;
  border-radius: 1px;
  background: var(--ink-200);
}

.cmp-tick circle { fill: var(--success); }
.cmp-tick path {
  fill: none;
  stroke: #ffffff;
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 15;
}

/* Row by row down the column, because the argument is a list and a list read
   all at once is a wall. The disc lands, then the stroke is drawn through it a
   beat later, so each row is two small events rather than one appearing thing.

   `--r` sits on the tr and reaches the cells by inheritance. Without a script
   none of this runs and every tick is simply drawn. */
.js .cmp-wrap.is-in tbody th,
.js .cmp-wrap.is-in tbody td span,
.js .cmp-wrap.is-in .cmp-mark {
  animation: cmp-say 420ms var(--ease-out) both;
  animation-delay: calc(var(--r, 0) * 110ms + 120ms);
}
.js .cmp-wrap.is-in .cmp-tick {
  animation: cmp-land 460ms var(--ease-spring) both;
  animation-delay: calc(var(--r, 0) * 110ms + 220ms);
}
.js .cmp-wrap.is-in .cmp-tick path {
  animation: cmp-draw 320ms var(--ease-out) both;
  animation-delay: calc(var(--r, 0) * 110ms + 380ms);
}

@keyframes cmp-say {
  from { opacity: 0; }
  to { opacity: 1; }
}
@keyframes cmp-land {
  from { opacity: 0; transform: scale(0.4); }
  to { opacity: 1; transform: scale(1); }
}
@keyframes cmp-draw {
  from { stroke-dashoffset: 15; }
  to { stroke-dashoffset: 0; }
}

/* On a phone a six by three table is a horizontal scroll, and a comparison you
   have to swipe sideways to finish is not a comparison. Each row becomes a
   card: the step at the top, then the two answers under it, labelled - because
   the column headings they were relying on are no longer above them. */
@media (max-width: 46rem) {
  .cmp thead { display: none; }
  .cmp,
  .cmp tbody,
  .cmp tr,
  .cmp th,
  .cmp td {
    display: block;
    width: auto;
  }

  .cmp tbody tr {
    margin-top: var(--space-md);
    border: 1px solid var(--ink-200);
    border-radius: var(--radius-card);
    background: var(--surface);
    overflow: hidden;
  }
  .cmp tbody tr:first-child { margin-top: 0; }

  .cmp tbody th[scope='row'] {
    width: auto;
    padding: 14px var(--space-md) 12px;
    border-top: 0;
    font-size: 1rem;
  }

  .cmp tbody td {
    width: auto;
    padding: 12px var(--space-md) 12px calc(var(--space-md) + 26px);
    border-top: 1px solid var(--ink-200);
  }
  .cmp tbody td::before {
    display: block;
    margin: 0 0 4px -26px;
    color: var(--ink-400);
    font-size: 0.68rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    text-indent: 0;
  }
  .cmp tbody .cmp-them::before { content: 'Most recipe apps'; }
  .cmp tbody .cmp-ours::before { content: 'FasterChef'; color: var(--orange-deep); }

  .cmp tbody tr:first-child .cmp-ours,
  .cmp tbody .cmp-ours { border-top-color: rgb(253 118 5 / 0.18); }
  .cmp tbody tr:last-child .cmp-ours { border-radius: 0; }
}

/* ---------------------------------------------------------------- pricing */

.tiers {
  display: grid;
  gap: var(--space-md);
  margin-top: var(--space-xl);
}
.tier {
  position: relative;
  background: var(--surface);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-card);
  padding: var(--space-lg);
  box-shadow: var(--shadow-card);
  transition: transform var(--settle), box-shadow var(--settle), border-color var(--settle);
}
.tier:hover {
  transform: perspective(900px) translateY(-4px)
    rotateX(var(--tilt-x, 0deg)) rotateY(var(--tilt-y, 0deg));
  box-shadow: var(--shadow-lift);
  border-color: color-mix(in srgb, var(--orange) 45%, var(--ink-200));
}
.tier.is-featured:hover { box-shadow: 0 20px 44px rgb(253 118 5 / 0.24); }
.tier ul {
  list-style: none;
  margin: var(--space-md) 0 0;
  padding: 0;
  display: grid;
  gap: 9px;
  font-size: 0.95rem;
  color: var(--ink-600);
}
.tier li { padding-left: 24px; position: relative; }

/*
 * The allowance, drawn.
 *
 * Five, sixty and unlimited are three numbers in a column, and a reader
 * comparing three columns of numbers is doing arithmetic rather than choosing.
 * A length is read without being read. The track stands for a month, so the
 * free tier's stub is honestly a stub, and the unlimited one does not stop at
 * the end of the track â€” it fades off it, because a full bar would say "the
 * most there is" when the claim is "there is no most".
 *
 * It grows as the tier lands, left to right, in the order the tiers arrive.
 */
.imports {
  display: block;
  height: 5px;
  margin-top: 8px;
  border-radius: 3px;
  background: color-mix(in srgb, var(--ink-900) 8%, transparent);
  overflow: hidden;
}
.imports::before {
  content: '';
  display: block;
  width: var(--fill, 100%);
  height: 100%;
  border-radius: 3px;
  background: var(--orange);
  transform-origin: left;
  transition: transform var(--flow);
}
.imports.is-endless::before {
  width: 100%;
  background: linear-gradient(90deg, var(--orange) 58%, transparent);
}
/* Full by default, so a page whose script never ran shows three finished bars
   rather than three empty tracks. */
.js .imports::before { transform: scaleX(0); }
.js .tier.is-in .imports::before {
  transform: none;
  transition-delay: calc(var(--d, 0) * 90ms + 400ms);
}
.tier li::before {
  content: '';
  position: absolute;
  left: 3px;
  top: 8px;
  width: 5px;
  height: 9px;
  border: solid var(--ink-400);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}
/* The tick arrives before the line it belongs to, and the lines arrive in
   order. A plan is a list of things you get, so it fills in like one being
   read out â€” and the delay picks up where the panel's own reveal left off, so
   the three columns never overlap each other. */
.js .tier li {
  opacity: 0;
  transform: translateX(-8px);
  transition: opacity var(--settle), transform var(--settle);
}
.js .tier li::before { transition: transform var(--snap); transform: rotate(45deg) scale(0.3); }
.js .tier.is-in li { opacity: 1; transform: none; }
.js .tier.is-in li::before { transform: rotate(45deg) scale(1); }
.js .tier.is-in li:nth-child(1) { transition-delay: calc(var(--d, 0) * 80ms + 260ms); }
.js .tier.is-in li:nth-child(2) { transition-delay: calc(var(--d, 0) * 80ms + 330ms); }
.js .tier.is-in li:nth-child(3) { transition-delay: calc(var(--d, 0) * 80ms + 400ms); }
.js .tier.is-in li:nth-child(4) { transition-delay: calc(var(--d, 0) * 80ms + 470ms); }
/* A fifth line arrived with the recipe generator. Without its own delay it
   would inherit none and land first, ahead of the four above it. */
.js .tier.is-in li:nth-child(5) { transition-delay: calc(var(--d, 0) * 80ms + 540ms); }
.js .tier.is-in li:nth-child(1)::before { transition-delay: calc(var(--d, 0) * 80ms + 300ms); }
.js .tier.is-in li:nth-child(2)::before { transition-delay: calc(var(--d, 0) * 80ms + 370ms); }
.js .tier.is-in li:nth-child(3)::before { transition-delay: calc(var(--d, 0) * 80ms + 440ms); }
.js .tier.is-in li:nth-child(4)::before { transition-delay: calc(var(--d, 0) * 80ms + 510ms); }
.js .tier.is-in li:nth-child(5)::before { transition-delay: calc(var(--d, 0) * 80ms + 580ms); }

.tier strong { color: var(--ink-900); }
/* Monthly or yearly.
   Off unless a script is running, because a switch that does not switch is
   worse than the sentence it replaced. */
.plan-switch { display: none; }
.js .plan-switch {
  position: relative;
  display: inline-flex;
  margin-top: var(--space-lg);
  padding: 4px;
  border: 1px solid var(--ink-200);
  border-radius: 999px;
  background: var(--surface);
  box-shadow: var(--shadow-card);
}
.plan-switch button {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 9px var(--space-md);
  border: 0;
  border-radius: 999px;
  background: none;
  color: var(--ink-400);
  font: inherit;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  transition: color var(--snap), transform var(--snap);
}
.plan-switch button:hover { color: var(--ink-900); }
.plan-switch button[aria-pressed='true'] { color: var(--orange-on-tint); }
.plan-switch button:active { transform: scale(0.97); transition-duration: var(--press); }

/* One tinted pill that slides between the two, rather than two backgrounds
   fading in and out. The travel is the whole point: it says these are two
   settings of one control, not two separate buttons. Measured by the script,
   because the labels are words and words are not a fixed width. */
.plan-slide {
  position: absolute;
  top: 4px;
  bottom: 4px;
  left: 4px;
  width: var(--slide-w, 0);
  border-radius: 999px;
  background: var(--orange-tint);
  box-shadow: inset 0 0 0 1px rgb(253 118 5 / 0.3);
  transform: translateX(var(--slide-x, 0));
  transition: transform var(--settle), width var(--settle);
}
/* Nothing to slide to until the script has measured it. */
.plan-slide:not([style]) { opacity: 0; }

.plan-off {
  padding: 2px 7px;
  border-radius: 999px;
  background: var(--success);
  color: var(--on-success);
  font-size: 0.7rem;
  font-weight: 700;
  line-height: 1.5;
}

.tier-price { margin: 0; font-size: 0.85rem; color: var(--ink-400); }
.tier-price b {
  display: block;
  font-family: var(--font-display);
  font-size: 2.1rem;
  font-weight: 700;
  color: var(--ink-900);
  line-height: 1.1;
}
/* The price rolls rather than being replaced.
   Each digit is a strip of ten stacked numerals inside a window one numeral
   tall, and changing the price slides the strip. Tabular figures so a 1 takes
   the same room as a 9 and the row does not shuffle sideways mid-roll; the
   digits that did not change do not move at all, because the script lines the
   two amounts up from the right before it starts. */
.tier-price b.roll {
  display: flex;
  font-variant-numeric: tabular-nums;
}
.roll-col {
  display: block;
  height: 1.1em;
  overflow: hidden;
}
.roll-strip {
  display: block;
  transition: transform var(--flow);
}
.roll-strip span {
  display: block;
  height: 1.1em;
}
.roll-flat { display: block; }

/* The line under the price is prose, not digits, so it changes the way prose
   does: out, swapped, back. */
.tier-price [data-per] {
  display: block;
  transition: opacity var(--snap);
}
.tier.is-turning .tier-price [data-per] { opacity: 0; }

.tier.is-featured {
  border-color: var(--orange);
  box-shadow: 0 14px 36px rgb(253 118 5 / 0.16);
}
.tier.is-featured li::before { border-color: var(--orange-deep); }
.tier-flag {
  position: absolute;
  top: -12px;
  left: var(--space-lg);
  margin: 0;
  padding: 4px var(--space-sm);
  border-radius: 999px;
  background: var(--orange);
  color: #fff;
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.04em;
}
/* The flag lands after its panel has settled, not with it. Arriving late is
   what makes it read as a label pinned onto the card rather than part of it. */
.js .tier.is-in .tier-flag { animation: flag-pop 460ms 380ms var(--ease-spring) both; }
@keyframes flag-pop {
  from { opacity: 0; transform: translateY(6px) scale(0.86); }
  to { opacity: 1; transform: none; }
}

.pricing-note {
  margin-top: var(--space-lg);
  font-size: 0.88rem;
  color: var(--ink-400);
}

/* ----------------------------------------------------------------- limits */

.limits-grid { display: grid; gap: var(--space-2xl); }

.limits-inner {
  max-width: 46rem;
  border-left: 3px solid var(--orange);
  padding: var(--space-xs) 0 var(--space-xs) var(--space-lg);
}
.limits-inner p { color: var(--ink-600); }
.limits-inner p:last-child { margin-bottom: 0; }

.limits-hint { display: none; }
.js .limits-hint { display: block; font-size: 0.85rem; color: var(--ink-400); }

.limits-art { display: flex; justify-content: center; }
.limits-art .phone { --phone-w: min(320px, 82vw); }
/* It lands in the library, so that is the tab that is lit. */
.limits-art .pu-tab:nth-child(1) { color: var(--orange-deep); }

.pst-screen {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  padding: calc(var(--phone-w) * 0.136) 0 calc(var(--phone-w) * 0.196);
  background: var(--surface);
}

.pst-nav {
  display: flex;
  align-items: center;
  gap: 2px;
  margin: 0 0 9px;
  padding: 0 12px;
  font-size: 0.8rem;
  font-weight: 700;
  color: var(--ink-900);
}
.pst-nav svg { flex: none; width: 15px; height: 15px; margin-left: -5px; color: var(--orange-deep); }

/* The post that failed, kept on screen for the whole flow. Take it away and
   the paste box is a box with no reason to exist. */
.pst-post {
  display: flex;
  gap: 9px;
  margin: 0 12px 9px;
  padding-bottom: 9px;
  border-bottom: 1px solid color-mix(in srgb, var(--ink-900) 8%, transparent);
}
.pst-thumb {
  flex: none;
  display: grid;
  place-items: center;
  width: 42px;
  height: 56px;
  border-radius: 7px;
}
.pst-thumb .reel-play { width: 19px; height: 19px; }
.pst-thumb .reel-play::after {
  left: 7px;
  top: 5px;
  border-left-width: 6px;
  border-top-width: 4px;
  border-bottom-width: 4px;
}
.pst-post-body { display: block; min-width: 0; }
.pst-handle { display: block; margin-bottom: 3px; font-size: 0.68rem; font-weight: 700; color: var(--ink-900); }
.pst-caption { display: block; font-size: 0.63rem; line-height: 1.45; color: var(--ink-400); }

/* What went wrong and what to do about it, in that order, which is the house
   rule for an error. Tinted rather than red: nothing has broken, the post is
   simply not the kind we can read. */
.pst-flag {
  display: flex;
  gap: 7px;
  margin: 0 12px 8px;
  padding: 8px 9px;
  border-radius: 10px;
  background: var(--orange-tint);
  font-size: 0.65rem;
  line-height: 1.45;
  color: var(--ink-600);
}
.pst-flag svg { flex: none; width: 14px; height: 14px; margin-top: 1px; color: var(--orange-deep); }
.pst-flag b { color: var(--ink-900); }

/* Dashed while it is waiting for something and solid once it holds it â€” the
   border is the only part of an empty box that can say which of the two it is. */
/* It takes the whole of the screen it is not using for anything else, because
   that is what a field you are about to paste a wall of text into looks like.
   The result below it is flex: none, so the box gives up exactly the room the
   result needs and no more. */
.pst-box {
  position: relative;
  flex: 1;
  min-height: 64px;
  display: flex;
  overflow: hidden;
  margin: 0 12px;
  border: 1.5px dashed color-mix(in srgb, var(--ink-900) 22%, transparent);
  border-radius: 10px;
  transition: border-color var(--settle), background-color var(--settle);
}
.pst-box.is-full {
  border-style: solid;
  border-color: color-mix(in srgb, var(--ink-900) 11%, transparent);
  background: color-mix(in srgb, var(--ink-900) 4%, transparent);
}
.pst-inner {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 9px 10px;
  scrollbar-width: none;
  scroll-behavior: smooth;
}
.pst-inner::-webkit-scrollbar { display: none; }
.pst-ph { display: block; font-size: 0.68rem; color: var(--ink-400); }
.pst-text { display: block; font-size: 0.63rem; line-height: 1.6; color: var(--ink-600); }
/* Falls in from above, because that is the direction a paste comes from. */
.js .pst-text { animation: pst-drop 340ms var(--ease-spring) both; }
@keyframes pst-drop {
  from { opacity: 0; transform: translateY(-9px); }
  to { opacity: 1; transform: none; }
}

/* The same highlighter as the caption scene, on purpose: it is the same
   operation on the same kind of text, and only the source has changed. */
.pst-text mark {
  border-radius: 3px;
  padding: 1px 2px;
  margin: 0 -2px;
  color: var(--ink-600);
  background:
    linear-gradient(
        color-mix(in srgb, var(--orange) 30%, transparent),
        color-mix(in srgb, var(--orange) 30%, transparent)
      )
      0 0 / 0 100% no-repeat;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}
.pst-box.is-reading .pst-text mark {
  animation: cap-mark 280ms calc(var(--i) * 110ms) var(--ease-out) both;
}

.pst-out { margin: 10px 12px 0; }
.pst-out > * { animation: card-in 320ms var(--ease-spring) both; }
.pst-out > *:nth-child(2) { animation-delay: 70ms; }
.pst-out > *:nth-child(3) { animation-delay: 140ms; }
.pst-out-head {
  display: flex;
  align-items: center;
  gap: 5px;
  margin: 0 0 7px;
  font-size: 0.68rem;
  font-weight: 700;
  color: var(--success);
}
.pst-out-head svg { flex: none; width: 13px; height: 13px; }
.pst-row {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 8px 9px;
  border-radius: 10px;
  background: color-mix(in srgb, var(--ink-900) 4%, transparent);
}
/* The same row the library section draws, because that is where it went. */
.pst-row-thumb {
  flex: none;
  width: 38px;
  height: 38px;
  border-radius: 8px;
  background: linear-gradient(145deg, var(--orange), var(--orange-deep));
  filter: hue-rotate(-14deg) saturate(0.6) brightness(0.97);
}
.pst-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.pst-row-title { font-size: 0.84rem; font-weight: 600; line-height: 1.25; color: var(--ink-900); }
.pst-row-meta { font-size: 0.66rem; line-height: 1.3; color: var(--ink-400); }
.pst-out-note { margin: 7px 0 0; font-size: 0.62rem; line-height: 1.4; color: var(--ink-400); }

/* Pinned to the foot of the screen above the tab bar, where iOS puts the thing
   you are meant to press next. */
.pst-actions { display: none; }
.js .pst-actions {
  display: flex;
  flex-direction: column;
  gap: 3px;
  margin-top: auto;
  padding: 10px 12px 0;
}
.pst-btn {
  border: 0;
  border-radius: 11px;
  padding: 10px 12px;
  background: var(--orange);
  font: inherit;
  font-size: 0.78rem;
  font-weight: 700;
  color: #fff;
  cursor: pointer;
  transition: transform var(--press), opacity var(--snap);
}
.pst-btn:active { transform: scale(0.975); }
.pst-btn[disabled] { cursor: default; opacity: 0.72; }
/* One ring when the work moves to this button, so a label that changed under
   your finger does not change unannounced. */
.pst-btn.is-next { animation: pst-next 560ms var(--ease-out); }
@keyframes pst-next {
  from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--orange) 50%, transparent); }
  to { box-shadow: 0 0 0 13px color-mix(in srgb, var(--orange) 0%, transparent); }
}
.pst-again {
  border: 0;
  padding: 3px;
  background: none;
  font: inherit;
  font-size: 0.72rem;
  font-weight: 600;
  color: var(--ink-400);
  cursor: pointer;
  transition: color var(--snap);
}
.pst-again:hover { color: var(--ink-900); }

/* -------------------------------------------------------------------- faq */

.questions { margin-top: var(--space-xl); display: grid; gap: var(--space-xs); }

details {
  position: relative;
  overflow: hidden;
  background: var(--surface);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-card);
  padding: var(--space-md) var(--space-lg);
  transition: border-color var(--snap), background var(--snap);
}
details:hover {
  border-color: color-mix(in srgb, var(--orange) 40%, var(--ink-200));
  background: color-mix(in srgb, var(--orange) 4%, var(--surface));
}
/* A bar that grows down the open question's edge. With six of these stacked it
   is the only thing that tells you, at a glance and from across the list,
   which one you have actually got open. */
details::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 3px;
  background: var(--orange);
  transform: scaleY(0);
  transform-origin: 50% 0;
  transition: transform var(--settle);
}
details[open]::before { transform: scaleY(1); }

summary {
  cursor: pointer;
  font-weight: 600;
  list-style: none;
  display: flex;
  justify-content: space-between;
  gap: var(--space-md);
  min-height: 28px;
  align-items: center;
  transition: transform var(--snap), color var(--snap);
}
summary::-webkit-details-marker { display: none; }
details:hover summary { transform: translateX(3px); }
/* The same nudge for the keyboard, so tabbing through the list points at the
   question you are on exactly the way the pointer does. */
summary:focus-visible { transform: translateX(3px); }
details[open] summary { color: var(--orange-deep); }
summary::after {
  content: '';
  flex: none;
  width: 11px;
  height: 11px;
  border: solid var(--orange);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform var(--settle);
}
details[open] summary::after { transform: rotate(-135deg) translate(-3px, -3px); }
details p { margin: var(--space-sm) 0 0; color: var(--ink-600); }
details[open] p { animation: strike-in 260ms both; }

/* The answer opens to its own height rather than appearing at it. app.js
   measures the panel and drives the number; the transition below is the only
   thing here that has to exist for that to look like anything.
   `.js-faq` gates it: with the script blocked, <details> is left to snap open
   on its own, which is the correct fallback and still perfectly usable. */
.answer { overflow: hidden; }
.js-faq .answer {
  height: 0;
  transition: height var(--settle);
}
/* The resting state of an open answer is its natural height, not a number JS
   left behind. app.js only writes an inline height for the length of the
   animation and then takes it off again â€” so if anything ever goes wrong
   mid-transition, the answer is still fully readable. */
.js-faq details[open] .answer { height: auto; }

/* ----------------------------------------------------------------- closer */

/* Painted from `--room` and `--room-ink`, never from `--ink-900` on `--canvas`.
   The card is a key change, not an ordinary section: in the dark scheme it goes
   a shade below the page rather than a shade above it, which no page-level
   token will give you. So it keeps a pair of its own, and every colour standing
   on it is written against that pair instead of against the page's. */
.closer {
  --room: var(--surface);

  position: relative;
  overflow: hidden;
  isolation: isolate;
  text-align: center;
  background: var(--room);
  color: rgb(var(--room-ink) / 0.94);
  margin: var(--space-2xl) auto 0;
  max-width: var(--page);
  border-radius: var(--radius-sheet);
  padding: var(--space-4xl) var(--space-lg);
}
.closer .glow {
  background:
    radial-gradient(40rem 30rem at 50% -10%, rgb(254 139 5 / 0.34), transparent 62%),
    radial-gradient(30rem 24rem at 8% 108%, rgb(217 168 198 / 0.14), transparent 66%);
}
.closer-inner { position: relative; z-index: 1; }
.closer h2 { margin-inline: auto; color: rgb(var(--room-ink)); }
.closer p { color: rgb(var(--room-ink) / 0.74); }
.closer .closer-note {
  margin: var(--space-md) 0 0;
  font-size: 0.85rem;
  color: rgb(var(--room-ink) / 0.52);
}

/* The second form, on the closing card.
   ----------------------------------------------------------------------------
   Every colour here is `--room-ink` at some opacity rather than a fixed white,
   which is the whole reason the card can change key along with the rest of the
   page: a border at 18% ink is a pale hairline on the dark card and a faint
   dark one on the lit card, and neither had to be written twice.

   The two signal colours are the exception, and they only need saying in the
   dark scheme. Green appears four times inside this card and red twice, and all
   six read from a token, so redefining the token once is the whole fix and none
   of the places using it can drift apart later. In the light scheme the card is
   a light surface like any other and the page's own signal colours already are
   the right ones. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .closer {
    --success: #4caf7d;
    --danger: #ff8a7a;
  }
}
:root[data-theme='dark'] .closer {
  --success: #4caf7d;
  --danger: #ff8a7a;
}
.closer .waitlist { margin: var(--space-xl) auto 0; text-align: left; }
.closer .field label { color: rgb(var(--room-ink) / 0.62); }
.closer .field:focus-within label { color: var(--orange); }
.closer .field input {
  border-color: rgb(var(--room-ink) / 0.18);
  background: rgb(var(--room-ink) / 0.07);
  color: rgb(var(--room-ink));
  box-shadow: none;
}
.closer .field input::placeholder { color: rgb(var(--room-ink) / 0.42); }
.closer .field input:focus {
  border-color: var(--orange);
  background: rgb(var(--room-ink) / 0.11);
}
/* Restated, because the rules above are more specific than the ones they are
   standing in for and would otherwise paint a rejected field in the colour of
   an accepted one. A field the form has just refused says so even while the
   caret is still in it â€” that is the moment it matters most. */
.closer .field input[aria-invalid='true'],
.closer .field input[aria-invalid='true']:focus { border-color: var(--danger); }
.closer .field input[aria-invalid='true']:focus { box-shadow: 0 0 0 4px rgb(255 138 122 / 0.16); }

.closer .status { color: rgb(var(--room-ink) / 0.7); }
.closer .status.is-error { color: var(--danger); }
.closer .status.is-success { color: var(--success); }
.closer .counter {
  border-color: rgb(var(--room-ink) / 0.16);
  background: rgb(var(--room-ink) / 0.07);
  color: rgb(var(--room-ink) / 0.84);
  box-shadow: none;
}
.closer .waitlist-done {
  text-align: left;
  border-color: rgb(var(--room-ink) / 0.14);
  background: rgb(var(--room-ink) / 0.06);
  box-shadow: none;
}
.closer .waitlist-done h3 { color: rgb(var(--room-ink)); }
.closer .waitlist-done ul { color: rgb(var(--room-ink) / 0.78); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .closer { --room: #0d0b0a; }
}
:root[data-theme='dark'] .closer { --room: #0d0b0a; }

/* --------------------------------------------------- blog and legal pages */

.prose {
  max-width: 42rem;
  margin: 0 auto;
  padding: var(--space-xl) var(--space-lg) var(--space-3xl);
}
.prose h1 { font-size: clamp(1.9rem, 5vw, 2.9rem); margin-bottom: var(--space-sm); }
.prose h2 { font-size: 1.55rem; margin-top: var(--space-xl); }
.prose h3 { margin-top: var(--space-lg); }

/* The page scrolls smoothly and the header is sticky and opaque, so a link to
   a section used to deliver its heading to a spot underneath the bar — you
   landed on the second paragraph of the thing you asked for. */
.prose h2,
.prose h3 { scroll-margin-top: 86px; }

/* The section you have just been sent to says so, briefly. Legal pages are
   walls of similar-looking type and the eye needs telling where it landed;
   the mark fades because it is an answer to a click, not a permanent state. */
.prose h2:target { animation: heading-land 2.4s var(--ease-out) both; }
@keyframes heading-land {
  0%, 55% { color: var(--orange-deep); }
  100% { color: var(--ink-900); }
}

/* The top of a written page. These used to open on one line of type sitting
   directly under the bar, which read as a document nobody had designed — next
   to a homepage that opens on a full act. The rule is the same orange the site
   uses everywhere to mean "this is the live thing", drawn short so it reads as
   a mark rather than as a border, and it draws itself left to right when the
   block arrives. */
/* Comparison tables in posts. The wrapper scrolls rather than the page: a
   four-column table on a 375px phone has to go somewhere, and pushing the
   whole document sideways to accommodate it is the worse answer. */
.table-scroll {
  overflow-x: auto;
  margin: var(--space-lg) 0;
  -webkit-overflow-scrolling: touch;
}
.prose table {
  border-collapse: collapse;
  width: 100%;
  min-width: 30rem;
  font-size: 0.94rem;
}
.prose th,
.prose td {
  text-align: left;
  padding: var(--space-sm) var(--space-md);
  border-bottom: 1px solid var(--ink-200);
  vertical-align: top;
}
.prose th {
  font-weight: 600;
  color: var(--ink-900);
  border-bottom-width: 2px;
  white-space: nowrap;
}
.prose td { color: var(--ink-600); }
.prose tbody tr:last-child td { border-bottom: none; }

/* Where you are, above the title. The structured data on these pages declares
   a breadcrumb trail, and declaring one a reader cannot see is both a
   guidelines violation and a small lie; this is also the only navigation a
   post has ever had, on a site whose links were all in the footer. */
.breadcrumb {
  font-size: 0.82rem;
  color: var(--ink-400);
  padding-top: var(--space-lg);
  margin-bottom: calc(var(--space-lg) * -1);
}
.breadcrumb a { color: var(--ink-600); text-decoration: none; }
.breadcrumb a:hover { color: var(--orange-deep); text-decoration: underline; }
.breadcrumb span { margin: 0 0.35em; }

.page-head { padding-top: var(--space-lg); margin-bottom: var(--space-xl); }
.page-head h1 { margin-bottom: var(--space-xs); }
.page-head .post-meta { margin-bottom: 0; }

/* The answer, first.
   Every post opens with a standalone paragraph that answers the question in
   its own title, before any narrative. Two audiences read it: somebody
   deciding in three seconds whether this page is what they meant, and an
   answer engine choosing a passage to quote. Both want the same thing, which
   is the useful part — this is not a concession to machines, it is what
   putting the conclusion first has always looked like.
   Set slightly larger than the body and marked with the brand rule so it reads
   as the summary it is rather than as a first paragraph. */
.answer-lede {
  font-size: 1.12rem;
  line-height: 1.6;
  color: var(--ink-900);
  margin-top: var(--space-md);
  margin-bottom: 0;
  padding-left: var(--space-md);
  border-left: 3px solid var(--orange);
}
.page-head .lede {
  font-size: 1.08rem;
  color: var(--ink-600);
  margin-top: var(--space-md);
  margin-bottom: 0;
}
.page-head::after {
  content: '';
  display: block;
  width: 56px;
  height: 3px;
  margin-top: var(--space-lg);
  border-radius: 2px;
  background: linear-gradient(90deg, var(--orange), var(--orange-deep));
  transform-origin: left center;
}
.js [data-reveal] .page-head::after {
  transform: scaleX(0);
  transition: transform var(--flow) 220ms;
}
.js [data-reveal].is-in .page-head::after { transform: scaleX(1); }

/* What is in here, before you start reading it.
 *
 * Not decoration. Both of these documents are things people arrive at with one
 * question — can I get my data out, what happens if I cancel — and a list of
 * the answers at the top is the difference between a policy and a wall. The
 * numbers are drawn by CSS from the list itself, so adding a section cannot
 * leave the contents numbered wrong.
 */
.toc {
  margin: 0 0 var(--space-2xl);
  padding: var(--space-md) var(--space-lg) var(--space-lg);
  background: var(--surface);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-card);
}
.toc-label {
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-400);
  margin: 0 0 var(--space-sm);
}
.toc ol {
  list-style: none;
  margin: 0;
  padding: 0;
  counter-reset: toc;
}
.toc li { margin: 0; counter-increment: toc; }
.toc a {
  display: flex;
  align-items: baseline;
  gap: 12px;
  padding: 5px 0;
  color: var(--ink-600);
  text-decoration: none;
  transition: color var(--snap), transform var(--snap);
}
/* Leading zero because these are section numbers, not a countdown, and a column
   of 01..09 stays a column when it reaches 10. */
.toc a::before {
  content: counter(toc, decimal-leading-zero);
  flex: none;
  font-size: 0.8rem;
  font-weight: 700;
  color: var(--ink-400);
  font-variant-numeric: tabular-nums;
  transition: color var(--snap);
}
.toc a:hover,
.toc a:focus-visible {
  color: var(--ink-900);
  transform: translateX(3px);
}
.toc a:hover::before,
.toc a:focus-visible::before { color: var(--orange-deep); }
.toc a:active { transform: translateX(1px); transition-duration: var(--press); }
/* The one you are actually inside. app.js keeps this in step with the scroll,
   so the contents doubles as a position — you can tell at a glance how far
   through the document you are without measuring the scrollbar. */
.toc a.is-here { color: var(--ink-900); font-weight: 600; }
.toc a.is-here::before { color: var(--orange-deep); }
.prose p, .prose li { color: var(--ink-600); }
.prose li { margin-bottom: 6px; }
.prose strong { color: var(--ink-900); }
.prose blockquote {
  margin: var(--space-lg) 0;
  padding: var(--space-md) var(--space-lg);
  background: var(--plum-tint);
  border-radius: var(--radius-card);
  color: var(--plum);
  font-family: var(--font-display);
}
.prose code {
  font-size: 0.9em;
  background: var(--ink-200);
  padding: 2px 6px;
  border-radius: 5px;
}
.prose pre {
  background: var(--ink-900);
  color: var(--canvas);
  padding: var(--space-md);
  border-radius: var(--radius-card);
  overflow-x: auto;
}
.prose pre code { background: none; padding: 0; }
.prose hr { border: none; border-top: 1px solid var(--ink-200); margin: var(--space-xl) 0; }

.post-meta {
  font-size: 0.85rem;
  color: var(--ink-400);
  margin-bottom: var(--space-lg);
}

.post-list { display: grid; gap: var(--space-md); margin-top: var(--space-lg); list-style: none; padding: 0; }
.post-list a {
  position: relative;
  display: block;
  background: var(--surface);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-card);
  padding: var(--space-lg);
  padding-right: var(--space-2xl);
  text-decoration: none;
  color: inherit;
  box-shadow: var(--shadow-card);
  transition: transform var(--settle), border-color var(--settle), box-shadow var(--settle);
}
.post-list a:hover {
  border-color: var(--orange);
  transform: translateY(-3px);
  box-shadow: var(--shadow-lift);
}
.post-list a:active { transform: translateY(-1px); transition-duration: var(--press); }
/* The card is a link and used to be the only thing on the page that did not
   say so — a bordered box you had to guess was clickable. The arrow is faint
   until you point at the card, then it commits and moves the way the click
   will take you. */
.post-list a::after {
  content: '';
  position: absolute;
  top: 50%;
  right: var(--space-lg);
  width: 9px;
  height: 9px;
  margin-top: -5px;
  border-right: 2px solid var(--ink-400);
  border-top: 2px solid var(--ink-400);
  transform: rotate(45deg);
  opacity: 0.5;
  transition: transform var(--snap), opacity var(--snap), border-color var(--snap);
}
.post-list a:hover::after,
.post-list a:focus-visible::after {
  border-color: var(--orange-deep);
  opacity: 1;
  transform: rotate(45deg) translate(3px, -3px);
}
/* Full ink, and no top margin. The card is a list item, list items in prose are
   set in --ink-600, and headings take their colour from what they sit in — so
   every post title on the index was being drawn in body grey, a shade lighter
   than the word "Blog" above it. The top margin was the one `.prose h2` gives a
   section heading, which inside a card is just a dent in the top of it. */
.post-list h2 {
  font-size: 1.3rem;
  margin-top: 0;
  margin-bottom: 6px;
  color: var(--ink-900);
}
.post-list p { color: var(--ink-600); margin: 0; }
.post-list .post-meta { margin-top: 10px; margin-bottom: 0; }

.post-cta {
  margin-top: var(--space-2xl);
  padding: var(--space-lg);
  background: var(--orange-tint);
  border-radius: var(--radius-card);
  text-align: center;
}
.post-cta p { color: var(--ink-900); }

/* ----------------------------------------------------------------- 404 */

/* A missing page is still a page somebody landed on, and this one used to be
   two sentences and a full stop — no footer, no way onward but a text link.
   The number is set large and hollow because the useful information is the
   sentence under it, not the status code. */
.notfound {
  max-width: var(--measure);
  /* Held in the middle of the space it has rather than pinned to the top, so
     the footer lands at the bottom of a short page instead of halfway down it. */
  min-height: min(70vh, 34rem);
  display: grid;
  align-content: center;
  margin: 0 auto;
  padding: var(--space-2xl) var(--space-lg) var(--space-3xl);
  text-align: center;
}
/* Pale and solid. An outlined version of this was the first attempt, and
   Fraunces has ball terminals and very thin joins — stroking it drew a dark
   ring round every thin part and filled the counters, so the number came out
   looking like a rendering fault instead of a watermark. */
.notfound h1 { font-size: clamp(1.7rem, 5vw, 2.5rem); margin-bottom: var(--space-sm); }
.notfound p { color: var(--ink-600); margin-left: auto; margin-right: auto; }
/* Under `.notfound p`, and qualified, because it is a paragraph too — one class
   loses to a class plus an element, and the number came out in body grey.

   Body font, not the display one. Fraunces draws the crossbar of a 4 as a
   hairline, and a hairline in --ink-200 at this size is not there: the number
   came out as two broken fours with a ball on top of each. The watermark has to
   be legible as a number or it is just a shape. */
.notfound .notfound-code {
  font-family: var(--font-body);
  font-size: clamp(4.5rem, 18vw, 8rem);
  line-height: 0.9;
  font-weight: 700;
  color: var(--ink-200);
  margin-bottom: var(--space-md);
  letter-spacing: -0.02em;
}
.notfound-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
  justify-content: center;
  margin-top: var(--space-xl);
}
/* Side by side, so neither is the width of the column. `.as-link` centres a
   lone button on the blog with `margin: 0 auto`; here the row does the
   centring and the buttons should only be as wide as their words. */
.notfound-actions .button-primary.as-link { max-width: none; margin: 0; }
.notfound-actions .button-secondary {
  text-decoration: none;
  display: inline-flex;
  align-items: center;
}

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

.site-footer {
  border-top: 1px solid var(--ink-200);
  padding: var(--space-xl) var(--space-lg);
  max-width: var(--page);
  margin: var(--space-3xl) auto 0;
  display: grid;
  gap: var(--space-md);
  font-size: 0.9rem;
  color: var(--ink-400);
}
.footer-brand {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  font-family: var(--font-display);
  font-weight: 700;
  color: var(--ink-900);
}
.footer-brand img { border-radius: 50%; }
.footer-links { display: flex; flex-wrap: wrap; gap: var(--space-md); }

/* The rule is drawn from the left rather than switched on, and it leaves the
   same way it arrived. A link that only changes colour on hover is easy to
   miss on a grey footer; one that draws a line under itself is not. */
.footer-links a {
  position: relative;
  color: var(--ink-600);
  text-decoration: none;
  transition: color var(--snap);
}
.footer-links a::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: -2px;
  height: 1.5px;
  border-radius: 2px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform var(--settle);
}
.footer-links a:hover,
.footer-links a:focus-visible { color: var(--orange-deep); }
.footer-links a:hover::after,
.footer-links a:focus-visible::after { transform: scaleX(1); }
.footer-links a:active { transform: translateY(1px); }
.copyright { margin: 0; }

/* -------------------------------------------------------- larger screens */

@media (min-width: 48rem) {
  .hero {
    grid-template-columns: 1.1fr 0.9fr;
    align-items: center;
    padding-block: var(--space-2xl) var(--space-4xl);
    gap: var(--space-2xl);
  }
  .cards { grid-template-columns: repeat(3, 1fr); }
  .trust-grid { grid-template-columns: repeat(2, 1fr); }
  /* Two cards, not three. Letting a pair stretch across the full 72rem measure
     turns each one into a wide, short slab with its bullet list stranded to the
     right of its own price, so the pair is capped — and capped from the left,
     not centred. Everything else in this section (heading, lede, switch, note)
     starts at the gutter, and a centred pair would be the one block that does
     not, hanging in the middle of a column it does not belong to. */
  .tiers { grid-template-columns: repeat(2, 1fr); align-items: start; max-width: 46rem; }
  /* The phone takes exactly its own width and the prose takes what is left,
     rather than both taking a share of the row â€” a fraction here would leave
     the handset either cropped or floating in a column wider than it is. */
  /* Top-aligned, not centred. The handset is half as tall again as the prose
     beside it, and centring against it would push the first sentence a third
     of the way down the page for no reason a reader could see. */
  .lib { grid-template-columns: 1fr auto; align-items: start; }
  .limits-grid { grid-template-columns: 1fr auto; align-items: start; }
  .difference .feature { grid-template-columns: 1.05fr 0.95fr; align-items: center; gap: var(--space-2xl); }
  .difference .feature.is-reversed .feature-copy { order: 2; }
  .site-footer { grid-template-columns: auto 1fr auto; align-items: center; }
  .footer-links { justify-content: center; }
}

/*
 * Short windows.
 *
 * A laptop is wide and not tall, and once the browser has taken its chrome the
 * page often has less than 780px to work with. The hero is the one section that
 * has to fit in one screen, and at that height the handset ran off the bottom
 * edge â€” which reads as a page still loading rather than as a page that
 * continues, and cuts the phone off exactly where the home indicator would have
 * proved it was a phone.
 *
 * Nothing here changes the size of the type. What gives is the air around it
 * and the distance the reader is standing from the device: 2.12 is the handset's
 * own ratio, height over width, so solving against the space left after the
 * header and the padding gives the largest phone that still lands above the
 * fold. Windows 800px and taller never reach the cap and are untouched.
 */
@media (min-width: 48rem) and (max-height: 800px) {
  .hero { padding-block: var(--space-md) var(--space-xl); }
  .hero h1 { margin-bottom: var(--space-sm); }
  .hero-art .phone { --phone-w: min(300px, 80vw, calc((100vh - 140px) / 2.12)); }
}

@media (min-width: 62rem) {
  .scrolly-inner { grid-template-columns: 0.9fr 1.1fr; gap: var(--space-3xl); align-items: start; }

  /* The pin. The stage is one viewport tall and sticks; the steps column is
     four viewports tall, which is what gives the scroll something to measure. */
  /* Pinned below the header, not behind it. The header is sticky and opaque,
     so a stage pinned at top: 0 centres the handset in a viewport whose top
     62 pixels are covered — which puts it high, and close enough to the header
     to look like it is about to run into it. Centring in the part you can
     actually see is the same rule, applied to the real box. */
  .js-scrolly .scrolly-stage {
    position: sticky;
    top: 62px;
    height: calc(100vh - 62px);
    align-items: center;
  }
  /* A real handset is 2.12 of its own widths tall, so on a short laptop the
     width has to be solved for rather than guessed at, or the pinned phone
     grows out through the top of its pin. 62px of header and 76px for the
     stopwatch and the gap above it come off first, and 34px more for the
     gesture caption and its own gap; what is left is the height the device
     actually has to fit in. */
  .js-scrolly .scrolly-stage .phone {
    --phone-w: min(300px, calc((100vh - 172px) / 2.12));
  }
  /* The 50vh tail is a derivation, not a taste. The pin releases after
     (4h + T âˆ’ 100)vh of travel; the timeline ends at the last step's bottom,
     (4h âˆ’ 50)vh, because progress is measured from the middle of the viewport.
     Setting those equal cancels h and leaves T = 50 â€” so the last scene is
     held until the exact frame the phone lets go, at any step height. */
  .js-scrolly .scrolly-steps { gap: 0; padding-bottom: 50vh; }

  /*
   * Two screens of scroll per step, and the words hold still for most of it.
   *
   * The phone on the left is the thing worth watching, and at one screen per
   * step it was possible to pass all four in four flicks of a wheel â€” which is
   * the reader deciding they have seen it when what they have seen is four
   * scenes each given a handful of frames. The scene machinery already knows
   * this: it replays a scene that was scrolled past before it finished. Rather
   * than lean on the repair, this gives the step the room it needed in the
   * first place.
   *
   * The height goes on the item and the words go in a sticky box inside it, so
   * the extra screen is scroll rather than emptiness. Read down the column and
   * the step arrives, parks itself level with the handset, stays there while
   * you keep scrolling and the screen plays itself out, and only then is pushed
   * up by the next one. Nothing on the right moves during the hold, which is
   * the point: it is the only moment on the page where the copy stands aside.
   *
   * `top` is the middle of the pinned stage, not the middle of the window â€”
   * the stage starts below the 62px header, so its centre sits half of that
   * lower. The translate takes the box up by half its own height to centre it
   * on that line; sticky is worked out before transforms, so the offset is
   * cosmetic and does not move the point at which it catches.
   */
  .js-scrolly .step { min-height: 128vh; display: block; padding: 0; }
  /* The indent moves down with the words. A sticky box is a positioned box, so
     the number now hangs off this rather than off the item â€” and left: 0 has to
     mean the left of the column, not the left of the text. */
  .js-scrolly .step-body {
    position: sticky;
    top: calc(50vh + 31px);
    padding-left: 58px;
    transform: translateY(-50%);
  }
  /* Half the box, less the height of the heading it belongs beside. It reads
     the same as it always did: the sticky box is now the number's containing
     block, and half of the words is exactly where half of the old 64vh item
     put it, because the words were centred in that item. */
  .js-scrolly .step-number { top: calc(50% - 3.2rem); }

  /* Bigger type in the pinned view. One step at a time against a full-height
     phone needs the weight to hold its half of the screen, otherwise the
     column reads as empty rather than as room. */
  .js-scrolly .step h3 { font-size: clamp(1.6rem, 2.3vw, 2.1rem); }
  .js-scrolly .step p:not(.step-detail) { font-size: 1.12rem; }
  .js-scrolly .step p.step-detail { font-size: 1rem; }
}

/* On a phone the stage pins to the top of the viewport instead of the middle,
   at about half height, and the steps scroll underneath it. */
@media (max-width: 61.99rem) {
  /* Not a grid here. A sticky grid item can only travel inside its own row,
     which in a one-column layout is its own height â€” so it would not move at
     all. In normal flow the whole section is its containing block. */
  .js-scrolly .scrolly-inner { display: block; }
  .js-scrolly .scrolly-steps { margin-top: var(--space-xl); }

  .js-scrolly .scrolly-stage {
    position: sticky;
    top: 62px;
    z-index: 2;
    padding-bottom: var(--space-md);
    /* Opaque all the way down to the stopwatch, then faded across the padding
       below it. A percentage would put the fade inside the pill, because the
       stopwatch is the last thing in the stage rather than the phone, and a step
       sliding up through it would run a sentence past the number. */
    background: linear-gradient(var(--room) calc(100% - var(--space-md)), transparent);
  }
  /* Three limits, and the tightest wins. 68vw keeps a margin either side on a
     narrow handset; 30vh keeps the whole phone â€” which is a little over two of
     its own widths tall â€” inside the top two thirds of a short one, leaving the
     step below it room to be read. Before this the phone was small enough that
     a caption inside it could only ever show a line or two at a time. */
  .js-scrolly .scrolly-stage .phone { --phone-w: min(268px, 68vw, 30vh); }
  /* The screens are drawn at 253px here rather than 300px, and everything
     inside them is set in rem â€” so the chrome shrinks with the handset and the
     content does not. Two screens are full to the edge at 300px and therefore
     overrun at 253px: the recipe loses its link back, and the shopping list
     loses the aisle that makes the point about aisles. The room comes out of
     the spacing, which is the only thing on either screen nobody is reading. */
  .js-scrolly .scene[data-scene='2'] .rc-hero { height: 16%; }
  .js-scrolly .scene[data-scene='2'] .rc-ings li { padding: 1px 0; }
  .js-scrolly .scene[data-scene='2'] .rc-steps { gap: 6px; }
  .js-scrolly .scene[data-scene='4'] .row { padding: 4px 4px; }
  .js-scrolly .scene[data-scene='4'] .aisle { margin: 8px 4px 3px; }

  .js-scrolly .step { min-height: 74vh; display: flex; flex-direction: column; justify-content: center; }
  /* The number is absolutely positioned, so when the text is centred in a
     74vh box the two come apart â€” a lone numbered dot with nothing beside it.
     Pull it down to meet the text. */
  .js-scrolly .step-number { top: calc(50% - 2.6rem); }
  /* Same idea as the desktop tail: hold the pin past the end of the timeline so
     the last scene is read rather than glimpsed. The phone pins near the top
     here rather than filling the viewport, so the number is smaller. */
  .js-scrolly .scrolly-steps { padding-bottom: 26vh; }
}

/* --------------------------------------------------------- touch targets */

/*
 * A cursor is a single pixel and a fingertip is about nine millimetres wide, so
 * a control that is comfortable with a mouse can be genuinely difficult with a
 * thumb. Everything in here is gated on a coarse pointer: a mouse never sees a
 * pixel of it, and the design on a desktop is exactly what it was.
 *
 * Two different fixes, because there are two different situations.
 *
 * Where the control is a real control on a real page - a question in the FAQ, a
 * preference chip, the pricing switch - it simply gets bigger. A taller row is
 * an improvement on a phone anyway.
 *
 * Where the control lives inside one of the drawn handsets, it cannot get
 * bigger without breaking the drawing: a filter chip in the Library mockup is
 * small because a filter chip on a phone screen shown at a third of life size
 * is small, and stretching it to 44 pixels would make the mockup stop looking
 * like a phone. Those get an invisible pad instead - an absolutely positioned
 * pseudo-element centred on the control, at least 44 by 44, catching the taps
 * that land near the mark without changing a single thing you can see.
 */
@media (hover: none) and (pointer: coarse) {
  /* Real controls, grown. */
  summary { min-height: 44px; }
  .theme-toggle { width: 44px; height: 44px; }
  .plan-switch button { min-height: 44px; }
  .chip { min-height: 44px; }
  .brand { min-height: 44px; }

  /* Invisible pads. `.tz-chip` is already absolutely positioned so that it can
     be dragged across a row boundary, and must stay that way; the rest need a
     containing block before their pad has anything to measure against. */
  .tryword,
  .lib-chip,
  .lib-sort,
  .pst-btn,
  .pst-again {
    position: relative;
  }

  /* The filter row scrolls sideways, and `overflow-x: auto` quietly clips the
     other axis too, which would shear the top and bottom off its chips' pads.
     Padding is inside the scrollport, so this hands the pads eight pixels of
     room each way; the margin gives back exactly what the padding took, and the
     row sits where it always sat. */
  .lib-filters {
    padding-top: 8px;
    padding-bottom: 9px;
    margin-top: -8px;
    margin-bottom: 2px;
  }

  /* The footer's links take their pad on `::before`, because `::after` is
     already the rule that draws itself under them on hover, and it is measured
     from the padding box - growing the box would leave the line floating. */
  .tryword::after,
  .lib-chip::after,
  .lib-sort::after,
  .pst-btn::after,
  .pst-again::after,
  .tz-chip::before,
  .footer-links a::before {
    content: '';
    position: absolute;
    left: 50%;
    top: 50%;
    width: max(100%, 44px);
    height: max(100%, 44px);
    transform: translate(-50%, -50%);
  }
}

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

/* Everything above degrades to a plain cross-fade. The scrolly pin is removed
   entirely by app.js in this mode, not just slowed down: pinning is motion
   whether or not anything is animating inside it. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.12s !important;
    /* Delays too, not just durations. A stagger built out of transition-delay
       survives a duration override untouched, and the result is worse than the
       animation it replaced: a list that snaps into place one item at a time
       over half a second. */
    animation-delay: -1ms !important;
    transition-delay: 0s !important;
  }
  .js [data-reveal] { opacity: 1; transform: none; }
  .hero-art .phone { transform: none; }
  .scene { transition: opacity 0.12s; }
  .mark::after { animation: none; }
  /* The pasted comment jumps to its end rather than gliding there; app.js
     collapses the whole read to a single frame in this mode anyway. */
  .pst-inner { scroll-behavior: auto; }

  /* Not slowed â€” removed. Every other animation here ends where it should be
     left; this one ends at the bottom of the caption, so a 0.01ms version
     would open on the hashtags with the recipe already scrolled past. */
  .scene.is-active .cap-text { animation: none !important; }

  /* Words land in place rather than rising into it, and the clip that made
     the rise possible is lifted so nothing can be left cropped. */
  .js .split .w { overflow: visible; }
  .js .split .w > i { transform: none; }

  /* The pointer-tracked wash is removed outright. It is the one effect here
     that follows you around the page, and slowing it down does not make it
     any less of a thing moving in your peripheral vision. */
  .card::before,
  .trust-item::before,
  .tier::before { display: none; }
}

/* ----------------------------------------------------------------- recipes */

/*
 * The recipes section. Two pages: a grid that has to work as an Instagram
 * landing — somebody taps a link in the in-app browser, on a phone, and decides
 * in a second whether to stay — and a recipe page somebody reads standing up,
 * with their hands busy.
 *
 * Built to look finished with no photography, because there is none yet. A grid
 * of cards with an empty image box at the top reads as broken; a grid of
 * typographic cards reads as a decision. When photos arrive the same card takes
 * them without a rewrite — the art slot is already the right shape.
 */

.recipe-grid {
  display: grid;
  gap: var(--space-sm);
  margin-top: var(--space-md);
  list-style: none;
  padding: 0;
  /* Two on a phone, not one. A single column made every card a 366px square on
     a 390px screen — one recipe per screenful, which is a slideshow, not an
     index. Two means you see four at once and the pictures are still big
     enough to want. */
  grid-template-columns: repeat(2, minmax(0, 1fr));
}
/*
 * The grid steps outside the reading column on wide screens.
 *
 * `.prose` is 42rem, which is right for a paragraph and much too narrow for
 * three cards: at 1280px the grid sat in the left half of the window with the
 * right half empty, and the titles broke across lines that had plenty of room
 * beside them. Widening `.prose` itself would ruin every article on the site,
 * so the grid — and the filter above it, which has to stay aligned with it —
 * takes the wider page measure on its own.
 */
@media (min-width: 30rem) {
  .recipe-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-md); }
}
@media (min-width: 64rem) {
  /* The heading comes with them. Left where it was, it started a third of the
     way across while the cards started at the edge — two left margins on one
     page, which reads as a mistake rather than as a narrow column. */
  body:has(.recipe-grid) .page-head,
  .recipe-empty,
  .recipe-grid,
  .recipe-filter {
    width: min(var(--page), calc(100vw - 2 * var(--space-lg)));
    margin-left: calc(50% - min(var(--page), calc(100vw - 2 * var(--space-lg))) / 2);
  }
  /* The lede keeps a readable line length even though its heading no longer
     does — 72rem of running text is not something anyone reads. */
  body:has(.recipe-grid) .page-head .lede { max-width: 38rem; }
  .recipe-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (min-width: 84rem) {
  .recipe-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

.recipe-grid a {
  display: flex;
  flex-direction: column;
  height: 100%;
  background: var(--surface);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-card);
  overflow: hidden;
  /* Belt and braces. The card is a link, its title is inside it, and a browser
     that decides to underline either makes the grid look like a list of blue
     links with pictures. */
  text-decoration: none;
  color: inherit;
  box-shadow: var(--shadow-card);
  transition: transform var(--settle), border-color var(--settle), box-shadow var(--settle);
}
.recipe-grid a,
.recipe-grid a:hover,
.recipe-grid a:focus,
.recipe-grid a h2,
.recipe-grid a p { text-decoration: none; }
.recipe-grid a:hover {
  border-color: var(--orange);
  transform: translateY(-4px);
  box-shadow: var(--shadow-lift);
}
.recipe-grid a:active { transform: translateY(-1px); transition-duration: var(--press); }
.recipe-grid a:focus-visible {
  outline: 2px solid var(--orange);
  outline-offset: 3px;
}

/* The art slot. Fixed ratio so every card in a row is the same height whether
   it holds a photograph or a letter. */
/* Square, because a grid of squares reads as a grid and a grid of 3:2s reads
   as a list of banners. The file is the same 4:5 crop; the frame squares it. */
.recipe-card-art {
  position: relative;
  display: block;
  aspect-ratio: 1 / 1;
  background: var(--canvas);
  overflow: hidden;
}
.recipe-card-art img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: transform var(--flow, 0.5s cubic-bezier(0.2, 0.8, 0.2, 1));
}
.recipe-grid a:hover .recipe-card-art img { transform: scale(1.04); }
@media (prefers-reduced-motion: reduce) {
  .recipe-card-art img { transition: none; }
  .recipe-grid a:hover .recipe-card-art img { transform: none; }
}

/* A card with no photograph gets no frame either — it is type, with room to
   breathe. Orange is reserved for things you press, so a tinted decorative
   panel would be spending it on nothing. */
.recipe-grid a.is-typographic .recipe-card-body {
  padding: var(--space-lg);
  gap: var(--space-sm);
}
.recipe-grid a.is-typographic .recipe-card-body h2 { font-size: 1.3rem; }

.recipe-card-body {
  display: flex;
  flex-direction: column;
  gap: 5px;
  padding: var(--space-sm) var(--space-sm) var(--space-md);
  flex: 1 1 auto;
}
.recipe-card-body h2 {
  font-family: var(--font-display);
  font-size: 1rem;
  line-height: 1.2;
  margin: 0;
  text-wrap: balance;
  color: var(--ink-900);
  /* Three lines, then an ellipsis. Titles here run to nine words and without
     this one card sets the height of its whole row. */
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
/* The description is for the page, not the card. On a two-up phone grid it was
   four lines of grey under a two-line title and the picture had to fight it. */
.recipe-card-body p { display: none; }
.recipe-card-meta {
  margin-top: auto;
  padding-top: 5px;
  font-size: 0.72rem;
  color: var(--ink-400);
  font-variant-numeric: tabular-nums;
}
@media (min-width: 48rem) {
  .recipe-card-body { padding: var(--space-md); gap: var(--space-xs); }
  .recipe-card-body h2 { font-size: 1.1rem; }
  .recipe-card-body p {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    margin: 0;
    color: var(--ink-600);
    font-size: 0.875rem;
    line-height: 1.45;
  }
}

/* The filter. Plain buttons, so it works as a list of links to nowhere with
   JavaScript off — the grid simply stays whole, which is the right failure. */
/*
 * The filter, as a rail.
 *
 * Wrapping put ten chips on three lines and the last one still ran off the
 * right edge on a 390px phone, because the row was inside the reading column
 * while the grid below it was not. A single scrolling row is what a phone
 * expects, keeps the chips on one line, and cannot overflow: it scrolls.
 */
.recipe-filter {
  display: flex;
  flex-wrap: nowrap;
  gap: var(--space-xs);
  margin-top: var(--space-lg);
  overflow-x: auto;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  scroll-snap-type: x proximity;
  /* Full bleed on a phone so the first chip starts at the margin and the last
     one can scroll past it, instead of being clipped by the column. */
  margin-inline: calc(-1 * var(--space-lg));
  padding-inline: var(--space-lg);
  padding-bottom: 2px;
  /* The row fades out at the right, which is how a rail says "there is more". */
  mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 28px), transparent 100%);
}
.recipe-filter::-webkit-scrollbar { display: none; }
.recipe-filter .tag-chip { flex: 0 0 auto; scroll-snap-align: start; }
@media (min-width: 64rem) {
  .recipe-filter {
    flex-wrap: wrap;
    overflow: visible;
    mask-image: none;
    margin-inline: 0;
    padding-inline: 0;
  }
}
.tag-chip {
  font: inherit;
  font-size: 0.82rem;
  font-weight: 500;
  padding: 7px 15px;
  min-height: 36px;
  white-space: nowrap;
  border-radius: 999px;
  border: 1px solid var(--ink-200);
  background: var(--surface);
  color: var(--ink-600);
  cursor: pointer;
  transition:
    background var(--snap),
    color var(--snap),
    border-color var(--snap),
    transform var(--snap);
}
.tag-chip:hover { border-color: var(--ink-400); color: var(--ink-900); }
.tag-chip:active { transform: scale(0.96); }
.tag-chip:focus-visible { outline: 2px solid var(--orange); outline-offset: 2px; }
.tag-chip.is-on {
  background: var(--ink-900);
  border-color: var(--ink-900);
  color: var(--canvas);
  font-weight: 600;
}
.tag-chip.is-on:hover { border-color: var(--ink-900); color: var(--canvas); }
@media (prefers-reduced-motion: reduce) {
  .tag-chip:active { transform: none; }
}

.recipe-empty {
  margin-top: var(--space-lg);
  color: var(--ink-600);
}
.recipe-empty button {
  font: inherit;
  color: var(--orange-on-tint);
  background: none;
  border: 0;
  padding: 0;
  text-decoration: underline;
  cursor: pointer;
}

/* ------------------------------------------------------------ one recipe */

/* The hero. First thing on the page, full width of the column, 4:5 — the shape
   a phone holds best and the shape the photographs are cropped to. */
/*
 * The three shots at the top of a recipe.
 *
 * One photograph at full width was 487px of a 390px phone — a third of the
 * first screen before a word, on a page whose whole argument is that the
 * recipe comes first. Three smaller frames say more about the dish and take
 * less room: the plate large on the left, two supporting shots stacked beside
 * it. On a phone the first spans the row and the other two sit under it, which
 * keeps the largest one about a fifth of the height the single hero had.
 */
.recipe-shots {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-xs);
  margin: var(--space-sm) 0 var(--space-lg);
}
.recipe-shots .shot { margin: 0; min-width: 0; }
.recipe-shots .shot-1 { grid-column: 1 / -1; }

.recipe-shots picture { display: block; }
.recipe-shots img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: var(--radius-card);
  background: var(--canvas);
}
.recipe-shots .shot-1 img { aspect-ratio: 16 / 10; max-height: 230px; }
.recipe-shots figcaption {
  margin-top: 5px;
  font-size: 0.72rem;
  line-height: 1.3;
  color: var(--ink-400);
}

@media (min-width: 40rem) {
  /* The plate takes two thirds and the full height; the other two stack. */
  .recipe-shots { grid-template-columns: 2fr 1fr; grid-template-rows: auto auto; }
  .recipe-shots .shot-1 { grid-column: 1; grid-row: 1 / 3; }
  .recipe-shots .shot-1 img { aspect-ratio: 4 / 3; max-height: none; height: 100%; }
  .recipe-shots .shot-2 { grid-column: 2; grid-row: 1; }
  .recipe-shots .shot-3 { grid-column: 2; grid-row: 2; }
  .recipe-shots .shot-2 img,
  .recipe-shots .shot-3 img { aspect-ratio: 4 / 3; }
}

/* Serves / prep / cook / rest. A definition list because that is what it is,
   and it reads correctly to a screen reader without extra labelling. */
.recipe-facts {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: var(--space-xs);
  margin: var(--space-md) 0 var(--space-xl);
  padding: 0;
  border: 0;
}
.recipe-facts .fact {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: var(--space-sm) var(--space-md);
  background: var(--surface);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-button);
  min-width: 0;
}
.recipe-facts dt {
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-400);
}
.recipe-facts dd {
  margin: 0;
  font-size: 1rem;
  font-weight: 600;
  color: var(--ink-900);
  font-variant-numeric: tabular-nums;
}
.fact-note { display: block; font-size: 0.78rem; font-weight: 400; color: var(--ink-400); }

.ingredients-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-md);
}
.ingredients-head h2 { margin: 0; }
.copy-ingredients {
  font: inherit;
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--orange-on-tint);
  background: var(--orange-tint);
  border: 1px solid transparent;
  border-radius: 999px;
  padding: 7px 14px;
  min-height: 34px;
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--snap), border-color var(--snap);
}
.copy-ingredients:hover { border-color: var(--orange); }
.copy-ingredients.is-done { background: var(--success); color: var(--on-success); }

/* The scaler. Multiplies the quantities in place — the numbers people are
   standing in a shop with, not a different page. */
.scaler {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  margin: var(--space-sm) 0 var(--space-md);
}
.scale-chip {
  font: inherit;
  font-size: 0.85rem;
  font-weight: 600;
  min-width: 44px;
  min-height: 34px;
  border-radius: 999px;
  border: 1px solid var(--ink-200);
  background: var(--surface);
  color: var(--ink-600);
  cursor: pointer;
  font-variant-numeric: tabular-nums;
  transition: background var(--snap), color var(--snap), border-color var(--snap);
}
.scale-chip:hover { border-color: var(--orange); color: var(--ink-900); }
.scale-chip.is-on {
  background: var(--orange-tint);
  border-color: var(--orange);
  color: var(--orange-on-tint);
}
.scale-yield { font-size: 0.85rem; color: var(--ink-400); margin-left: auto; font-variant-numeric: tabular-nums; }

.ingredient-group {
  font-family: var(--font-body);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--orange-on-tint);
  margin: 0 0 var(--space-xs);
  padding-bottom: var(--space-xs);
  border-bottom: 1px solid var(--ink-200);
}

/*
 * Ingredients, in two columns.
 *
 * Nineteen rows of "1.3 lb (600g) boneless chicken thighs" set as one run of
 * text at one weight is a wall — you cannot find the number you want without
 * reading the sentence. Measurement on the left in a fixed column, the thing
 * itself on the right: the numbers line up, the eye goes down them, and the
 * list is usable standing at a counter with your hands full.
 *
 * Grouped recipes get a card per group, which is what stops "Chicken / Lemon
 * rice / Bowl" reading as one list of nineteen things to buy.
 */
.ingredient-card {
  background: var(--surface);
  border: 1px solid var(--ink-200);
  border-radius: var(--radius-card);
  padding: var(--space-sm) var(--space-md);
  margin-top: var(--space-sm);
}
.ingredient-card:first-of-type { margin-top: var(--space-md); }

.recipe .ingredient-list { list-style: none; padding: 0; margin: 0; }
.recipe .ingredient-list li {
  display: grid;
  grid-template-columns: minmax(5.5rem, auto) 1fr;
  gap: var(--space-sm);
  align-items: baseline;
  margin: 0;
  padding: 10px 0;
  border-bottom: 1px solid var(--ink-200);
  line-height: 1.45;
}
.recipe .ingredient-list li:last-child { border-bottom: 0; }

.recipe .ingredient-list .qty {
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  color: var(--ink-900);
  white-space: nowrap;
}
/* An ingredient with no measurement — "Parsley", "Olive oil, to finish" —
   keeps its column so the names stay in one line down the page. */
.recipe .ingredient-list .qty:empty::before {
  content: '—';
  color: var(--ink-200);
  font-weight: 400;
}
.recipe .ingredient-list .what { color: var(--ink-600); }

/* Long measurements stop fighting the narrow column on a small phone. */
@media (max-width: 22rem) {
  .recipe .ingredient-list li { grid-template-columns: 1fr; gap: 2px; }
  .recipe .ingredient-list .qty:empty::before { content: none; }
}

/* The method. Numbered in the margin and set larger than body copy, because it
   is read at arm's length. */
.recipe ol { counter-reset: step; list-style: none; padding: 0; }
.recipe ol > li {
  counter-increment: step;
  position: relative;
  padding-left: 2.75rem;
  margin-bottom: var(--space-md);
  line-height: 1.65;
}
.recipe ol > li::before {
  content: counter(step);
  position: absolute;
  left: 0;
  top: -1px;
  width: 1.9rem;
  height: 1.9rem;
  display: grid;
  place-items: center;
  border-radius: 999px;
  background: var(--orange-tint);
  color: var(--orange-on-tint);
  font-size: 0.85rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

/*
 * The waitlist block, after the recipe.
 *
 * It was an input and a button on one flex row, and on a 390px phone the
 * button's own label ran off the screen — the single control the block exists
 * for, cut in half. Stacked now, always, on every width: the field full width
 * with a visible label above it, the button full width under it. Nothing to
 * wrap, nothing to clip, and no media query deciding whether today is the day
 * it fits.
 */
.recipe-waitlist {
  margin-top: var(--space-2xl);
  padding: var(--space-lg);
  border-radius: var(--radius-card);
  background: var(--surface);
  border: 1px solid var(--ink-200);
  box-shadow: var(--shadow-card);
}
.waitlist-copy h2 {
  margin: 0 0 var(--space-xs);
  font-size: 1.3rem;
  line-height: 1.2;
  text-wrap: balance;
}
.waitlist-copy p {
  margin: 0 0 var(--space-md);
  color: var(--ink-600);
  font-size: 0.95rem;
  line-height: 1.5;
}

.waitlist-form { display: flex; flex-direction: column; gap: var(--space-sm); }

.field { display: flex; flex-direction: column; gap: 6px; }
.field-label {
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-400);
}
.waitlist-input {
  font: inherit;
  width: 100%;
  min-height: 52px;
  padding: 0 var(--space-md);
  border: 1.5px solid var(--ink-200);
  border-radius: var(--radius-button);
  background: var(--canvas);
  color: var(--ink-900);
  transition: border-color var(--snap), background var(--snap), box-shadow var(--snap);
}
.waitlist-input::placeholder { color: var(--ink-400); }
.waitlist-input:hover { border-color: var(--ink-400); }
.waitlist-input:focus {
  outline: none;
  background: var(--surface);
  border-color: var(--orange);
  box-shadow: 0 0 0 3px var(--orange-tint);
}

.waitlist-go { width: 100%; min-height: 52px; }

.waitlist-note { margin: 0; font-size: 0.875rem; color: var(--ink-600); min-height: 1.25rem; }
.waitlist-note.is-good { color: var(--success); font-weight: 600; }
.waitlist-note.is-bad { color: var(--danger); }
.waitlist-small {
  margin: var(--space-sm) 0 0;
  font-size: 0.78rem;
  color: var(--ink-400);
}

/* Side by side only where there is genuinely room for both. */
@media (min-width: 44rem) {
  .waitlist-form { flex-direction: row; align-items: flex-end; flex-wrap: wrap; }
  .field { flex: 1 1 16rem; }
  .waitlist-go { width: auto; flex: 0 0 auto; padding-inline: var(--space-xl); }
  .waitlist-note { flex: 1 0 100%; }
}

/*
 * The sticky bar, once somebody is most of the way down.
 *
 * Held back until 60% because the whole promise of this page is that the
 * recipe comes first. A bar that appears while you are still reading the
 * ingredients is the behaviour every other recipe site has, and the reason
 * people bounce off them.
 */
.recipe-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  padding: var(--space-sm) var(--space-md);
  padding-bottom: calc(var(--space-sm) + env(safe-area-inset-bottom));
  background: var(--surface);
  border-top: 1px solid var(--ink-200);
  box-shadow: 0 -8px 24px rgb(0 0 0 / 8%);
  transform: translateY(110%);
  transition: transform var(--settle);
}
.recipe-bar.is-up { transform: translateY(0); }
.recipe-bar p { margin: 0; font-size: 0.875rem; line-height: 1.35; flex: 1 1 auto; }
.recipe-bar .button-primary { flex: 0 0 auto; min-height: 42px; padding-inline: var(--space-md); }
.recipe-bar-close {
  flex: 0 0 auto;
  width: 32px;
  height: 32px;
  border: 0;
  border-radius: 999px;
  background: none;
  color: var(--ink-400);
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
}
.recipe-bar-close:hover { color: var(--ink-900); }
@media (prefers-reduced-motion: reduce) {
  .recipe-bar { transition: none; }
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.waitlist-fix {
  font: inherit;
  font-weight: 600;
  color: var(--orange-on-tint);
  background: none;
  border: 0;
  padding: 0;
  text-decoration: underline;
  cursor: pointer;
}

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

/*
 * Recipes and Blog in the bar. The site's only navigation was a footer, which
 * is fine for a page somebody arrived at on purpose and useless for one they
 * landed on from a link in a bio — they have no way to see there is anything
 * else here.
 */
.header-nav {
  display: flex;
  align-items: center;
  gap: var(--space-md);
  margin-left: var(--space-lg);
  margin-right: auto;
}
.header-nav a {
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--ink-600);
  text-decoration: none;
  padding: 6px 0;
  border-bottom: 2px solid transparent;
  transition: color var(--snap), border-color var(--snap);
}
.header-nav a:hover { color: var(--ink-900); border-bottom-color: var(--orange); }
.header-nav a[aria-current='page'] { color: var(--ink-900); border-bottom-color: var(--orange); }

/*
 * On a narrow phone the bar holds a logo, two links and a button, and they do
 * not fit. Measured rather than guessed: at 390px the CTA ran from 276 to 443,
 * fifty-three pixels past the edge, and it was the only thing on the page
 * making it scroll sideways. Its own label was cut in half.
 *
 * So the button goes and the links stay. Every page that has this bar also
 * carries the waitlist in its body — the recipe pages twice, counting the
 * sticky bar — while navigation existed nowhere else at all until now. A
 * second ask in the corner is worth less than a way out of the page, and much
 * less than not scrolling sideways.
 */
@media (max-width: 34rem) {
  .site-header .header-cta { display: none; }
  .header-nav { gap: var(--space-md); margin-left: var(--space-md); }
}
@media (max-width: 26rem) {
  .header-nav { gap: var(--space-sm); margin-left: var(--space-sm); }
  .header-nav a { font-size: 0.85rem; }
}

/* ------------------------------------------------------ the sticky bar, fixed */

/*
 * The bar held a sentence, a button and a close control on one line, and on a
 * 390px phone the button's label was clipped mid-word — the one control it
 * exists for. Two rows on a phone: the line above, the button full width below,
 * where it cannot be cut.
 */
.recipe-bar {
  flex-wrap: wrap;
  row-gap: var(--space-xs);
}
.recipe-bar p {
  flex: 1 1 100%;
  order: 1;
  font-size: 0.9rem;
  padding-right: 2rem;
}
.recipe-bar .button-primary {
  flex: 1 1 auto;
  order: 2;
  min-height: 46px;
}
.recipe-bar-close {
  order: 0;
  position: absolute;
  top: 6px;
  right: 8px;
}
@media (min-width: 34rem) {
  .recipe-bar { flex-wrap: nowrap; }
  .recipe-bar p { flex: 1 1 auto; order: 0; padding-right: 0; }
  .recipe-bar .button-primary { flex: 0 0 auto; order: 1; }
  .recipe-bar-close { position: static; order: 2; }
}

/* The waitlist block's own button had the same problem for the same reason. */
@media (max-width: 30rem) {
  .waitlist-form .button-primary { flex: 1 1 100%; }
}

/* ------------------------------------------------- mobile touch and legibility */

/*
 * Measured on a 390px frame, page by page: the footer links were 23px tall,
 * the breadcrumb 16px, and several labels sat at 11.5px. Apple asks for 44pt
 * and Google for 48dp; nothing here was close, and a footer is exactly where
 * somebody with a thumb misses.
 *
 * The links are not made bigger — they are given room. Padding round a 23px
 * link makes a 44px target without changing a single thing about how the page
 * looks, which is the point: the only visible difference should be that it
 * stops being annoying.
 */
.footer-links a,
.site-footer a {
  display: inline-block;
  padding-block: 11px;
  min-height: 22px;
}
.footer-links {
  /* The padding above would otherwise show as a gap between rows. */
  row-gap: 0;
}

.breadcrumb {
  /* 16px tall links, in the one control that takes somebody back out of a page
     they arrived at from a link with no other way back. */
  padding-block: 4px;
}
.breadcrumb a {
  display: inline-block;
  padding-block: 10px;
  padding-inline: 2px;
}

/* Nothing this site wrote itself sits under 12px any more. Anything smaller is
   inside a phone mock-up on the homepage, where it is drawing an iOS label and
   the size is the likeness rather than a reading size. */
.recipe-card-meta { font-size: 0.76rem; }
.recipe-shots figcaption { font-size: 0.76rem; }
.field-label { font-size: 0.75rem; }
.waitlist-small { font-size: 0.8rem; }
.recipe-facts dt { font-size: 0.75rem; }
.ingredient-group { font-size: 0.75rem; }
