@charset "UTF-8";
/* ===========================================================================
   StashFlow — the public site's whole design system.

   One file, linked (not inlined) so `style-src 'self'` can replace
   `style-src 'unsafe-inline'`. There is no build step: this is the shipped
   artefact, comments and all. Edit it directly.

   ---------------------------------------------------------------------------
   WHERE THE LOOK COMES FROM

   The site is supposed to read as the same product as the app, so the values
   below are taken out of the app rather than eyeballed:

     radii            tailwind.config.js  field 14 / card 20 / hero 28 / pill 999
     card shadow      src/constants/theme.ts  CardShadow: #0B1220 8%, 12px blur, 4px down
     float shadow     src/constants/theme.ts  FloatShadow: #0B1220 18%, 24px blur, 10px down
     surfaces + inks  ACCENT_PALETTES.flow.surfaces, both modes
     type scale       src/global.css  display 32/38 title 26/32 heading 20/26
                      section 17/22 body 15/22 caption 13/18 micro 11/14
     palette          lime #607A00 by day, the wordmark's neon #C8FF00 on dark
                      surfaces, moss-black #121509, celery-white #F6F8F2

   The app is warm and papery. Nothing here is cold, and nothing here is
   purple: the brand brief (docs/brand-image-prompts.md) bans mesh gradients,
   glows, bevels, glass, long shadows, isometric people and device mockups, and
   docs/play-submission.md §6 bans putting a screenshot inside a phone frame.
   That last one is why .shot-media is a plain rounded card and not a bezel.

   ONE EXCEPTION, DECIDED BY THE OWNER ON 4 SEPTEMBER 2026: the hero carries
   the neon streak photograph from the launch banner (`assets/img/
   hero-backdrop.jpg`, the band above the wordmark card, so no baked-in text)
   under a scrim, and the accent is the wordmark's own lime — full-chroma
   #607A00 where it is text on a light ground, the neon #C8FF00 itself on the
   dark slabs and in dark mode. The app made the same move the same day
   (`src/constants/theme.ts`, `AccentPalette.dark`). The glow lives in that
   one picture; nothing else on the page glows, and the ban above still
   covers everything else.

   ---------------------------------------------------------------------------
   WHO EMITS THE MARKUP

   `web/inc/` does. This file styles it and defines nothing itself.

     components.php  the markup vocabulary (`sf_section`, `sf_hero`, `sf_card`…)
     chrome.php      the stylesheet pass's own seam — the icon set, the inline
                     wordmark, the language menu, the light/dark screenshot
                     pair, and the two theme scripts. Included BEFORE
                     components.php, and everything there is
                     `function_exists`-guarded, so chrome.php wins.
     render.php      links this file as `/assets/site.css` and calls
                     `sf_head_extra()` / `sf_body_end()` for the scripts.

   Three pieces of markup this file cannot produce and depends on:

   - **The theme control** is `<fieldset class="switch">` with three radios
     named `theme`, ids `theme-auto` / `theme-light` / `theme-dark`, each label
     holding an `<svg>` and a `<span class="vh">` name. Icon-only, so that
     hidden name IS the accessible name; without it a screen reader announces
     "radio button, blank". Rendered at most once per document — the ids are
     selected on.
   - **The language menu** is `<details class="langs">` with a `<summary>` that
     ends in the current language's own name, and a `<nav><ul>` of four links
     inside. A disclosure, not a button plus a list, because it has to open with
     scripting off. `assets/js/ui.js` adds only what HTML does not: Escape,
     click-outside, and focus into the list.
   - **Screenshots** come in pairs, `.only-light` and `.only-dark`, swapped by
     `--img-light` / `--img-dark` below. See `sf_shot_img()` in chrome.php for
     why this is not a `<picture>` with `prefers-color-scheme`.

   Two rules the markup genuinely cannot enforce, both of them below:

       .hero-still { display: none }
       @media (prefers-reduced-motion: reduce) {
         .hero-video { display: none } .hero-still { display: block }
       }

   ---------------------------------------------------------------------------
   THE ICONS

   `sf_icon()` in `web/inc/chrome.php` emits every glyph inline, on a 24 grid,
   stroked in `currentColor` at 1.75. Lucide draws at 2 and the app's
   Ionicons-outline set reads at about 1.5; 1.75 is the weight at which a line
   icon sits at the same optical density as 650-weight Segoe beside it. Sizes
   are in `em`, so an icon always scales with the text it belongs to — which is
   the whole reason emoji had to go. An emoji is a font glyph the page cannot
   colour, cannot align and cannot size consistently across three operating
   systems, and it is the loudest "generated" signal a page can carry.

   Line icons: Lucide 0.469.0, ISC. Platform marks: Simple Icons 13.21.0, CC0.
   Flags: drawn for this project. Full texts in `assets/icons/LICENSES.txt`,
   which ships because redistributing the first two requires it.

   ---------------------------------------------------------------------------
   MEASURED CONTRAST (WCAG 2.x relative luminance, computed from the hexes below)

   | pair                                   | light | dark  |
   |----------------------------------------|-------|-------|
   | ink on canvas                          | 15.74 | 16.72 |
   | ink on card                            | 16.84 | 14.44 |
   | ink on band                            | 13.88 | 14.44 |
   | ink on accent-soft                     | 14.43 | 13.10 |
   | ink-muted on canvas                    |  6.06 |  8.54 |
   | ink-muted on card                      |  6.48 |  7.37 |
   | ink-muted on band                      |  5.34 |  7.37 |
   | ink-muted on elevated (card in a band) |  6.48 |  6.00 |
   | ink-muted on accent-soft               |  5.55 |  6.69 |
   | accent on canvas (links, kickers)      |  4.58 | 15.62 |
   | accent on card                         |  4.91 | 13.49 |
   | accent-on-tint on band                 |  7.03 | 13.49 |
   | accent-on-tint on canvas               |  7.97 | 15.62 |
   | accent-on-tint on accent-soft          |  7.31 | 12.24 |
   | accent-on-tint on elevated             |  8.53 | 10.97 |
   | on-accent on accent (filled button)    |  4.91 | 15.62 |
   | on-accent on accent-hover              |  6.46 | 16.12 |
   | brand-flow on canvas (the wordmark)    |  4.58 | 15.62 |
   | brand-flow on band (footer wordmark)   |  4.04 | 13.49 |

   Every text token clears 4.5:1 in both schemes. Two entries need a note:

   `accent-on-tint on canvas` is new, and it is there because the band directly
   under the hero now drops its GROUND but keeps its token rebinds — see
   `.hero + .section--band`. In light that puts #415300 on #F6F8F2, measured
   7.97:1; in dark `--accent-on-tint` is `--accent`, so it is the 15.62 already
   in the table. Both clear 4.5 with room, which is why the rebinds could be
   left alone rather than unwound.

   - `--ink-faint` (3.51 on canvas, 3.76 on card, 3.10 on band in light; 5.99 /
     5.18 / 5.18 in dark) is NOT a text colour and never carries a word. It is
     the icon stroke, the list marker and the decorative rule, all of which live
     under the 3:1 non-text floor. Its band figure moved when the band's ground
     was deepened (see `--band`) and 3.10 still clears that floor.

     `--line-control` — the boundary of an interactive control, which SC 1.4.11
     genuinely holds to 3:1 — is a SEPARATE token that happens to share the hex,
     and the band's value cannot touch it: every control that draws with it
     (`.skip`, `.chips li`, `.btn--secondary`) sets its own
     `background: var(--card)`, and `.section--band` rebinds `--card` to
     `--elevated`, so a control inside a band sits on WHITE and measures 3.76:1
     there exactly as it does anywhere else. Checked before the band moved,
     because "the band got darker so the buttons failed" is precisely the kind
     of coupling a token file is supposed to prevent and is worth proving absent
     rather than assuming.
   - `--brand-flow` is the "Flow" half of the wordmark, and 4.04:1 on the footer
     band is under 4.5. It stays exactly that colour anyway: WCAG 1.4.3 exempts
     text that is part of a logotype, and a brand mark that changes colour
     depending on which section it lands in is not a brand mark. The word is
     also duplicated as an accessible name, so nothing is lost if it is hard to
     read. Do not use --brand-flow for anything that is not the lockup.

   THREE DELIBERATE DEPARTURES FROM THE APP'S OWN HEXES, all forced by AA on a
   website (the app holds itself to the 3:1 non-text floor in places a site
   cannot):

   1. Dark mode gets the neon. The light lime #607A00 on the dark canvas is
      3.7:1 — fine behind white button text, a fail for a link. Dark uses the
      wordmark's neon #C8FF00 (15.62 on canvas), the same second accent the
      app resolves for its dark mode, and flips the filled button to a neon
      fill with a moss-black label (15.62) rather than white on neon (1.18, a
      fail by a mile).
   2. `--ink-muted` is darker than the app's. The app's light `inkMuted`
      #6B725A measures 4.70 on canvas but only 4.35 on the band and 4.30 on the
      accent tint — both fails, and body text lands on both here. #5A6149 holds
      5.55 as its worst case. Dark mode is unchanged from the app.
   3. `--ink-faint` is darker than the app's, for the same reason: the app's
      #899471 measures 2.78 on a tinted ground, below even its own 3:1 floor,
      and this site puts rules and markers on tinted grounds.

   Anything new gets measured before it ships. There is no eyeballed colour in
   this file.
   =========================================================================== */

/* ---------------------------------------------------------------------------
   1. TOKENS, AND THE WHOLE LIGHT/DARK STORY

   TWO layers now, and it used to be four. What went, and why the removal is
   not a loss:

     1. `:root` holds light — the scheme a reader gets when their system has no
        opinion.
     2. `prefers-color-scheme: dark` overrides it. Same specificity (0,1,0), so
        it wins on source order alone.

   Layers 3 and 4 were `:root[data-theme="…"]`, written by a script before first
   paint, and `:root:has(#theme-…:checked)`, driven by three radio buttons in the
   header. They existed to serve a manual auto/light/dark switch. THE SWITCH IS
   GONE: the scheme follows the operating system and nothing else, so there is
   nothing to persist, nothing to restore before paint, and no second mechanism
   that could disagree with the first. Two token blocks instead of six, one
   render-blocking script fewer, and one fewer CSP hash to keep in step.

   The colour scheme is the one preference on this site a browser already knows
   the answer to. Asking again, in a control the reader has to find, was the
   header's whole justification.

   `color-scheme` is declared in both layers, not just the first: it is what
   tells the browser which way to paint the scrollbar and the native focus ring.

   The background lives on <body>, never on <html>. With no background on
   <html>, the body's propagates to the viewport canvas, so overscroll at the
   top and bottom of the page is the right colour without a second rule to keep
   in step.

   ---------------------------------------------------------------------------
   THE DARK ANCHOR — the tokens the page's two ends are built from

   The page used to open and close on the same pale ground it runs on, which is
   most of why it read as thin: nine sections of celery-white with nothing
   saying "this is where it starts" or "this is where it ends". `--anchor` and
   its four companions are a second, self-contained palette for exactly two
   blocks — the masthead-and-hero at the top and the footer at the bottom — and
   they do NOT invert with the scheme the way everything else does. They are
   dark in both modes, because a dark slab on a light page is the contrast, and
   a dark slab on a dark page is the frame.

   In dark mode `--canvas` is already #121509, so an anchor of the same hex
   would vanish. Dark's anchor therefore drops to #070A03 — below the canvas
   rather than above it — and gains a hairline. That is the only difference
   between the two modes here.

   MEASURED CONTRAST ON THE ANCHOR (relative luminance, both modes):

   | pair                                | light anchor | dark anchor |
   |-------------------------------------|--------------|-------------|
   | anchor-ink on anchor                |    16.72     |    18.04    |
   | anchor-ink-muted on anchor          |     8.54     |     9.21    |
   | anchor-accent on anchor (the mark)  |    15.62     |    16.85    |
   | anchor-link on anchor (links)       |    10.80     |    11.66    |

   Every one of them clears 4.5:1 with room, which is the point of using the
   app's own dark-mode inks rather than inventing a set: those hexes were
   already measured against #121509 in the table at the top of this file.
   --------------------------------------------------------------------------- */

:root {
  color-scheme: light;

  /* Surfaces — ACCENT_PALETTES.flow.surfaces.light, celery-white and paper. */
  --canvas: #F6F8F2;
  --card: #FFFFFF;
  --elevated: #FFFFFF;
  /* The alternating section ground that gives the page its rhythm. The app has
     no name for it, and it used to be the app's `sunken` — the same #EDF0E5,
     reused because it was there.

     THAT WAS THE BUG, AND IT ONLY SHOWS AS A NUMBER. A ground is not judged by
     contrast ratio (that metric is for text on it); it is judged by how far it
     moves in perceived lightness, L*. Measured from these hexes:

       | scheme | canvas L* | band L* | step  | CR    |
       |--------|-----------|---------|-------|-------|
       | light  |   97.28   |  94.30  | 2.98  | 1.080 |  <- was
       | light  |   97.28   |  92.36  | 4.92  | 1.130 |  <- is
       | dark   |    6.18   |  13.12  | 7.09  | 1.160 |  (unchanged)

     The same hex distance is not the same perceptual distance at the two ends
     of the scale, and light was getting barely two fifths of dark's step. That
     is why the dark bands read on the rendered page and the light ones did not
     — the tokens looked symmetrical in the file and were not symmetrical to an
     eye. A band that cannot be seen is not rhythm, it is a wasted section.

     4.92 and not more: at ~5.8 the ground stops reading as paper and starts
     reading as a grey-green panel laid over the page, which is a different and
     louder thing than this page wants. Dark is left exactly where it was; it
     was never the broken half.

     `--sunken` KEEPS #EDF0E5 and does not follow. It is the phone's body in
     `.shot-frame`, it has nothing to do with section rhythm, and the two were
     only ever the same hex by coincidence. Separating them is the point of
     their being two tokens. */
  --band: #E7EBDB;
  --sunken: #EDF0E5;
  --hairline: #E1E7D5;
  /* The hairline a card gets when it has no shadow to lean on. */
  --hairline-strong: #CBD4B3;
  /* The boundary of an interactive control (button, switch), which WCAG 2.1
     SC 1.4.11 puts under a 3:1 floor that --hairline-strong (1.54:1 on card)
     does not meet. Cards and separators are decoration and keep the hairline. */
  --line-control: #7E8770;

  --ink: #1A1F0F;
  --ink-muted: #5A6149;
  --ink-faint: #7E8770;

  --accent: #607A00;
  /* Accent text on a TINTED surface. #607A00 on the chip fill is only 4.20:1;
     ramp stop 700 (#415300) is 7.31. Nothing has to remember this — .chip,
     .callout and .section--band rebind --accent to it, so anything nested
     inside a tint is correct by construction. In dark the two tones are the
     same colour. */
  --accent-on-tint: #415300;
  --accent-hover: #516600;
  --accent-soft: #ECEFE0;
  --accent-line: #D6DCBD;
  --on-accent: #FFFFFF;
  /* The "Flow" half of the wordmark only. See the contrast note in the header. */
  --brand-flow: #607A00;
  --img-light: block;
  --img-dark: none;

  /* ---- THE DARK ANCHOR. Same in both schemes, by design; see §1's header. --
     `--anchor-accent` is the wordmark's neon itself and is used for the mark
     and for the eyebrow rule on the slab; `--anchor-link` is the neon's
     pressed stop (#A8D600, 10.80 on the slab), which is what a link and a
     hovered footer entry take. Two tones because the brand colour and the
     interactive colour must not be the same green on a surface where one of
     them is a logo. */
  --anchor: #121509;
  --anchor-ink: #F3F4F0;
  --anchor-ink-muted: #AEB2A4;
  --anchor-line: #333B22;
  --anchor-accent: #C8FF00;
  --anchor-link: #A8D600;

  /* ---- ELEVATION -----------------------------------------------------------
     `--shadow` and `--shadow-float` are CardShadow / FloatShadow from
     src/constants/theme.ts, translated exactly, and they stay that way.

     The three below them are the SITE's own, and they exist because the app's
     two do not cover what a web page needs. The app is a stack of full-bleed
     surfaces read at arm's length; a landing page is a set of discrete objects
     read at a metre, and an object needs two things the app never asks for — a
     lit top edge, and a hover state that says it is a thing rather than a
     rectangle. Everything here is a shadow or a light: no bevels, no glass
     panes, no coloured haze behind text. See §GLOW for where accent light is
     allowed and where it is not.

       --shadow-lift  the hover twin of --shadow. TWO shadows, not one turned
                      up: a 2px contact term that stays put and a wide,
                      negative-spread cast term that grows. That pair is what
                      reads as "the card came towards me" instead of "the card
                      got blurrier", and it is the same trick §8 already uses
                      to seat the phone on the page.
       --edge         one inset hairline of light along the top. On a tinted
                      card in light mode it is the paper catching the window;
                      in dark it is the ONLY thing that separates a card from
                      the canvas once the cast shadow has nothing left to
                      darken. It is a light, so it is white in both schemes and
                      only the alpha moves.
     THERE IS NO `--sheen`, AND THERE WAS. It put a top-lit white wash over
     every card, callout, chip and secondary button, and the verdict on it was
     blunt and correct: a gradient-faced widget is the dated look, and four of
     them per screen is worse than one. Surfaces are FLAT fills now. Depth
     comes from `--edge` and the shadow pair, which is light and geometry
     rather than a painted highlight, and every colour transition on the page
     has moved to where a gradient still belongs — the GROUNDS behind content
     (§SCI-FI GROUND), never the faces of controls.

     `--radius-panel` is the site's own radius and the app has no counterpart:
     it is for blocks that are page furniture rather than app furniture — the
     proof band, the closing card. The four app radii above it do not move. */
  --shadow: 0 4px 12px rgba(11, 18, 32, 0.08);
  --shadow-float: 0 10px 24px rgba(11, 18, 32, 0.18);
  --shadow-lift: 0 2px 6px rgba(11, 18, 32, 0.07), 0 18px 34px -14px rgba(11, 18, 32, 0.26);
  --edge: inset 0 1px 0 rgba(255, 255, 255, 0.7);
  --radius-panel: 24px;

  /* The Windows faces are named first on purpose. `system-ui` on Windows 11
     resolves to plain Segoe UI, but the system ships two optically-sized
     variable cuts — Text for reading sizes, Display for headlines — and asking
     for them is the difference between a page that looks designed on Windows
     and one that looks like a default. Every other platform falls straight
     through to SF / Roboto / whatever the desktop is set to. */
  --font-text: 'Segoe UI Variable Text', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Noto Sans', 'Helvetica Neue', Arial, sans-serif;
  --font-display: 'Segoe UI Variable Display', ui-sans-serif, system-ui, -apple-system, 'Segoe UI Semibold', 'Segoe UI', Roboto, 'Noto Sans', 'Helvetica Neue', Arial, sans-serif;
  --font-mono: ui-monospace, 'Cascadia Mono', 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace;

  /* Type scale. The minimum of every clamp is the APP's ratio against a 1rem
     body: micro 0.73, caption 0.87, body 1, section 1.13, heading 1.33,
     title 1.73, display 2.13 (from the px values in src/global.css). At 320px
     — the width where the site is read in the same posture as the app — the
     two are the same scale. Above that the site is allowed to grow, because a
     1440px page is not a phone; step-5 is the site's own display size and has
     no counterpart in the app.

     THE TOP OF THE SCALE WAS INVERTED, AND THIS IS THE FIX.

     Measured at 1400px before this pass: the `<h1>` came out at 62.4px and ran
     SEVEN LINES, 472px of display type, while every section heading under it
     sat at 33.6px — a ratio of 1.86 between the line a reader skims past and
     the lines that give the page its structure. A headline that long stops
     being a headline; it is a paragraph set in display type, and it made the
     page read as a template with the volume knob on the wrong control.

     Three changes, all at the MAXIMUM end of the clamps only. The minimums are
     the app's own ratios and are load-bearing — they are what makes the site
     and the app the same scale at 320px — so none of them moved:

       step-5  3.9rem → 3.25rem   the statement: 62.4 → 52px at 1400
       step-3  2.1rem → 2.35rem   section headings: 33.6 → 37.6px
       step-2  1.55rem → 1.75rem  the per-row claims: 24.8 → 28px

     and the hero `<h1>` was moved off step-5 onto step-4 (44.8px), which is
     the size every other page's `<h1>` already used. The ladder at 1400 is now
     52 / 44.8 / 37.6 / 28 / 20 / 17.2 — ratios of 1.16, 1.19, 1.34, 1.4, 1.16
     — instead of one 62.4px shout and a long flat tail.

     The statement stays ABOVE the headline on purpose. That was already this
     file's position (see §THE STATEMENT) and it survives the retune: the
     confession is the page's argument, the headline is only its label.

     Every mid-term was recomputed so each clamp still hits its minimum at
     320px and its maximum at about 1280px, rather than being left to reach the
     new cap somewhere off the end of the useful width range. */
  --step--2: clamp(0.73rem, 0.71rem + 0.09vw, 0.8rem);
  --step--1: clamp(0.87rem, 0.85rem + 0.1vw, 0.935rem);
  --step-0: clamp(1rem, 0.98rem + 0.11vw, 1.075rem);
  --step-1: clamp(1.13rem, 1.1rem + 0.17vw, 1.25rem);
  --step-2: clamp(1.33rem, 1.19rem + 0.7vw, 1.75rem);
  /* A half-step between 2 and 3, and the only addition to the ladder. It exists
     because two headings on this page have to look PROMOTED without competing
     with the section heading directly above them — the featured feature card,
     and the claim on the interrupted showcase row. Both used to take step-3;
     now that step-3 is the section-heading size, step-3 would make them equal
     to the head they sit under, which is not promotion, it is a collision. */
  --step-2h: clamp(1.5rem, 1.32rem + 0.9vw, 2rem);
  --step-3: clamp(1.73rem, 1.52rem + 1.03vw, 2.35rem);
  --step-4: clamp(2.13rem, 1.9rem + 1.15vw, 2.8rem);
  --step-5: clamp(2.4rem, 2.12rem + 1.42vw, 3.25rem);

  /* Spacing — the app's Spacing scale, same numbers. */
  --r-1: 4px;
  --r-2: 8px;
  --r-3: 16px;
  --r-4: 24px;
  --r-5: 32px;
  --r-6: 64px;

  /* Radii — tailwind.config.js borderRadius, unchanged. */
  --radius-field: 14px;
  --radius-card: 20px;
  --radius-hero: 28px;
  --radius-pill: 999px;

  /* The device frame. Bezel and radius live together because the screen's own
     radius is derived from both; `--device-tilt` is one place to make every
     tilted phone lean the same amount.

     ## The body is NOT a theme token, and that is the change

     These used to be `--sunken` and a 5px edge, so the phone was celery-grey on
     a light page and near-black on a dark one — consistent with the palette,
     and it read as a rounded rectangle around a screenshot rather than as a
     device. Real phones are dark-bezelled on any desk, and the brief for this
     pass was "ať to vypadá jako reálný telefon". So the body and its rail are
     fixed colours that do not follow the scheme, and they are the ONLY fixed
     colours in this file. They are allowed to be, for the same reason a
     photograph of a phone would be: this is an object on the page, not a
     surface of it.

     `--device-rail` is a hair lighter than the body and only ever appears as a
     1px edge, which is what reads as the metal frame catching the light. Larger
     than 1px it reads as a border and the illusion goes. */
  --device-bezel: 9px;
  --device-radius: 34px;
  --device-tilt: 13deg;
  --device-body: #101319;
  --device-rail: #3A414C;
  --device-glass: rgba(255, 255, 255, 0.1);

  --pad: clamp(1.25rem, 0.9rem + 1.8vw, 2.5rem);
  --w-wide: 72rem;
  --w-prose: 40rem;
  /* Was clamp(2.75rem, 1.8rem + 4vw, 5.5rem), which resolved to 84.8px a side
     at 1400 — 170px of nothing between the last line of one section and the
     first of the next, nine times down a page that carries nine sections and
     eight screenshots. A page that tall on that little ink reads as thin, not
     as generous. 66px a side (132px between sections) is still more air than
     any block inside a section gets, so the rhythm still says "new section". */
  --section-y: clamp(2.5rem, 1.85rem + 2.6vw, 4.25rem);

  --ease: cubic-bezier(0.2, 0, 0.2, 1);
}

@media (prefers-color-scheme: dark) {
  :root {
    color-scheme: dark;
    --canvas: #121509;
    --card: #1E2412;
    --elevated: #2D341B;
    /* On an OLED-dark page a band has to move AWAY from the canvas in the
       direction that reads as "a different section", and that direction is up,
       not down. So dark's band is the app's `card`, one step above canvas. */
    --band: #1E2412;
    --sunken: #0A0C05;
    --hairline: #3A4326;
    --hairline-strong: #4A5533;
    --line-control: #909583;
    --ink: #F3F4F0;
    --ink-muted: #AEB2A4;
    --ink-faint: #909583;
    --accent: #C8FF00;
    --accent-on-tint: #C8FF00;
    --accent-hover: #D8FF4C;
    --accent-soft: #232D0A;
    --accent-line: #506600;
    --on-accent: #121509;
    /* The lockup's own dark twin, from assets/brand/wordmark-*-dark.svg. */
    --brand-flow: #C8FF00;
    --img-light: none;
    --img-dark: block;
    /* The anchor cannot be the canvas, and in dark it would be: both were
       #121509. So it drops BELOW the canvas rather than rising above it, and
       the hairline does the separating that a colour step no longer can. */
    --anchor: #070A03;
    --anchor-line: #2B3320;
    /* THIS USED TO BE `none`, IN BOTH, AND IT IS MOST OF WHY THE DARK PAGE READ
       AS FLAT.

       The note that stood here was borrowed from src/constants/theme.ts — "a
       drop shadow on a near-black canvas is invisible work" — and the premise
       is right while the conclusion is not. A shadow DARKENS, so on a canvas
       that is already near-black there is nothing left to take away and the
       shadow does no work. What the note missed is that this is an argument
       against the LIGHT-mode shadow being reused, not an argument for having
       no elevation at all. Dark interfaces get their depth the other way up:
       from a lit top edge, not from a dark bottom one.

       So dark keeps a cast shadow — smaller job, bigger numbers, because it is
       now separating a card from a canvas 1.5 luminance points away rather
       than from white — and leans on `--edge`, which is a light and therefore
       works better here than it does in light mode. Measured on the dark
       landing page: a `.card` against `--canvas` went from a 1px hairline as
       its only boundary to a hairline, a 7%-white top edge and a 26px cast.
       Nothing else in the palette moved, so §MEASURED CONTRAST is untouched.

       `--shadow-float` keeps its old job and is the only one big enough to be
       read as "off the page" rather than "on it". */
    --shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 10px 26px -12px rgba(0, 0, 0, 0.75);
    --shadow-float: 0 4px 10px rgba(0, 0, 0, 0.5), 0 26px 50px -18px rgba(0, 0, 0, 0.85);
    --shadow-lift: 0 2px 6px rgba(0, 0, 0, 0.55), 0 24px 44px -16px rgba(0, 0, 0, 0.85);
    --edge: inset 0 1px 0 rgba(255, 255, 255, 0.07);
  }
}


/* ---------------------------------------------------------------------------
   2. RESET AND BASE TYPOGRAPHY

   Line heights are the app's, computed from src/global.css: display 38/32 =
   1.19, title 32/26 = 1.23, heading 26/20 = 1.30. Body is the one departure —
   the app sets 22/15 = 1.47, this file sets 1.6, because a 44rem web measure is
   twice the width of a phone column and needs the extra leading to track.

   `overflow-wrap: break-word` at the root is the 320px insurance policy: Czech
   compounds and a long support address are the two things that would otherwise
   push a horizontal scrollbar onto the narrowest phone.
   --------------------------------------------------------------------------- */

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* Nothing is pinned to the top of the viewport any more — the sticky header
     is gone — so an in-page anchor needs no allowance for a bar, only enough
     air that the heading it lands on is not welded to the window's edge. */
  scroll-padding-top: var(--r-4);
}

body {
  min-height: 100vh;
  background: var(--canvas);
  color: var(--ink-muted);
  font-family: var(--font-text);
  font-size: var(--step-0);
  font-optical-sizing: auto;
  line-height: 1.6;
  overflow-wrap: break-word;
  text-rendering: optimizeLegibility;
}

h1, h2, h3, h4 {
  font-family: var(--font-display);
  color: var(--ink);
  letter-spacing: -0.02em;
  font-weight: 650;
  text-wrap: balance;
}

h1 { font-size: var(--step-4); line-height: 1.19; }
h2 { font-size: var(--step-3); line-height: 1.23; letter-spacing: -0.018em; }
h3 { font-size: var(--step-2); line-height: 1.3; letter-spacing: -0.012em; }
h4 { font-size: var(--step-1); line-height: 1.29; letter-spacing: -0.005em; }

p { text-wrap: pretty; }

/* A narrow column needs MORE leading, not less: the eye returns to the left
   edge more often, and 1.6 that reads as generous at 700px reads as tight at
   320px. The type scale itself does not collapse — its clamp minimums are the
   app's own ratios, which is the size this copy was written to be read at. */
@media (max-width: 40rem) {
  body { line-height: 1.7; }
}
strong, b { color: var(--ink); font-weight: 650; }
em { font-style: italic; }
small { font-size: var(--step--1); }

a { color: var(--accent); text-decoration-thickness: from-font; text-underline-offset: 0.18em; }
a:hover { text-decoration-thickness: 2px; }

code, kbd, samp {
  font-family: var(--font-mono);
  font-size: 0.9em;
  background: var(--sunken);
  border-radius: var(--r-1);
  padding: 0.1em 0.35em;
}

img, svg { max-width: 100%; height: auto; }
svg { display: block; }

hr {
  border: 0;
  border-top: 1px solid var(--hairline);
  margin-block: var(--r-5);
}

ul, ol { padding-left: 1.35em; }
li { margin-block: 0.35em; }
li::marker { color: var(--ink-faint); }

/* ---- Focus -----------------------------------------------------------------
   One ring, everywhere, in the brand accent, and it survives both schemes.

   Three decisions, all of which the browser default gets wrong on a page made
   of pills and cards:

   - **`outline`, not `box-shadow`.** An outline follows the element's own
     `border-radius`, so the ring round a 999px pill is a pill and the ring
     round a card is a card. A box-shadow ring has to be told the radius twice
     and drifts the first time one of them changes.
   - **`outline-offset: 2px`**, so the ring is separated from the control by a
     band of whatever surface it sits on. That gap is what makes it legible on a
     filled button, where a flush accent ring on an accent fill would vanish.
   - **`var(--accent)`, which flips with the scheme.** Measured: 4.62 on the
     light canvas and 4.94 on a card; 6.56 and 5.66 in dark. The non-text floor
     is 3:1, so the ring clears it with room in both, and inside a tinted
     section `--accent` is already rebound to `--accent-on-tint` (7.41 light /
     5.66 dark), so it stays visible there too without a special case.

   No `border-radius` is set here. The old rule did, and it silently squared off
   whatever it landed on for as long as focus was held.
   ----------------------------------------------------------------------------- */
:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}
/* Keyboard focus is a state, and states get a transition like every other. */
:where(a, button, summary, label, [tabindex]) {
  transition: outline-offset 120ms var(--ease);
}

::selection { background: var(--accent-soft); color: var(--ink); }

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

.skip {
  position: absolute;
  left: var(--r-3);
  top: var(--r-3);
  z-index: 10;
  transform: translateY(-160%);
  background: var(--card);
  color: var(--ink);
  border: 1px solid var(--line-control);
  border-radius: var(--radius-field);
  padding: 0.6em 1em;
  text-decoration: none;
  box-shadow: var(--shadow-float);
}
.skip:focus { transform: none; }

/* THE LANGUAGE OFFER'S STYLES LIVED HERE and are deleted with the bar.

   It was one line above the header, unhidden by a script when the reader's
   browser asked for a language the page was not in. The server decides that now
   — `sf_apply_language()` in inc/render.php reads `Accept-Language` and answers
   with the right document rather than with an offer to go and find it — so
   there is no bar, no `[hidden]` to out-specify, and no script deciding
   anything after first paint. */

/* ---------------------------------------------------------------------------
   3. ICONS, FLAGS AND THE BRAND LOCKUP

   Every icon is an inline <svg> on a 24 unit grid, stroked in `currentColor` at
   1.75 — Lucide draws at 2, the app's Ionicons-outline reads at about 1.5, and
   1.75 is the weight at which a line icon sits at the same optical density as
   650-weight Segoe next to it. Sizing is in `em` so an icon always scales with
   the text it belongs to, which is the whole reason emoji had to go: an emoji
   is a font glyph the page cannot colour, cannot align and cannot resize
   consistently across three operating systems.
   --------------------------------------------------------------------------- */

.icon {
  display: inline-block;
  width: 1.15em;
  height: 1.15em;
  flex: none;
  vertical-align: -0.2em;
}
/* The platform marks (TikTok, Instagram, …) are filled, not stroked. */
.icon--solid { fill: currentColor; stroke: none; }

.flag {
  display: inline-block;
  width: 1.35em;
  height: 0.9em;
  flex: none;
  vertical-align: -0.08em;
}

/* The lockup is the brand name; it must not be re-typeset in the page font.
   Mark and "Stash" inherit `currentColor` from .brand, "Flow" takes the brand
   accent — so the whole thing follows the RESOLVED theme, including a manual
   override that disagrees with the system, with no image swap and no second
   file to keep in step. The literal fill inside the SVG is only the fallback
   for opening the file on its own. */
/* The lockup is two pieces now: a vector mark that takes `currentColor`, and
   the app's own wordmark as an image, one file per colour scheme.

   The proportions come off the app's header rather than out of the air — the
   word is a little under five times the mark's width there, with roughly two
   fifths of a mark between them. `aspect-ratio` on both is stated so the header
   reserves its space in the frame before either asset has resolved; the word is
   371×89 and cannot be inferred from anything else in time. */
.wordmark {
  display: inline-flex;
  align-items: center;
  gap: 0.42em;
}
.wordmark .mark-glyph {
  display: block;
  height: 2rem;
  width: auto;
  aspect-ratio: 1;
  flex: none;
}
/* Beats `.only-light { width: 100% }` from the screenshot pair, which would
   otherwise stretch the word to the header's width. The display value still
   comes from there, so the wordmark follows the RESOLVED theme exactly like
   every screenshot on the page. */
.wordmark .wordmark-word {
  height: 2.1rem;
  width: auto;
  max-width: none;
  aspect-ratio: 371 / 89;
  flex: none;
}
.mark { display: block; height: 2rem; width: auto; aspect-ratio: 1; }

/* ---------------------------------------------------------------------------
   THE GOOGLE PLAY BADGE

   Markup rather than Google's PNG: the CSP allows no external host, so the
   asset has to be ours either way, and the badge exists in a different file per
   language — four rasters kept in step with four locales, by hand, forever.
   As markup it takes the reader's language for free and stays sharp at any
   size.

   It deliberately does NOT use `.btn`. It is a piece of somebody else's design
   system and the whole value of a store badge is that a visitor recognises it
   before reading it; restyling it to match our buttons would throw that away.
   Dark pill in both themes, for the same reason.
   --------------------------------------------------------------------------- */
/* GOOGLE'S FILE, SIZED AND OTHERWISE LEFT ALONE. Everything this rule set used
   to do — the black pill, the border, the two text sizes — was rebuilding the
   badge in CSS, and the badge is not ours to rebuild. All that is left is how
   big it is and what happens under the cursor.

   The height is what is set, not the width: Google's guidance is expressed as a
   minimum height with clear space around it, and the supplied PNG has that clear
   space baked in — which is why the visible pill looks smaller than the box.
   `width: auto` keeps the aspect ratio from the file's own 646x250.

   `translate`, not `transform`: same reason as the cards one section down —
   these are reveal targets and a `transform` here loses to the animation. */
.play-badge {
  display: inline-block;
  line-height: 0;
  border-radius: 0.75rem;
  transition: translate 160ms var(--ease), filter 160ms var(--ease);
}
.play-badge img {
  display: block;
  height: 3.4rem;
  width: auto;
}
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  /* `:not(.play-badge--pending)` is the whole point of the modifier. The lift
     is the badge saying "I am a control"; while there is no listing that is a
     lie, and a lie the mouse discovers is worse than a plain image. */
  .play-badge:not(.play-badge--pending):hover { translate: 0 -2px; filter: brightness(1.12); }
  .play-badge:not(.play-badge--pending):active { translate: none; }
}

/* The badge before there is a listing: still Google's artwork, still the same
   size so nothing on the page moves when it goes live, but visibly not a
   destination — no lift, no pointer, and a line underneath that says why.
   Dimming alone was not enough: at 0.6 the badge still read as a button on a
   dark background, and dimming HARDER would have started to look like a
   loading state. The caption does the work the styling cannot. */
.play-badge--pending {
  cursor: default;
  display: inline-flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.45rem;
}
/* The dimming belongs on the ARTWORK, not on the wrapper. On the wrapper it
   also composited the caption toward the backdrop, and measured in the browser
   that took one instance to 4.2:1 — under the 4.5:1 a 13px label needs. The
   badge is what should look unavailable; the sentence explaining why has to
   stay legible. */
.play-badge--pending img {
  opacity: 0.72;
}
.play-badge__note {
  font-size: 0.8125rem;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: 0.01em;
  color: var(--ink-muted);
}

/* The closing card puts the badge and the mail button on one line, and lets
   them stack rather than shrink when there is no room for both. */
.cta-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--r-3);
}
.cta-row .actions { margin: 0; }
.hero-actions { display: flex; flex-wrap: wrap; gap: var(--r-3); }

/* The mid-page badge. It ends a section rather than starting one, so it takes a
   hairline above it and sits centred: a store badge floated against the left
   rail reads as a stray image, the same badge under a rule reads as the point
   the section was making. Nothing else changes — the badge is the identical
   component the hero and the closing card use. */
.cta-inline {
  display: flex;
  justify-content: center;
  margin-top: var(--r-5);
  padding-top: var(--r-4);
  border-top: 1px solid var(--hairline);
}

/* ---------------------------------------------------------------------------
   4. LAYOUT

   Two container widths and a section rhythm that is deliberately uneven. A page
   whose every gap is the same size reads as a list of blocks: --section-y is
   the normal beat, .section--tight halves it where a section continues the one
   above, .section--loose adds half again before a change of subject.

   `.wrap` is the only element with horizontal padding, so nothing can double it
   and push content off a 320px screen.
   --------------------------------------------------------------------------- */

.wrap {
  width: 100%;
  max-width: var(--w-wide);
  margin-inline: auto;
  padding-inline: var(--pad);
}
.wrap--prose { max-width: var(--w-prose); }

/* ONE LEFT RAIL, on the pages that need one.

   The landing page alternates 72rem sections with 40rem prose ones, and both
   were centred, so the left edge stepped 256px inward at #why, back out at
   #share, in again at #honesty. Three steps down one page: at full width it
   reads as two documents pasted together, because a reader tracks the left
   edge and nothing else explains why it moved.

   So a prose section keeps the wide container and constrains its CHILDREN to
   the reading measure instead. Same line length, same left edge as everything
   above it.

   The `:has()` is the whole point and is not decoration: it asks whether this
   page actually mixes widths. /privacy and /faq are prose from top to bottom —
   there is no rail for them to line up with, and left-aligning a 40rem column
   in a 72rem box would just hang it off one side with a third of the page
   empty. Those pages stay centred. The rule applies itself only where the
   problem exists. */
main:has(.section > .wrap:not(.wrap--prose)) .wrap--prose { max-width: var(--w-wide); }
main:has(.section > .wrap:not(.wrap--prose)) .wrap--prose > * { max-width: var(--w-prose); }

.page {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}
.page > main { flex: 1 1 auto; }

/* THE 404 SITS IN THE MIDDLE OF WHAT IS LEFT.
   Three lines and a button pinned to the top of a 1600px window put 500px of
   empty page between the button and the footer, which reads as a page that
   failed to load rather than as one that is telling you something. `main`
   already takes the leftover height; this makes the block use it.

   Scoped by `:has(> #not-found)` so it cannot reach any other page. Everywhere
   else, content starting at the top is correct — it is only correct here
   because there is no content below it to start above. */
.page > main:has(> #not-found) { display: flex; align-items: center; }
.page > main > #not-found { width: 100%; }

/* THE RHYTHM IS A PROPERTY OF THE BOUNDARY, NOT OF THE SECTION, and the first
   attempt at this got it wrong in a way worth recording because the result
   looked deliberate.

   `padding-block` applies to both ends, so what a reader sees between two
   sections is the sum of the one above's bottom and the one below's top. Set
   tight and loose as symmetric padding and they alternate into each other: a
   tight section's small bottom lands against a loose section's large top and
   the sum comes back to the normal gap. Measured at 1400px, the modifiers
   produced 146 / 132 / 99 / 132 / 132 / 132 / 132 / 132 — one variation in
   eight boundaries, on a page where six sections carried a modifier. Every
   modifier but one was cancelled by its neighbour.

   So the padding stays constant — it is the section's own breathing room, and
   for a `.section--band` it is the tinted slab itself, which must be symmetric
   or the tint looks like it slipped — and the modifiers move the boundary with
   `margin-top`, which belongs to exactly one gap and cannot be cancelled.
   Measures 99 / 132 / 198.
   --------------------------------------------------------------------------- */
.section { padding-block: var(--section-y); }
.section--tight { margin-top: calc(var(--section-y) * -0.5); }
.section--loose { margin-top: var(--section-y); }
/* ═══ §SCI-FI GROUND ═══════════════════════════════════════════════════════
   The complaint this answers, in order: the page was flat; then only the hero
   got fixed; then the fix leaked onto the widgets as face gradients, which is
   the dated look. So the rule this section settles is —

     GRADIENTS LIVE ON GROUNDS. NEVER ON THE FACE OF A CONTROL.

   and the register they are drawn in is instrument panel: a lit horizon at
   each section's leading edge, the accent used as EMITTED light rather than
   as paint, and — behind it all — the section's own subject drawn very large
   and very faint. That is what "sci-fi" buys here without costing the brand:
   the palette is untouched (olive, lime, moss-black, the same three the app
   ships), the type is untouched, and nothing becomes glass, chrome or
   neon-on-black. It is the same warm olive page, lit from behind.

   TWO LAYERS:

     background-image   the ground — a diagonal accent sweep, strongest at the
                        section's top-trailing corner, dying out well before
                        the reading column. Every section, canvas and band
                        alike; a band's is stronger for having a tint under it.
     .section-mark      the watermark — see §THE WATERMARK below.

   THERE WAS A COORDINATE GRID HERE AND IT IS GONE. 1px lines on a 64px pitch,
   over every section. It was the right instinct and the wrong object: a grid
   is the SAME texture everywhere, so nine sections got one identical overlay
   and none of them got anything that said what it was about. The watermark
   replaces it with a drawing that changes per section — the platform logos
   behind the section about those platforms, a cart behind the one about
   carts — which is the difference between decorating a page and illustrating
   one.

   The hero is excluded from both — it has its own, louder atmosphere (two
   light pools, the ring field, and the slab gradient) and a second background
   idea on top of that is noise. */
.section:not(.hero) { position: relative; isolation: isolate; }
/* THE GROUND IS A `background-image` ON THE SECTION, NOT A PSEUDO-ELEMENT, and
   that is forced rather than stylistic: `.section--band::before` is already
   taken — it is the animated seam at the top of a band (§11) — and a
   `.section:not(.hero)::before` rule outranks it at (0,2,1) against (0,1,1),
   so putting the ground there silently deletes the seam. Backgrounds layer
   without needing a box, so nothing is lost by using one.

   `--accent` resolves per section: inside a `.section--band` it is already
   rebound to `--accent-on-tint`, so a band's sweep is automatically the
   band's own accent and no variant rule is needed. Plain sections set no
   `background-color`, so the canvas shows through underneath. */
.section:not(.hero) {
  background-image:
    linear-gradient(
      118deg,
      transparent 0%,
      transparent 46%,
      color-mix(in oklab, var(--accent) 10%, transparent) 78%,
      color-mix(in oklab, var(--accent) 20%, transparent) 100%
    ),
    radial-gradient(70% 60% at 88% 0%, color-mix(in oklab, var(--accent) 14%, transparent), transparent 62%);
}
/* ---- §THE WATERMARK -------------------------------------------------------
   The section's subject, drawn enormous and nearly invisible behind it.
   `sf_section()` emits the marks — see its `marks` option — as real inline
   `<svg>`, so each one inherits `currentColor` and follows the accent and the
   colour scheme, which a `background-image` of data: URIs could not.

   IT LIVES IN THE MARGIN, NOT UNDER THE TEXT. The marks are laid out along
   the section's trailing edge, in the same corner the ground's sweep is
   brightest, because that is the part of every row on this page that is
   empty — the copy column is left-aligned and stops at 40rem. So the
   illustration fills the void the layout already had instead of putting a
   picture under a paragraph.

   Three guards, and none is optional:

     z-index: -1        with `isolation: isolate` on the section, so a mark
                        can never paint over type at any zoom or width.
     pointer-events     none. It is decoration and must not eat a click.
     opacity            0.06. High enough to see when you look for it, low
                        enough that §MEASURED CONTRAST is unaffected — the
                        marks sit on the ground, and nothing is read against
                        them.

   Below 62rem it is not rendered at all. Narrow layouts have no empty
   trailing margin to put it in, so the marks would slide under the copy, and
   a watermark under body text is exactly what the `z-index` guard is there to
   make impossible rather than merely unlikely. */
.section-mark {
  position: absolute;
  z-index: -1;
  inset-block: 0;
  inset-inline-end: 0;
  width: 42%;
  display: none;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: clamp(1rem, 3vw, 3rem);
  padding: var(--r-5);
  pointer-events: none;
  color: var(--accent);
  opacity: 0.06;
}
@media (min-width: 62rem) {
  .section-mark { display: flex; }
}
/* The marks are not all the same size on purpose: a row of five identical
   glyphs is a sprite sheet, and the point of a watermark is that it reads as
   one drawing rather than as a list. The first is the biggest — it is the
   platform the section's own copy names first — and the rest step down. */
.section-mark svg { width: 100%; height: auto; display: block; }
.section-mark > span { flex: 0 0 auto; width: clamp(3.5rem, 7vw, 6.5rem); }
.section-mark > span:first-child { width: clamp(6rem, 12vw, 11rem); }
.section-mark > span:nth-child(3) { width: clamp(4.5rem, 9vw, 8rem); }
/* A single mark has nothing to compose with, so it simply gets the whole
   panel and sits in the middle of it. */
.section-mark > span:only-child { width: clamp(8rem, 16vw, 15rem); }
@media (prefers-reduced-motion: no-preference) {
  /* It drifts, at the same rate and for the same reason as the showcase
     phones (§11): the watermark is the largest thing in the margin, and a
     large thing that is perfectly static while the page moves reads as a
     sticker. `entry`-to-`exit` so it is tied to the whole passage of the
     section rather than to its arrival. */
  @supports (animation-timeline: view()) {
    .section-mark {
      animation: shot-drift linear both;
      animation-timeline: view();
      animation-range: entry 0% exit 100%;
    }
  }
}

/* A NEGATIVE MARGIN MUST NEVER PULL CONTENT UP INTO A BAND. The band's tint
   ends at its padding, not at its last line, so a section pulled into that zone
   puts its heading on the tint — which reads as a broken background rather than
   as a tighter gap. `#more` sat directly under the grocery band and would have
   done exactly this. Guarded here rather than in the page, so the next person
   to write `'space' => 'tight'` under a band gets a smaller effect instead of a
   visual bug. */
.section--band + .section--tight { margin-top: 0; }

.section--band {
  /* `background-color`, NOT the `background` shorthand. The shorthand also
     resets `background-image`, and this rule is later in the file than the
     shared ground in §SCI-FI GROUND at the same specificity (0,2,0) — so
     writing it as a shorthand here silently strips the sweep and the grid off
     every band, which is precisely the "only the hero got fixed" state this
     pass is undoing. A band differs from a plain section by its FILL and its
     token rebinds; the lit ground is common to both and is set in one place. */
  background-color: var(--band);
  border-block: 1px solid var(--hairline);
  --card: var(--elevated);
  --accent: var(--accent-on-tint);
}

/* A BAND DIRECTLY UNDER THE HERO USED TO SEPARATE NOTHING. NOW IT DOES.

   The rule that stood here removed the band's ground whenever it followed the
   hero, and the reasoning was right for the page it was written against: the
   hero was itself a tinted slab, so a tinted band under it landed tint-on-tint
   and the two read as one long murky block — "consecutive grounds is a rhythm
   problem, not a contrast problem".

   The hero is not a tinted slab any more, it is #121509. A band under it is now
   the FIRST light ground on the page, arriving straight off the darkest one,
   which is the biggest step the page has anywhere. Suppressing it was the one
   thing that could waste that. So the exception is deleted rather than tuned —
   the general rule was always correct, and the condition it was avoiding no
   longer exists.

   Kept as a note because "remove the band under the hero" is a plausible thing
   to re-derive from first principles, and the reason it is wrong now is not
   visible in the rule that would replace it. */

/* Section heads sit left and stop at 40rem. Centring them would make every
   section the same shape, which is most of what "template" means. */
.section-head { max-width: 40rem; margin-bottom: var(--r-5); }

/* THE RULE ABOVE A SECTION HEADING.

   The hero has an `.eyebrow` — a short accent rule and a word — and it is the
   only thing on the page that announced a section before its heading. Every
   other section opened on bare display type on a bare ground, which is most of
   why the page read as a stack of paragraphs with occasional larger paragraphs
   in it: nothing marked where one argument stopped and the next began except
   the size of the first line.

   So a section whose head has no eyebrow gets the rule without the word. Same
   mark, same colour, same 2px it has above the hero, and it costs no copy —
   which matters, because a kicker would have to be written and translated ten
   times to say what the heading already says.

   `> h2:first-child` is the guard: where an eyebrow IS present it already draws
   this exact rule, and two would be a ladder. */
.section-head > h2:first-child::before {
  content: "";
  display: block;
  width: 3.5rem;
  height: 3px;
  border-radius: 3px;
  margin-bottom: var(--r-3);
  /* A gradient rather than a flat 2.75rem of accent, and the extra width is
     paid for by the fade: the mark now starts solid and dies away to the right
     over its last third, so it reads as a stroke that was drawn rather than as
     a dash that was placed. Same ink on the page, more length to see it in.
     Same construction as the band seam in §11, at a twentieth of the width. */
  background: linear-gradient(90deg, var(--accent) 0%, var(--accent) 62%, transparent 100%);
  transform-origin: left center;
}
/* AND IT DRAWS ITSELF, for the same reason the band seam does — this is the
   mark that says "new subject", and a mark that arrives with the heading is
   read, while a mark that was already there is furniture. It finishes at
   `entry 55%`, before the heading it belongs to has finished fading up, so the
   rule is complete by the time there is a word above it to label.

   The base state is `scaleX(1)`: with no scroll-driven timeline, or with
   reduced motion, the rule is simply drawn. Nothing here can strand it at
   zero — same guarantee as every other reveal on this page. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .section-head > h2:first-child::before {
      animation: seam-draw linear both;
      animation-timeline: view();
      animation-range: entry 5% entry 60%;
    }
  }
}
.section-head > * + * { margin-top: var(--r-3); }

/* A SECTION HEAD ABOVE A PICTURE ROW NEEDS TWICE THE GAP, and this is the bill
   for top-aligning the showcase copy (see §THE ROW'S VERTICAL PROPORTION).

   While the row's claim was centred against an 806px phone it started 335px
   down and could not possibly collide with anything. Top-aligned it starts at
   the row's first pixel, so in `#what` — the one section whose head is a
   heading and nothing else — the h2 and the first row's h3 ended up 18px of box
   apart, 37.6px type beside 28px type, both bold, both near the top of the same
   screen. Measured 18px, and it read as a two-column heading rather than as a
   title with a section under it: the reader cannot tell which line names the
   section.

   32px -> 64px, so the measured gap becomes 50px, which is close to the 55px
   the showcase already puts between two rows. The section head is then one step
   further from its first row than the rows are from each other, which is the
   order those two distances have to be in.

   Fixing a proportion is allowed to cost space somewhere else; pretending it
   did not is how the next pass finds a new complaint. */
.section-head:has(+ .showcase, + .showcase-row) { margin-bottom: var(--r-6); }
.section-lead { font-size: var(--step-1); color: var(--ink-muted); }

.stack > * + * { margin-top: var(--r-3); }

/* A list that fills the rail instead of trailing down the left half.
   `columns`, not a grid: the items are one sentence each and of uneven length,
   and multi-column balances them by height without anybody deciding which item
   starts the second column. `break-inside: avoid` is what stops an item being
   cut across the gutter, which is the one way this reads worse than a single
   column. Below 64rem it is one column — two columns of a sentence each, on a
   phone, is a newspaper nobody asked for. */
@media (min-width: 64rem) {
  .cols-2 { columns: 2; column-gap: var(--r-6); }
  .cols-2 > li { break-inside: avoid; }
}

/* A list whose bullet is a drawn mark. The icon is `flex: none` and nudged
   down by a hair so it sits on the x-height of the first line rather than on
   its cap height — text centres optically on the x-height, which is why the
   cap-height version always looks high (Pixel Perfect p28). */
.marked { list-style: none; padding-left: 0; }
.marked > li { display: flex; align-items: flex-start; gap: 0.7em; }
.marked > li + li { margin-top: var(--r-3); }
.marked > li > svg {
  flex: none;
  width: 1.15em;
  height: 1.15em;
  margin-top: 0.18em;
  color: var(--accent);
}

/* The kicker above a heading. A tinted uppercase PILL is the single most
   recognisable generated-landing-page component there is, so this is a ruled
   label instead: a short accent rule, then the word. Same information, and it
   reads as editorial rather than as a component library. */
.eyebrow {
  display: flex;
  align-items: center;
  gap: 0.7em;
  font-size: var(--step--1);
  font-weight: 650;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--accent);
}
.eyebrow::before {
  content: "";
  flex: none;
  width: 1.75rem;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
}

/* ---------------------------------------------------------------------------
   5. THE DARK ANCHOR — masthead, hero slab, footer

   ## What this section replaces, and why the whole thing went

   It used to be "HEADER, BRAND, AND THE TWO SWITCHERS": a sticky glass topbar
   carrying the lockup, a `<details>` language dropdown and a three-radio theme
   switch, at 77px on every page. All of it is gone.

   The two controls went first, and they took the bar's reason for existing with
   them. The colour scheme is the one preference a browser already knows the
   answer to, so it follows `prefers-color-scheme` and nothing else; the language
   is negotiated from `Accept-Language` before a byte of HTML is written
   (`sf_apply_language()` in inc/render.php), with the footer's ten-flag list as
   the manual override. A bar holding a wordmark and two controls that no longer
   exist is not a header, it is a 77px margin that follows you down the page.

   And it did follow you down the page, translucently. Measured on the live site
   at 1440: the confession's `<h2>` passed BEHIND the wordmark on the way up, and
   at 3000px of scroll a screenshot's top edge was visible through the fill. A
   sticky translucent bar over a page of photographs is a collision waiting for
   the right scroll offset, and there is no offset at which it looks deliberate.

   ## What is here instead

   The page now has a beginning and an end, and they are the same object: a dark
   slab built from `--anchor` and its four companions (see §1). At the top it is
   the masthead-and-hero; at the bottom it is the footer. In between, nine light
   sections. That is the whole composition, and it is what the old page was
   missing — nine sections of celery-white opening and closing on more
   celery-white, with the only structure a 1px rule under a bar.

   The slab does not invert with the scheme. Dark on a light page is the
   contrast; dark on a dark page is the frame. Only its own ground moves, from
   #121509 to #070A03, because in dark mode the first of those IS the canvas.

   ## The token rebinds, which are the entire mechanism

   Three selectors take one block of rebinds and everything nested inside them —
   headings, leads, links, the eyebrow rule, the Play badge's surroundings, the
   screenshot pair — resolves correctly with no further rules. Nothing inside
   the slab knows it is on a slab.

   `--img-light: none; --img-dark: block` is the load-bearing one and the least
   obvious. The wordmark's word is a raster pair (`wordmark-light.webp` is the
   DARK-INK artwork for a light ground, and vice versa), and so is every
   screenshot. On the dark slab both must take their dark-ground variant even
   when the page around them is light — which is also why the hero's phone shows
   the app in dark mode on a light page, and why that reads as intentional
   rather than as a mismatch: a dark screen on a dark slab is what a phone
   actually looks like sitting on that surface.
   --------------------------------------------------------------------------- */

.masthead,
.hero,
.site-footer {
  color-scheme: dark;
  background: var(--anchor);
  color: var(--anchor-ink);
  --ink: var(--anchor-ink);
  --ink-muted: var(--anchor-ink-muted);
  --ink-faint: #7C8470;
  --accent: var(--anchor-link);
  --accent-on-tint: var(--anchor-link);
  --accent-hover: var(--anchor-accent);
  --accent-soft: #1C2411;
  --accent-line: #3C4826;
  --hairline: var(--anchor-line);
  --hairline-strong: #46512F;
  --line-control: #8F9782;
  --card: #1B2110;
  --elevated: #232B15;
  --sunken: #050702;
  --canvas: var(--anchor);
  --on-accent: #0B0E05;
  --brand-flow: var(--anchor-accent);
  --shadow: none;
  --shadow-float: none;
  --img-light: none;
  --img-dark: block;
}

/* THE PHOTOGRAPH, UNDER A SCRIM — the hero's signature since 4 September
   2026 (see the header). The neon streaks from the launch banner sit under
   a vertical scrim that keeps the top clearer, where the lockup rides on the
   glow, and darkens towards the foot so the headline, the lead and the Play
   badge read on what is effectively `--anchor` again. The scrim is stated in
   the anchor's own hex, not in black, so the slab's hue does not go grey
   where the picture fades. `cover` and a 5.4:1 strip: on a wide window the
   streaks run edge to edge, on a phone the middle of the band shows, which
   is its darkest, calmest part — right under the copy. Only `.hero`: the
   masthead and footer stay flat, so the picture reads as the hero's own
   signature rather than as the page's default surface. The diagonal sweep
   this replaced is in git history; it had nothing the picture does not. */
.hero {
  background:
    linear-gradient(
      180deg,
      color-mix(in srgb, var(--anchor) 35%, transparent) 0%,
      color-mix(in srgb, var(--anchor) 62%, transparent) 45%,
      color-mix(in srgb, var(--anchor) 92%, transparent) 100%
    ),
    url("img/hero-backdrop.jpg") center / cover no-repeat,
    var(--anchor);
}

/* ON A DARK PAGE THE SLAB NEEDS AN EDGE, AND ONLY THERE.

   In light the boundary is #121509 against #F6F8F2 and there is nothing a
   border could add. In dark the canvas is #121509 and the slab drops below it
   to #070A03 — a real step, and the right direction, but a small one, and a
   1px hairline is what turns "slightly darker" into "a different surface".

   Three separate declarations rather than `border-block` on all three: the
   masthead's top edge, the footer's bottom edge and the hero's top edge are all
   at the very edge of the document, where a line is either invisible or a
   scratch across the top of the window. */
@media (prefers-color-scheme: dark) {
  .masthead { border-bottom: 1px solid var(--anchor-line); }
  .masthead + .hero { border-top: 0; }
  .hero { border-bottom: 1px solid var(--anchor-line); }
  .site-footer { border-top: 1px solid var(--anchor-line); }
}

/* ---- The masthead: the lockup, on the pages that have no hero -------------
   Not a bar. It does not stick, it has no border and it holds nothing but the
   mark, on the same rail the prose below it starts from. On /privacy, /faq and
   /delete-account it is the whole of the dark anchor at the top — a slim slab
   that gives a legal document a masthead instead of dropping the reader
   straight into a heading. The landing page does not get one; its hero carries
   the lockup at hero scale, and two lockups a hundred pixels apart was the
   thing being fixed. */
.masthead {
  padding-block: clamp(1.5rem, 1.1rem + 1.6vw, 2.5rem);
}

/* THE MASTHEAD SHARES THE RAIL OF WHATEVER IS UNDER IT, and getting this wrong
   is visible from across the room. Every page that has a masthead today is
   prose from top to bottom, so its content sits in a 40rem column: at 1440 that
   column starts 400px in, while a 72rem `.wrap` starts at 184px. A lockup 216px
   to the left of the first line of the document does not read as a wide
   masthead, it reads as a mistake.

   The `:has()` mirrors the one at `.wrap--prose` above and for the same reason:
   it asks whether this page actually has a wide section to line up with. A page
   that mixes widths keeps the wide rail, because that is the rail its prose has
   already been widened to. */
.masthead > .wrap { max-width: var(--w-prose); }
.page:has(main .section > .wrap:not(.wrap--prose)) .masthead > .wrap {
  max-width: var(--w-wide);
}
.brand {
  display: inline-flex;
  align-items: center;
  color: var(--anchor-ink);
  text-decoration: none;
  /* 44px, because it is a link and it is the only one in this block. */
  min-height: 44px;
}
.brand:hover .wordmark-word { opacity: 0.86; }
.brand:focus-visible { outline: 2px solid var(--anchor-link); outline-offset: 6px; border-radius: 6px; }

/* ---- The lockup ---------------------------------------------------------
   `.hero-brand` is the landing page's, set two steps larger than the
   masthead's: it is the first thing in the first block and it has a whole hero
   to hold its own against. The mark and the word are sized independently
   because the word's artwork carries its own optical padding, so matching their
   box heights would leave the glyph looking small beside it. */
.hero-brand { margin: 0; }
.wordmark--hero .mark-glyph { height: clamp(2.1rem, 1.7rem + 1.5vw, 2.9rem); }
.wordmark--hero .wordmark-word { height: clamp(2.2rem, 1.8rem + 1.55vw, 3rem); }

/* ---------------------------------------------------------------------------
   6. HERO

   Single column until 62rem — below that a side-by-side hero has nowhere to put
   the visual without starving the headline. The wash is a corner-anchored,
   two-stop radial between two tokens that both flip with the theme, so the
   effect is authored once and is genuinely different in each mode rather than
   an inversion. Both stops are opaque colours: fading to `transparent` renders
   as a grey haze in engines that interpolate premultiplied, which on an olive
   tint is exactly the wrong direction. It is anchored in the top-left corner
   and the copy is left-aligned, which is the opposite of the centred glow the
   brand brief bans.
   --------------------------------------------------------------------------- */

/* ---------------------------------------------------------------------------
   THE HERO'S ATMOSPHERE

   A reference was given — durin.cz — and the useful thing about it is what it
   is NOT. It is not neon and it is not dark: it is a light page with a quiet
   technical weather system behind the words. Soft coloured light, a faint
   engineering grid, a constellation of thin lines and nodes. The content sits
   in front of it, fully legible, and the page reads as built rather than as
   typeset.

   Three layers here, all in `::before`, all `pointer-events: none`:

     1  two soft radial pools of accent light, off-centre and different sizes,
        so the ground is never flat;
     2  faint concentric rings radiating from behind the phone;
     3  a mask that fades all of it out towards the bottom, so the atmosphere
        belongs to the hero and does not leak into the confession below it.

   ## Layer 2 was a 44px grid, and a grid was the wrong answer

   It was replaced because it read as generic — a hairline grid behind a hero is
   the single most recognisable "generated landing page" tell there is, and it
   said nothing about this product. Rings say something: they come from behind
   the device, and the thing this app does is receive a share and send back a
   list. A grid is a surface; this is a signal.

   It is also the shape the brand already uses — the mark is a rotated square
   cut by arms, the wordmark has a swoosh under it, and neither of them is
   orthogonal. A grid was fighting the logo.

   ## No image, no request, no third party

   Every layer is a gradient. The CSP forbids a foreign host and there is no
   build step to inline an asset, so a "look" that needs a texture file is not
   available here — which turned out to be a constraint worth having, because
   the result scales to any width and inverts with the theme for free.

   ## The rings are `--hairline`, not a fixed grey

   In dark they become the dark hairline and the effect survives the theme
   without a second declaration. A hard-coded `rgb(0 0 0 / .04)` would have
   vanished on the dark page, which is where this kind of layer usually dies.

   ## Why the rings are a background and not an SVG

   A curve would be nicer and cannot be had here: `img-src 'self'` does not
   include `data:`, so a `data:` SVG in `background-image` is blocked outright by
   the CSP — which is the sort of thing that fails only in production. A
   `repeating-radial-gradient` is a real gradient, needs no source, and stretches
   to any hero height on its own.
   --------------------------------------------------------------------------- */
/* ---- THE SLAB -----------------------------------------------------------
   The hero is now the top half of the dark anchor (§5 holds its token
   rebinds), so the wash it used to carry — accent-soft fading into the canvas,
   corner-anchored — is gone: that gradient was a way of making a pale ground
   less pale, and this ground is not pale. What is left is the atmosphere, and
   it had to be re-tuned rather than inherited, because light behaves in the
   opposite direction on a dark surface. On paper the pools DARKEN the ground;
   here they LIGHTEN it, and at 16% they were a smear. 9% and 7% respectively.

   The bottom border went with the wash. A hairline between #121509 and #F6F8F2
   is a line nobody can see doing a job nothing needs done — the colour change
   IS the boundary, and it is the strongest one on the page. */
.hero {
  position: relative;
  isolation: isolate;
  /* Deeper than the masthead's block by design: the lockup sits in the same
     slab, and the run-up between it and the headline is what makes the two read
     as one masthead rather than as a logo above a section. */
  padding-block: clamp(2.25rem, 1.6rem + 3vw, 4rem) clamp(3rem, 2.2rem + 3.6vw, 5.75rem);

  /* ---- FULL SCREEN, AND THE THREE THINGS THAT MAKES LOAD-BEARING ---------
     The hero fills the first screen. Measured before this rule at a 975px
     viewport: 954px — near enough to look intentional and not near enough to
     BE it, and on any other window height it was simply whatever the content
     came to.

     `min-height`, NEVER `height`. The hero is a two-column grid whose left
     column is a headline in ten languages and whose right column is a phone;
     German and Hungarian set that headline longer than Czech does, and a
     fixed height would put the Play badge under the fold on exactly the
     languages that need it most. `min-height` grows the section on a tall
     window and gets out of the way on a short one.

     `100svh`, not `100vh`. On a phone `100vh` is the height of the viewport
     with the browser's URL bar RETRACTED, so a hero sized to it is taller
     than the screen it is meant to fit and the badge sits just below the
     fold until the reader scrolls — the exact bug this rule exists to avoid.
     `svh` is the small — bar-visible — height, which is what "one screen"
     means to somebody holding the phone. `100vh` is declared first as the
     fallback for engines without `svh`, where it is still much closer to
     right than no rule at all.

     `align-content: center` on the grid, because a min-height with no
     alignment leaves the slack at the bottom: the copy stays welded to the
     top edge and the whole gain shows up as a void under the phone. Centred,
     the extra height becomes air above AND below, which is what makes a tall
     window read as generous rather than as a layout that ran out of content.
     `.hero-inner` is already `display: grid`, so this is one declaration and
     not a new box. */
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  align-content: center;
}
/* The masthead pages have no hero, and if one is ever added there it must not
   claim a whole screen UNDER a masthead that has already taken part of one —
   that is two full screens before the first sentence. Stated as a rule rather
   than left to whoever writes that page. */
.masthead + .hero {
  min-height: 0;
}
.masthead + .hero { padding-top: 0; }
.hero::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  /* TWO POOLS, NOT ONE, AND BOTH TURNED UP.
     9% and 7% composited over near-black was measured, and it was measured
     correctly — and it still read as "the same flat dark bar" on a real
     screen, because a pool that faint is closer to a rounding error than to
     light. Lime in the top corner, the cooler sage-link low on the other,
     stronger now (18% / 14%) so the slab has two visibly different corners
     instead of one uniform near-black field with a texture on it. */
  background-image:
    radial-gradient(42rem 30rem at 88% 2%, color-mix(in oklab, var(--anchor-accent) 18%, transparent), transparent 70%),
    radial-gradient(36rem 30rem at 2% 98%, color-mix(in oklab, var(--anchor-link) 14%, transparent), transparent 72%),
    repeating-radial-gradient(
      circle at 78% 20%,
      transparent 0 92px,
      color-mix(in oklab, var(--anchor-line) 85%, transparent) 92px 93px
    );
  /* The rings carry their own alpha rather than borrowing this one, because the
     two jobs need different strengths: at the opacity that makes the light
     pools read, the rings shout. Tuned on the LIGHT page — which is now the
     page where the slab is a foreign object and therefore the harder of the two
     to get right. */
  /* Turned down from 0.95 when the photograph arrived: the pools were the
     light before there was any, and over real streaks at full strength they
     only muddied the picture. Half strength keeps the ring texture and lets
     the picture be the light. */
  opacity: 0.5;
  /* The mask no longer fades to nothing at the bottom. It used to, so the
     atmosphere would not leak into the light section below; there is a hard
     colour edge there now, so the fade would only mean the slab going flat
     towards its own foot. It fades to 55% instead: still lighter at the bottom
     than the top, still no leak, no dead zone. */
  -webkit-mask-image: linear-gradient(180deg, #000 0%, #000 46%, rgba(0, 0, 0, 0.55) 100%);
  mask-image: linear-gradient(180deg, #000 0%, #000 46%, rgba(0, 0, 0, 0.55) 100%);
}
/* The constellation: thin lines between nodes, drawn once and reused. It is the
   one part of the atmosphere that is a shape rather than a field, so it is an
   element the markup can place — `sf_scene('constellation')` — rather than a
   background. See §THE DRAWN SCENES. */
.hero-atmos {
  position: absolute;
  inset: 0;
  z-index: -1;
  width: 100%;
  height: 100%;
  pointer-events: none;
  opacity: 0.5;
}
.hero-inner {
  display: grid;
  gap: clamp(2rem, 1rem + 5vw, 4rem);
  align-items: center;
}
/* The note is a grid child, not part of `.hero-copy` — see `sf_hero()`. On one
   column it simply falls after the visual, which is the whole point; on two it
   goes back under the copy, and the visual spans both rows so the picture keeps
   the height it had. `align-self` replaces the container's `center` for the two
   text rows, which otherwise float apart with the note stranded at the bottom. */
@media (min-width: 62rem) {
  .hero-inner {
    grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
    grid-template-areas: "copy visual" "note visual";
    grid-template-rows: auto auto;
    /* The container gap is a column gap here: between the copy and the note it
       would open a 64px hole and leave the note floating. They are one block of
       text, so the note keeps the same 16px rhythm as the rest of `.hero-copy`. */
    row-gap: 0;
  }
  .hero-copy { grid-area: copy; align-self: end; }
  .hero-visual { grid-area: visual; align-self: center; }
  .hero-note { grid-area: note; align-self: start; margin-top: var(--r-4); }
}

.hero-copy > * + * { margin-top: var(--r-4); }
/* step-4, not step-5 — the same size every other page's `<h1>` takes. At step-5
   this headline ran 62.4px and seven lines at 1400px, which is not a headline,
   it is a paragraph set in display type. The tracking and leading stay tighter
   than the base `h1` because this one is still the largest block of type in the
   viewport and tight setting is what keeps a four-line headline reading as one
   object. See the scale note in §1 for the whole ladder. */
.hero h1 { font-size: var(--step-4); letter-spacing: -0.028em; line-height: 1.12; }

/* ---- THE SEAM INSIDE THE `<h1>` THAT NOTHING EVER STYLED ------------------
   `sf_hero()` has always emitted two spans — `.hero-slogan` for the four-word
   line and `.hero-claim` for the sentence that names the product — and the
   stylesheet had no rule for either. So both were set at `--step-4` and the
   `<h1>` was 104 characters of display type:

     320px   34.1px   8 lines   305px      hero is 1.74x the window
     375px   34.7px   6 lines   233px      hero is 1.70x the window
     768px   39.2px   3 lines   132px
     1440px  44.8px   5 lines   251px

   The comment directly above says a seven-line headline "is not a headline, it
   is a paragraph set in display type" and fixed the top of the type scale. It
   fixed the wrong end: the problem was never the size, it was that a headline
   and a subheading were being set as one thing. On a phone the consequence is
   that you scroll almost two full screens of hero before anything else exists —
   including the clip, whose whole job is to show the gesture in five seconds.

   The `<h1>` keeps both sentences, because the long one is what a search engine
   should read and what a link preview should quote. Only the setting changes:
   the slogan stays display, the claim becomes what it always was.

   `font-weight: 400` and `color: var(--ink-muted)` rather than a second heading
   level — this is one heading typographically split, not two headings, and the
   outline must stay one `h1`. */
/* THE ONE LINE ON THE PAGE THAT GETS COLOUR IN THE TYPE ITSELF.
   Everything else on this page carries colour in its ground — a shadow, a
   glow, a tinted card — and never in the letters, because coloured display
   type is the fastest way to make a page unreadable in exactly the spot
   where it most needs to be read. The slogan is the one exception, and it
   earns it: it is four words, it is the largest type on the page, it sits on
   a flat near-black ground with nothing behind it to fight, and it is not
   read at length — it is looked at once. A two-stop gradient along the
   headline's own two hues (lime core, sage-link edge — the same pair the
   slab atmosphere and the phone's floor both use, so this is not a third
   colour entering the page) is what turns the first thing a visitor reads
   into the thing that says "considered", not the paragraph under it.
   `background-clip: text` degrades to a solid `color` fallback in any engine
   that does not support it — declared first, so it is what renders until the
   next line is understood. */
.hero-slogan {
  display: block;
  color: var(--anchor-accent);
  background: linear-gradient(100deg, var(--anchor-accent) 0%, var(--anchor-accent) 35%, var(--anchor-link) 100%);
  background-clip: text;
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}
.hero-claim {
  display: block;
  margin-top: var(--r-3);
  font-size: var(--step-1);
  font-weight: 400;
  letter-spacing: normal;
  line-height: 1.4;
  color: var(--ink-muted);
  max-width: 44ch;
}
.hero-lead {
  font-size: var(--step-2);
  line-height: 1.45;
  color: var(--ink-muted);
  max-width: 34ch;
}
.hero-note { font-size: var(--step--1); color: var(--ink-muted); }

/* A quiet tinted plaque, not a glass panel and not a device. Flat fill, one
   hairline, the app's card shadow — the same construction as every other
   surface on the page, at a bigger radius. Use .hero-visual--bare when the
   content is itself a framed screenshot and does not want a second frame. */
.hero-visual {
  display: grid;
  place-items: center;
  border-radius: var(--radius-hero);
  border: 1px solid var(--accent-line);
  background: var(--accent-soft);
  box-shadow: var(--shadow);
  padding: clamp(1.5rem, 1rem + 3vw, 3rem);
}
.hero-visual img, .hero-visual svg { border-radius: var(--radius-card); }
.hero-visual .mark, .hero-visual .mark-glyph { height: auto; width: min(58%, 200px); color: var(--accent-on-tint); }
/* The plaque was drawn for a hero whose visual is the bare mark: a tinted panel
   gives a floating logo something to sit on. A DEVICE does not want one — a
   phone inside a rounded card reads as two nested objects, and the frame
   already carries its own edge and its own shadow. So the panel takes itself
   off whenever the hero's picture is a device, and `--bare` stays for a caller
   that wants to say so explicitly. */
.hero-visual:has(.hero-media),
.hero-visual--bare {
  border: 0;
  background: none;
  box-shadow: none;
  padding: 0;
}

/* ---------------------------------------------------------------------------
   7. BUTTONS, FEATURE CARDS, CALLOUTS, CHIPS
   --------------------------------------------------------------------------- */

.actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--r-2) var(--r-3);
  align-items: center;
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5em;
  min-height: 48px;
  padding: 0.7em 1.5em;
  border-radius: var(--radius-pill);
  border: 1px solid transparent;
  font-family: var(--font-display);
  font-size: var(--step-0);
  font-weight: 650;
  line-height: 1.2;
  text-decoration: none;
  cursor: pointer;
  transition:
    background-color 160ms var(--ease),
    border-color 160ms var(--ease),
    box-shadow 200ms var(--ease),
    transform 160ms var(--ease);
}
.btn:hover { text-decoration: none; }
.btn:active { transform: translateY(1px); }
/* A trailing arrow that leans into the direction of travel on hover. This is
   the only decorative motion on the site and it is 2px. */
.btn .icon { transition: transform 160ms var(--ease); }
.btn:hover .icon { transform: translateX(2px); }

/* The filled button carries `--edge` and a cast shadow of its own, which is the
   difference between a pill of colour and a key you could press. On hover it
   rises 1px and the cast term grows and TINTS: `color-mix` puts a little of the
   accent into the shadow so the light under the button is the button's own
   colour rather than a grey smudge, which is what a shadow under a saturated
   object actually looks like. It is a shadow, not a bloom — it never leaves the
   footprint of the control. */
.btn--primary {
  /* Flat, not a gradient face — a gradient-filled pill is exactly the look
     that reads as dated now, and the shadow underneath already does the job
     of making the pill read as an object instead of a cutout. */
  background: var(--accent);
  color: var(--on-accent);
  box-shadow: var(--edge), 0 2px 6px -1px color-mix(in oklab, var(--accent) 30%, transparent);
}
.btn--primary:hover {
  background: var(--accent-hover);
  box-shadow: var(--edge), 0 6px 20px -4px color-mix(in oklab, var(--accent) 55%, transparent);
  transform: translateY(-1px);
}

.btn--secondary {
  background-color: var(--card);
  color: var(--ink);
  border-color: var(--line-control);
  box-shadow: var(--edge), var(--shadow);
}
.btn--secondary:hover {
  border-color: var(--accent);
  color: var(--accent);
  box-shadow: var(--edge), var(--shadow-lift);
  transform: translateY(-1px);
}
.btn--secondary:active { background-color: var(--sunken); }
.btn--primary:active { background: var(--accent-hover); }
/* `.btn:active` sets `translateY(1px)` at the top of this section, and it is at
   the same specificity as the two `:hover` rules above but EARLIER in the file
   — so without this it loses, and a button held down stays lifted. A pressed
   control that does not move is the one interaction on a page everybody
   notices. Restated here, after the hovers, so press beats hover. */
.btn--primary:active,
.btn--secondary:active { transform: translateY(1px); }
@media (prefers-reduced-motion: reduce) {
  .btn--primary:hover,
  .btn--secondary:hover { transform: none; }
}

.btn--quiet {
  background: transparent;
  color: var(--accent);
  padding-inline: 0.35em;
  min-height: 44px;
}
.btn--quiet:hover { text-decoration: underline; text-underline-offset: 0.2em; }

@media (max-width: 26rem) {
  .actions .btn--primary, .actions .btn--secondary { width: 100%; }
}

/* The grid uses minmax(min(100%, 17rem), 1fr), not minmax(17rem, 1fr): the bare
   version overflows below 17rem plus padding, which is every phone in portrait.
   The min(100%, …) form collapses to a single full-width column instead, and it
   is the reason the page has no horizontal scrollbar at 320px.

   `auto-fit` alone is wrong for exactly four cards: at 1440px the 72rem
   container fits three tracks and the fourth sits alone on a second row.
   .grid--4 overrides it to 2×2 from 42rem and 4×1 from 68rem — measured, not
   guessed: at 68rem the container is 1008px and four tracks come out at 234px,
   which still holds three lines of Czech body text. */
.grid {
  display: grid;
  gap: clamp(0.9rem, 0.6rem + 1vw, 1.5rem);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
}
@media (min-width: 42rem) { .grid--4 { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 68rem) { .grid--4 { grid-template-columns: repeat(4, 1fr); } }

/* ---- THE MATERIAL OF A SURFACE ---------------------------------------------
   Every panel on this page — card, callout, chip, plaque — is built from the
   same three declarations, and they are written once here rather than four
   times below:

     background-color: <fill>            FLAT. No wash, no gradient, no bevel.
     box-shadow: var(--edge), <cast>     lit top edge, then the cast shadow
     border: 1px solid <line>            unchanged

   THE FACE OF A SURFACE IS FLAT AND STAYS FLAT. There was a `--sheen` here —
   a top-lit white wash over every fill — and it went, because a
   gradient-faced widget is the dated look and this page carried four of them
   per screen. Depth is `--edge` plus the shadow pair: light and geometry,
   not a painted highlight. Colour transitions belong to the GROUNDS behind
   content (§SCI-FI GROUND) and to nothing that a reader can click.

   `background-color` rather than the `background` shorthand, so a variant can
   rebind the fill (`.callout` takes the accent tint, `.callout--neutral` the
   band) without disturbing anything else set on the box.

   ORDER INSIDE `box-shadow` IS LOAD-BEARING: an inset listed after an outset
   paints under it, and the top edge has to be the topmost thing on the border
   box or it reads as a seam rather than as light. */
.card {
  background-color: var(--card);
  border: 1px solid var(--hairline);
  border-radius: var(--radius-card);
  padding: clamp(1.15rem, 0.9rem + 1vw, 1.75rem);
  box-shadow: var(--edge), var(--shadow);
  transition:
    transform 220ms var(--ease),
    box-shadow 220ms var(--ease),
    border-color 220ms var(--ease);
}
.card > * + * { margin-top: var(--r-2); }
.card h3 { margin-top: 0; }

/* THE HOVER IS 3PX AND IT IS NOT DECORATION.
   A grid of five cards with no hover state is a picture of a grid of five
   cards; the same grid where the one under the pointer rises is a page that is
   responding. Three pixels, the `--shadow-lift` pair, and the hairline warming
   to the accent line — no scale (a scaled card resamples its own text), no
   colour change on the ink, no glow.

   `@media (hover: hover)` because a touch device fires `:hover` on tap and
   leaves it stuck there until the next tap somewhere else — a card that lifts
   when you touch it and never comes back down. `prefers-reduced-motion` keeps
   the shadow and the border and drops the travel: the feedback survives, the
   movement does not. */
@media (hover: hover) {
  .card:hover {
    border-color: var(--accent-line);
    /* `--shadow-lift` alone is a neutral cast shadow — correct for "this card
       came off the page" but colourless, and a grid of five cards that only
       ever answer in grey is a grid that never feels like the brand touched
       it. The third layer tints the lift with the section's own accent, the
       same `color-mix` trick the primary button's shadow already uses, so the
       card rises AND catches a little of the page's own colour on the way
       up — never enough to read as a border, always under the neutral shadow
       that is doing the actual lifting. */
    box-shadow: var(--edge), var(--shadow-lift), 0 0 0 1px color-mix(in oklab, var(--accent) 16%, transparent);
    transform: translateY(-3px);
  }
}
@media (prefers-reduced-motion: reduce) {
  .card:hover { transform: none; }
}

/* The feature card, and the two things that stop a grid of them reading as a
   template.

   One: the icon sits ON the heading line, not on a coloured circle above it.
   A row of identical cards each with a tinted disc behind a glyph is the
   generated-landing-page house style; an accent-coloured line icon inline with
   the title is how the app itself labels a row.

   Two: the first card in an auto-fit grid takes two tracks and a tinted ground
   from 62rem up. The features are not equally important — the first one is the
   product's whole promise — and a page where every block has the same weight is
   a page nobody read before publishing. */
.feature {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--r-2) 0.6em;
}
.feature > * + * { margin-top: 0; }
/* THE ICON SITS ON THE FIRST LINE OF THE TITLE, NOT IN THE MIDDLE OF THE CARD.
   `align-self: center` centres it across the whole flex line, which is the
   title's ENTIRE height — so on a one-line title it was 10-13px low and on a
   two-line title it hung 18.9px down, exactly between the two lines. Measured
   at 1440 in both themes: three of the five cards are two-line titles, so it
   was the usual case rather than the edge one.

   The fix gives the icon a box the height of the title's FIRST line and centres
   the glyph inside it. `--feature-title` carries the size the `<h3>` is actually
   set at — one value, two places, and the promoted first card overrides both at
   once further down rather than needing its own icon rule. 1.3 is the h3's
   line-height ratio; it is stated here because a box that has to match a line
   box has to know what makes one. */
.feature { --feature-title: var(--step-2); }
.feature-icon {
  flex: none;
  color: var(--accent);
  align-self: start;
  display: grid;
  place-items: center;
  height: calc(var(--feature-title) * 1.3);
}
.feature-icon svg { width: 1.5rem; height: 1.5rem; vertical-align: middle; }
.feature > h3 { flex: 1 1 8ch; min-width: 0; }
/* Everything that is not the icon or the title takes a full row of its own.
   Written as :not() rather than by tag name because the icon is a <div> and a
   `.feature > div` rule would match it and stretch it across the card. */
.feature > *:not(.feature-icon):not(h3) { flex: 1 0 100%; }

@media (min-width: 62rem) {
  .grid:not(.grid--4) > .feature:first-child {
    grid-column: span 2;
    background: var(--accent-soft);
    border-color: var(--accent-line);
    --accent: var(--accent-on-tint);
  }
  /* step-2h and not step-3, for the same reason as the interrupted showcase
     row: step-3 is the section heading's size now, and this card sits directly
     under one. */
  .grid:not(.grid--4) > .feature:first-child { --feature-title: var(--step-2h); }
  .grid:not(.grid--4) > .feature:first-child > h3 { font-size: var(--feature-title); }
  .grid:not(.grid--4) > .feature:first-child > p { font-size: var(--step-1); }
  .grid:not(.grid--4) > .feature:first-child .feature-icon svg {
    width: 2rem;
    height: 2rem;
  }
}

.callout {
  --accent: var(--accent-on-tint);
  background-color: var(--accent-soft);
  border: 1px solid var(--accent-line);
  border-radius: var(--radius-card);
  padding: clamp(1.15rem, 0.9rem + 1vw, 1.75rem);
  box-shadow: var(--edge), var(--shadow);
}
.callout > * + * { margin-top: var(--r-3); }
/* A callout may now be as wide as the rail; its prose may not. Without this,
   widening the closing card would have set its one paragraph across 72rem,
   which is roughly twice a comfortable line. */
.callout p { max-width: 62ch; }
/* THE CLOSING CARD IS THE ONE A READER IS SUPPOSED TO ACT ON, and it was the
   quietest thing on the page: an accent tint one step off the canvas, a
   hairline nobody could see, and a Play badge inside it. It sat between a pale
   section and a dark footer and read as one more paragraph.

   A 3px accent edge along the top is the same mark the section heads and the
   band seams carry, at the one place it is allowed to be structural rather than
   decorative. Nothing else changes: same tint, same radius, same padding, so
   the card is not a different component, it is the same one turned up.

   `border-top-width` and not a `::before` — the card already has a border and a
   pseudo-element would have to be positioned over the radius, which is how a
   3px line ends up with two square corners on it. */
#get > .wrap > .callout {
  border-top: 3px solid var(--accent);
  border-radius: var(--radius-panel);
  box-shadow: var(--edge), var(--shadow-float);
  position: relative;
  isolation: isolate;
}
/* ---- §GLOW — the one place on the page that gets accent light --------------
   The brand brief bans glows, and this is the exception it is worth arguing
   for, because it is not the thing the ban is about. What the brief forbids is
   a coloured haze used as ATMOSPHERE — the mesh-gradient bloom behind a
   headline that makes a page look like every other page. This is a single soft
   pool of the brand green under the one card a reader is supposed to act on,
   sitting BEHIND the card rather than around its text, and it exists because
   the closing card had a 3px accent rule at the top and nothing else telling a
   reader that the page had arrived somewhere.

   Two rules keep it from becoming the thing that was banned:

     1. It is the ONLY accent glow on the page. Not on the cards, not on the
        section heads, not behind the hero copy.
     2. It never touches type. `z-index: -1` with `isolation: isolate` on the
        parent puts it under the card's own fill, so it is light escaping from
        under an object, not light cast onto one. No text on this page is ever
        read against it.

   `color-mix` in oklab and not a typed rgba: the pool has to be the accent of
   whichever scheme is running, and oklab is what keeps a 26%-strength olive
   from going grey the way an sRGB mix of the same two colours does. */
#get > .wrap > .callout::before {
  content: "";
  position: absolute;
  z-index: -1;
  inset: -12% -4% -22%;
  border-radius: inherit;
  pointer-events: none;
  background: radial-gradient(
    60% 70% at 50% 55%,
    color-mix(in oklab, var(--accent) 34%, transparent),
    transparent 72%
  );
  filter: blur(28px);
}

.callout--neutral {
  --accent: var(--accent-on-tint);
  background-color: var(--band);
  border-color: var(--hairline-strong);
}
.callout-head {
  display: flex;
  align-items: center;
  gap: 0.6em;
  color: var(--accent-on-tint);
}
.callout-head h3 { margin: 0; color: var(--ink); }
.callout ol, .callout ul { margin: 0; }
.callout ol { counter-reset: step; list-style: none; padding-left: 0; }
/* The counter chip is absolutely positioned into a padding gutter rather than
   being a grid column. `display: grid` on the <li> looks equivalent and is not:
   it turns every inline element inside the step — a <strong>, a <code>, a link
   — into its own grid item, which drops it onto a new row and wraps it mid-word.
   The copy does contain <strong>, so this is not hypothetical. */
.callout ol > li {
  counter-increment: step;
  position: relative;
  padding-left: 2.5em;
  min-height: 1.7em;
}
.callout ol > li::before {
  content: counter(step);
  position: absolute;
  left: 0;
  top: 0.05em;
  display: inline-grid;
  place-items: center;
  width: 1.7em;
  height: 1.7em;
  border-radius: var(--radius-pill);
  background: var(--card);
  border: 1px solid var(--accent-line);
  color: var(--accent-on-tint);
  font-size: 0.85em;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--r-2);
  list-style: none;
  padding: 0;
  margin: 0;
}
.chips li {
  --accent: var(--accent-on-tint);
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  margin: 0;
  padding: 0.5em 1em;
  border-radius: var(--radius-pill);
  background-color: var(--accent-soft);
  border: 1px solid var(--accent-line);
  color: var(--accent-on-tint);
  font-weight: 650;
  font-size: var(--step--1);
  box-shadow: var(--edge), var(--shadow);
  transition: transform 200ms var(--ease), box-shadow 200ms var(--ease), border-color 200ms var(--ease);
}
.chips li > span { color: var(--ink-muted); font-weight: 400; }
/* The platform's mark, ahead of its name. `em` so it scales with the chip's
   own type rather than being a fixed 16px that drifts out of step at the ends
   of the clamp — the same rule §THE ICONS states for every other glyph on the
   site. 1.15em and not 1em: these are SOLID silhouettes sitting beside 650-
   weight text, and a filled mark at the cap height of the letters beside it
   reads smaller than it is.

   `flex: none` because `.chips li` is a flex container and an `<svg>` with a
   percentage-free intrinsic size is otherwise a shrinkable item — at the
   narrow end of the row the marks would squash to ellipses before the text
   wrapped. */
.chips .icon {
  flex: none;
  width: 1.15em;
  height: 1.15em;
}
/* A partner shop's own logo, ahead of its name: the intrinsic width comes
   from the markup, the rendered height from the chip's type, same as the
   marks above. On the dark chip the mark sits on a small white disc, because
   these are other companies' colours and half of them are drawn for a white
   page — a dark-green wordmark on a dark-green chip is a smudge. */
.chips .logo {
  flex: none;
  height: 1.35em;
  width: auto;
  border-radius: 4px;
}
@media (prefers-color-scheme: dark) {
  .chips .logo {
    background: #FFFFFF;
    padding: 2px 4px;
    box-sizing: content-box;
  }
}
/* A chip is not a control — these are labels, `<li>`, nothing to click — so
   the hover is 2px and there is no colour change. It is there because a row of
   nine shop names is the one place on this page where a reader's pointer
   naturally wanders, and a row that answers reads as a list of real things
   rather than as a decorative strip. */
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .chips li:hover {
    transform: translateY(-2px);
    border-color: var(--accent);
    box-shadow: var(--edge), var(--shadow-lift);
  }
}

/* ---------------------------------------------------------------------------
   8. THE DEVICE FRAME

   An earlier revision of this file said "NO DEVICE FRAME" and cited two bans.
   Both citations were wrong, and the correction is recorded here so nobody
   re-derives the mistake: `docs/brand-image-prompts.md:68` bans putting **the
   logo** on a phone in *generated brand artwork*, and `docs/play-submission.md`
   requires a real device in the *Play reviewer's demo video*. Neither is about
   this site, and the owner has asked for the opposite — the app shown in a
   phone, with some dimension to it.

   ## Why this is CSS and not a picture of a phone

   Four reasons, none of them aesthetic:

   - The screenshots already swap by colour scheme. A bitmap frame would need a
     light and a dark twin of its own and would have to stay in step with that
     swap; the moment it drifted, the site would show a black phone on a white
     page.
   - `perspective` + `rotateY` is resolution-independent. A frame image is not,
     and a phone bezel is exactly the kind of thin, high-contrast edge that
     shows its pixels on a 2x display.
   - It can answer `prefers-reduced-motion`. A tilted device is a 3D transform,
     and a baked image cannot present itself flat for a reader who asked for
     less of that. Below, it does.
   - It leaves the screenshot a real `<img>` with its real `alt`. Compositing
     the screen into a frame image would throw both away, and those alt strings
     are the four-language descriptions the copy pass wrote.

   Cost: zero bytes.

   ## What makes it read as deliberate rather than as a stock mockup

   The bezel is thin (5px) and built from tokens that already exist — `--sunken`
   for the body, `--hairline-strong` for the outer edge, `--hairline` as an
   inset ring that reads as the glass edge — so the phone is celery-grey on a
   light page and near-black on a dark one without a single new colour. The lift
   is the app's own `--shadow` (#0B1220 at 8%, 12px blur, 4px down), NOT a
   heavier "floating device" shadow: the frame sits on the page, it does not
   hover over it. Nothing crops the screen — the shot is the argument.

   ## Where the tilt is used, and where it is not

   Twice, both times for emphasis: the hero, and one showcase row. Everywhere
   else the phone is square-on. A tilt applied to every screenshot stops being
   emphasis and becomes the house style, which is the failure this was meant to
   fix. Tilt is also **suppressed below 48rem**: in a single column a tilted
   phone looks like a mistake, and a rotated element contributes its painted box
   to scrollable overflow, which is how a 320px page grows a horizontal
   scrollbar.

   ## The strip

   Auto-fit on a min(100%, 15rem) track: three across on a desktop, two on a
   tablet, one full-width column on a phone, never a horizontal scrollbar. From
   62rem the middle figure drops half a step — three identical tops in a row
   read as a stock trio, and one offset makes the eye travel along them.

   `.shot-frame` (the name components.php emits) and `.shot-media` are the same
   thing; both are accepted so neither pass has to move first.
   --------------------------------------------------------------------------- */

/* THE TRACKS ARE CAPPED AT THE FRAME'S OWN WIDTH, not at a fraction of the row.
   With `1fr` tracks the strip gave each phone a 520px column to stand a 320px
   object in, so the two figures sat at the quarter and three-quarter points of
   the rail with a hundred pixels of nothing on either side of each — which
   reads as two loose objects rather than as a pair. `justify-content: start`
   keeps them on the page's one left rail; the cap is what pulls them together.
   Measured at 1400: 520px tracks -> 320px tracks, and the pair now reads as
   one group. */
.shots {
  display: grid;
  gap: clamp(1.25rem, 0.8rem + 1.6vw, 2rem);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 20rem));
  justify-content: start;
  align-items: start;
}
.shot { margin: 0; display: flex; flex-direction: column; gap: var(--r-3); }
.shot-media, .shot-frame, .video-media {
  /* The phone body. `--device-bezel` of padding is the only thing between the
     outer edge and the screen, and the inner radius is derived from the outer
     one rather than typed twice — get that wrong by a pixel and the corner
     reads as a manufacturing defect.

     Five box-shadows, and each one is doing a different job. Read outward:

       1  inset 0 0 0 1px rail@45%   the inner lip where body meets glass
       2  inset 0 1px 0 rail@60%     light catching the top of that lip
       3  0 0 0 1px body@90%         the outer edge of the rail itself
       4  0 2px 3px black@22%        contact shadow, tight under the object
       5  0 22px 40px black@22%      the cast shadow that puts it on the page

     Four and five together are what a single `--shadow` cannot do: one shadow
     at one distance reads as a card lifted off a surface, two at very different
     blurs read as a solid object resting on one. That difference is most of
     "looks like a real phone", more than the bezel width is.

     `overflow: visible`, unlike before: the side buttons below are drawn
     outside the body and a hidden overflow clips them off. Nothing needed the
     clipping — the screen inside has its own `border-radius`. */
  padding: var(--device-bezel);
  border: 1px solid var(--device-rail);
  border-radius: var(--device-radius);
  background: var(--device-body);
  box-shadow:
    inset 0 0 0 1px rgba(58, 65, 76, 0.45),
    inset 0 1px 0 rgba(58, 65, 76, 0.6),
    0 0 0 1px rgba(16, 19, 25, 0.9),
    0 2px 3px rgba(0, 0, 0, 0.22),
    0 22px 40px rgba(0, 0, 0, 0.22);
  position: relative;
  max-width: 20rem;
  width: 100%;
  margin-inline: auto;
}

/* ---- The side buttons ------------------------------------------------------
   Power on the right, volume rocker above it, both on the right rail, which is
   where a Galaxy puts them — the screenshots are from one, so this is a portrait
   of that phone rather than of a generic slab.

   Three bars from ONE pseudo-element: the element itself is the power key and
   two `box-shadow` copies of it, offset upwards, are volume-down and volume-up.
   `::after` is already the sheen and an element has only the two, so this is
   the only way to get three without adding a wrapper to markup that four
   different functions emit.

   Offsets are px and not %, because `box-shadow` offsets must be lengths — a
   percentage there is not a smaller button, it is a dropped declaration and no
   buttons at all.

   3px of travel outside the body, roughly what a real key stands proud of the
   rail. Suppressed below 48rem along with the tilt: at phone widths the frame
   is nearly the width of the page, and 3px hanging off the right edge is 3px of
   horizontal scroll waiting to happen. */
@media (min-width: 48rem) {
  :where(.shot-media, .shot-frame, .video-media)::before {
    content: "";
    position: absolute;
    inset-inline-end: -3px;
    inset-block-start: 30%;
    width: 3px;
    height: 52px;
    border-radius: 2px;
    background: var(--device-rail);
    box-shadow:
      0 -66px 0 0 var(--device-rail),
      0 -128px 0 0 var(--device-rail);
  }
}

/* `:where()` zeroes this selector's specificity to (0,0,0) on purpose. A plain
   `.shot-media img` would be (0,1,1) and would beat the `.only-light` /
   `.only-dark` rules below on `display`, so both scheme variants would render,
   stacked, and only the network panel would tell you why. */
:where(.shot-media, .shot-frame, .video-media) :where(img, video) {
  display: block;
  width: 100%;
  height: auto;
  border-radius: calc(var(--device-radius) - var(--device-bezel));
  /* The glass edge. One inset hairline is the whole difference between "a
     screenshot on a grey rectangle" and "a screen set into a body". */
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.55);
}

/* ---- The sheen -------------------------------------------------------------
   One diagonal highlight across the glass, and it is the cheapest large gain
   in this whole section: a screenshot is matte and a screen is not, and the eye
   reads "pane of glass" from a single soft reflection long before it reads any
   amount of bezel detail.

   `pointer-events: none` so it never eats a click, `aria` untouched because a
   pseudo-element is not in the tree, and 10% white at the strongest point —
   over a light screenshot that is barely there and over a dark one it is the
   whole effect, which is the right way round, because a dark screen is the one
   that looks flattest without it. */
:where(.shot-media, .shot-frame, .video-media)::after {
  content: "";
  position: absolute;
  inset: var(--device-bezel);
  border-radius: calc(var(--device-radius) - var(--device-bezel));
  pointer-events: none;
  background: linear-gradient(
    118deg,
    var(--device-glass) 0%,
    rgba(255, 255, 255, 0) 42%,
    rgba(255, 255, 255, 0) 100%
  );
}
.shot figcaption { color: var(--ink-muted); font-size: var(--step--1); text-wrap: pretty; }

/* THE OFFSET NEEDS A MIDDLE TO BE THE MIDDLE OF.
   This drops the second figure half a step, and the reason given for it was that
   three identical tops in a row read as a stock trio while one offset makes the
   eye travel along them. That was true when the strip held three. It now holds
   two — the other screens moved into the `#what` rows — and with two the
   "middle" figure is the LAST one, so the effect is not a rhythm, it is one of
   a pair sitting thirty-two pixels lower than the other for no reason a reader
   can see. Reported as "not aligned", and correctly.

   `:has(> .shot:nth-child(3))` puts the condition where the assumption was:
   the offset applies when there is something on both sides of it, and goes away
   on its own if the strip ever shrinks again. */
@media (min-width: 62rem) {
  .shots:has(> .shot:nth-child(3)) > .shot:nth-child(2) {
    margin-block-start: var(--r-5);
  }
}

/* ---------------------------------------------------------------------------
   LIGHT AND DARK SCREENSHOTS

   The app screenshots ship in both schemes and the site must show the one that
   matches the scheme the READER IS LOOKING AT.

   The obvious answer — <picture><source media="(prefers-color-scheme: dark)">
   — is wrong here and fails silently. `prefers-color-scheme` reports the
   OPERATING SYSTEM, and this site has a manual override that is allowed to
   disagree with it: a visitor on a light OS who picks the moon would get a dark
   page full of light screenshots, with nothing to report the mismatch. So the
   swap is driven by the same four cascade layers as every other token, through
   two custom properties set in each of them. There is exactly one rule pair
   below and it is correct in all four layers by construction.

   Markup: both variants as real <img>, each with its own srcset, both
   `loading="lazy"`, the same alt on both.

     <div class="shot-media">
       <img class="only-light" alt="…" loading="lazy" decoding="async"
            width="320" height="711" sizes="(min-width: 48rem) 20rem, 100vw"
            src="/assets/shots/shot-feed-light-320.webp"
            srcset="/assets/shots/shot-feed-light-320.webp 320w,
                    /assets/shots/shot-feed-light-640.webp 640w">
       <img class="only-dark" alt="…" loading="lazy" decoding="async" …>
     </div>

   WHAT THIS COSTS, MEASURED (Chrome 141, Resource Timing): the hidden variant
   is NOT fetched. `loading="lazy"` defers the fetch until the image is near the
   viewport, and an image with `display: none` has no box, so it never becomes
   near the viewport. Two <img> in the DOM, one download. The alternatives were
   a CSS `background-image` (one download guaranteed, but it throws away `alt`
   and `srcset`, and these images ARE the argument the page is making) and a
   script that rewrites `src` (one download, keeps <img>, but makes the correct
   picture depend on JavaScript). This keeps `alt`, keeps 1x/2x density
   selection, and keeps working with scripting off.

   THE CAVEAT, because "mostly" was doing real work in that sentence: lazy-load
   behaviour for display:none images is engine-dependent, not specified. If a
   future engine fetches both, the page is heavier — it is never WRONG. If that
   shows up in the field, move to the script variant; do not move to <picture>.
   --------------------------------------------------------------------------- */

.only-light { display: var(--img-light); width: 100%; height: auto; }
.only-dark { display: var(--img-dark); width: 100%; height: auto; }

/* ---------------------------------------------------------------------------
   THE HERO CLIP

   `.hero-still` is a poster image that only exists for readers who have asked
   their system to stop moving things. Autoplaying video is exactly what that
   setting is for, and the app honours reduced motion in every animated module
   it has; the site does not get to be worse than the app. The markup cannot
   express this on its own, so these four lines are load-bearing rather than
   decorative.

   The poster is almost certainly the LCP element on the landing page, so
   nothing here may stand between it and the paint: no `background-image` on the
   hero container, no filter, no opacity animation, no webfont in the way (the
   site has none — see --font-text). `aspect-ratio` is stated explicitly so the
   540×960 box is reserved before a byte of video arrives; the `<video>` carries
   the same numbers as attributes, and this keeps them true when the width goes
   fluid.
   --------------------------------------------------------------------------- */

/* `perspective` on the PARENT, transform on the child: that is what makes the
   near edge genuinely larger than the far one. A `rotateY` with no perspective
   ancestor is an affine squash and looks like a squashed rectangle, which is
   how most CSS "3D" phones on the web give themselves away.
   1500px is a long lens — barely any convergence, so the screen stays readable.
   A short perspective (say 600px) is what makes a mockup look like a stock
   asset: the far edge shrinks, the screenshot skews, and the app's own UI stops
   being legible, which defeats the entire point of showing it. */
/* 24rem, not 20rem. The hero's picture column is 0.9fr of a 72rem container —
   about 454px at 1400 — and a 320px phone standing in the middle of it left
   67px of empty page on each side, which reads as a gap in the layout rather
   than as air around a subject. The screenshots are 540px wide at 1x with a
   1080px @2x, so the frame can grow to 384px and still be showing a DOWNSCALED
   source; nothing here is being stretched to fill a hole.
   The inner frame has to be released from its own 20rem cap or the wrapper
   grows and the phone does not — which is the same bug as `max-width: none` on
   the clipped wordmark, one section up. */
.hero-media {
  width: 100%;
  max-width: 24rem;
  margin-inline: auto;
  perspective: 1500px;
  perspective-origin: 65% 45%;
  position: relative;
  isolation: isolate;
}

/* ---- THE FLOOR UNDER THE HERO PHONE ---------------------------------------
   The device already carries the five-shadow stack from §8, and that stack was
   tuned to seat it on a PAGE. In the hero it is not on a page, it is on the
   dark anchor slab — near-black, where every one of those shadows has nothing
   left to darken and the phone ends up cut out and pasted on. Same failure as
   `--shadow: none` in §1, in the one place it is most visible, because the
   phone is the largest object above the fold.

   So the hero phone gets a floor: one wide, low ellipse of accent-tinted light
   directly under it. Not a glow around the object — a pool beneath it, which is
   what a lit thing standing on a surface actually produces, and it is the same
   argument §GLOW makes for the closing card. It reads as the screen throwing
   light down onto the slab.

   Deliberately NOT a `filter: blur()`: a radial gradient is already soft, and a
   filter on a sibling of the LCP element promotes a layer the poster then has
   to composite against. The note above this section forbids exactly that, and
   it is right — the poster is the landing page's LCP and nothing decorative
   gets to stand between it and the paint. `z-index: -1` keeps the pool behind
   the device, and no type is ever read against it.

   Below 48rem there is no tilt, the phone is nearly the width of the page, and
   a pool wider than its object would run off both edges — so it stops with the
   tilt, at the same breakpoint, for the same reason. */
@media (min-width: 48rem) {
  .hero-media::before {
    content: "";
    position: absolute;
    z-index: -1;
    left: -18%;
    right: -18%;
    bottom: -3rem;
    /* A LENGTH, NOT A PERCENTAGE. `.hero-media` has no explicit height — it is
       sized by its content, the phone — and a percentage height on an
       absolutely positioned box resolves against its containing block's
       height ONLY when that height is itself an explicit value. Against an
       auto (content-sized) ancestor the spec treats the percentage as `auto`,
       which with no `top` set collapses to a zero-height box: the glow was
       being emitted and painting nothing. `clamp()` in rem keeps it
       proportionate to the phone (whose own width is capped at 24rem) without
       depending on a parent height that does not exist. */
    height: clamp(8rem, 9vw + 6rem, 13rem);
    pointer-events: none;
    /* Two lights, not one, and both stronger than the first pass. 22% was
       measured off `getComputedStyle` and read as almost nothing on a real
       screen; 38% fixed that but was still a single flat lime patch. This
       is the same two-hue move as the slab atmosphere above it — lime for
       the core, the cooler sage-link bleeding out at the rim — so the floor
       under the phone reads as a light source with some depth to it rather
       than a coloured smudge. */
    background:
      radial-gradient(55% 60% at 50% 55%, color-mix(in oklab, var(--anchor-accent) 48%, transparent), transparent 68%),
      radial-gradient(70% 70% at 50% 65%, color-mix(in oklab, var(--anchor-link) 30%, transparent), transparent 75%);
    animation: hero-glow-breathe 7s ease-in-out infinite;
  }
}
/* A SLOW BREATH, NOT A PULSE. 7 seconds and a range of 0.85–1 scale / opacity
   is well under the threshold that reads as "blinking" — it is closer to the
   rate a resting person breathes than to any UI notification — and it is the
   one piece of motion on the page that runs the whole time the hero is in
   view rather than firing once on arrival. That is deliberate: everything
   else on this page (§11) reveals and then holds still, and a page that never
   moves again after the first second is exactly the "static screenshot" read
   the brief is trying to get away from. `prefers-reduced-motion` removes it
   the same way it removes every other animation in this file. */
@media (prefers-reduced-motion: reduce) {
  .hero-media::before { animation: none; }
}
@keyframes hero-glow-breathe {
  0%, 100% { opacity: 0.85; transform: scale(0.96); }
  50% { opacity: 1; transform: scale(1.04); }
}
.hero-media .shot-frame,
.hero-media .shot-media,
.hero-media .video-media { max-width: 100%; }
@media (min-width: 48rem) and (prefers-reduced-motion: no-preference) {
  .hero-media .shot-frame,
  .hero-media .shot-media,
  .hero-media .video-media {
    /* Leaning its left edge toward the reader, i.e. toward the sentence it is
       evidence for. Thirteen degrees reads as premium; the same construction at
       thirty reads as a template, and the difference really is only the
       number. */
    transform: rotateY(calc(var(--device-tilt) * -1)) rotateX(4deg) rotate(0.4deg);
  }
}

/* Until the hero has a picture, it must not pretend to be a two-column layout.
   Without this the 62rem grid reserves a whole empty track and the page opens
   on a headline beside a void — which reads, correctly, as a template with a
   missing slot. `:has()` lets the hero answer for itself, so the day the
   footage or the still lands nothing here has to change. */
.hero-inner:not(:has(.hero-visual)) { grid-template-columns: minmax(0, 1fr); }
.hero:not(:has(.hero-visual)) .hero-lead { max-width: 44ch; }
.hero:not(:has(.hero-visual)) h1 { max-width: 18ch; }
/* 540 / 1152, NOT 540 / 960. The clip is the capture's own shape — a 1080x2400
   phone screen with the status bar cropped off, halved — and it is the same
   shape as every screenshot on the page, which is the whole reason a loop and a
   still can share one `.shot-frame`. The 9:16 that stood here was a guess made
   while the hero had no footage; the day a real file arrived it would have been
   letterboxed inside a box 192px too short, i.e. a black band across the page's
   largest element. `assets/img/shots.json` and `SF_VIDEO_HEIGHT` in
   `inc/config.php` carry the same pair. */
/* No `display` here, and that omission is load-bearing. Which of the two files
   shows is decided by `only-light` / `only-dark` — the same pair of classes and
   the same `--img-light` / `--img-dark` properties the screenshots use, so a
   clip can never disagree with the screenshot beside it about which scheme the
   reader chose. Setting `display` on `.hero-video` here would beat them on
   source order and both schemes would play, stacked. */
.hero-video, .hero-still {
  width: 100%;
  height: auto;
  aspect-ratio: 540 / 1152;
  border-radius: inherit;
}
.hero-still { display: none !important; }
@media (prefers-reduced-motion: reduce) {
  /* `!important` on both, because these have to beat `.only-light` /
     `.only-dark`, which are (0,1,0) exactly as these are and come first in the
     file. The scheme classes still decide WHICH still shows; this decides
     whether a still or a clip does. */
  .hero-video { display: none !important; }
  .hero-still.only-light { display: var(--img-light) !important; }
  .hero-still.only-dark { display: var(--img-dark) !important; }
}

/* ---------------------------------------------------------------------------
   THE SECTION LOOPS

   Three more clips — `#share` and the first two `#what` rows — each in the same
   `.shot-frame` as the screenshot it plays over. See `sf_sketch()` in
   `inc/components.php` for why there are four moving things on this page now
   and not one.

   The two rules that matter are the same pair the hero owes: with motion
   allowed the loop shows and the screenshot is `display: none`, and with
   reduced motion they swap. The swap is not a downgrade — the still is the
   REAL screenshot, in the reader's language and colour scheme, not the clip's
   Czech poster — which is also why it is `display: none` rather than
   `visibility: hidden` or `opacity: 0`: a hidden `<img loading="lazy">` has no
   box, never becomes near the viewport, and is therefore never downloaded.
   A reader who has not asked for less motion pays nothing for the still, and a
   reader who has pays nothing for the clip.

   `aspect-ratio` on the clip for the same reason the hero states it: the box is
   reserved before a byte of video arrives, and this one sits mid-page where a
   late resize would shove a whole section.
   --------------------------------------------------------------------------- */

/* As with `.hero-video`: no `display`. `only-light` / `only-dark` own it, so the
   loop follows the reader's colour scheme through the same four cascade layers
   as every screenshot on the page. */
.sketch-clip {
  width: 100%;
  height: auto;
  aspect-ratio: 540 / 1152;
}
.sketch-still { display: none; }
@media (prefers-reduced-motion: reduce) {
  .sketch-clip { display: none !important; }
  .sketch-still { display: block; }
}

/* ---------------------------------------------------------------------------
   THE SHOWCASE ROWS

   A screenshot on one side, the claim it is evidence for on the other, the
   sides swapping every row. Two columns from 48rem, one below — and the
   single-column order is always picture-then-claim, because `--flip` puts the
   copy FIRST in the DOM on those rows, so the stacked reading order stays
   consistent while the desktop zig-zag alternates. That zig-zag is the whole
   point: four identical left-picture rows are a list, and a list is what a page
   looks like when nobody decided anything.

   ## THE PROPORTION, WHICH IS THE ONE THING THIS ROW HAS TO GET RIGHT

   Measured at 1400px before this pass: the device was 288px wide, a FIFTH of
   the viewport, with the paragraph beside it and 56px of nothing between them.
   This is a product page whose entire case is "look at what the app does", and
   the app was a thumbnail. Two numbers changed and one selector was added:

   - `--shot-col` is the picture track AND the cap on the frame inside it, in
     one token, because a frame narrower than its own track IS the gutter. It
     goes to 24rem — 384px, 27% of a 1400px viewport, up from 20.6%. It stops
     there and not higher because the screenshots are 540px wide at 1x: at 384
     the device is still showing a downscaled source, and above ~540 it would
     start showing its own pixels on a 1x display.
   - the column gap comes down from 3.5rem to 2.25rem, so the screen and the
     sentence it is evidence for sit close enough to read as one object.
   - `#share`'s row puts its copy first in the DOM but carries no `--flip`, so
     it took the picture-left template with the copy in the 18rem track and the
     phone floating in the middle of a 1fr one — 276px of dead page between
     them, the same hole as the other rows but mirrored. `:has()` lets the row
     answer for itself rather than waiting for a markup pass, and it means any
     future copy-first row is correct by construction.

   ### WHY 24rem IS A CEILING AND NOT A PREFERENCE

   The half-pass above stopped the phone at 24rem on a source-resolution
   argument. That argument is true but it is not the binding one, and the
   binding one has to be written down or the next person will read "27% of the
   viewport" as timid and turn it up.

   The screenshots are 540 x 1152 — an aspect of 2.133 — so the frame's height
   is not a free variable: it is its width times 2.133, plus two bezels and two
   borders. The budget below was computed against a sticky topbar that took 61px
   once the page had moved; that bar is gone, so a 1400x900 laptop now leaves
   900px rather than 839px of page visible at once. The table is left as
   measured, which means 24rem now clears by 94px instead of 33 and 25rem fits
   too. It stays at 24rem: the binding argument was never the last pixel, it was
   that a whole device has to be on screen at once, and 24rem satisfies it with
   room the next viewport change cannot take away.

   Frame heights, measured at 1400 by setting `--shot-col` on a live baseline
   row and reading `getBoundingClientRect()`:

       | --shot-col |  W  |  H  | against 839px of visible page |
       |------------|-----|-----|-------------------------------|
       | 20rem      | 320 | 669 | fits, 170 spare  (the old size)|
       | 22rem      | 352 | 737 | fits, 102 spare               |
       | 24rem      | 384 | 806 | fits,  33 spare               |
       | 25rem      | 400 | 840 | MISSES BY 1                   |
       | 26rem      | 416 | 874 | misses by 35                  |
       | 28rem      | 448 | 942 | misses by 103                 |

   24rem is not a compromise, it is the last step that fits — and the next step
   up misses by a single pixel. That is the number to defend, because a whole
   device on screen at once is the only thing that makes a screenshot read as a
   product rather than as a picture of one. Growing it further does not make the
   app bigger; it makes the app *cropped by the window*, which is the same
   defeat as cropping it in CSS (see §THE DEVICE FRAME: "nothing crops the
   screen — the shot is the argument") and it is why the interruption row is the
   only one allowed to break the bound.

   Two consequences worth stating so they are not re-litigated:

   - The row's proportion is therefore DERIVED, not chosen: 384px of a 1072.8px
     row is 35.8% picture, 3.4% gutter, 60.9% claim at 1400. The picture cannot
     take more without leaving the window, and it should not take less, so this
     is the proportion and every picture-plus-claim row on the page holds it.
   - The interruption keeps its +4rem and therefore does NOT fit — 942 against
     839. That is the point of it. If it obeyed the same bound as the baseline
     it would not be an interruption, it would be a slightly wider row.

   ## THE ROW'S VERTICAL PROPORTION, WHICH IS WHAT WAS STILL WRONG

   The half-pass fixed the row across and left it wrong down. Measured at 1400
   after it landed: a baseline row was 806px tall — the phone's height, since
   nothing else in the row is close — and carried a claim 135px tall, a heading
   and three lines. `align-items: center` therefore put

       335px of nothing above the claim, and 335px below it,

   so the one sentence in the row floated in the middle of a 653 x 806 empty
   rectangle with a phone beside it. That is the "dead space between the
   columns" complaint seen from the other axis, and it is worse than the
   horizontal version was: a gutter separates two things, but a claim with 335px
   of page over it is not attached to anything at all.

   `align-items: start` — the claim hangs from the TOP of the picture. Three
   reasons, in order of how much they matter:

   - The frame's top border and the heading's cap-line become one line across
     the row. That shared edge is the entire difference between "a picture and
     a sentence" and "a picture captioned by a sentence"; it is the only
     alignment the two columns can share, because their heights never match.
   - It points at the right part of the picture. Every one of these screenshots
     puts its subject — the dish, the list header, the week, the map — in its
     top half, and the bottom half is a list running off the frame. The claim
     now sits beside the half that is evidence for it.
   - The leftover height collects in ONE place, at the bottom of the copy
     column, where it is contiguous with the row gap and reads as page margin.
     Split in two by centring, the same pixels read as a hole.

   The rule this replaces was `.section-head + .showcase-row > .showcase-copy
   { align-self: start }`, added by the half-pass for `#share` alone, with a
   note arguing that rows inside `.showcase` should keep `center` because "a
   sentence hanging off the top of a two-metre phone reads as a mistake".
   Rendered, it is the other way round: `#share` was the best-proportioned row
   on the page and the four centred ones were the worst. The special case is
   gone and its behaviour is now the rule. One kind of row, one proportion —
   which is the point, because a proportion the page only sometimes keeps is
   not a proportion, it is an accident that happened once.
   --------------------------------------------------------------------------- */

/* Between rows, not inside them: 80px at 1400 before, 55 now. Four rows of a
   tall picture already give the eye plenty to travel; the extra 25px a row was
   just page. */
.showcase { display: grid; gap: clamp(2.25rem, 1.5rem + 2.2vw, 3.5rem); }
/* THE SAME FLOOR THE HERO PHONE STANDS ON, under every phone in the page.
   §THE FLOOR UNDER THE HERO PHONE put one light pool under the hero's device;
   this repeats it here so the four proof rows are not four flat screenshots
   in a row — each one gets its own quiet pool of the section's own accent,
   which on a `.section--band` is `--accent-on-tint`, so the colour already
   changes row to row with the tint the row sits in rather than needing a
   fifth hardcoded hue. `--card`-brightness ground behind it (light mode) does
   not fight the pool the way `--anchor` never did in the hero; it is simply
   quieter, at a fifth of the hero's strength, because these compete with body
   copy beside them and the hero pool does not. */
.showcase-shot { position: relative; isolation: isolate; }
.showcase-shot::before {
  content: "";
  position: absolute;
  z-index: -1;
  left: 6%;
  right: 6%;
  bottom: -2rem;
  height: clamp(5rem, 7vw + 3rem, 8rem);
  pointer-events: none;
  background: radial-gradient(
    55% 60% at 50% 55%,
    color-mix(in oklab, var(--accent) 22%, transparent),
    transparent 72%
  );
}
.showcase-row {
  display: grid;
  /* Doubles as the vertical gap in the stacked single-column layout, which is
     why the minimum is 1.5rem and not smaller — a caption needs more room under
     a phone than a phone needs beside a paragraph. */
  gap: clamp(1.5rem, 1.2rem + 1.2vw, 2.25rem);
  /* `start`, and this is the second half of the proportion — see §THE ROW'S
     VERTICAL PROPORTION above. `center` was putting 335px of nothing above the
     claim and 335px below it. */
  align-items: start;
  /* Below 48rem this is the frame's own cap, matching the base .shot-media. */
  --shot-col: 20rem;
}
@media (min-width: 48rem) {
  /* The picture column is sized to the picture rather than to a fraction of the
     row. A fractional column leaves the frame centred inside a much wider
     track, with a channel of empty page between the screen and the sentence it
     is evidence for — which reads as two unrelated things rather than one claim.
     `order` and `direction` are both avoided: the columns are named explicitly
     so the tab order and the reading order stay exactly the DOM order. */
  .showcase-row { --shot-col: 18rem; grid-template-columns: var(--shot-col) minmax(0, 1fr); }
  /* Copy-first rows, whether they say so with the modifier or only by their DOM
     order. `#share` is the second kind: it had the modifier's markup and not the
     modifier, so it laid its copy into the picture track and left the phone
     adrift in a 1fr one. */
  .showcase-row--flip,
  .showcase-row:has(> .showcase-copy:first-child) {
    grid-template-columns: minmax(0, 1fr) var(--shot-col);
  }

  /* THE INTERRUPTION.

     Four rows of picture-beside-paragraph is a rhythm, and a rhythm that never
     breaks is the thing that reads as generated — it is the honest complaint
     this file was carrying before the device work landed. So the SECOND row
     (the shopping list, the claim the product is most often described by) gets
     a bigger phone, angled the other way, with more air around it. One break,
     at the point the eye has just learned the pattern.

     Second and not first: breaking the first row means there was never a
     pattern to break, and the reader simply sees two unrelated layouts. */
  .showcase > .showcase-row:nth-child(2) {
    /* Four rem wider than whatever the baseline row is, so the interruption
       stays an interruption when the baseline moves — which it just did. */
    --shot-col: calc(var(--shot-col-base) + 4rem);
    grid-template-columns: minmax(0, 1fr) var(--shot-col);
    padding-block: var(--r-4);
  }
  .showcase > .showcase-row:nth-child(2) .showcase-shot { perspective: 1500px; }
  /* step-2h, not step-3. step-3 is now the SECTION heading's size, and a row
     heading set as large as the head it sits under is not emphasis, it is a
     collision. The half-step keeps the promotion and loses the argument. */
  .showcase > .showcase-row:nth-child(2) .showcase-copy h3 { font-size: var(--step-2h); }
}

/* `--shot-col-base` exists only so the interrupted row can say "the baseline
   plus four" without hard-coding the baseline. It has to be a SEPARATE token:
   `--shot-col: calc(var(--shot-col) + 4rem)` on the same element is a cycle,
   and a cycle in a custom property is not an error you see — the declaration is
   simply dropped and the row silently keeps the width it already had. */
@media (min-width: 48rem) { .showcase { --shot-col-base: 18rem; } }
@media (min-width: 68rem) {
  /* 24rem = 384px. At 1400 that is 27% of the viewport against 20.6% before,
     and the copy track still holds 58ch without the paragraph running long. */
  .showcase-row { --shot-col: 24rem; }
  .showcase { --shot-col-base: 24rem; }
}

@media (min-width: 48rem) and (prefers-reduced-motion: no-preference) {
  .showcase > .showcase-row:nth-child(2) .shot-frame,
  .showcase > .showcase-row:nth-child(2) .shot-media {
    /* The mirror of the hero's lean, for a phone that sits on the right. */
    transform: rotateY(var(--device-tilt)) rotateX(4deg) rotate(-0.4deg);
  }
}
/* The rule that used to live here — `.section-head + .showcase-row >
   .showcase-copy { align-self: start }` — is gone because it is now what every
   row does. It was right about `#share` and wrong to stop there; see §THE ROW'S
   VERTICAL PROPORTION. One rule for one kind of row is the whole point. */

/* ---- THE HOLE UNDER THE CLAIM --------------------------------------------

   Measured at 1440, on every one of the five picture rows: the phone is 806px
   tall, the copy beside it runs 300-430px, and the remainder was a single empty
   rectangle at the FOOT of the copy column — about 430px of it in `#share` and
   in each showcase row. Five times down one page, and it is the thing that made
   the page read as unfinished rather than as spacious.

   THREE ARRANGEMENTS WERE TRIED. This is the third and the reasoning matters,
   because the first two are the ones you reach for first.

   1. Top-aligned, all of it below (what stood here). The column reads as having
      run out. This is the version the complaint was about.
   2. Foot-anchored: claim at the top, supporting box dropped to the bottom with
      `margin-top: auto`, both columns beginning and ending together. Measured on
      the rendered page it puts the WHOLE 430px in one gap in the middle, which
      is worse — an empty band across the middle of a row reads as an element
      that failed to load, where an empty foot only reads as thin.
   3. Centred. The void is halved and mirrored: ~215px above the claim and ~215
      below it, and the claim lands on the phone's optical middle. Nothing is
      pinned to an edge, so nothing looks like it is missing a neighbour.

   The note above at `align-items: start` argued against centring on the grounds
   that it "was putting 335px of nothing above the claim and below it". That is
   the same measurement, read as a cost rather than as a distribution — and it
   was made when the row's copy was a heading and a claim, before the supporting
   points were added. With the points in, the column is half the phone's height
   rather than a sixth of it, and symmetric air around it is proportion, not
   emptiness.

   `align-items: start` STAYS ON THE ROW, because the picture column must still
   hang from the top: it holds the frame and its caption, and a caption that has
   floated away from the bottom of its own picture is a real defect rather than
   a proportion argument. Only the copy centres. */
.showcase-copy > * + * { margin-top: var(--r-3); }
.showcase-copy h3 { font-size: var(--step-2); }
.showcase-copy p { max-width: 58ch; }
@media (min-width: 48rem) {
  .showcase-copy { align-self: center; }
}

/* ---------------------------------------------------------------------------
   THE STATEMENT

   The one genuinely large, unhedged line on the site, and the only reason it
   works is that it is the only one — `tools/check.php` fails the build if a
   second `.statement` ever appears, which is the right way to protect a
   typographic decision.

   Display size, tight leading, no quotation marks. It is set at `--step-5`, the
   same size as the `<h1>`, deliberately: this sentence is the page's argument
   and the headline is only its label. Everything around it is quiet so it can
   be loud.
   --------------------------------------------------------------------------- */

.story > * + * { margin-top: var(--r-4); }
.statement {
  font-size: var(--step-5);
  line-height: 1.1;
  letter-spacing: -0.032em;
  /* 21ch, up from 18. step-5 came down from 3.9rem to 3.25rem in this pass, and
     at 18ch the same sentence simply grew a line instead of getting shorter —
     the measure has to move with the size or the block keeps its old height and
     none of the reclaimed space arrives. */
  max-width: 21ch;
  text-wrap: balance;
}
.story > p { font-size: var(--step-1); max-width: 60ch; }

/* THE CONFESSION IS A SPREAD, NOT A COLUMN.
   Once every section shares one left rail, a prose section at 1400px is 640px
   of text with 512px of nothing beside it. On the short sections that reads as
   air. On this one it did not: the statement is the largest type on the page
   after the h1 and it was sitting alone in the left half of a full-height
   band, which reads as a layout that lost something rather than as emphasis.

   So the claim takes one column and the explanation takes the other — the
   oldest magazine opener there is, and the reason it survives is that it lets
   a big line be big without stranding it. Two columns of ~30rem also keep the
   measure honest: the paragraphs never run to the 72rem the band is now wide.

   Below 64rem it stays one column, because at that width two columns of
   anything are two columns of nothing. */
@media (min-width: 64rem) {
  .story {
    display: grid;
    grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
    column-gap: var(--r-6);
    row-gap: var(--r-3);
    align-items: start;
  }
  /* `.stack` puts margins between siblings; in a grid those margins fight the
     row-gap and push the second column's first line off the statement's. */
  .story > * + * { margin-top: 0; }
  .story > .statement { grid-column: 1; grid-row: 1 / span 2; }
  .story > p { grid-column: 2; max-width: 46ch; }
}
.story-turn { color: var(--ink); }
.story-turn strong { color: var(--accent); }

/* ---------------------------------------------------------------------------
   THE FOOTER'S PLATFORM MARKS
   --------------------------------------------------------------------------- */

.socials ul { display: flex; flex-wrap: wrap; gap: var(--r-2); }
.socials li { margin: 0; }
.socials a {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* 44px: these are the smallest tappable things on the page and a bare 24px
     glyph would be a 24px target. The visible mark stays 20px; the padding is
     what makes it hittable. */
  width: 44px;
  height: 44px;
  border-radius: var(--radius-field);
  border: 1px solid transparent;
  color: var(--ink);
  transition: color 160ms var(--ease), background-color 160ms var(--ease),
    border-color 160ms var(--ease);
}
.socials a:hover { color: var(--accent); background: var(--card); border-color: var(--hairline-strong); }
.socials a:active { background: var(--accent-soft); }
.socials .icon { width: 1.25rem; height: 1.25rem; }

/* ---------------------------------------------------------------------------
   9. LONG-FORM LEGAL TEXT

   `.prose` is the only place with generous vertical margins between blocks —
   the marketing sections control their own spacing, but a privacy policy is
   read top to bottom and needs paragraph rhythm to do the sectioning.

   The table turns into stacked rows below 46rem. A three-column recipients
   table is unreadable at 320px however it is squeezed, and a horizontal scroll
   container hides the third column entirely. `td::before { content:
   attr(data-label) }` re-attaches the header to each cell, which is why every
   cell has to carry `data-label` — invisible above the breakpoint, and the
   whole layout below it.
   --------------------------------------------------------------------------- */

.prose { max-width: var(--w-prose); }
/* Belt on top of the braces: --w-prose is a layout width, this is a reading
   measure. At the body size these clamps settle on, 68ch is about 68 characters
   — inside the 60-75 band where a line ends before the eye has to hunt for the
   next one. Tables and code blocks are exempt: they are not prose. */
.prose > p, .prose > ul, .prose > ol, .prose > blockquote { max-width: 68ch; }
.prose > * + * { margin-top: var(--r-3); }
.prose h2 { margin-top: var(--r-6); font-size: var(--step-2); }
.prose h3 { margin-top: var(--r-5); font-size: var(--step-1); }
.prose h2 + *, .prose h3 + * { margin-top: var(--r-2); }
.prose ul, .prose ol { margin-top: var(--r-2); }
.prose-meta { color: var(--ink-muted); font-size: var(--step--1); }
.prose-title { font-size: var(--step-3); }

.toc {
  background: var(--band);
  border: 1px solid var(--hairline);
  border-radius: var(--radius-card);
  padding: var(--r-3) var(--r-4);
  --accent: var(--accent-on-tint);
}
.toc h2 {
  margin: 0 0 var(--r-2);
  font-size: var(--step--1);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.toc ul { list-style: none; padding: 0; margin: 0; columns: 2; column-gap: var(--r-4); }
.toc li { margin-block: 0.2em; break-inside: avoid; }
@media (max-width: 34rem) { .toc ul { columns: 1; } }

.scroll-x { overflow-x: auto; }

table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--step--1);
}
caption { text-align: left; color: var(--ink-muted); padding-bottom: var(--r-2); }
th, td {
  text-align: left;
  vertical-align: top;
  padding: 0.7em 0.9em;
  border-bottom: 1px solid var(--hairline);
}
thead th {
  color: var(--ink);
  font-weight: 650;
  background: var(--sunken);
  border-bottom: 1px solid var(--hairline-strong);
}
tbody tr:last-child td { border-bottom: 0; }
.table {
  border: 1px solid var(--hairline);
  border-radius: var(--radius-field);
  overflow: hidden;
  background: var(--card);
}

@media (max-width: 46rem) {
  .table thead { display: none; }
  .table, .table tbody, .table tr, .table td { display: block; width: 100%; }
  .table tr {
    border-bottom: 1px solid var(--hairline-strong);
    padding-block: var(--r-2);
  }
  .table tr:last-child { border-bottom: 0; }
  .table td { border-bottom: 0; padding: 0.35em 0.9em; }
  .table td[data-label]::before {
    content: attr(data-label);
    display: block;
    font-size: 0.85em;
    font-weight: 650;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--ink-muted);
  }
}

/* ---------------------------------------------------------------------------
   10. FOOTER — link groups that collapse from four columns to one with no
   media query at all.
   --------------------------------------------------------------------------- */

/* THE FOOTER IS THE OTHER HALF OF THE DARK ANCHOR. Its ground and its token
   rebinds come from the block in §5 that it shares with the masthead and the
   hero; what is left here is layout.

   It used to be `--band` with a hairline on top — the same tint as three
   sections above it, which meant the page did not end so much as run out. A
   page that opens on #121509 and closes on #121509 is a composition; the light
   run in between is the content, and the reader can see where it begins and
   where it stops.

   No `border-top`: the colour change is the boundary, exactly as at the hero's
   foot. The padding is asymmetric on purpose — a full section's worth above, so
   the last light section is not crowded by the slab, and less below, because
   the fine print is the end of the document and does not want a field under
   it. */
.site-footer {
  padding-block: var(--section-y) var(--r-5);
  font-size: var(--step--1);
}
.footer-top {
  display: grid;
  gap: var(--r-5);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
}
.footer-brand { max-width: 22rem; }
.footer-brand > * + * { margin-top: var(--r-3); }
/* The footer takes the same horizontal lockup, one step smaller. The stacked
   one is available (assets/brand/lockup-stacked.svg) but it needs a column of
   its own, and this column also holds a sentence. */
.footer-brand .wordmark { color: var(--anchor-accent); }
.footer-brand .mark-glyph { height: 1.7rem; }
.footer-brand .wordmark-word { height: 1.8rem; }
.footer-group h2 {
  font-size: var(--step--1);
  font-family: var(--font-text);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin-bottom: var(--r-2);
}
.footer-group ul { list-style: none; padding: 0; margin: 0; }
/* The margin used to be 0.4em a side and the anchor had no padding at all,
   which made every footer link a 24px-tall target with a 6px gap either side.
   Both books put the floor higher than that — 48px on mobile (Fundamentals
   p93), 7mm ≈ 48px with a 2mm gap between neighbours (Pixel Perfect p48) —
   and these are the links a thumb reaches for at the very bottom of a 14 000px
   page, where it is least accurate. The hit area moves into the anchor, so it
   grows without the list growing with it. */
.footer-group li { margin-block: 0; }
.footer-group a {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  min-height: 44px;
  padding-block: 0.25rem;
  color: var(--ink);
  text-decoration: none;
}
.footer-group a:hover { color: var(--accent); text-decoration: underline; }
.footer-group a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }

/* ---- THE LANGUAGE LIST, which is now the site's only language control -----
   Ten entries, so it cannot be the single column the other three groups are:
   at 0.4em a side that is a 300px tower next to three short lists, and it would
   set the footer's height on its own. Two columns of five, from the width at
   which the footer stops being one column at all.

   The flag is what makes the list scannable — a reader looking for their own
   language finds a rectangle of colour long before they read ten names — and
   it is decoration, so it takes no accessible name: the name beside it is the
   name. `aria-current` marks the one you are already reading. */
.footer-group.langs ul {
  display: grid;
  grid-template-columns: 1fr;
  column-gap: var(--r-3);
}
@media (min-width: 30rem) {
  .footer-group.langs ul { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.footer-group.langs a { gap: 0.6em; }
.footer-group.langs .flag {
  flex: none;
  width: 1.15rem;
  height: auto;
  aspect-ratio: 3 / 2;
  border-radius: 2px;
  /* A flag is a filled rectangle and several of these are pale at one edge;
     without an outline the Polish and the Hungarian bleed into the slab. */
  outline: 1px solid rgb(255 255 255 / 0.22);
  outline-offset: -1px;
}
.footer-group.langs [aria-current="page"] {
  color: var(--anchor-accent);
  font-weight: 600;
}
.footer-fine {
  margin-top: var(--r-5);
  padding-top: var(--r-3);
  border-top: 1px solid var(--hairline);
  color: var(--ink-muted);
}
.footer-fine > * + * { margin-top: var(--r-2); }

/* ---------------------------------------------------------------------------
   10b. THE DRAWN SCENES

   Three illustrations — `#why`, `#grocery`, `#privacy-note` — drawn as inline
   SVG in `inc/chrome.php` and moved from here. The drawings are documented
   there; this is how they are coloured, sized and animated.

   ## THE RESTING STATE IS THE FINISHED PICTURE

   Every rule below that hides or offsets a part of a scene lives inside
   `prefers-reduced-motion: no-preference`, and nothing outside that guard sets
   an opacity or a transform. So with motion off — or in an engine that ignores
   the animations, or if one of them is ever deleted — each scene renders as its
   own last frame: the pile complete, the basket filled, the shield closed. That
   is the same discipline the scroll-driven reveals follow one section down, and
   for the same reason: a decoration that can strand content at `opacity: 0` is
   not a decoration, it is a blank page waiting to happen.

   ## Colour

   Four tokens and no hex. `--card` and `--accent-line` for a piece of paper,
   `--accent` for anything the product did, `--accent-soft` for a tint. In dark
   they invert with everything else, and inside a `.section--band` — which is
   where two of the three sit — `--accent` is already rebound to the
   on-tint stop, so the drawings pass contrast for free.

   ## Weight

   THE FIRST VERSION OF THIS NOTE DID THE ARITHMETIC AGAINST A VIEWBOX THAT DOES
   NOT EXIST. It claimed `stroke-width: 2` on "a 120–140 unit viewBox" landed
   within a hair of the icons' 1.75-on-24. The boxes are 172, 174 and 180 —
   `SF_SCENE_BOXES` in `chrome.php` — and the drawn widths are 208 to 304px, so
   the strokes came out at 2.4 to 3.4 screen pixels against an icon's 1.22 to
   1.75. Up to 2.8x, and different for each of the three scenes. Visible: the
   cart is plainly heavier than the book and the magnifier in the cards below it.

   `vector-effect: non-scaling-stroke` ends the class of mistake, not just this
   instance. The width stops being a viewBox unit that has to be recomputed
   every time a drawing is reframed or a column changes size, and becomes what
   it always meant: 1.75 device pixels, the icons' own weight, at any scale. The
   three heavier parts keep their old ratios to it.

   The scenes and the icons have to look like one hand; two weights on one page
   reads as two vendors, and three read as an accident.
   --------------------------------------------------------------------------- */

.scene {
  display: block;
  width: 100%;
  max-width: 20rem;
  height: auto;
  fill: none;
  stroke: var(--accent);
  stroke-width: 1.75;
  vector-effect: non-scaling-stroke;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* A sheet of paper: the surface, edged with the accent's own hairline. */
.scene .sc-card,
.scene .sc-panel,
.scene .sc-phone { fill: var(--card); stroke: var(--accent-line); }
.scene .sc-card--lit { fill: var(--accent-soft); stroke: var(--accent); }
/* Writing on it. Not text — a rule that stands for a line of it. */
.scene .sc-rule { stroke: var(--ink-faint); stroke-width: 2.6; }
.scene .sc-dot { fill: var(--accent); stroke: none; }
.scene .sc-pip { fill: var(--accent); stroke: none; }
.scene .sc-tick { stroke: var(--accent); stroke-width: 2.3; }
.scene .sc-stroke,
.scene .sc-wheel { stroke: var(--accent); }
.scene .sc-wheel { fill: var(--card); }
.scene .sc-shield-body { fill: var(--accent-soft); stroke: var(--accent); }
/* The boundary in `vault`. Dashed rather than solid because a solid rounded
   rectangle around a phone reads as a second device; a dashed one reads as a
   line nothing crosses. */
.scene .sc-fence {
  stroke: var(--accent-line);
  stroke-width: 2.2;
  stroke-dasharray: 7 8;
}
.scene .sc-rule--faint { stroke: var(--accent-line); }

/* ---- Where the three of them sit -----------------------------------------
   `.split` is a claim beside a drawing: one column below 64rem, two above, and
   — this is the part that keeps it honest — TWO ONLY WHEN THERE IS A DRAWING.
   `sf_scene()` returns an empty string in a tree without `chrome.php`, and a
   fixed two-track grid would then reserve a column for nothing, which is the
   same "template with a missing slot" the hero has a `:has()` rule to avoid. */
.split { display: grid; gap: var(--r-5); align-items: center; }
.split > .stack > p { max-width: 56ch; }
@media (min-width: 64rem) {
  /* 19rem for `#grocery`, whose drawing has to hold its own beside five chips
     and a callout; 13rem for `#privacy-note`, which is a short paragraph and a
     button and would be overpowered by anything larger. The two numbers are the
     only difference between the sections. */
  .split:has(> .scene) { grid-template-columns: minmax(0, 1fr) minmax(0, 19rem); }
  .split--tight:has(> .scene) { grid-template-columns: minmax(0, 1fr) minmax(0, 13rem); }
}

/* THE DRAWING MOVED COLUMNS, AND THE MEASUREMENT IS WHY.

   It used to sit under the statement, in column 1, on the reasoning that the
   statement is what it illustrates. Measured at 1440 that left the section
   ending like this:

       column 1   statement 286 + drawing 181 + gaps      = 539px
       column 2   two paragraphs                          = 304px

   — a 235px x 496px empty rectangle at the BOTTOM RIGHT of the band, which is
   the largest single hole on the page and sits directly under the paragraph
   that carries the product's whole claim.

   Swapping it into column 2 does not create the mirror problem, and that is the
   non-obvious part. Column 1 then ends 223px short — but column 1 is one
   sentence set at display size, and air under a big line reads as emphasis. Air
   under a paragraph reads as a block that ran out. The same void in the two
   columns does not cost the same.

   It is still the statement's illustration; the spread is one composition and
   the drawing reads across it. And it is allowed to be large — a small drawing
   under the biggest type on the page reads as a stray icon. */
.story > .scene { max-width: 17rem; }
@media (min-width: 64rem) {
  .story > .scene {
    grid-column: 2;
    grid-row: 3;
    margin-top: var(--r-4);
    max-width: 20rem;
  }
  .story > .statement { grid-row: 1 / span 2; align-self: start; }
  /* Without this the three rows share the section's height evenly and the two
     paragraphs drift apart to fill it. */
  .story { align-content: start; }
}

@media (prefers-reduced-motion: no-preference) {
  /* --- the pile that only grows ------------------------------------------
     Six seconds, three beats: a card falls onto the stack, a line and its tick
     appear to the right, and both hold long enough to be read before the loop
     starts over. Nothing eases in from off-screen and nothing bounces; the
     drawing is a diagram, not a cartoon. */
  .scene--stash .sc-drop {
    animation: sc-drop 6s var(--ease) infinite;
    transform-origin: 50% 50%;
  }
  .scene--stash .sc-out { animation: sc-emerge 6s var(--ease) infinite; }
  .scene--stash .sc-tick--late { animation-delay: 0.35s; }

  /* --- the list becoming a basket ---------------------------------------
     Three pips leave the panel in turn and the cart dips under each. The dip is
     4px and 200ms: enough to register as weight, not enough to look like a
     spring. */
  /* Each pip sets its own starting offset, which is the vector from where it is
     drawn (in the basket) back to the line it came from. One shared offset put
     the third pip ten units above its own line on the way out, which in a
     116-unit drawing is visible as a wobble.

     THESE THREE NUMBERS ARE NOT FREE. Every source line starts at x=20 (see
     `M20 40h48M20 56h34M20 72h42` in the scene), so each `--fx` must be
     exactly `20 - <that pip's rest x>`. The pips were moved right, out of the
     basket's leaning left rail, so all three grew by the same amount they
     moved; get this out of step and a pip sets off from beside its line
     instead of from it. Rest x is 142 / 143 / 144, hence -122 / -123 / -124. */
  .scene--cart .sc-fly {
    --fx: -122px;
    --fy: -6px;
    animation: sc-fly 4.5s var(--ease) infinite;
  }
  .scene--cart .sc-fly--2 { --fx: -123px; --fy: -1px; animation-delay: 0.5s; }
  .scene--cart .sc-fly--3 { --fx: -124px; --fy: 4px; animation-delay: 1s; }
  .scene--cart .sc-cart { animation: sc-load 4.5s var(--ease) infinite; }

  /* --- nothing leaves the phone -----------------------------------------
     The boundary's dashes drift slowly round it and the shield takes one breath
     per cycle. Both are deliberately close to the threshold of noticing: this
     scene sits beside the quietest claim on the page, and a lively drawing
     beside a promise about your data undercuts the promise. */
  .scene--vault .sc-fence { animation: sc-patrol 6s linear infinite; }
  .scene--vault .sc-shield {
    animation: sc-guard 6s var(--ease) infinite;
    transform-origin: 50% 60%;
  }
}

@keyframes sc-drop {
  0% { transform: translateY(-26px); opacity: 0; }
  14%, 100% { transform: none; opacity: 1; }
}
@keyframes sc-emerge {
  0%, 20% { transform: translateX(-18px); opacity: 0; }
  36%, 100% { transform: none; opacity: 1; }
}
/* The pips start on the panel's lines and finish where they are drawn — in the
   basket. Ending on the drawn position, rather than starting there, is what
   makes the still frame the finished picture. */
@keyframes sc-fly {
  0%, 6% { transform: translate(var(--fx), var(--fy)); opacity: 0; }
  16% { opacity: 1; }
  46%, 100% { transform: none; opacity: 1; }
}
@keyframes sc-load {
  0%, 40% { transform: none; }
  50% { transform: translateY(4px); }
  60%, 100% { transform: none; }
}
/* One dash period (7 + 8 units) per cycle: the boundary drifts, it does not
   march. A longer offset here reads as a progress indicator, which this is not. */
@keyframes sc-patrol {
  from { stroke-dashoffset: 0; }
  to { stroke-dashoffset: 15; }
}
@keyframes sc-guard {
  0%, 34% { transform: none; }
  44% { transform: scale(1.06); }
  56%, 100% { transform: none; }
}

/* ---------------------------------------------------------------------------
   NAVIGATION BETWEEN PAGES

   Sixteen documents, no router, no framework — and the complaint was that every
   link "strašně trvá". Two different things were making it feel that way and
   only one of them is a stylesheet's business.

   The other one first, so nobody looks for it here: the live host answers every
   slash-less URL with a 301, so each navigation costs an extra round trip
   before the document even starts. That is `SF_TRAILING_SLASH` in `config.php`
   and it is a decision about URLs, not about motion.

   What IS a stylesheet's business is the white flash. A cross-document
   navigation tears the old page down and paints the new one, and for the ~100ms
   in between the reader sees nothing — which is read as slowness even when it
   is not. `@view-transition` hands that gap to the browser as an animation:
   the old document fades out, the new one fades up eight pixels, and the header
   does not move at all because it is named and therefore morphed rather than
   replaced.

   ## Why this and not a single-page app

   A router would need a framework, a build step, and a script — and the CSP
   here is `default-src 'none'` with `script-src` made of exactly two sha256
   hashes. Every one of those is a rule this project holds on purpose. This is
   four declarations of CSS, needs no script, and an engine that has never heard
   of it simply navigates the way it does today.

   ## The reduced-motion kill has to name the pseudo-elements

   A view transition's animations do not live on any element in the document, so
   the blanket `*, *::before, *::after` rule below cannot reach them. Without
   these three selectors a reader who asked for less motion would get the one
   animation on the site that ignored them.
   --------------------------------------------------------------------------- */
@view-transition { navigation: auto; }

/* Nothing carries a `view-transition-name` any more. The header did — it was
   the one object present and identical on both documents, so morphing it beat
   cross-fading it. With the header gone there is no such object: the masthead
   is absent on the landing page and the hero is absent everywhere else, so
   naming either would ask the browser to morph a thing into nothing. The root
   cross-fade below is the whole transition now. */

@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(root) { animation: 90ms linear both vt-fade-out; }
  ::view-transition-new(root) { animation: 220ms var(--ease) both vt-rise-in; }
}
@keyframes vt-fade-out { to { opacity: 0; } }
@keyframes vt-rise-in { from { opacity: 0; transform: translateY(8px); } }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}

/* ---------------------------------------------------------------------------
   11. MOTION AND PRINT

   The only motion on the site is a hover transition and the smooth scroll to an
   in-page anchor, but the reduced-motion block is a blanket kill anyway — it
   has to keep holding when someone adds an animation later without reading
   this file.

   Print matters because people do print a privacy policy for their records.
   Everything interactive is dropped, and so is the screenshot strip, which is
   three full-page colour images and pure argument, not evidence, on paper. The
   tinted grounds go white so the page does not eat a cartridge, cards keep a
   hairline so their grouping survives, and external links print their target —
   the whole point of a printed policy is that it stands on its own off-screen.
   Internal links are excluded from that: annotating every cross-reference with
   a bare /privacy adds noise and no information.

   The selector used to be `:root, :root[data-theme]`, to outrank the attribute
   a script wrote for a manually darkened page. There is no such attribute any
   more. Its replacement as the thing that outranks a bare `:root` is the dark
   anchor, whose rebinds sit on three element classes at (0,1,0) — see the block
   below that undoes them, and note that it has to restate every literal, not
   just the inks.
   --------------------------------------------------------------------------- */

/* ---- Scroll-driven motion, in CSS, with no third script -------------------
   Cards, screenshots and callouts fade up a short distance as they arrive. The
   header used to condense as the page scrolled; there is no header.

   NOT "once each", which is what this note used to claim. `view()` is a
   SCRUBBED timeline: the animation is a function of scroll position, so
   scrolling back up plays it backwards and the content dims again. Measured
   0.54 on a section head and 0.71 on a chip while moving upwards. Nothing here
   can fix that — an animation tied to a timeline has no memory — and it is not
   worth a script to fix, but the file should not say otherwise.

   This is `animation-timeline: view()` / `scroll()` rather than an
   IntersectionObserver, and that was a deliberate trade. An observer would have
   meant a third inline script and a third CSP hash to justify — and a script
   whose only job is decoration is exactly the kind of thing a strict policy
   exists to keep off a page. Scroll-driven CSS costs nothing, runs off the main
   thread, and — the part that decides it — DEGRADES CORRECTLY.

   That last claim was measured rather than assumed, twice, because "content
   stuck at opacity 0" is a blank page and it is the failure mode of every
   reveal effect ever shipped:

   - **When the timeline cannot resolve, the animation produces no output at
     all** and the element renders its base style, which is visible. That is
     what makes the short-page case safe: a document that does not scroll has no
     scrollport progress, so a `.section-head` below the fold on a two-paragraph
     404 is simply shown rather than faded out and stranded. An
     IntersectionObserver written the obvious way strands it.
   - It is also what happens in an engine that has not implemented scroll-driven
     animation at all: no timeline, no output, every element visible and
     unanimated. The page is correct there, just plainer.

   Checked in Chrome 151: the header condenses as the page scrolls and the cards
   fade up as they arrive. Note for anyone verifying this the way it was
   verified — read it off a screenshot, not off `getComputedStyle`. A devtools
   or extension context can hand back a stale computed value for a scroll-driven
   animation and for a pseudo-element that has just started matching, which
   makes a working effect look dead.

   So this is decoration that cannot cost anybody the content. Nothing else on
   the page is allowed to be, either.

   Everything is inside BOTH `@supports` and `prefers-reduced-motion:
   no-preference`, so reduced motion does not merely shorten the movement, it
   never declares it.

   ## BOTH ENDS ARE `entry`, AND THE SECOND ONE USED TO BE `cover`

   `entry 10% entry 90%` means: start when the element is a tenth of the way
   into the scrollport and finish when it is nine tenths of the way in — that
   is, before it has finished arriving. It reads as a fade-up and it is over by
   the time there is anything to read.

   The old range ended at `cover 26%`, and that was wrong in a way that only
   shows on a tall window. `cover` is normalised over (viewport height + element
   height), so a bigger window makes the same percentage cost more scrolling —
   while "the element is now fully on screen" happens SOONER. The two move in
   opposite directions, and the result was content that was still half
   transparent when it was completely visible. Measured on the live page, taking
   each reveal target's opacity at the first moment it was entirely inside the
   viewport:

     window 700    average 0.67    20 of 32 below 0.90    worst 0.26
     window 900    average 0.60    25 of 33 below 0.90    worst 0.24
     window 1200   average 0.48    31 of 33 below 0.90    worst 0.18

   After anchoring both ends to `entry`, all three heights: average 1.00, none
   below 0.90. A range expressed only in `entry` is a fraction of the element's
   own arrival and does not know how tall the window is, which is the property
   this effect needed all along.

   Two traps if you re-measure this. Sample when the element is FULLY visible,
   not when it first intersects — the second one always looks fine. And set
   `scrollBehavior = 'auto'` first: this page has `scroll-behavior: smooth`, so
   a loop of `scrollTo()` calls samples positions the page is still travelling
   towards, and the measurement returns nothing at all.

   Short, either way. Nothing flies in from off-screen, nothing bounces, nothing
   moves further than 14px. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .reveal,
    .grid > .card,
    .shots > .shot,
    .section .callout,
    .scene,
    .section > .wrap > .section-head {
      animation: reveal-up linear both;
      animation-timeline: view();
      animation-range: entry 10% entry 90%;
    }

    /* THE CHIPS ARRIVE IN ORDER, and this is the one place on the page where a
       stagger is right: a row of chips is a LIST of things, and five of them
       appearing together is one shape while five appearing left to right is
       five things. Everything else on the page reveals as a block, because
       everything else IS a block.

       `nth-child` and not a per-element delay written into the markup: the
       networks come from `content.php` and the shops from `config.php`, and
       neither list should have to know it is being animated. Past the sixth
       they all share the last step — a stagger that keeps growing turns the
       tail of a long row into a wait. */
    .chips li {
      animation: reveal-up linear both;
      animation-timeline: view();
      animation-range: entry 5% entry 75%;
    }
    .chips li:nth-child(2) { animation-range: entry 10% entry 80%; }
    .chips li:nth-child(3) { animation-range: entry 15% entry 85%; }
    .chips li:nth-child(4) { animation-range: entry 20% entry 90%; }
    .chips li:nth-child(n + 5) { animation-range: entry 25% entry 95%; }

    /* THE PHONE DRIFTS AS THE ROW PASSES. Thirty-six pixels of travel across
       the whole time a row is on screen — about one pixel per forty scrolled,
       far too slow to read as movement and exactly fast enough to stop four
       stacked rows feeling like four stacked pictures. This is the only effect
       on the page tied to the FULL passage of an element rather than to its
       arrival, which is what makes it read as depth rather than as an entrance.

       On the picture only. Parallax on text is how a page becomes hard to read
       while the reader is trying to read it.

       Below 48rem it does not run: in one column the phone is the whole width
       of the page, and a full-width element sliding against nothing is just the
       page wobbling. */
    @media (min-width: 48rem) {
      .showcase-shot {
        animation: shot-drift linear both;
        animation-timeline: view();
        animation-range: entry 0% exit 100%;
      }
    }
  }
}
@keyframes reveal-up {
  from { opacity: 0; transform: translateY(14px); }
  to { opacity: 1; transform: none; }
}
@keyframes shot-drift {
  from { transform: translateY(18px); }
  to { transform: translateY(-18px); }
}

/* ---- The seam between two sections ---------------------------------------
   The complaint this answers is that the page reads as eight separate slabs.
   It does, and the reason is that every boundary is the same 1px hairline
   arriving at the same moment as the ground behind it changes: nothing on the
   page ever crosses a section edge, so nothing joins them.

   So the tinted sections get one accent rule along their top edge that DRAWS
   ITSELF as the section arrives — left to right, over the first tenth of the
   section's entrance, ending exactly when the heading below it has finished
   fading up. It is 2px and it fades to nothing at the right-hand end, so it
   reads as an underline being written rather than as a border switching on.

   Why the top edge only, and only the tinted sections: a rule at both edges of
   every section is a grid, and a grid is more slabs, not fewer. This marks the
   three places the page actually changes subject.

   The base rule below the guard is `scaleX(1)` — the finished state. With no
   scroll-driven animation, or with reduced motion, the seam is simply drawn.
   Nothing here can strand anything at zero. */
.section--band { position: relative; }
.section--band::before {
  content: "";
  position: absolute;
  inset-inline: 0;
  top: -1px;
  height: 2px;
  background: linear-gradient(90deg, var(--accent) 0%, var(--accent) 22%, transparent 78%);
  transform-origin: left center;
  /* THE SEAM EMITS. A 2px rule that merely exists is a border; the same rule
     casting a short glow below itself is a lit edge, and that one difference
     is most of what separates "stacked slabs" from "a panel that just came
     on". Downward only — `0 Npx` with no negative spread, so the light falls
     INTO the section the seam belongs to and never up into the one above,
     which would blur the boundary the seam exists to draw. Two terms, tight
     and wide, the same pairing §8 uses to seat the phone. */
  box-shadow:
    0 0 6px color-mix(in oklab, var(--accent) 55%, transparent),
    0 0 22px color-mix(in oklab, var(--accent) 30%, transparent);
}
/* The band directly under the hero KEEPS its seam now. It used to be exempted,
   because that band had had its ground and its border removed; both are back —
   see the note where that exception used to live — so the seam has an edge to
   sit on again, and it is the accent's first appearance on the page. */

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .section--band::before {
      animation: seam-draw linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 55%;
    }
  }
}
@keyframes seam-draw {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

/* ---- The page answers the cursor ------------------------------------------
   Cards and chips are not links and never will be — a chip names a network the
   app reads, a card names something the app does — so none of this may look
   like an affordance. What it may do is acknowledge the pointer, which is the
   difference between a page that is being read and a page that is being used.

   Two rules for both: a two-pixel lift and a warmer edge, over 160ms on the
   same `--ease` as every other transition on the site. No scale, no shadow
   bloom, no colour inversion. `translateY(-2px)` is under the threshold at
   which a row of chips starts to look like it is bouncing, which is the failure
   this is aimed at avoiding.

   `@media (hover: hover)` so a touch device never gets a stuck hover state
   after a tap — on a phone `:hover` latches until you touch something else, and
   a chip frozen two pixels above its neighbours reads as a rendering bug.

   ## `translate`, NOT `transform`, AND THAT IS NOT A STYLE CHOICE

   Every one of these elements is also a reveal target, and a reveal is a
   `transform` animation with `animation-fill-mode: both`. A finished animation
   still writes its final value, and an animation's value beats a declarative
   one in the cascade whatever the specificity — so `.card:hover { transform }`
   is silently dead the moment `.grid > .card` starts revealing, which is to say
   always. `translate` is a separate property that composes with `transform`
   rather than replacing it, so the lift and the reveal can both be true.
   This was found by hovering, not by reading; it looks correct in the file. */
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .chips li,
  .card {
    transition:
      translate 160ms var(--ease),
      border-color 160ms var(--ease),
      box-shadow 160ms var(--ease);
  }
  .chips li:hover { translate: 0 -2px; border-color: var(--accent); }
  .card:hover {
    translate: 0 -2px;
    border-color: var(--hairline-strong);
    box-shadow: var(--shadow-float);
  }
  /* The icon leans into it. `.feature-icon` is the only mark on a card, so it
     is the only part that can move without the card looking like it flexed. */
  .feature-icon svg { transition: rotate 220ms var(--ease), scale 220ms var(--ease); }
  .card:hover .feature-icon svg { rotate: -7deg; scale: 1.09; }
}

/* The scroll-driven header condense lived here — `topbar-condense` shrinking
   the bar's padding and `topbar-lift` growing it a shadow, both on
   `animation-timeline: scroll(root block)`. Both are gone with the bar. They
   are named here rather than silently deleted because `animation-timeline` is
   still used further up the file for the section reveals, and the next reader
   of that `@supports` block should know this one was removed on purpose and
   not lost in a merge. */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  .btn:active { transform: none; }
  .btn:hover .icon { transform: none; }
  /* Belt and braces: even if a future edit puts one of these outside the
     no-preference guard above, nothing here may move.
     The theme switch's selected-state pill used to be exempted here, because
     its opacity was state and not motion. There is no theme switch. */
  .reveal, .grid > .card, .shots > .shot, .section .callout,
  .section > .wrap > .section-head,
  .chips li, .showcase-shot, .scene, .scene *, .section--band::before {
    animation: none !important;
    transform: none !important;
    opacity: 1 !important;
  }
  /* The hover lift and the icon's lean are `translate` / `rotate` / `scale`,
     which the blanket above does not name — it kills `transform`, and those are
     four different properties. Without these three lines a reader who asked for
     less motion still gets a card that jumps two pixels under the cursor, only
     now with the transition removed, which is worse than the animation was. */
  .chips li, .card { translate: none !important; }
  .feature-icon svg { rotate: none !important; scale: none !important; }
  /* The phones present flat and square. A static 3D tilt is not motion, but it
     is the same vestibular family, and the frame loses nothing by being shown
     straight on — the screen is actually easier to read. This is the thing a
     baked-in mockup image cannot do at all. */
  .shot-frame, .shot-media, .video-media { transform: none !important; }
}

@media print {
  :root {
    color-scheme: light;
    --canvas: #FFFFFF;
    --card: #FFFFFF;
    --elevated: #FFFFFF;
    --band: #FFFFFF;
    --sunken: #FFFFFF;
    --accent-soft: #FFFFFF;
    --ink: #000000;
    --ink-muted: #1A1F0F;
    --ink-faint: #4A4F40;
    --accent: #000000;
    --accent-on-tint: #000000;
    --brand-flow: #000000;
    --img-light: block;
    --img-dark: none;
    --hairline: #B5B5B5;
    --hairline-strong: #8A8A8A;
    --line-control: #8A8A8A;
    --accent-line: #8A8A8A;
    --shadow: none;
    --shadow-float: none;
  }
  /* THE DARK ANCHOR COMES OFF ON PAPER, AND IT HAS TO BE UNDONE EXPLICITLY.

     The block above resets `:root`, and that is not enough: the anchor's rebinds
     live on `.masthead, .hero, .site-footer` at (0,1,0), so they survive a
     `:root` reset and would print `--ink: #F3F4F0` — white text on white paper,
     across the hero and the whole footer. The failure is total and invisible
     until somebody prints the privacy policy, which is the one page on this site
     people genuinely do print.

     Every literal in that block is restated here, not only the inks: `--card`,
     `--accent-soft` and friends are literals there too, and a white page with a
     #1B2110 card on it is the same bug one step down.

     `--img-light: block; --img-dark: none` is the one that is easy to miss. On
     screen the anchor forces the dark-ground artwork for the wordmark and the
     screenshots; on white paper that is a light logo on nothing. */
  .masthead, .hero, .site-footer {
    color-scheme: light;
    background: #FFFFFF;
    color: #000000;
    --canvas: #FFFFFF;
    --card: #FFFFFF;
    --elevated: #FFFFFF;
    --sunken: #FFFFFF;
    --accent-soft: #FFFFFF;
    --ink: #000000;
    --ink-muted: #1A1F0F;
    --ink-faint: #4A4F40;
    --accent: #000000;
    --accent-on-tint: #000000;
    --accent-hover: #000000;
    --accent-line: #8A8A8A;
    --hairline: #B5B5B5;
    --hairline-strong: #8A8A8A;
    --line-control: #8A8A8A;
    --brand-flow: #000000;
    --on-accent: #FFFFFF;
    --img-light: block;
    --img-dark: none;
    --shadow: none;
    --shadow-float: none;
  }
  /* The atmosphere is three gradients over the slab. With no slab it is three
     grey smears across the top of the first sheet. */
  .hero::before { content: none; }

  body { font-size: 10.5pt; line-height: 1.5; }
  .masthead, .skip, .hero-visual, .actions, .toc, .shots { display: none !important; }
  .hero { background: none; padding-block: 0 var(--r-4); border: 0; }
  .section, .site-footer { padding-block: var(--r-4); border: 0; }
  .card, .callout { border: 1px solid var(--hairline); box-shadow: none; }
  .shot-frame, .shot-media, .video-media {
    transform: none;
    padding: 0;
    background: none;
    box-shadow: none;
    border-color: var(--hairline);
  }
  /* The side buttons and the glass sheen are the device, and on paper there is
     no device — there is a picture of a screen. Both would print as grey marks
     over the screenshot. */
  .shot-frame::before, .shot-frame::after,
  .shot-media::before, .shot-media::after,
  .video-media::before, .video-media::after { content: none; }
  .card, .callout, .footer-group, tr, li { break-inside: avoid; }
  h1, h2, h3 { break-after: avoid; }
  a { color: #000000; text-decoration: underline; }
  a[href^="http"]::after, a[href^="mailto"]::after {
    content: " (" attr(href) ")";
    font-size: 0.85em;
    word-break: break-all;
  }
  .table thead { display: table-header-group; }
  .table, .table tbody, .table tr, .table td { display: revert; }
  .table td[data-label]::before { content: none; }
}
