/* ============================================================
   babah3d.com — the whole stylesheet. There is only one.

   No build step, no framework, no fonts to download, no npm. A page
   here is an HTML file and this file, and that is the entire stack.
   It is meant to stay that way: the site will be edited far more often
   than it is engineered, usually to change a sentence or swap a photo,
   and a toolchain is a thing that breaks between those visits.

   THE RULE THIS DESIGN IS BUILT ON: one idea per screen, and the
   picture does the talking. Big type, a lot of empty space, almost no
   chrome, and never two paragraphs where one line will do. If a
   section needs explaining, the photograph in it is the wrong
   photograph. Adding copy is how this design dies.
   ============================================================ */

:root {
    /* Surfaces. Near-white rather than white: a page of pure #fff with
       #fff cards has no depth at all, and the soft grey is what makes a
       full-bleed photograph look placed rather than pasted. */
    --bg: #ffffff;
    --bg-soft: #f5f5f7;
    --ink: #1d1d1f;
    --ink-2: #6e6e73;
    --hairline: rgba(0, 0, 0, 0.12);
    --hairline-soft: rgba(0, 0, 0, 0.06);

    /* The pink is Babah3D's, carried over from the app's stylesheet so
       the site and the tool are visibly the same shop.

       THE RULE, IN BOTH DIRECTIONS — the first version of this comment
       only stated the first half, and the second half shipped broken for
       weeks behind it:

         1. --pink must never be a `color:`.  #ff7aa8 on #ffffff = 2.44:1.
         2. --pink must never be a fill that carries white `color:`.
            #ffffff on #ff7aa8 = 2.44:1 — THE SAME TWO COLOURS AND THE
            SAME RATIO. Contrast is symmetric: which of the pair is the
            ink makes no difference whatsoever to the number.

       So --pink is decorative only: a wash, never a boundary and never
       under type. It is also under the 3:1 that WCAG 1.4.11 asks of a
       border or a control's edge, which is why the .todo outline and the
       .ph border are --pink-text and not this.

       Four tokens, and each one has exactly one job:

       --pink       #ff7aa8  decorative wash. Never ink. Never under ink.
       --pink-text  #b3275d  ink ON the page background, 6.24:1 on #ffffff
                             and 5.73:1 on #f5f5f7. It used to be
                             scheme-aware, with a #de6896 value for the
                             dark page — same hue, same saturation, lifted
                             in lightness. That second value went with the
                             dark palette on 2026-08-13 and this token is
                             now a single pigment.
       --pink-btn   #90194c  a fill that carries WHITE ink, 8.64:1. It
                             stays a SEPARATE token from --pink-text even
                             though only one theme ships, and the reason
                             outlives the dark palette. It is NOT that
                             --pink-text fails under white — measured,
                             #ffffff on #b3275d is 6.24:1 and clears 4.5,
                             so a merge would be legible. It is that the
                             two do different jobs: #90194c is the logo's
                             own magenta, the pigment the owner picked, and
                             holding it apart from the link colour is what
                             makes the primary out-contrast its own
                             secondary (8.64 against 6.24) instead of
                             tying with it. Merging would be legible and
                             flat.
       --pink-btn-ring #bb2163  UNUSED TODAY. It was the button's edge in
                             dark mode only, and that rule was deleted on
                             2026-08-13 because the fill measures 8.64:1 on
                             a light page and needs no edge — the argument
                             is at .btn further down. The token is kept
                             because it is a correct tint of --pink-btn's
                             own hue and costs one line, but nothing paints
                             it, and a reader should not go looking for
                             where it is spent.

       WHY #90194c AND NOT #b3275d. #b3275d was a colour chosen to clear a
       bar. #90194c is the logo's own magenta — the value in the mesh
       markup on services.html, alongside orange #ed5f2f and teal #207a64.
       The owner picked it. It is the brand's colour rather than a
       near-miss of it, and it is also the better number in light mode.

       The site now has THREE pinks, not two: --pink-text is still
       #b3275d, so the filled pill and the plain text link beside it are
       no longer the same pigment. In light mode that is an improvement
       and it is measured below — the primary now out-contrasts its own
       secondary, which it did not when they were the same value.

       Measured this session in a real headless Chrome, from the computed
       style of an actual .btn against the backdrop resolved by walking
       its ancestors — not from the token (fleet/STATUS-A53.md §3):
         #ffffff on --pink-btn #90194c ........ 8.64:1  (bar 4.5)  was 6.24
         #ffffff on hover      #c2185b ........ 5.87:1  (bar 4.5)
         --pink-btn vs #ffffff page ........... 8.64:1  (bar 3)    was 6.24
         --pink-btn vs #f5f5f7 --bg-soft ...... 7.94:1  (bar 3)    was 5.73

       TWO MORE LINES USED TO SIT UNDER THOSE — --pink-btn against the dark
       page's #000000 at 2.43:1 and against its #0e0e10 at 2.23:1, both
       under WCAG 1.4.11's 3:1, and both the reason a dark-mode edge ring
       existed. The dark palette went on 2026-08-13 and those two surfaces
       no longer exist, so the figures are gone with them rather than kept
       as measurements of nothing. #90194c is darker than the #b3275d it
       replaced, which is why every light figure above got BETTER — the
       owner's pigment and the accessible choice were the same choice here.

       Hover goes BRIGHTER, not deeper. Deeper was measured first and
       rejected: the obvious hover #a22354 is 7.23:1 with white but the
       reason it lost was its 2.90:1 against the black page, which no
       longer applies. It is left as chosen because #c2185b is 5.87:1 with
       white and 5.87:1 against the page, both comfortable, and re-opening
       a settled colour decision is not what removing a theme is for.

       #c2185b is now 9.7 points of lightness above the base rather than
       0, so the hover reads as a bigger step than it used to. That is a
       second colour decision and the owner made one, so it is left alone
       and written up in fleet/STATUS-A53.md §5 instead of adjusted here. */
    --pink: #ff7aa8;
    --pink-text: #b3275d;
    --pink-btn: #90194c;
    --pink-btn-hover: #c2185b;
    --pink-btn-ring: #bb2163;

    --radius: 14px;
    --nav-h: 48px;

    /* THE FACET. Added 2026-08-01.

       The owner asked for "some slight pixelated theme ... just like the
       logo". The logo is not pixelated — it is LOW-POLY: triangular facets
       with the gradient running across them. The distinction is the whole
       design, because a low-poly mesh is not a decoration here. An STL file
       IS a triangulated surface. The brand mark is a mesh and so is every
       object this shop prints, so the motif is the craft rather than a
       texture laid over it.

       Three placements and no more — see the FACET MOTIF block near the end
       of this file for what they are and what was tried and dropped. The
       rule at the top of this file has not moved: one idea per screen, and
       the picture does the talking. A faceted texture that competes with a
       product photograph has failed, so none of these three sit ON a
       photograph — they sit on its frame, on a label, or behind the logo.

       --facet is the corner a picture frame loses.

       IT USED TO BE clamp(11px, 1.9vw, 20px) — fluid, so a 300px card and a
       980px hero read as the same shape. 2026-08-02 it became a fixed
       multiple of --px, and that is not a regression, it is the point.

       THE OWNER ASKED FOR A PIXELATED THEME, SUBTLY, WITH THE LAYOUT KEPT.
       A pixel grid that scales with the viewport is not a pixel grid: the
       whole idea is one unit that never changes, so a step is a step is a
       step. --px is that unit. Everything the motif draws is an integer
       multiple of it, which is also the only way the staircases stay crisp
       — 4px and 8px are whole device pixels at DPR 1 and at DPR 2, and a
       stepped edge on a fractional boundary is just a blurry diagonal.

       WHAT IT COSTS THE LAYOUT, measured rather than asserted. I wrote
       "nothing moves" here first and it was wrong; a53-layoutdiff.mjs
       records the bounding rect of every element on all five pages at 375
       and 1280, injects the pre-pixel rules back, and diffs:

         every page's total scrollHeight  IDENTICAL, to the pixel
         every element's width and height IDENTICAL
         154 elements shifted VERTICALLY  by 0.63px to 1.65px
         404.html and guide.html          0 elements moved

       All of it is one cause: the .eyebrow mark is 9px tall where the
       triangle was 8px, which grows that one line box, so whatever follows
       an eyebrow sits about a pixel lower. Nothing reflows, nothing
       rewraps, nothing overflows, no page gets longer. Keeping it at 8px
       would put the three steps on 2.67px boundaries, which is a blurred
       diagonal — the exact thing this pass exists to avoid. A pixel of
       baseline for a crisp staircase is the trade, and it is stated here
       rather than glossed. */
    --px: 4px;
    --facet: calc(var(--px) * 4);
    --facet-lg: calc(var(--px) * 8);

    /* The monospace stack was already in this file, on .ph__path. It is now
       a token because it does a second job: the small technical labels.
       Every one of these ships with the OS — ui-monospace resolves to SF
       Mono on Apple, and Consolas is on every Windows since Vista — so this
       costs nothing to fetch, exactly like the body stack below. */
    --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
        "Liberation Mono", monospace;

    /* THE DISPLAY FACE. Added 2026-08-02. The full argument, the licence,
       the provenance and the measured legibility floor are in the DISPLAY
       FACE block below — this is only the stack.

       The fallback behind it is the body stack verbatim, and it is not
       decoration: font-display is `optional`, so on a cold visit that
       loses the 100ms race THIS is what paints the headings, for the whole
       of that page load. It has to be a face somebody would have chosen. */
    --display-face: "Pixelify Sans", -apple-system, BlinkMacSystemFont,
        "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

    /* One measure for body copy. 34em is about 68 characters, which is
       the width text stops being tiring to read at. */
    --measure: 34em;
    --page: 1120px;
    --gutter: clamp(20px, 5vw, 40px);

    /* Vertical rhythm. A "chapter" gets a lot of air — this number is
       the single biggest reason the page reads as calm. */
    --chapter-y: clamp(72px, 13vh, 168px);

    /* THIS SITE IS LIGHT, FULL STOP. Owner's decision, 2026-08-13, in
       their own words: 'i dont think i want dark hue in any of future
       development', then 'remove dark mode from everything'. It is a
       standing rule across the whole family of apps, recorded as P0003 in
       product-app-playbook/references/portfolio-decisions.md and argued in
       launch-playbook/SKILL.md §3.5. It supersedes the 2026-08-03 decision
       this comment used to quote, which said the same thing about this one
       site: 'in dark mode is not nice. like apple website it does not
       affected by dark mode... to preserve the coolness of the UI.'

       THE DARK PALETTE IS DELETED, NOT DISARMED. It used to live here as
       an @media block whose query was made unmatchable with a bogus
       `and (min-width: 999999px)`, on the reasoning that restoring dark
       would then be re-enabling one query rather than re-deriving a
       palette. That is exactly the half-removed state the playbook forbids:
       a disarmed block is one specificity accident or one careless edit of
       the query away from applying, and it re-enables a theme nobody has
       measured since. Four such blocks and four dead `[data-theme]` rules
       came out on 2026-08-13. Restoring dark now means writing a block and
       re-measuring every ratio against the new surfaces — which is the
       honest price, and the token layer below is what keeps it cheap.

       `color-scheme: light` is the half that deletion alone does not do.
       Without it a dark-mode machine still paints the UA's own surfaces
       dark — the scrollbar, form controls, the caret, the canvas before
       first paint — so a light page ends up in a dark frame. Every page
       also carries <meta name="color-scheme" content="light"> so this is
       true before this stylesheet has even loaded. Both are asserted by
       tools/check-light-theme.mjs. */
    color-scheme: light;
}

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

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

@media (prefers-reduced-motion: reduce) {
    html {
        scroll-behavior: auto;
    }

    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        transition-duration: 0.01ms !important;
    }
}

body {
    /* THE BODY STACK, AND IT IS STILL THE SYSTEM STACK. On a Mac or an
       iPhone this is SF Pro, on Windows Segoe, on Android Roboto — every
       one of them already installed, so there is no font file to fetch for
       the text somebody actually reads, and no flash of the wrong typeface
       on a paragraph.

       THIS PARAGRAPH USED TO SAY THERE WAS NO WEBFONT ON THIS SITE. There
       is one now, and leaving the old text here would have made this
       comment a description of a stylesheet that no longer exists — the
       failure this file keeps finding in itself. What was rejected on
       2026-08-01 (fleet/STATUS-A36.md) was a geometric display sans, and
       the reason was that a geometric sans reads ROUNDER than the logo's
       wordmark while costing a font file: it moved away from the brand and
       charged for the privilege. That judgement still stands and the face
       that landed is not that face — see the DISPLAY FACE block below.

       The bar that block had to clear is the one this comment set, and it
       was set deliberately high: not "does it look nice" but "is it worth
       the site's only external-shaped request", with a real fallback and
       no flash. It is answered there with measurements rather than with an
       opinion, and the answer includes a floor size below which the face
       is NOT used — which is why you are reading the system stack here and
       will keep reading it on every paragraph, every link in prose, every
       form label and all the legal copy.

       The third face is --mono, for the small technical labels. Unchanged,
       still zero bytes, and still the part that echoes the engineering
       half of the logo. */
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
        "Helvetica Neue", Arial, sans-serif;
    font-size: 17px;
    line-height: 1.5;
    letter-spacing: -0.01em;
    color: var(--ink);
    background: var(--bg);
    -webkit-font-smoothing: antialiased;
}

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

a {
    color: inherit;
}

/* One global focus ring, so a control added next year is visible to the
   keyboard the moment it exists rather than when somebody remembers. */
:focus-visible {
    outline: 2px solid var(--pink-text);
    outline-offset: 3px;
    border-radius: 4px;
}

.skip-link {
    position: absolute;
    left: -9999px;
    top: 0;
    z-index: 100;
    padding: 12px 18px;
    background: var(--bg);
    border: 1px solid var(--hairline);
    border-radius: 0 0 var(--radius) 0;
}

/* 6px and not 0, and the reason is the focus ring rather than the box.
   :focus-visible above draws `outline: 2px` at `outline-offset: 3px`, and an
   outline paints OUTSIDE the border box — so with the link flush at 0,0 the
   top and left of the ring land 3px outside the VIEWPORT and are clipped
   away. What a keyboard user actually saw was a pink arc down the right-hand
   side and along the bottom, cut flat at the corner: it reads as a rendering
   fault rather than as "you are here", on the first control on every page.

   Nothing a test asserts was wrong — the ring was present, 2px, and the
   right colour in both schemes. It had to be looked at. See
   fleet/STATUS-A51.md §2.1 for the before and after rasters.

   Inset the BOX, not the ring: 6px clears the 3px offset plus the 2px stroke
   with a pixel to spare, and the link still arrives in the top-left corner
   where a skip link belongs. The radius goes to the full --radius because
   the `0 0 var(--radius) 0` above is shaped for a box that meets the corner
   of the window, and this one no longer does. */
.skip-link:focus {
    left: 6px;
    top: 6px;
    border-radius: var(--radius);
}

.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
}

/* ============================================================
   LAYOUT
   ============================================================ */

.wrap {
    width: 100%;
    max-width: var(--page);
    margin-inline: auto;
    padding-inline: var(--gutter);
}

.wrap--narrow {
    max-width: 760px;
}

/* A chapter is one idea. Headline, at most one sentence, one picture.
   If you find yourself adding a third element, it is two chapters. */
.chapter {
    padding-block: var(--chapter-y);
}

.chapter--soft {
    background: var(--bg-soft);
}

.chapter__head {
    max-width: var(--measure);
    margin-inline: auto;
    text-align: center;
    margin-bottom: clamp(32px, 6vh, 64px);
}

/* ============================================================
   THE DISPLAY FACE — Pixelify Sans, self-hosted. Added 2026-08-02.

   The owner, having seen the 4px staircase this file already draws on the
   picture frames and the eyebrows: "the website is not redesign with
   pixelated font". The staircase was too quiet to read as a decision. So
   the pixel goes into the type, which is the loudest thing on every page.

   PROVENANCE, so nobody has to guess later:
     face      Pixelify Sans, variable, weight axis 400-700
     designer  Stefie Justprince
     copyright Copyright 2021 The Pixelify Sans Project Authors
     licence   SIL Open Font License 1.1 — verbatim at fonts/OFL.txt,
               which is not documentation: OFL §2 requires the notice and
               the licence to travel WITH the font. No Reserved Font Name,
               which is why the family below is the designer's own name
               rather than an alias that would hide where it came from.
     upstream  https://github.com/eifetx/Pixelify-Sans
     source    Google Fonts, css2?family=Pixelify+Sans:wght@400..700,
               the `latin` subset, v3, from fonts.gstatic.com
     obtained  2026-08-02
     file      fonts/pixelify-sans-v3-latin-wght.woff2, 12,016 bytes

   SELF-HOSTED, AND THAT IS NOT A PREFERENCE. This site makes no external
   request at all and the deploy gate follows what the pages reference, so
   a <link> to fonts.googleapis.com would be a new third-party origin on
   every page — and it is also two round trips before a heading paints.
   12 KB from our own origin is one, and it is preloaded.

   WHY THIS FACE AND NOT THE OBVIOUS ONES. Five OFL pixel faces were
   rendered on this actual page rather than on a specimen sheet
   (a72-mock.mjs), and measured (a72-type.mjs, a72-floor.mjs). Two numbers
   decided it:

   1. ADVANCE WIDTH against the system stack, same px size. A pixel face is
      usually much wider per character, and the nav labels are text, so
      width is what breaks a header:

        Pixelify Sans .... 1.02x - 1.11x     <- essentially metric-neutral
        Silkscreen ....... 1.18x - 1.53x
        Silkscreen Bold .. 1.38x - 1.80x
        Press Start 2P ... 1.67x - 2.32x
        VT323 ............ 0.67x - 0.93x

   2. WHETHER IT SURVIVES clamp(). This is the one that actually
      disqualified the favourites. Silkscreen and Press Start 2P are true
      bitmap designs on an em/8 grid: they are perfectly crisp at multiples
      of 8px and greyed at everything else. Measured as the fraction of
      marked pixels that are neither ink nor paper, at DPR 1:

        Silkscreen   8px 0.003 · 16px 0.000 · 24px 0.003 · 32px 0.002
                     13px 0.483 · 17px 0.278 · 21px 0.205 · 27px 0.255

      Every display size on this site is a clamp() — a fluid value that is
      whatever the viewport makes it. So on a real page Silkscreen is a
      different quality of thing at every window width, and it is worse
      than blurry: at 13, 15, 19, 21, 22, 27, 29, 30, 35, 37 and 38 px the
      counters of o, 8, 9 and 0 CLOSE COMPLETELY (a72-floor.mjs). A zero
      that swallows its own hole at some window widths and not others is
      not a typeface, it is a bug with a licence.

      Pixelify Sans is an OUTLINE face drawn as pixels rather than a bitmap
      snapped to them, so it anti-aliases like any other font and its
      counters grow smoothly with size. That is exactly the property this
      site's fluid type needs, and it is why the loudest-looking candidate
      is not the one that shipped.

   THE LEGIBILITY FLOOR IS 21px, AND IT IS MEASURED, NOT A TASTE CALL.
   a72-floor.mjs renders a, o, 8, B, D, 9, 0 and 6 at every size from 8 to
   40 at DPR 1 and DPR 2, floods the background in from the border to find
   the counters, and records the thinnest horizontal run of ink in DEVICE
   pixels. At DPR 1 Pixelify Sans stops drawing whole-device-pixel strokes
   below 21px:

        19px  minRun 2      21px  minRun 2      24px  minRun 2
        20px  minRun 1      22px  minRun 2      32px  minRun 3

   Below 21 the face is still READABLE — that is not the point. Below 21
   its own pixel is smaller than the screen's, so it renders as a blurred
   approximation of a pixel font, which is the worst of both: it stops
   looking deliberate and starts looking like a rendering fault. Looked at
   as well as counted, 8x nearest-neighbour at DPR 1:
   a72-type\a72-wordmark-8x-wordmark.png.

   SO EVERY SURFACE BELOW 21px KEEPS THE FACE IT HAD, and the list of what
   that excludes is the honest half of this change:

     .nav__brand__label  17px  the wordmark. The brief asked for it and
                               the measurement refused it — and raising it
                               to 21px would also widen the brand lockup
                               and move A70's 410px no-script threshold.
     .card__title        17px
     .eyebrow            12px  and it already carries the 4px staircase
     .foot h2            12px
     .ph__label          12px
     .todo               17px
     .nav__links a       14px  navigation, and A70's thresholds live here
     .btn                17px  a control, not display
     every paragraph, lede, caption, table cell and line of legal copy

   There are no numerals in stat-like positions on this site to set — no
   counts, no prices, no measurements on a page. If any are ever added,
   they are display and they belong in the list below, above 21px.

   font-display: OPTIONAL, and this is the whole no-flash-no-shift answer.
   `swap` guarantees a shift: the fallback paints, the face arrives, every
   heading moves. `block` hides the text and then shifts it if the face is
   late. `optional` gives the browser about 100ms and then commits for the
   life of that page load — it NEVER swaps in afterwards, so a heading
   cannot move because of font loading, by construction rather than by
   luck. The cost is real and is stated rather than hidden: on a cold visit
   that loses the race, that page renders in the system stack. The preload
   in each page's <head> and the year-long immutable cache in _headers are
   what make losing it rare; --display-face's fallback is what makes losing
   it survivable.
   ============================================================ */

@font-face {
    font-family: "Pixelify Sans";
    src: url("../fonts/pixelify-sans-v3-latin-wght.woff2") format("woff2");
    /* One file, both ends of the variable axis. .display sets 700 and the
       headings 600, and the browser interpolates rather than synthesising
       a fake bold — a synthesised bold on a pixel face smears every
       module sideways by a fraction of a pixel, which is the one thing
       this whole block exists to avoid. */
    font-weight: 400 700;
    font-style: normal;
    font-display: optional;
    /* Google Fonts' own `latin` range, copied from the @font-face this
       file was cut from. It is stated rather than omitted so that a
       character outside the subset — a Malay diacritic, a currency sign —
       falls straight through to the body stack instead of arriving as a
       missing glyph. Nothing in a display position on this site is outside
       it today; every heading, on all five pages, is plain ASCII. */
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
        U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC,
        U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ============================================================
   TYPE
   ============================================================ */

h1,
h2,
h3 {
    font-weight: 600;
    letter-spacing: -0.03em;
    line-height: 1.06;
    text-wrap: balance;
}

/* 700 and -0.045em, not 600 and -0.04em. This is the whole of the "change
   the font" answer and it is deliberately not a new typeface.

   What the logo's wordmark actually is: heavy, tight, and squared off. A
   geometric display face (the obvious reach) is ROUNDER than that, so it
   moves away from the wordmark while costing a font file. Weight and
   tracking move toward it and cost nothing. Only .display changes — the
   headline and subhead stay at 600, because at 32-52px the extra weight
   stops being confident and starts being shouty. */
.display {
    font-family: var(--display-face);
    font-size: clamp(2.6rem, 8vw, 5.5rem);
    font-weight: 700;
    letter-spacing: -0.045em;
}

.headline {
    font-family: var(--display-face);
    font-size: clamp(2rem, 5vw, 3.25rem);
}

/* 22px at its smallest, which clears the 21px floor by one pixel. That is
   the tightest thing on this list and it is deliberately the last one in.
   If .subhead's clamp minimum is ever lowered, this line comes out with
   it. (It has no user in the markup today — grep: zero occurrences across
   all five pages — so it is here as part of the scale, not as something
   currently on screen.) */
.subhead {
    font-family: var(--display-face);
    font-size: clamp(1.375rem, 3vw, 1.75rem);
    font-weight: 600;
    letter-spacing: -0.02em;
}

/* THE TECHNICAL LABEL. The eyebrow, the placeholder's label and the footer
   column headings are all the same thing: a four-to-twenty-character
   uppercase tag that names a block. Monospace, because the other half of
   this brand is a workshop — a mesh, a slicer, a file with dimensions in it
   — and a monospace tag reads as something machine-set rather than
   art-directed. It is also the one type change that costs nothing at all:
   every platform ships a monospace face, so --mono is a zero-byte accent
   where a display webfont would have been 15-25 KB.

   The COLOUR IS UNCHANGED: --pink-text, measured 6.24:1 on white and
   5.73:1 on --bg-soft, and re-measured after this change. Monospace faces
   run narrow, so the size drops 13px -> 12px and the tracking opens
   0.04em -> 0.14em; neither moves the contrast, and neither is a tap
   target. Do not put --pink here — see the token block. */
.eyebrow {
    font-family: var(--mono);
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--pink-text);
    margin-bottom: 14px;
}

/* THE CLICKER WONDERLAND LOCKUP — index.html, #clicker, once. Added
   2026-08-08, when the owner supplied artwork for the app the way they had
   already supplied it for Babah3D.

   IT REPLACES THE EYEBROW that read "Clicker Wonderland" rather than sitting
   above it. The artwork sets the name AND the byline; an eyebrow over it would
   print the product's name twice inside 40 px of page, which is the same
   mistake services.html's logo block exists to avoid. The cost is the eyebrow's
   facet triangle, which is placement 1 of 3 and still appears on every other
   chapter head — so the mark is not lost from the site, only from this one row.

   The source is 1920 x 1080 with the artwork floating in a 60% empty frame and
   NO alpha channel; img/brand/clicker-lockup.png is that artwork cut out and
   resampled to 1100 x 183. assets/brand/README.md records the crop and why a
   flat white knockout was not good enough (the background is a gradient, not a
   colour).

   1100 IS SIZED BY THE PDF, NOT BY THIS SLOT. The cut is 1681 px wide and that
   was the first thing shipped — fine for the web at any size here, but this
   same PNG is embedded in downloads/Clicker-Wonderland-Complete-Guide.pdf,
   where it took a 424 KB customer download to 584 KB. At 1100 the largest use
   (the guide cover, 420 CSS px) is still covered 2.6x, which is past retina, so
   everything above it was paying only the PDF. Raising this number means
   re-printing the guide and re-checking that file size.

   The WebP is what a browser actually gets — 42 KB against the PNG's 104 KB.
   The PNG stays as the fallback so no browser meets a missing logo, and alpha
   is required in both because this sits on --bg-soft rather than on a plate.

   min() rather than a media query: there is exactly one thing to protect, which
   is a 320 px viewport where a fixed 340 would push the wrap sideways. */
.chapter__lockup {
    display: block;
    width: min(560px, 90vw);
    height: auto;
    margin: 0 auto 14px;
}

/* The same lockup on guide.html's cover, larger. A manual's cover page is the
   one place a product mark cannot be mistaken for decoration, and unlike the
   home page there is no redundancy problem in the <h1> repeating the name
   underneath it — that is what a cover is.

   THIS ONE PRINTS. downloads/Clicker-Wonderland-Complete-Guide.pdf is rendered
   FROM this page by headless Chrome, so the PDF's cover carries it too, and
   `%` rather than `vw` is deliberate: the print medium has no viewport and vw
   resolves against the paper unpredictably. After touching this rule, re-print:
   `node tools/build-guide-pdf.mjs`, then `node tools/check-guide-pdf.mjs`. */
.cover .chapter__lockup {
    width: min(420px, 80%);
    margin-bottom: 22px;
}

/* THE FACET MARK — placement 1 of 3. A single triangle, drawn with
   clip-path rather than set as a "▲" character, for three reasons: it is
   geometry rather than a glyph so it is identical on every platform, a
   font may not have the character at all, and a real triangle can be given
   the exact proportions of a facet in the logo instead of whatever the
   font designer chose. currentColor, so it can never drift away from the
   label's own measured pink. Purely decorative and announced by nothing —
   it is generated content, so a screen reader that reads it at all reads
   an empty string. */
/* PIXELATED, 2026-08-02. This was a 9x8 solid triangle — placement 1 of the
   facet motif, and the only one of the three A51 said read well. It is now
   a 9x9 THREE-STEP STAIRCASE rising left to right: three 3px columns, 3px
   then 6px then 9px tall.

   Why change something that worked: it is the mark a visitor sees most
   often on this site — every chapter head on every page — so it is the
   cheapest place to say "pixel" once and be consistent. A triangle and a
   staircase are the same gesture at different resolutions, which is
   exactly the joke the low-poly logo is already making. Same 9px width,
   same currentColor, no new colour; percentages keep the three steps at
   exactly 3px each.

   THE BOX IS 9px TALL WHERE THE TRIANGLE WAS 8, and that is the one thing
   in this whole pass that moves anything: it grows this line box, so
   content after an eyebrow sits 0.63-1.65px lower. Total page height is
   unchanged on every page. 8px would put the steps on 2.67px boundaries
   and blur them. The measurement is in the --px block above.

   Still `background: currentColor` and still purely decorative — it
   inherits --pink-text from .eyebrow, announced by nothing, and carries no
   information a screen reader loses. */
.eyebrow::before {
    content: "";
    display: inline-block;
    width: 9px;
    height: 9px;
    margin-right: 9px;
    background: currentColor;
    clip-path: polygon(
        0 100%,
        0 66.667%,
        33.333% 66.667%,
        33.333% 33.333%,
        66.667% 33.333%,
        66.667% 0,
        100% 0,
        100% 100%
    );
}

.lede {
    font-size: clamp(1.0625rem, 2vw, 1.3125rem);
    line-height: 1.45;
    color: var(--ink-2);
    max-width: var(--measure);
    margin-top: 18px;
}

.chapter__head .lede {
    margin-inline: auto;
}

.fine {
    font-size: 0.8125rem;
    line-height: 1.5;
    color: var(--ink-2);
}

.quote-start .chapter__head {
    margin-bottom: 40px;
}

.quote-start .fine {
    margin-top: 14px;
}

.quote-next {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: clamp(28px, 5vw, 64px);
    max-width: 920px;
    margin-inline: auto;
}

.quote-next h3 {
    margin-bottom: 16px;
    font-size: 1.125rem;
    letter-spacing: -0.02em;
}

.quote-next ol {
    padding-left: 22px;
}

.quote-next li {
    padding-left: 4px;
    margin-top: 12px;
    line-height: 1.55;
}

.quote-faq dl {
    margin: 0;
}

.quote-faq dl > div {
    padding: 13px 0;
    border-top: 1px solid var(--hairline);
}

.quote-faq dt {
    font-weight: 650;
    line-height: 1.4;
}

.quote-faq dd {
    margin: 5px 0 0;
    color: var(--ink-2);
    line-height: 1.5;
}

@media (max-width: 680px) {
    .quote-next {
        grid-template-columns: 1fr;
        gap: 32px;
    }
}

/* ============================================================
   BUTTONS AND LINKS
   ============================================================ */

.actions {
    display: flex;
    flex-wrap: wrap;
    gap: 14px 28px;
    align-items: center;
    margin-top: 32px;
}

.chapter__head .actions,
.hero .actions {
    justify-content: center;
}

.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 44px; /* thumb-sized, not decorative */
    padding: 12px 26px;
    border: 0;
    border-radius: 999px;
    /* --pink-btn, not --pink: see the token block. White on --pink is
       2.44:1 against a 4.5:1 bar, and it was the worst thing on the site.
       Do not "restore the brand pink" here — the brand pink is still on
       this button, it is just the one that can be read. */
    background: var(--pink-btn);
    color: #ffffff;
    font: inherit;
    font-weight: 600;
    letter-spacing: -0.01em;
    text-decoration: none;
    transition: background 0.2s ease, transform 0.2s ease;
}

.btn:hover {
    background: var(--pink-btn-hover);
}

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

/* THE PRIMARY BUTTON HAS NO EDGE RING, AND DOES NOT NEED ONE.

   A dark-mode-only `box-shadow: 0 0 0 2px var(--pink-btn-ring)` used to sit
   here. It existed for one measured reason: --pink-btn #90194c is 2.43:1
   against the #000 dark page and 2.23:1 against that scheme's #0e0e10, and
   WCAG 1.4.11 asks 3:1 of the visual information that identifies a control
   — which, for a filled pill with no border, is the fill itself. Under 3:1
   the button's own shape stopped being reliably findable, so dark mode drew
   a boundary around it rather than change the owner's pigment.

   NONE OF THAT IS TRUE ON A LIGHT PAGE, and this was re-measured on
   2026-08-13 before the rule was deleted rather than assumed from the fact
   that the block said "dark mode only":

       #90194c fill vs #ffffff page ......... 8.64:1   (bar 3)
       #90194c fill vs #f5f5f7 soft chapter . 7.94:1   (bar 3)
       #c2185b hover vs #ffffff / #f5f5f7 ... 5.87 / 5.39:1
       #ffffff label on the #90194c fill .... 8.64:1   (bar 4.5)

   The fill clears the bar by 2.9x on its own, so the ring was compensating
   for a black page that no longer exists. Deleting it removes a device, not
   a safeguard. --pink-btn-ring is kept as a token because nothing else was
   spending it and it costs one line, but nothing paints it today.

   The old comment also recorded a worry that dimming --pink-text moved the
   focus outline and this ring to 1.86:1 of each other. That worry is gone
   with the ring: :focus-visible draws at outline-offset: 3px, so in light
   the outline's neighbour is the page, at 6.24:1 against a 3:1 bar.

   .btn--quiet was excluded from the ring and still has no ring. Its own
   edge is 1px of --hairline, and that pair is measured in the note on
   .btn--quiet below — it is the one thing on this site the dark theme's
   removal exposed as never having been measured at all. */

/* THE ONE PAIR REMOVING THE DARK THEME EXPOSED AS NEVER MEASURED.

   The deleted edge-ring comment waved this button through in one sentence:
   ".btn--quiet is excluded on purpose. It is transparent with a --hairline
   border, so its edge is already its own and already flips." That is a
   claim about a token flipping, not a contrast measurement, and nobody had
   taken one. The dark figure sitting next to it in the same file is what
   made it look answered. Measured on 2026-08-13, both schemes:

       border --hairline rgba(0,0,0,0.12) on #ffffff .... 1.32:1
       border --hairline rgba(255,255,255,0.16) on #000 . 1.44:1
       the label, --ink #1d1d1f on #ffffff .............. 16.83:1

   So the edge was never near WCAG 1.4.11's 3:1 in EITHER scheme — this is
   not a regression the deletion caused, it is a number the deletion made
   visible. It is left as it is, deliberately, and that is a judgement
   rather than an oversight: this control is a link reading "Download the
   PDF" at 16.83:1, so the visual information identifying it is the label,
   which is what 1.4.11 asks about. The border is reinforcement, not the
   identifier.

   IF IT IS EVER TIGHTENED, tighten --hairline sitewide rather than this
   rule — the token is spent on seven boundaries and a one-off here would
   make this button the only edge on the site with its own opacity. That is
   a design change and it belongs to the owner, not to a theme removal.
   .btn--quiet appears exactly once in the whole site, in guide.html. */
.btn--quiet {
    background: transparent;
    color: var(--ink);
    border: 1px solid var(--hairline);
}

.btn--quiet:hover {
    background: var(--bg-soft);
}

/* The "Learn more ›" of this site. Deliberately not a button: a page
   with two buttons has one call to action and one distraction. */
.more {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    min-height: 44px;
    color: var(--pink-text);
    font-weight: 500;
    text-decoration: none;
}

.more::after {
    content: "\203A"; /* › */
    font-size: 1.25em;
    line-height: 1;
    transform: translateY(-1px);
    transition: transform 0.2s ease;
}

.more:hover::after {
    transform: translate(3px, -1px);
}

/* ============================================================
   HEADER — a hairline and nothing else.
   ============================================================ */

.nav {
    position: sticky;
    top: 0;
    z-index: 50;
    /* min-height, not height. The bar is allowed to become two rows on a
       narrow screen — see .nav__inner. A fixed height would keep the bar
       48px tall and let the second row hang out of the bottom of it. */
    min-height: var(--nav-h);
    /* Solid first, translucent second. A browser that does not know
       color-mix() drops that line and keeps the opaque bar — without
       the fallback it would drop the background entirely and the nav
       would sit unreadable on top of the page content. */
    background: var(--bg);
    background: color-mix(in srgb, var(--bg) 82%, transparent);
    backdrop-filter: saturate(180%) blur(20px);
    -webkit-backdrop-filter: saturate(180%) blur(20px);
    border-bottom: 1px solid var(--hairline-soft);
}

/* THE WRAP FALLBACK, which is the actual fix for the nav overflow.

   The old bar was one row that could never become two: .nav__links was
   `display:flex; overflow-x:auto` with `white-space:nowrap` links and no
   wrap anywhere. So whenever the links were wider than the space they
   got, the last one was cut mid-word — 29px clipped at 320px on every
   page, and 15.4px past the viewport at 375px the moment a fourth link
   exists. It stopped reproducing at 375px only because the Reviews link
   was removed; the bug was never fixed, the nav just got shorter, and it
   comes back the day anybody renames or adds a link.

   Two levels of give, so no single word can push a link off the screen:
   the menu drops to its own row under the brand, and if the links still
   do not fit on that row they wrap among themselves. Neither is a
   breakpoint — nothing here is keyed to a width, so it cannot be wrong
   for the next set of words. */
.nav__inner {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    /* row gap first: it only exists once the bar is two rows. */
    gap: 6px clamp(16px, 4vw, 34px);
    min-height: var(--nav-h);
    padding-block: 6px;
    max-width: var(--page);
    margin-inline: auto;
    padding-inline: var(--gutter);
}

/* The <nav> landmark added around the list. It takes the auto margin
   that used to sit on the <ul>, because the <ul> is no longer a child of
   the flex row. The declaration on .nav__links stays as well — it is
   inert here, and the one page without the wrapper (reviews.html) was
   deleted on 2026-08-02, so it now keeps nothing aligned. Harmless, and
   left alone rather than removed in the same change that deleted a page:
   one thing at a time on a stylesheet 2,400 lines long. */
.nav__menu {
    margin-left: auto;
}

/* THE BRAND LOCKUP: logo mark + text wordmark, side by side.
   Added 2026-08-01 — the owner pointed out that their logo was nowhere on
   their own website (it existed only as A2's derived favicon monogram and
   inside img/og.jpg, which nobody sees unless they paste a link).

   inline-flex and not block: this is an <a> inside a flex row, and it has
   to size to its contents so .nav__menu's `margin-left:auto` still pushes
   the menu to the far edge. */
.nav__brand {
    display: inline-flex;
    align-items: center;
    gap: 9px;
    font-size: 1.0625rem;
    font-weight: 600;
    letter-spacing: -0.02em;
    text-decoration: none;
    white-space: nowrap;
}

/* The mark is a FIXED box, and both dimensions are stated on purpose.
   The global `img { max-width:100%; height:auto }` near the top of this
   file would otherwise let the intrinsic 100x64 of the file drive layout
   and the bar would jump as the PNG decoded. `flex:none` stops it being
   squeezed when the brand row gets tight at 320px — a squashed logo is
   worse than a wrapped nav.

   50x32 displayed from a 100x64 file: 2x, so it stays sharp on a phone
   without a second asset or a srcset. --nav-h is 48px and .nav__inner
   adds 6px of padding top and bottom, so 32px is the tallest the mark can
   be without driving the bar taller than 48px. Do not raise it without
   re-measuring the bar.

   THE CROP WAS REDONE 2026-08-02 AND THE BOX DELIBERATELY DID NOT MOVE.
   The previous logo-mark.png cut the B3D block on three sides at once,
   which is why it read as "B3)": measured on the file, the bottom row of
   the old 100x64 was 63% painted and the right-hand column 66% painted —
   coverage that high at the frame is a letterform running off the edge,
   not a finished edge. The D's bottom-right stroke was the casualty. The
   top row was 20% painted, which was the wireframe cut mid-object that
   read as a compression artifact at 50px.

   The new crop is source logo-full.png x[-3..494] y[290..608]. y=290 is
   the last row before the letters' wireframe cage begins (rows 291-294
   are its top band, so cutting any lower severs it again) and y=608 sits
   in the empty band between the letters and the "Babah3D" wordmark, which
   is unreadable at this size and stays excluded. 3px of transparent
   padding either side makes the source region 497x318, which is 1.563 —
   the 100x64 box's own aspect ratio to within 0.03%. THAT is why the
   50x32 display box, the 48px bar and every measurement above are
   unchanged. Verified: .nav is 49px (48px inner + hairline) at 1280 and
   375, and 77px at 320, exactly as before.

   Same file, new frame: on the new mark the top row is 3% painted, the
   bottom row 2%, and the right-hand painted column is 97 of 100. Nothing
   is cut. The 3% at the top is the tip of the pen's orange filament,
   which is drawn attached to the cage's apex and cannot be excluded
   without cutting the cage. Erasing it would be redrawing the owner's
   artwork, which is the thing the block below refuses to do. */
.nav__brand img {
    width: 50px;
    height: 32px;
    flex: none;
}

/* THE WORDMARK GOES WHEN IT STOPS FITTING — NOT AT A WIDTH.

   THIS WAS `@media (max-width: 420px)` UNTIL 2026-08-02, AND 420 HAD GONE
   STALE. It was an honest number when it was written: with the wordmark
   the brand measured 126px, the menu 226px and the gap 16px — 368px of
   content, needing about a 408px viewport — so the text was dropped just
   below that and the bar was 48px again.

   Then the menu became four items. Measured this session in a real
   headless Chrome across a 1px sweep (a63-nav.mjs, a63-nav-before-fine.json),
   index.html, .nav height at each viewport width:

       <= 381   two rows, 76.5px   wordmark already hidden, still no room
       382-420  ONE ROW,    49px   the breakpoint doing its job
       421-466  two rows, 76.5px   <-- the wordmark comes back at 421 and
                                       does not fit again until 467
       >= 467   ONE ROW,    49px

   421-466 is not a rounding error, it is 46px of phone: 428, 430 and 440
   are all in it, and 430 is an iPhone Pro Max. The bar there was two rows
   with the wordmark showing, which is the worst of both — the tall bar
   AND the long brand. Nobody had measured above 420 since the guide link
   landed, because the number looked like it had been chosen carefully.
   It had. It was chosen carefully for a menu that no longer exists.

   SO THE NUMBER IS GONE AND site.js MEASURES INSTEAD. It hides the
   wordmark only when hiding it actually buys a single row, and shows it
   again when it does not — which is why the two-row bar below the wrap
   point carries the whole lockup instead of a lone 50px mark on an empty
   first row (A51 §3.6 called that out and it was right). Same argument as
   .nav__links.is-scrollable further down, same reason: a width is a guess
   about words, and the words change.

   THEN THE MENU BECAME THREE ITEMS AGAIN — WITHIN THE DAY — WHICH IS THE
   ARGUMENT ABOVE HAPPENING TWICE. The owner asked for Guide off the top
   bar on 2026-08-02; the nav comment on index.html has the reasoning and
   where the guide went. Re-measured the same way, same day, same browser
   (a70-nav.mjs, 1px sweep 316-500, index.html):

       <= 331   two rows, 76.5px   wordmark shown, still no room
       332-409  ONE ROW,    49px   wordmark hidden
       >= 410   ONE ROW,    49px   wordmark shown

   375 CAME BACK. It was two rows from the day the guide link landed until
   the day it left — deficit 6.23px, measured by A63, which A53 had
   already declined to close with a 3px link-gap tweak because one
   browser's text metrics differ by more than 3px on a 117px string. That
   refusal was right and it is why 375 waited for this. Nothing in this
   file changed to win it: a 35.64px label and its 14px gap left the menu,
   the one-row threshold moved 382 -> 332, and 375 now clears by 43.41px.
   The wordmark also returns 57px earlier, 467 -> 410, so a 430px iPhone
   Pro Max gets one row AND the full lockup.

   AND HERE IS THE TRAP. DO NOT WALK INTO IT. The menu is 225.59px wide
   now, within half a pixel of the 226px that made 420 an honest number in
   the first place — so `@media (max-width: 420px)` would appear to work
   again if somebody put it back. It would be right for exactly as long as
   these three labels never change, and this menu has now changed twice in
   two days, each time silently invalidating a width somebody had reasoned
   about carefully. That is the whole failure, and it does not become a
   different failure because the number happens to fit again today.

   HOW IT IS HIDDEN IS UNCHANGED AND IS STILL THE WHOLE POINT. These are
   the .sr-only declarations. display:none would be one line shorter and
   would BREAK THE LINK: the <img> carries alt="", so the visible text is
   the only thing giving the link an accessible name, and display:none
   takes it out of the accessibility tree. Clipped text stays in it.
   Verified at 375px — the link's computed accessible name is still
   "Babah3D". See fleet/STATUS-A22.md.

   The label is position:absolute, so it stops being a flex item and the
   9px gap above collapses on its own. No gap override needed.

   WITH JAVASCRIPT OFF the class is never set, the wordmark is always
   shown, and the bar is two rows below 410px. That figure is measured
   with script execution actually disabled in Chrome rather than derived
   from the gap arithmetic (a70-jsoff.mjs) — colGap is a clamp() and so
   depends on the width you are solving for, and the derived answer came
   out a pixel low. It was 467px while the guide link was in the menu, so
   the no-script fallback improved by 57px as well. That is the designed
   fallback — 0 elements overflow, every target stays 24px — and it is
   the same trade .nav__links.is-scrollable already makes. */
.nav__inner.is-compact .nav__brand__label {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
}

.nav__links {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: flex-end;
    gap: 6px clamp(14px, 3vw, 30px);
    margin-left: auto;
    list-style: none;
    /* The sideways scroll is kept as the last resort — for one link so
       long that even a row of its own cannot hold it. It is no longer
       the only thing standing between a narrow screen and a clipped
       word; flex-wrap above is. */
    overflow-x: auto;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
}

.nav__links::-webkit-scrollbar {
    display: none;
}

/* 24px is WCAG 2.5.8's floor for a target, and a 14px link is 19px tall
   — measured, on all four pages, for all three of these and all eight in
   the footer. inline-flex rather than padding so the extra height is
   centred on the text and the row does not shift. */
.nav__links a {
    display: inline-flex;
    align-items: center;
    min-height: 24px;
    font-size: 0.875rem;
    color: var(--ink-2);
    text-decoration: none;
    white-space: nowrap;
    transition: color 0.15s ease;
}

.nav__links a:hover,
.nav__links a[aria-current="page"] {
    color: var(--ink);
}

/* The sideways scroll above is the design, but nothing on screen said so.
   A3 and A5 both measured the same thing: the last link was cut off flat
   at the edge, which reads as a rendering fault rather than as a list you
   can push. A soft right edge says "there is more this way".

   KEPT ON PURPOSE after the wrap fallback landed above, and it now
   almost never fires: once the links can wrap there is usually nothing
   left to scroll, so site.js measures no overflow and this class is not
   set. It is the affordance for the one case wrap cannot rescue — a
   single link too long for a row of its own. A fade is not a layout fix
   and was never sold as one; the layout fix is .nav__inner.

   Keyed off a class, not a width. The obvious version of this is a media
   query — and it is wrong. When the reviews link was removed the nav went
   from four items to three and stopped overflowing at 375px altogether
   (measured: scrollWidth 226 === clientWidth 226), while still
   overflowing at 320 (226 vs 197). Any breakpoint I picked would be a
   magic number derived from the current four words in the nav, and would
   silently start fading un-clipped text the next time somebody renames a
   link. site.js measures the actual overflow instead and toggles this
   class; with JavaScript off there is no fade, which is exactly the
   behaviour this site had before and is not broken.

   @supports because a browser without mask-image should get no mask
   rather than a broken one. */
@supports (mask-image: linear-gradient(#000, #000)) {
    .nav__links.is-scrollable {
        -webkit-mask-image: linear-gradient(to left, transparent 0, #000 20px);
        mask-image: linear-gradient(to left, transparent 0, #000 20px);
    }
}

/* ============================================================
   HERO — one picture, one line. Nothing else may be added here.
   ============================================================ */

.hero {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    text-align: center;
    /* svh, not vh: on a phone, vh is the height WITHOUT the browser's
       address bar, so a 100vh hero is taller than the screen and the
       first thing a visitor does is scroll past the top of it. The vh
       line is the fallback for anything that does not know svh. */
    min-height: min(84vh, 760px);
    min-height: min(84svh, 760px);
    padding: clamp(56px, 10vh, 110px) var(--gutter) clamp(40px, 8vh, 80px);
}

.hero__media {
    width: min(100%, 980px);
    margin-top: clamp(40px, 7vh, 80px);
}

/* ============================================================
   MEDIA AND ITS PLACEHOLDER

   Every picture slot on this site is a .media. Until a real photograph
   is dropped in it holds a .ph, which states the exact file it wants
   and the size it wants it at, and looks unmistakably unfinished.

   That is the point. A placeholder that looks like a design decision
   gets published. This one cannot be mistaken for finished work, and
   it tells whoever fills it precisely what to put where.
   ============================================================ */

.media {
    border-radius: var(--radius);
    overflow: hidden;
    background: var(--bg-soft);
}

/* THE FACET — placement 2 of 3, and the only one that is on every page.

   A picture frame loses two OPPOSITE corners to a chamfer instead of
   rounding them. That is the low-poly idea applied to the one element this
   whole site is built around, and it is deliberately applied to the FRAME
   and never to the photograph: nothing is laid over the product, nothing is
   tinted, nothing competes. The rule at the top of this file survives — the
   picture still does the talking, it is just cut rather than rounded.

   Two corners and not four. Four chamfers is a bevelled plate and reads as
   1998; two opposite ones read as a facet, because that is what a facet is
   — one plane meeting another at an angle, not a uniform edge treatment.

   :has(img) is what keeps this off the placeholders. A .ph carries its own
   2px dashed boundary, and clip-path CUTS rather than draws, so chamfering
   a .media that holds one would take two bites out of a border whose 5.73:1
   contrast is a measured accessibility item. So the chamfer only applies to
   frames that actually hold a photograph. A browser without :has() gets the
   rounded corner it has always had, which is a fine thing to fall back to. */
/* PIXELATED, 2026-08-02. The corner was a single 45-degree chamfer. It is
   now a FOUR-STEP STAIRCASE of --px, which is the owner's pixel theme and
   also the fix for the thing A51 measured: in light mode the chamfer did
   not exist on screen. Every product photograph is shot on white and the
   page is white, so a diagonal cut through white against white removes
   nothing you can see. In dark mode it was unmistakable. A brand pass that
   only exists at night is not a brand pass.

   Two things make it visible now. The staircase reads as a made shape
   rather than as a corner that happens to be missing — a diagonal is what
   a photo edge looks like when it is badly cropped, a 4px staircase is
   obviously deliberate. And the inset ring below traces it.

   THE CUT MOVED FROM THE FRAME TO THE PHOTOGRAPH, and that is the half
   that actually solves light mode. Clipping .media removed the frame's
   background along with the corner, so on a white page a white photo lost
   a corner to nothing and there was nothing to see. Now .media KEEPS its
   whole box and carries a --ink-2 ground, and the staircase is cut out of
   the <img> — so each notch shows the ground through it as a stepped grey
   wedge. Visible on white. Visible on black. Same shape in both.

   An inset box-shadow was tried first and it does not work: an inset
   shadow paints BELOW the element's content, and the <img> covers the
   whole box, so it was hidden under the photograph on every card. It
   looked exactly like a working fix in the source and rendered nothing.

   --ink-2 and not --hairline: this is a filled wedge, not a hairline, and
   a hairline-alpha wedge is invisible against white for the same reason
   the chamfer was. --ink-2 flips with the scheme (#6e6e73 / #a1a1a6), so
   the wedge is dark on the light page and light on the dark one. It is
   also, incidentally, the "dark mat" A51 offered as option 2 for the
   dark-mode photo glare: the seam between page and photograph is now on a
   frame element rather than on the product. */
/* THE STAIRCASE IS GONE, 2026-08-02, owner's call: "rather than in border
   like that". Everything above is the argument for a notched frame, kept
   because it records why the notch existed and what it cost — a chamfer
   invisible in light mode, then a staircase built to fix that, then a
   --ink-2 ground added so the notches had something to show through.
   Three rounds of work to make a frame visible. The owner looked at it and
   wanted the picture instead, which was the rule at the top of this file all
   along: the picture does the talking.

   So the frame keeps its rounded corner and its soft ground, the --ink-2
   wedge goes, and the reveal moves from the EDGE to the SURFACE — see the
   blur block below. */
.media:has(img) {
    border-radius: var(--radius);
    background: var(--bg-soft);
}

/* NO `display: block` here, deliberately. It looks like tidiness and it is a
   layout change: it removes the inline strut's descender space under every
   photograph and each page gets 20-46px shorter. Measured — a53-layoutdiff.mjs.
   The owner's constraint was that the layout stays, so the img keeps the
   display it has always had and clip-path applies to a replaced inline
   element perfectly well. */

/* ============================================================
   THE VIDEO FACADE — a poster and a button, and NO <iframe> until the
   visitor asks for one.

   ONE VIDEO ON THIS SITE, on the home page, and it is on YouTube because
   the master is 240 MB of HEVC — over Cloudflare Pages' 25 MB per-file
   limit and in a codec several browsers will not decode. That decision is
   the owner's and it is not re-argued here. What IS argued here is the
   shape of the embed.

   A BARE <iframe src="youtube..."> LOADS ON EVERY PAGE VIEW. Two costs,
   and neither is theoretical:

     * about a megabyte of Google's player JavaScript, fetched by every
       visitor whether they watch or not, on a page whose whole other
       payload is a 115 KB stylesheet and a 14 KB script;
     * the visitor's IP address and a Referer naming this page, sent to
       Google before they have chosen anything. README.md's "nothing in
       this folder makes an external request" was true until this block
       existed and is now conditional — see that file.

   So the resting state is a LOCAL poster and a real <button>. js/site.js
   §5 builds the iframe on click and not before. Nothing reaches Google
   until the button is pressed.

   WHY THIS IS NOT A .media. Every photograph on this site rests behind
   the pixel reveal and climbs to sharp under the pointer (see below), and
   tools/build-pixel-companions.mjs reads that rule off the pages —
   `.media > img` is what opts a picture in. A poster is not a photograph
   of a product, it is the first frame of a thing you press, and a frame
   that dissolves out of blocks under the pointer fights the one message
   this element has to carry. It also would have meant three generated
   companions per poster for a picture nobody is meant to study. So the
   frame is its own element, it opts out by construction rather than by an
   exception somebody has to remember, and the checker needs no edit.

   NO FIXED HEIGHT ANYWHERE. `aspect-ratio: 16 / 9` on the frame, 100%
   width, and the iframe fills the frame it replaces the poster in — so
   the box is identical before and after the click and nothing on the page
   moves when the player arrives. Measured at 320, 375 and 430 px.
   ============================================================ */
.vid {
    position: relative;
    aspect-ratio: 16 / 9;
    border-radius: var(--radius);
    overflow: hidden;
    background: var(--bg-soft);
}

/* object-fit: cover and not contain: the poster IS 16/9, so cover crops
   nothing today — it is here so a poster cut at a different ratio fills
   the frame instead of leaving two grey bars, which is the failure that
   would otherwise wait for whoever replaces it. */
.vid__poster {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* THE LOGO LOOP sits exactly over the still, which is its own last frame,
   so the swap from still to motion is invisible. It is hidden outright for
   prefers-reduced-motion: an autoplaying loop is exactly the motion that
   setting asks a page not to start, and the still underneath is the
   complete logo. */
.vid__loop {
    position: absolute;
    inset: 0;
    pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
    .vid__loop {
        display: none;
    }
}

/* The player, once §5 has built it. `border: 0` because a UA default
   border on an inset element paints a 2px ridge inside the rounded
   corner. */
.vid__frame {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    border: 0;
}

/* THE PLAY CONTROL AND THE PLACEHOLDER SHARE THIS BOX, because the
   placeholder is what stands where the button will go until the video is
   uploaded, and the two must sit in the same place or filling the token
   moves the page.

   NOT `left: 50%` PLUS A TRANSFORM, AND THIS COST A ROUND OF SCREENSHOTS.
   That is the reflex way to centre an absolutely positioned box and it
   silently halves the width available to it: a shrink-to-fit element
   offset from the left edge may only use what is left of the containing
   block, so inside a 280 px frame the pill could be 140 px and no wider.
   "Play the walkthrough (5 min)" wrapped onto three lines inside it. Both
   edges pinned, `width: max-content` and auto margins centre the same box
   against the WHOLE frame, so the label breaks only when it genuinely has
   to.

   max-width then keeps it off both edges at 320 px; the label wraps
   rather than the frame scrolling. */
/* 2026-09-25: TOP IS NOW 83%. The poster became the Clicker Wonderland
   logo, whose "BY BABAH3D" line sits at 62% of the frame; at 66% the pill
   covered it. 83% clears it by ~10px at 375 px and at desktop width (screenshotted
   both). The history below is why it was 66% for the old poster.

   TOP WAS 66% AND NOT 50%, WHICH IS THE ONE PLACE THIS DEPARTS FROM WHAT
   EVERY VIDEO PLAYER DOES. Dead centre is the convention and it was tried
   first; it puts the pill exactly on top of the two clickers on the build
   plate, which at 320 px — where the frame is 280 x 158 and the pill is
   213 x 44 — is most of what there is to see. Screenshotted at 320, 375
   and 1280 both ways. Two thirds down clears the caption above it and the
   objects below it at every one of those widths, and it still reads as a
   play control because it is a filled pill with a triangle in it in the
   middle of a picture. */
.vid__play,
.vid__pending {
    position: absolute;
    left: 0;
    right: 0;
    top: 83%;
    transform: translateY(-50%);
    width: max-content;
    max-width: calc(100% - 32px);
    margin: 0 auto;
    text-align: center;
}

/* .btn carries the colour, the 44px minimum and the pill; this adds the
   two things a <button> needs that an <a class="btn"> does not, and the
   scrim.

   THE SCRIM IS AN ACCESSIBILITY ITEM, NOT A FLOURISH. --pink-btn #90194c
   against white ink is 8.64:1 and that number does not depend on the
   poster — but WCAG 1.4.11 asks 3:1 of the boundary that identifies a
   control, and the current poster is a near-white screenshot behind a
   dark-red pill (measured 8.6:1 against the poster's white ground, so it
   passes today). A poster swapped for a dark frame would put a dark pill
   on a dark ground and that margin would go without anything failing
   loudly. The white ring is 2px of the one colour that is guaranteed
   contrast against --pink-btn whatever is behind it. */
.vid__play {
    cursor: pointer;
    gap: 10px;
    box-shadow: 0 0 0 2px #ffffff, 0 6px 20px rgba(0, 0, 0, 0.28);
}

/* A triangle drawn in borders rather than a glyph: ▶ renders as an emoji
   on some platforms and as a box on others, and it is decoration either
   way — the button's accessible name is its own visible text. */
.vid__play-icon {
    width: 0;
    height: 0;
    border-style: solid;
    border-width: 7px 0 7px 12px;
    border-color: transparent transparent transparent #ffffff;
}

/* transform is already claimed by the centring above, so .btn:active's
   `scale(0.98)` would throw the button to the corner. Restate it. */
.vid__play:active {
    transform: translateY(-50%) scale(0.98);
}

/* THE PILL HAS TO SHRINK, AND THIS WAS MEASURED RATHER THAN GUESSED. At
   320 px the frame is 280 x 158 CSS px. .btn's 16px type and 12/26px
   padding wrapped "Play the walkthrough (5 min)" onto three lines and
   produced a 170 x 126 button — 80% of the poster's height, sitting on it
   like a sticker. Nothing overflowed and nothing failed a check; it just
   looked broken, which is why this was screenshotted at 320 before it was
   believed.

   min-height stays 44px from .btn. That is the tap target and it is not
   negotiable — this reduces the type and the padding, never the target. */
@media (max-width: 560px) {
    .vid__play {
        padding: 8px 14px;
        gap: 8px;
        font-size: 0.8125rem;
    }

    .vid__play-icon {
        border-width: 6px 0 6px 10px;
    }
}

.vid__pending {
    font-size: 0.875rem;
    line-height: 1.5;
    /* The frame's ground, so the pink box and its sentence are readable
       over whatever the poster happens to be. */
    background: var(--bg);
    border-radius: var(--radius);
    padding: 14px 16px;
}

/* ============================================================
   THE PIXEL REVEAL — every picture rests as visible BLOCKS and climbs to
   the photograph under the pointer, in four holds.

   Owner's brief, 2026-08-02, on the blur this replaces: "I dont meant blur
   but rather pixelated just like the font, however when hover over it it
   become the sharp picture as original. the animation would look nice if
   like this. right now it is like video transition i dont want that."

   THE OBJECTION IS THE SPECIFICATION. A blur is continuous, so it reads as
   a video dissolve; that is the thing that was rejected. Blocks are
   countable, and the climb between them is a small number of visible
   holds. If a stage is so brief it reads as a fade, this rule is doing the
   opposite of what was asked.

   WHAT THIS REPLACED, twice over. First a four-step clip-path staircase
   cut notches out of every photograph's corners; the owner wanted the
   picture instead of the frame. Then `filter: blur(12px) saturate(1.12)`
   clearing to none over 0.55s, with a scale(1.04) to push the blur's
   fringe outside the frame. Both are gone. Nothing is cut out of anybody's
   product photograph and no filter is applied to one either — which is what
   stopped this rule colliding with the frame damping the way the blur did.
   That damping was dark-mode-only and was deleted with the dark theme on
   2026-08-13, so there is nothing left to collide with; the rule against
   putting a `filter` on an <img> inside a `.media` stands anyway, because
   the pixel reveal is still a filter and still the only one.

   CSS CANNOT PIXELATE A FULL-RESOLUTION IMAGE. `image-rendering: pixelated`
   only bites when a SMALL source is scaled up, so each picture needs
   companion files, and companion files need a build step:

       node tools/build-pixel-companions.mjs     make them
       node tools/check-pixel-companions.mjs     prove they are current

   The owner asked for exactly that — "automate the process from the
   original picture" — and it is what stops this drifting the way the guide
   PDF did. Each companion is the source averaged down: one output block is
   the arithmetic mean of the pixels underneath it, which is a statement
   about the picture rather than about a resampler.

   FOUR STAGES, 28 -> 44 -> 80 -> the photograph, the owner's choice
   against a two-stage alternative. Three companion files per picture,
   measured at 82,755 bytes for the whole site's nine pictures. That is
   the design and it is affordable; it is not a place to economise.

   THE TOP STAGE WAS ASKED FOR AS 96 AND IS 80, on the evidence of the
   render rather than on preference. What a visitor sees is not the file's
   width but how many CSS PIXELS ONE BLOCK COVERS, and a gallery card is
   325 CSS px wide, where 96 works out at 3.4 px per block. At that size the
   picture has stopped reading as blocks — it reads as slightly soft, which
   is the smooth thing that was rejected, arriving through the back door.
   80 gives 4.1 there and 8.4 on the 673 px picture on the home page, and it
   evens the ladder: block size falls 1.57x then 1.82x, against 1.57x then
   2.18x. tools/build-pixel-companions.mjs carries the full table.

   IT IS NOW THE ONLY REVEAL ON THE SITE. It began as a generalisation of a
   QUANTIZE REVEAL that used to live below this block — the hero arriving as
   the four inks it would be printed from and dissolving into the photograph
   on load. Same shape as this one: real <img> layers over the frame,
   position:absolute so they cost no layout, alt="" and aria-hidden so they
   are decoration twice over, pointer-events:none, and a stepped rather than
   smooth resolve; that block's argument for steps(4, end) was "this was made
   out of a countable number of things", and it is this one's too.

   THE QUANTIZE REVEAL WAS RETIRED ON 2026-08-02, at the owner's word: "this
   one should be pixelated as well just like others." It posterised where the
   brief (decision 15) asked for "pixelated just like the font", and the hero
   was the one picture on the site that had it. The block, the `.quant`
   layer in index.html and img/home/hero-q4.webp went together; the hero now
   carries the same three overlays as everything else. Nothing else on the
   site ever used it — grepped, not assumed.

   ONE REVEAL PER FRAME, and the two tools read that rule off the pages
   rather than carrying a list of filenames:

       .media:not(:has(> .quant)) > img:not(.px)

   THE `:not(:has(> .quant))` GUARD IS KEPT ON PURPOSE even though no element
   on the site now carries that class, and this is the reasoning rather than
   an oversight. It is what states "one reveal per frame" in a form the tools
   can execute: a frame that is given some other reveal drops out of the
   pixel climb with no edit to either tool. It also keeps tools/README.md,
   which quotes this selector verbatim, true — that file belongs to nobody
   this wave. It costs one `:not()` that matches everything.

   THE ORDINAL IN `px--1` IS THE POSITION IN THE CLIMB, not a size. 1 is
   the coarsest, is painted on top, and is the one you see at rest; 3 is
   the finest and the last to go. The pixel widths live in the filenames
   and in tools/build-pixel-companions.mjs, so changing a stage is one
   constant and one attribute per picture, and never a stylesheet edit.

   @media (hover: hover) WRAPS THE WHOLE MECHANISM and it is the
   load-bearing line. A phone has no pointer to move into the picture, so a
   hover-gated reveal would leave every photograph permanently blocky for
   every mobile visitor — on a site whose entire job is showing printed
   work. Touch gets the photograph, sharp, immediately. Note the direction:
   the overlays are display:none by DEFAULT and this query turns them on,
   so anything that is not a fine-pointer device gets the picture without
   having to ask.

   :focus-within resolves it as well, so a keyboard user tabbing through
   the cards sees what a mouse user sees.
   ============================================================ */

/* Scoped by :has(> .px): only frames that actually carry overlays become
   containing blocks, and position:relative changes stacking, never layout.
   .media used to get a containing block for free from the dark-mode
   `filter`, which was never something to rely on — it existed in one scheme
   only. That filter is gone entirely as of 2026-08-13, so this rule is now
   the only thing making these frames containing blocks. Do not delete it on
   the theory that a filter is doing the job. */
.media:has(> .px) {
    position: relative;
}

.media > .px {
    display: none;
}

@media (hover: hover) and (pointer: fine) {
    .media > .px {
        display: block;
        position: absolute;
        inset: 0;
        height: 100%;
        pointer-events: none;
        /* The whole point. Nearest-neighbour is what makes a 28-pixel-wide
           file read as blocks rather than as a bad photograph, and it is
           why the companions can be this small. */
        image-rendering: pixelated;
        opacity: 1;
        /* --px-hold is ONE HOLD. Three of them separate four stages, so the
           full climb is 0.45s — near enough the 0.55s the blur took, and
           each hold is about nine frames, which is long enough to be seen
           as a state rather than as a frame of a fade.

           steps(1, end) IS THE WHOLE ARGUMENT. Opacity is held at 1 for the
           entire duration and then jumps to 0: a hold and a snap, with
           nothing in between. A default ease would put this layer at 50%
           opacity halfway through, blending blocks into the layer under it
           — a cross-fade, which is the video transition that was rejected.
           There is no partially-transparent moment anywhere in this rule. */
        --px-hold: 0.15s;
        transition: opacity var(--px-hold) steps(1, end);
    }

    /* Resting, i.e. the climb running BACKWARDS as the pointer leaves: the
       finest layer returns first and the coarsest last, so the picture
       un-resolves through the same four stages it resolved through. */
    .media > .px--1 {
        z-index: 3;
        transition-delay: calc(var(--px-hold) * 2);
    }

    .media > .px--2 {
        z-index: 2;
        transition-delay: var(--px-hold);
    }

    .media > .px--3 {
        z-index: 1;
        transition-delay: 0s;
    }

    /* :is() collapses what would otherwise be four selectors written out
       three times. Its specificity is that of its most specific argument,
       `.card:hover .media`, so every rule here outranks the resting ones
       above without needing !important or a repeated element. */
    :is(.media:hover, .media:focus-within,
        .card:hover .media, .card:focus-within .media) > .px {
        opacity: 0;
    }

    :is(.media:hover, .media:focus-within,
        .card:hover .media, .card:focus-within .media) > .px--1 {
        transition-delay: 0s;
    }

    :is(.media:hover, .media:focus-within,
        .card:hover .media, .card:focus-within .media) > .px--2 {
        transition-delay: var(--px-hold);
    }

    :is(.media:hover, .media:focus-within,
        .card:hover .media, .card:focus-within .media) > .px--3 {
        transition-delay: calc(var(--px-hold) * 2);
    }
}

/* ------------------------------------------------------------------
   THE SAME CLIMB, DRIVEN BY SCROLL, FOR EVERY DEVICE WITHOUT A POINTER

   The owner, from their phone on 2026-08-02: "I see that from smartphone
   the animation pictures from pixelated into sharp does not happen."

   It did not, and the query above is the reason — working exactly as its
   comment says it should. READ THE `@media (hover: hover)` PARAGRAPH AT
   THE TOP OF THIS BLOCK BEFORE CHANGING ANYTHING HERE. Widening or
   deleting that query is the one edit that leaves every photograph on
   this site permanently blocky for every visitor who cannot hover, which
   is the failure that paragraph exists to prevent. It is untouched, and
   touch still gets the sharp photograph whenever this path does not run.

   SO ONLY THE TRIGGER CHANGES. Where a pointer entering the frame drives
   the climb above, the frame SCROLLING INTO VIEW drives it here: the same
   four holds, the same 28 -> 44 -> 80 -> photograph, the same
   steps(1, end). A touch visitor sees what a mouse visitor sees.

   IT RESOLVES ONCE AND STAYS RESOLVED. The hover path runs backwards when
   the pointer leaves; there is no touch equivalent of leaving, so there is
   no resting rule here and site.js unobserves each frame after it fires. A
   photograph that re-blocked itself further down the scroll would be a
   picture the visitor cannot read, which is the whole thing being avoided.

   `.px-armed` IS ADDED BY JAVASCRIPT, ONE FRAME AT A TIME, and that is a
   safety property rather than a style choice. The overlays are
   display:none by default (above), so a page whose script did not run has
   no overlays at all — precisely as if this block were not here. Arming a
   frame in the same statement that starts observing it means a frame can
   only be blocky while something is actually watching it. JavaScript off,
   IntersectionObserver missing, this file blocked, an exception earlier in
   the script: every one of them lands on the sharp photograph. The failure
   mode is "no animation", never "blocked picture". site.js §4 has the
   inventory.

   THE PROPERTIES BELOW ARE DUPLICATED FROM THE QUERY ABOVE ON PURPOSE,
   `--px-hold` included. Hoisting the shared half onto the base
   `.media > .px` rule would be shorter and would compute identically, but
   it edits the working hover path in order to serve this one, and that
   path is the one the owner has already accepted. The cost is stated
   plainly instead: THESE TWO ARE ONE DESIGN. Change a stage, a hold or the
   rendering in one and change it in the other, or the owner's four holds
   stop being the same four holds on the two halves of their traffic. */
.media.px-armed > .px {
    display: block;
    position: absolute;
    inset: 0;
    height: 100%;
    pointer-events: none;
    image-rendering: pixelated;
    opacity: 1;
    --px-hold: 0.15s;
    transition: opacity var(--px-hold) steps(1, end);
}

/* z-order IS the climb, same as above: px--1 is the coarsest, sits on top
   and is what you see at rest; px--3 is the finest and the last to go.
   No transition-delay at rest — unlike hover there is no backwards run to
   stage, because nothing here ever runs backwards. */
.media.px-armed > .px--1 { z-index: 3; }
.media.px-armed > .px--2 { z-index: 2; }
.media.px-armed > .px--3 { z-index: 1; }

/* --px-lead IS A WAIT FOR THE CARD, NOT A FADE, and it is here because 15
   of this site's 17 frames sit inside a `.reveal` (the two heroes are the
   exceptions). That section or card is itself still fading up over 0.7s —
   see SCROLL REVEAL near the end of this file — at the moment it scrolls
   into view. Climbing during that fade would blend the blocks into it and
   produce a dissolve on arrival: the video transition that was rejected
   twice, arriving through the back door on the one device the owner
   actually reported from.

   So a frame inside a reveal holds its blocks until the card has landed,
   then climbs in four countable holds against a settled picture. A frame
   with no reveal above it has no fade to wait for and starts at once. */
.media.px-armed { --px-lead: 0s; }
.reveal .media.px-armed { --px-lead: 0.45s; }

/* The climb. One hold apart, so the four stages are lead+0.00-0.15 showing
   blocks of 28, +0.15-0.30 of 44, +0.30-0.45 of 80, and the photograph
   from +0.45 onward — four states a visitor can count, not a dissolve. */
.media.px-armed.is-px-in > .px {
    opacity: 0;
}

.media.px-armed.is-px-in > .px--1 { transition-delay: var(--px-lead); }
.media.px-armed.is-px-in > .px--2 { transition-delay: calc(var(--px-lead) + var(--px-hold)); }
.media.px-armed.is-px-in > .px--3 { transition-delay: calc(var(--px-lead) + var(--px-hold) * 2); }

/* NONE, not shortened — the same call the retired quantize block made, for
   the same reason. A picture you must hover to read is a barrier rather
   than an effect, and "three one-frame flashes of blocks" is still an
   animation happening to somebody who asked for none. This must stay AFTER
   the query above: both rules are `.media > .px` at equal specificity, so
   order is what decides, and reversing them would silently switch
   reduced-motion off.

   `.media.px-armed > .px` IS LISTED SEPARATELY AND THAT IS NOT BELT AND
   BRACES — it is required. The scroll path's rule carries an extra class,
   so it outranks a bare `.media > .px` (0,2,1 against 0,1,1) and would
   have re-enabled the overlays here no matter what order these appeared
   in. Matching its specificity and sitting later is what makes this win.
   site.js also declines to arm anything under reduced motion, so the
   answer does not rest on either file alone; this one is the half that
   keeps working when the preference is switched on after load. */
@media (prefers-reduced-motion: reduce) {
    .media > .px,
    .media.px-armed > .px {
        display: none;
    }
}

.media img {
    width: 100%;
    object-fit: cover;
}

/* THE WORK CARDS LEAN IN ON HOVER - the gallery's replacement for the
   pixel reveal, owner's decision 2026-08-03: 'no need animation for all
   the image... if animation like zoom a bit to the image when hover it
   under work is okay, make it subtle so that look professional.' The
   reveal overlays came off every picture except the services design
   shot the same day - that one animates because its subject IS the
   journey from idea to object.

   Why hover feedback here and nowhere else: /work is many pictures at
   once, and hover is how a visitor says WHICH ONE they mean - feedback,
   not decoration. 1.04: it should register as the picture leaning
   toward you, and if you notice the number it is too big. The scale is
   on the IMAGE, the clipping is .media's overflow:hidden, so the card's
   edges never move and the grid never reflows. Gated like the reveal
   was - (hover:hover) and (pointer:fine) - because :hover on touch
   sticks after a tap and reads as a bug. */
@media (hover: hover) and (pointer: fine) {
    .card .media img {
        transition: transform 450ms cubic-bezier(0.2, 0.6, 0.2, 1);
    }

    .card:hover .media img,
    .card:focus-within .media img {
        transform: scale(1.04);
    }
}

@media (prefers-reduced-motion: reduce) {
    .card .media img {
        transition: none;
    }

    .card:hover .media img,
    .card:focus-within .media img {
        transform: none;
    }
}

/* THE QUANTIZE REVEAL USED TO LIVE HERE, and was removed on 2026-08-02.

   It gave the hero — and only the hero — a different reveal: the same
   photograph reduced by the app's own quantizer to the four inks an AMS
   would print it from (#fbfbf9 55.0%, #edb70b 20.7%, #464a4b 19.6%,
   #090a09 4.7%), dissolving into the photograph on load in steps(4, end).
   The argument for it was that this shop really does turn a picture into
   discrete blocks of physical colour, so four inks meant something.

   The owner's verdict is the one that counts and it was one sentence:
   "this one should be pixelated as well just like others." Four flat
   colours POSTERISE; decision 15 asked for "pixelated just like the font".
   The hero was the last picture on the site not doing what the brief said,
   and the fix was to give it the same three overlays as everything else
   rather than to keep a second mechanism for one picture.

   Removed with it: `img.quant` from index.html and img/home/hero-q4.webp
   (16,266 bytes). Nothing else referenced either — grepped across the site
   with the stale worktree excluded, not assumed. The generator lived in
   the app repo (a63-quantize.mjs, over src/quantize.js) and the site never
   imported from it, so nothing here depended on that repository and
   nothing now needs to be deleted from it.

   If a four-ink reveal is ever wanted again, this is a rebuild and not a
   revert: the tools' `:not(:has(> .quant))` guard is still in place, so a
   frame carrying such a layer would correctly drop out of the pixel climb.
   The full account is in fleet/STATUS-A63.md §5 and fleet/STATUS-J4.md. */

/* THE PLACEHOLDER DEVICE HAS NO USERS RIGHT NOW AND IS KEPT ON PURPOSE.
   DO NOT DELETE IT FOR BEING DEAD CODE — that is the one conclusion a grep
   supports and it is the wrong one.

   services.html was its only user sitewide and its last three boxes were
   filled on 2026-08-02, so `class="ph"` now appears in no page on this site.
   It is retained because the site is not finished: handover §2 lists
   photographs the owner still owes, work.html:132 documents the fill-a-card
   procedure in terms of deleting a `.ph` div, and services.html:195 records
   the judgement in its own words — the device earned its keep and is worth
   keeping for the next empty slot. A placeholder that states which file it
   wants is how this site shipped honest empty boxes at the owner's
   instruction rather than fake content.

   It costs about sixty lines and nothing at runtime: no page references it,
   so no visitor downloads a byte of it beyond the stylesheet it sits in.
   If you are removing it, the thing to check first is whether anyone is
   still waiting on a photograph. */
.ph {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 10px;
    padding: 24px;
    text-align: center;
    /* --pink-text: a 2px dashed border is a boundary, so it is held to
       WCAG 1.4.11's 3:1 and --pink is 2.44:1 on white. Same pigment
       problem as .todo, same fix. */
    border: 2px dashed var(--pink-text);
    border-radius: var(--radius);
    background: repeating-linear-gradient(
        -45deg,
        transparent,
        transparent 12px,
        var(--hairline-soft) 12px,
        var(--hairline-soft) 24px
    );
    color: var(--ink-2);
}

.ph--wide {
    aspect-ratio: 16 / 9;
}

.ph--square {
    aspect-ratio: 1 / 1;
}

.ph--portrait {
    aspect-ratio: 4 / 5;
}

/* --mono, same as .eyebrow: this is the same kind of thing, a tag naming a
   block. Colour unchanged — --pink-text, 5.73:1, which is the .ph border's
   measured figure too. */
.ph__label {
    font-family: var(--mono);
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--pink-text);
}

/* This was the site's first monospace and it is where --mono came from. */
.ph__path {
    font-family: var(--mono);
    font-size: 0.8125rem;
    word-break: break-all;
    color: var(--ink);
}

.ph__note {
    font-size: 0.75rem;
    max-width: 34ch;
}

/* Marks any text or link that is still a placeholder. Anything wearing
   this class is a thing the owner has to fill in before launch, and it
   is loud on purpose — an accidental deploy should look broken rather
   than look fine while pointing at nothing. Delete the class when you
   fill the value; `grep -rn "todo" site/` then finds what is left. */
.todo {
    /* The wash is the one place raw --pink is still correct: it is a
       tint behind type, not a boundary and not ink. Literal rgba first
       so a browser without color-mix() keeps the wash rather than losing
       it — same two-declaration pattern as .nav above. */
    background: rgba(255, 122, 168, 0.16);
    background: color-mix(in srgb, var(--pink) 16%, transparent);

    /* --pink-text, not --pink. This outline IS the device: it is the
       only thing that says "this value is not real yet". At --pink it
       measured 2.24:1 against --bg-soft, under the 3:1 at which a
       boundary is reliably noticed — so the safety mechanism was
       quietly not working. --pink-text is 5.73:1 on --bg-soft and
       6.24:1 on white. */
    box-shadow: 0 0 0 2px var(--pink-text);

    /* The token text itself was inheriting --ink-2, which is 4.08:1 on
       the wash where it sits over --bg-soft — under the 4.5:1 bar. --ink
       is 13.55:1 on that same wash and 14.66:1 on the wash over white. */
    color: var(--ink);
    border-radius: 4px;
    padding: 0 4px;

    /* A placeholder token is one unbroken 28-character run of capitals and
       underscores. Dropped into an h2 at 52px it cannot break, so it sets the
       minimum width of the whole page: A5 measured scrollWidth 1287 in a
       1280 window on services.html, and the entire site scrolled sideways
       — caused by the very device that is supposed to make unfinished work
       obvious. `anywhere` rather than `break-word` because only `anywhere`
       also shrinks the min-content size, which is the half that stops the
       overflow rather than just hyphenating after the damage is done.
       Global rather than scoped to headings: the featured quote is 48px in
       a 22ch box and would be next. */
    overflow-wrap: anywhere;
}

.todo::after {
    content: " ⚠ TODO";
    font-size: 0.7em;
    font-weight: 700;
    letter-spacing: 0.06em;
    color: var(--pink-text);
    white-space: nowrap;
}

/* ============================================================
   CARD GRID — past work, services
   ============================================================ */

.grid {
    display: grid;
    gap: clamp(20px, 3vw, 32px);
    grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
}

.card {
    display: flex;
    flex-direction: column;
    gap: 14px;
}

.card__body {
    padding-inline: 2px;
}

.card__title {
    font-size: 1.0625rem;
    font-weight: 600;
    letter-spacing: -0.02em;
}

.card__meta {
    font-size: 0.875rem;
    color: var(--ink-2);
    margin-top: 3px;
}

/* ============================================================
   QUOTES

   A review is one voice at a time. The featured one is set large
   because a wall of small testimonials is read as decoration and a
   single large sentence is read as a person.
   ============================================================ */

/* 28-48px, well clear of the floor, and it is a display setting by its own
   argument above. Its only consumer was reviews.html, which was DELETED on
   2026-08-02 — so this rule and .quote-card__translation below are now dead
   code, kept deliberately rather than by oversight: the reviews themselves
   survive in ../assets/reviews/ with their permission notes, and the page is
   one `git show 53cf78a:reviews.html` away. Deleting the styles too would
   turn a one-command restore into a redesign. If the reviews are still gone
   in a few months, these go. */
.quote {
    font-family: var(--display-face);
    max-width: 22ch;
    margin-inline: auto;
    text-align: center;
    font-size: clamp(1.75rem, 4.5vw, 3rem);
    font-weight: 600;
    letter-spacing: -0.03em;
    line-height: 1.14;
    text-wrap: balance;
}

.quote__by {
    display: block;
    margin-top: 26px;
    font-size: 0.9375rem;
    font-weight: 400;
    letter-spacing: 0;
    color: var(--ink-2);
}

.quote-card {
    display: flex;
    flex-direction: column;
    padding: clamp(22px, 3vw, 32px);
    border: 1px solid var(--hairline-soft);
    border-radius: var(--radius);
    background: var(--bg);
}

.quote-card p {
    font-size: 1.0625rem;
    line-height: 1.45;
}

.quote-card footer {
    margin-top: auto;
    padding-top: 20px;
    font-size: 0.875rem;
    color: var(--ink-2);
}

/* The English line under a Malay review. A translation is a footnote, not
   part of what the customer said, and without this it carries the same
   weight as their own words — which reads as if we wrote both.

   This styled reviews.html, which the owner unlinked on 2026-08-01 and
   which was DELETED on 2026-08-02. Dead code, kept on purpose — see the
   .quote comment above for the reasoning and the restore command. */
.quote-card__translation {
    margin-top: 10px;
    font-size: 0.875rem;
    line-height: 1.45;
    color: var(--ink-2);
}

/* ============================================================
   WHO WE ARE — the logo used large, on services.html, once.

   The owner: "the logo of babah 3d have not been used to full potential,
   make one bigger one somewhere, maybe at the service explaining who we
   are." Before this it existed at 50 x 32 in the header and inside
   img/og.jpg, and nowhere else.

   ONE PLACEMENT AND ONLY ONE. A logo repeated down a page stops being a
   mark and becomes wallpaper, and this design has exactly one home for it:
   the block that says who the business is. Everywhere else the header mark
   is enough.

   THE MESH BEHIND IT IS THE ARGUMENT OF THE WHOLE BRAND PASS. The logo is
   not pixelated, it is a triangulated surface — and a triangulated surface
   is precisely what an STL file is. The mark is a mesh; so is every object
   this shop prints. So the mark sits ON a mesh, generated as inline SVG in
   services.html rather than shipped as a picture: it stays crisp at any
   size, costs one path and nine polygons, and adds no request to a page
   that currently makes two.
   ============================================================ */

.who {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: clamp(28px, 5vw, 64px);
}

/* No breakpoint here, on purpose, like the nav. min() against a viewport
   unit means the mark shrinks with the screen and the row wraps when the
   two halves stop fitting, whatever the words underneath turn out to be. */
.who__mark {
    position: relative;
    flex: 0 0 auto;
    width: min(280px, 58vw);
}

/* <picture> is inline by default and would sit on a text baseline, leaving
   a few pixels of descender gap under the mark. */
.who__mark picture {
    display: block;
}

/* position:relative is what puts the mark ABOVE the mesh — z-index does
   nothing to a static element. */
.who__mark img {
    position: relative;
    z-index: 1;
    width: 100%;
    height: auto;
}

/* max-width:none is REQUIRED, not tidiness: the global
   `img, svg, video { max-width: 100% }` near the top of this file would
   otherwise clamp the mesh back to the mark's own width and the bleed would
   silently disappear.

   EXPLICIT width AND height, not `inset` with auto sizing. That was the
   first version and it is a trap: an <svg> with a viewBox is a REPLACED
   ELEMENT with an intrinsic ratio, so with width and height auto the
   browser sizes it from that ratio instead of from the insets — measured
   462.9px tall where the insets asked for 439.2, and the mesh drifted off
   centre. Percentages here are of .who__mark's padding box, which is
   definite for an absolutely positioned child even though it is
   content-derived. preserveAspectRatio="slice" in the markup then crops the
   small ratio difference rather than distorting the triangles.

   The 12% horizontal bleed is bounded on purpose. --gutter is at least 20px
   and the widest the mesh can ever overhang is 12% of 280px = 33.6px, so at
   the one width where .who fills its row exactly the overhang lands inside
   the gutter and never reaches the viewport edge — measured at 768px, where
   it is the tightest: 4.8px of clearance. Re-measure all 16 page/width
   combinations if you raise it. */
.who__mesh {
    position: absolute;
    left: -12%;
    top: -8%;
    width: 124%;
    height: 116%;
    max-width: none;
    z-index: 0;
    pointer-events: none;
    color: var(--ink);
}

.who__body {
    flex: 1 1 340px;
    min-width: 0;
    max-width: var(--measure);
}

/* ============================================================
   CONTACT AND FOOTER
   ============================================================ */

.contact-row {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 14px;
    margin-top: 34px;
}

.contact-link {
    display: inline-flex;
    align-items: center;
    gap: 10px;
    min-height: 48px;
    padding: 12px 22px;
    border: 1px solid var(--hairline);
    border-radius: 999px;
    text-decoration: none;
    font-weight: 500;
    transition: background 0.15s ease, border-color 0.15s ease;
}

.contact-link:hover {
    background: var(--bg-soft);
    border-color: var(--ink-2);
}

/* The icons are drawn inline in the markup as plain strokes rather than
   pulled from an icon font or a CDN — one less request, and nothing to
   break when a third party moves a file. */
.contact-link svg {
    width: 20px;
    height: 20px;
    flex: none;
}

.foot {
    padding-block: clamp(40px, 7vh, 72px);
    border-top: 1px solid var(--hairline-soft);
    background: var(--bg-soft);
}

.foot__grid {
    display: flex;
    flex-wrap: wrap;
    gap: 28px 56px;
    justify-content: space-between;
}

/* --mono, third and last of the technical labels. Colour unchanged. These
   are headings, not targets, so the 24px floor below does not apply to
   them — it applies to the <a>s underneath. */
.foot h2 {
    font-family: var(--mono);
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--ink-2);
    margin-bottom: 12px;
}

.foot ul {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: 9px;
}

/* Same 24px floor as the nav links — see the note there. */
.foot a {
    display: inline-flex;
    align-items: center;
    min-height: 24px;
    font-size: 0.875rem;
    color: var(--ink-2);
    text-decoration: none;
}

.foot a:hover {
    color: var(--ink);
    text-decoration: underline;
}

.foot__legal {
    margin-top: 34px;
    padding-top: 22px;
    border-top: 1px solid var(--hairline-soft);
    font-size: 0.75rem;
    color: var(--ink-2);
}

/* ============================================================
   SCROLL REVEAL

   Deliberately opt-in from JavaScript: site.js adds .reveal-on to
   <html> before anything paints, and only then does the hidden state
   below apply. With JavaScript off or broken, every element is simply
   visible. A reveal animation that can leave the page blank is a bug
   dressed as a flourish.
   ============================================================ */

.reveal-on .reveal {
    opacity: 0;
    transform: translateY(22px);
    transition: opacity 0.7s ease, transform 0.7s ease;
}

.reveal-on .reveal.is-in {
    opacity: 1;
    transform: none;
}

@media (prefers-reduced-motion: reduce) {
    .reveal-on .reveal {
        opacity: 1;
        transform: none;
    }
}

/* ---------------------------------------------------------------------
   PHOTOGRAPHY
   ---------------------------------------------------------------------
   The product photographs are shot on white, and on a white page that is
   simply continuous — there is nothing here to correct. This section is
   short now because the thing it used to describe is gone.

   WHAT IT USED TO SAY, kept because it is the reason not to reintroduce
   the rule: measured (A13, 2026-08-01) the hero's backdrop read 20.81:1
   against the DARK page — brighter than that page's own brightest ink at
   19.29:1 — so on a phone at night the hero was a light box, and a
   dark-mode-only brightness(.92) on the frame settled it to 17.29:1. The
   dark page stopped existing on 2026-08-13 and the damping went with it.
   The full argument is in the block below.

   The upstream fix that comment always pointed at — shoot on mid-grey —
   is no longer needed for contrast. It would still be a nicer photograph.

   Count braces with comments BLANKED OUT, not with a plain grep: two of
   the comments in this file contain braces, and a raw count reads two
   pairs high and sends whoever checks it looking for a bug that is not
   there.

   THE NUMBER IN THIS PARAGRAPH HAS BEEN STALE MORE OFTEN THAN IT HAS BEEN
   RIGHT — 91/91, then 177/177, and it measured 170/170 the moment before
   the scroll-driven climb above was added. Nobody had miscounted; the file
   simply grew and the sentence did not. What is worth checking here is the
   DEPTH, which is the actual invariant and is 0, and it is worth checking
   by counting rather than by believing this line. If you are reading this
   because the totals disagree, the file has grown again: re-measure, and
   only go looking for a bug if the depth is not 0. */

/* THE PHOTOGRAPHS ARE UNDAMPED, AND THAT IS THE CORRECT LIGHT RENDERING.

   Three rules used to live here — a dark-mode `.media { filter:
   brightness(.92) }`, a `:root[data-theme="dark"]` twin of it, and a
   `:root[data-theme="light"]` twin setting `filter: none`. All three are
   gone as of 2026-08-13, and the reasoning matters more than the deletion
   because a filter removed without looking is how a photograph ends up
   subtly wrong on every page.

   WHAT THE DAMPING WAS COMPENSATING FOR. These are product photographs on
   a pale set — near-white grounds, measured at 20.81:1 against the ink on
   them. On a #000 page a near-white rectangle is a light box: the eye is
   dark-adapted and the picture glares. brightness(.92) took 8% off the
   frame to settle it into a black page. The compensation was for the
   GROUND the picture sat on, and that ground was black.

   WHY REMOVING IT CHANGES NOTHING ON A LIGHT PAGE. On #ffffff a pale
   photograph is continuous with the page; there is nothing to glare
   against and nothing to settle. More to the point, this is not a
   judgement call about how it looks — the dark rule's query could not
   match and nothing on this site has ever set [data-theme], so NONE of
   these three rules has applied to a shipped page in any browser. The
   computed filter on .media was already `none` everywhere. Deleting them
   is provably a no-op on what visitors see, which was verified by
   rendering all six pages before and after.

   DO NOT DAMP THE PHOTOGRAPH ITSELF if this is ever revisited. The <img>
   inside .media carries the pixel-reveal filter (`.media img:not(.quant)`),
   and two `filter` declarations on one element cannot both apply — the
   loser vanishes with no warning and nothing in a source read shows it.
   That bug shipped once already: the damping was on `.media img` and the
   reveal silently won, so the damping stopped existing while this comment
   claimed it worked. A filter on the photograph is also the invariant
   `check-pixel-companions.mjs:369` exists to defend. The frame is the only
   safe place for one, which is why the deleted rules were on `.media`.

   `.hero img` was a second selector on those rules until 2026-08-02, and
   it was pure redundancy that damped the hero twice (0.846 effective
   against every other picture's 0.92) — the one picture a visitor sees
   first was 8.5% darker than the rest. Do not reintroduce a selector that
   reaches an <img> inside a `.media`. */

/* ============================================================
   THE LOGO HAS NO BACKING PLATE, BECAUSE THE LIGHT BAR IS THE BETTER
   GROUND — measured, not assumed.

   A dark-mode `background: #f5f5f7` plus `box-shadow: 0 0 0 3px #f5f5f7`
   used to paint a plate behind the mark in the nav, with a
   `[data-theme="dark"]` twin and a `[data-theme="light"]` rule turning it
   back off. All four declarations are gone as of 2026-08-13.

   WHAT THE PLATE WAS COMPENSATING FOR. The mark is the owner's real
   low-poly artwork and it has genuinely dark facets — deep purple and
   maroon. On the dark scheme the nav sat at 82% of a #000 page, so those
   facets composited against near-black and a large share of the mark's
   painted pixels fell below 3:1. Half the logo sank into the bar. The fix
   was the ground rather than the mark, because recolouring the artwork
   would have shown a logo the owner did not design — and those same dark
   facets are exactly right on a light page.

   THE LIGHT BAR IS ALREADY BETTER THAN THE PLATE WAS. The figures are
   this file's own, measured at the 50x32 display size, counting a pixel as
   painted at alpha >= 0.5, WCAG relative luminance, on the current crop:

       share of the mark's 794 painted pixels below 3:1
         on the dark page's bare #000 ....... 56.9%   <- the problem
         on the #f5f5f7 plate ............... 21.2%   <- the fix
         on the light page's own #ffffff .... 18.5%   <- what ships now

   So the light nav is 2.7 points BETTER than the plate it is replacing,
   and the plate on a light page would have been a #f5f5f7 rectangle on a
   #ffffff bar — 1.09:1, an invisible smudge that does nothing except
   announce itself at the one moment it is not wanted. Removing it improves
   the worst number and removes a visual artefact.

   Nothing on this site has ever set [data-theme] — grep the HTML and
   site.js — so the two twins were dead rules from the day they were
   written, and the media query could not match. As with the photographs,
   the deletion is a no-op on what any visitor has ever seen.
   ============================================================ */

/* ============================================================
   THE GUIDE — guide.html, and nothing else on this site.

   The owner wrote "Clicker Wonderland — The Complete Guide" as a
   standalone A4 print document with its own inline stylesheet: Segoe UI,
   point sizes, #d6336c pink and #7048e8 purple. That stylesheet is gone
   and this block replaces it. The MARKUP is the author's, unchanged —
   every heading, id, table, callout and .pb page-break marker is
   byte-identical to the file they wrote. Only the shell and the paint
   are the site's. See fleet/STATUS-A53.md.

   THE ONE RULE AT THE TOP OF THIS FILE DOES NOT APPLY HERE, and that is
   deliberate rather than an oversight. "One idea per screen, the picture
   does the talking, adding copy is how this design dies" is a rule about
   four marketing pages that have to sell in one screen. A 22-chapter
   manual is the opposite object: the only thing a visitor wants from it
   is words, quickly findable. Everything below is scoped to .guide so
   none of it can reach index, work, services or 404.

   COLOUR. Three accents, and they are not new: they are the logo's own
   pigments, the values already in the mesh markup on services.html.
       note (default)  --pink-text
       warning         #ed5f2f   the mark's orange
       tip             #207a64   the mark's teal
   They appear ONLY as a 3px left border — a boundary, held to WCAG
   1.4.11's 3:1, measured against --bg-soft (and against white, since the
   guide's callouts sit on both):
       --pink-text  5.73 on #f5f5f7 / 6.24 on #ffffff
       #ed5f2f      3.07 on #f5f5f7 / 3.34 on #ffffff   <- the tightest
       #207a64      4.79 on #f5f5f7 / 5.21 on #ffffff
   The dark column these figures used to carry went with the dark palette
   on 2026-08-13. #ed5f2f at 3.07 is the whole margin this site has on a
   1.4.11 boundary — 2% clear — so do not darken --bg-soft without
   re-measuring this line.
   NONE of them is allowed near type. #ed5f2f is 3.07:1 on #f5f5f7, under
   the 4.5:1 a label needs, so
   the callout's bold lead-in stays --ink. The original document coloured
   that text; it is the one visual thing here that was deliberately not
   carried over, and this paragraph is why. Raw #90194c is NOT used as a
   border either — 2.23:1 on #0e0e10. --pink-text is the scheme-aware
   token and it is what a boundary must use. Same rule as .ph and .todo.
   ============================================================ */

.guide {
    padding-block: clamp(40px, 7vh, 80px) var(--chapter-y);
}

/* The cover. The author's markup is a .cover div holding h1, two .sub
   lines, a .rule and four .badge pills. Their version centred it under
   120px of padding for a title page; on screen it is the top of a web
   page, so it keeps the centring and loses the title-page air. */
.guide .cover {
    text-align: center;
    padding-bottom: 8px;
}

/* THE COVER TITLE GETS THE DISPLAY FACE. THE 22 CHAPTER HEADS DO NOT, AND
   THAT LINE IS DRAWN ON PURPOSE.

   .guide h1 is one string, 35-54px, on the cover of the document — a
   display position by any reading, and the guide would otherwise be the
   only page on the site still opening in the old face.

   .guide h2/h3/h4 are 22 chapter heads, 22 sub-heads and their sub-heads
   INSIDE a 25,000px reading document. A display face on 60-odd signposts
   through a manual is where an aesthetic starts charging the reader rent,
   and h3 is 17px, which is under the measured floor anyway. They keep the
   body stack. See the DISPLAY FACE block for the floor and how it was
   measured.

   Print is handled separately at the foot of this file: the PDF at
   downloads/ is generated FROM this page, and it is not being reprinted. */
.guide h1 {
    font-family: var(--display-face);
    font-size: clamp(2.2rem, 6vw, 3.4rem);
    font-weight: 700;
    letter-spacing: -0.04em;
    line-height: 1.05;
}

.guide .sub {
    font-size: clamp(1rem, 2vw, 1.1875rem);
    color: var(--ink-2);
    margin-top: 10px;
    max-width: var(--measure);
    margin-inline: auto;
}

/* The one place the brand palette appears as itself. The mark is a
   low-poly sweep from orange through magenta to teal; this is that sweep,
   120px wide. It is decoration with no text on it and no job as a
   boundary — which is the only reason these three raw values are allowed
   in a gradient at all. */
.guide .rule {
    width: 120px;
    height: 4px;
    margin: 22px auto;
    border-radius: 2px;
    background: linear-gradient(90deg, #ed5f2f, #90194c, #207a64);
}

.guide .badge {
    display: inline-block;
    margin: 3px 2px;
    padding: 4px 13px;
    border: 1px solid var(--hairline);
    border-radius: 999px;
    font-family: var(--mono);
    font-size: 0.6875rem;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--ink-2);
}

/* The download. This block is the only markup on the page that is not the
   author's — see a53-build-guide.mjs in the scratchpad. .btn--quiet and
   not .btn: the site allows one filled call to action per screen and on
   this page it is not this one. */
.guide__dl {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: 10px 18px;
    margin: 26px 0 8px;
}

/* Contents. Two columns is the author's; it is kept because 22 links in
   one column is a screen of its own, and dropped below 620px where two
   columns would give about 20 characters each. */
.guide .toc {
    margin-top: 14px;
    columns: 2;
    column-gap: 34px;
}

@media (max-width: 620px) {
    .guide .toc {
        columns: 1;
    }
}

.guide .toc a {
    display: block;
    padding: 5px 0;
    color: var(--ink);
    text-decoration: none;
    break-inside: avoid;
    /* 24px is WCAG 2.5.8's floor for a target and these are 22px lines.
       Same fix, same reason, as .nav__links a. */
    min-height: 24px;
}

.guide .toc a:hover {
    color: var(--pink-text);
}

.guide .toc .num {
    display: inline-block;
    width: 28px;
    font-family: var(--mono);
    font-size: 0.8125rem;
    font-weight: 600;
    color: var(--pink-text);
}

/* Chapter heads. A hairline above rather than the author's coloured
   underline: on a page of 22 of them, a rule above is the thing that says
   "new chapter" without adding a colour. */
.guide h2 {
    margin-top: 56px;
    padding-top: 22px;
    border-top: 1px solid var(--hairline);
    font-size: clamp(1.375rem, 3vw, 1.75rem);
    font-weight: 650;
    letter-spacing: -0.025em;
    line-height: 1.2;
}

.guide h2#toc {
    margin-top: 34px;
}

.guide h3 {
    margin-top: 30px;
    font-size: 1.0625rem;
    font-weight: 650;
    letter-spacing: -0.015em;
}

.guide h4 {
    margin-top: 20px;
    font-size: 0.9375rem;
    font-weight: 650;
    color: var(--ink-2);
}

.guide p,
.guide li {
    margin-top: 12px;
    line-height: 1.62;
}

.guide ul,
.guide ol {
    margin-top: 12px;
    padding-left: 22px;
}

.guide li {
    margin-top: 6px;
}

.guide b {
    font-weight: 650;
}

/* Tables. 18 of them, some five columns wide, on a page that is also read
   on a phone. display:block with overflow-x:auto below 700px makes the
   TABLE scroll rather than the document — the site's own rule is that
   nothing pushes the page sideways, and this is what keeps it true
   without touching the author's markup. */
.guide table {
    width: 100%;
    margin-top: 18px;
    border-collapse: collapse;
    font-size: 0.9375rem;
    break-inside: avoid;
}

.guide th,
.guide td {
    padding: 9px 12px;
    border: 1px solid var(--hairline);
    text-align: left;
    vertical-align: top;
    line-height: 1.5;
}

.guide th {
    background: var(--bg-soft);
    font-weight: 650;
}

@media (max-width: 700px) {
    .guide table {
        display: block;
        overflow-x: auto;
    }
}

/* Callouts. Colour lives on the border and nowhere else — see the block
   comment at the top of this section for the measured reason. */
.guide .callout {
    margin-top: 18px;
    padding: 12px 16px;
    background: var(--bg-soft);
    border-left: 3px solid var(--pink-text);
    border-radius: 0 var(--radius) var(--radius) 0;
    break-inside: avoid;
}

.guide .callout > b:first-child {
    color: var(--ink);
}

.guide .callout.warn {
    border-left-color: #ed5f2f;
}

.guide .callout.tip {
    border-left-color: #207a64;
}

/* Inline code. The hairline is not decoration: the background is
   --bg-soft and a callout's background is ALSO --bg-soft, so without a
   border every code span inside a callout would vanish. There are nine of
   them and several are in callouts. */
.guide code,
.guide .k {
    padding: 1px 6px;
    background: var(--bg-soft);
    border: 1px solid var(--hairline-soft);
    border-radius: 4px;
    font-family: var(--mono);
    font-size: 0.875em;
    color: var(--ink);
}

.guide .small {
    font-size: 0.8125rem;
    color: var(--ink-2);
}

.guide__colophon {
    margin-top: 48px;
    padding-top: 16px;
    border-top: 1px solid var(--hairline);
    font-size: 0.8125rem;
    color: var(--ink-2);
    text-align: center;
}

/* ============================================================
   PRINT — the PDF is this page.

   Clicker-Wonderland-Complete-Guide.pdf is produced by running
   Page.printToPDF over guide.html in headless Chrome, so there is exactly
   ONE source of truth for the text. There is no separate export to drift
   out of date, and re-printing after an edit is one command.

   .pb is the author's own page-break marker and it survives here — eight
   of them, on the chapters they wanted a fresh page for.

   Colours are RESTATED here, and since 2026-08-13 that is no longer about
   defeating a dark theme — there is only one theme and it is already
   light. Two of these tokens are genuinely different on paper: --ink-2
   goes #6e6e73 -> #55555a and the hairlines roughly double in opacity,
   because a 4.66:1 grey and a 1.32:1 rule that are fine on a backlit
   screen are weak in toner on white paper. The rest restate their screen
   values deliberately, so that this block is a complete description of a
   printed page rather than a diff against one.
   ============================================================ */

@page {
    size: A4;
    margin: 18mm 16mm;
}

@media print {
    :root {
        --bg: #ffffff;
        --bg-soft: #f5f5f7;
        --ink: #1d1d1f;
        --ink-2: #55555a;
        --hairline: rgba(0, 0, 0, 0.28);
        --hairline-soft: rgba(0, 0, 0, 0.14);
        --pink-text: #b3275d;
        color-scheme: light;
    }

    html,
    body {
        background: #ffffff;
        color: #1d1d1f;
        font-size: 10.5pt;
    }

    /* The site chrome is not part of the document. The download button is
       the loudest of these: a PDF with a "Download the PDF" button
       printed on page one is the kind of thing that gets noticed after it
       has been sent to a customer. */
    .nav,
    .foot,
    .skip-link,
    .guide__dl {
        display: none !important;
    }

    .guide {
        padding: 0;
    }

    .wrap,
    .wrap--narrow {
        max-width: none;
        padding-inline: 0;
    }

    /* The cover gets its title page back. This is the one place the
       screen edition and the print edition are meant to differ. */
    .guide .cover {
        padding: 46mm 0 0;
        break-after: page;
    }

    .guide .pb {
        break-before: page;
    }

    /* A chapter head at the foot of a page with its first paragraph
       overleaf is the commonest way a printed manual looks careless. */
    .guide h2,
    .guide h3,
    .guide h4 {
        break-after: avoid;
        break-inside: avoid;
    }

    .guide h2 {
        margin-top: 22px;
        padding-top: 10px;
    }

    .guide table,
    .guide .callout {
        break-inside: avoid;
    }

    /* Tables must NOT be scrolling blocks on paper. */
    .guide table {
        display: table;
        overflow: visible;
    }

    .guide .toc {
        columns: 2;
    }

    /* The contents anchors are internal. On paper a link is not clickable
       and 22 underlined blue lines read as damage, so they print as plain
       text — which is what the author's own stylesheet did. */
    .guide a,
    .guide .toc a {
        color: inherit;
        text-decoration: none;
    }

    /* NO DISPLAY FACE ON PAPER, AND THIS IS THE LOAD-BEARING LINE OF THIS
       BLOCK, added 2026-08-02 with the face itself.

       downloads/Clicker-Wonderland-Complete-Guide.pdf is PRINTED FROM
       guide.html — that is the whole reason the page and the download
       cannot drift apart (fleet/STATUS-A53.md §5.3). So a screen-only type
       change to .guide h1 is a change to a customer-facing artefact unless
       this rule exists.

       THE FILE WAS REGENERATED ON 2026-08-02, and the sentence that stood
       here — "the PDF on disk … has NOT been regenerated: it is the same
       470,099 bytes it was at 01:24 on 2026-08-01" — was false by then. It
       is now 423,532 bytes over 27 pages, down from 470,099 over 30, and the
       difference is not this rule: the shipped file had never been a print
       of the stylesheet it was committed with. It carried the site nav, the
       footer and the download button into the PDF and set its type at screen
       size, 17/15pt against this sheet's 14/13.1pt. Reprinting the tree at
       the commit that first introduced the PDF also gives 27 pages, which is
       how that was established rather than assumed.

       There is a generator again and it is checked in this time:
       tools/build-guide-pdf.mjs, with check-guide-pdf.mjs beside it. The
       checker holds no list of phrases — that is exactly what went stale
       before — it loads guide.html under emulated print media and compares
       word multisets against the PDF's own extracted text, so the page and
       the download move together or it goes red.

       The general case matters more than the guide. A pixel face is a
       SCREEN idea: its whole argument is that one module lands on one
       device pixel, and paper has no device pixels — at 600dpi the modules
       resolve into small hard squares and the face reads as a fax of
       itself. Everything on this list is display type, so anything added
       to --display-face on screen should be added here too.

       Checked, not assumed: .quote and .subhead have no user in any
       document, and .display and .headline do not appear on guide.html.
       They are listed anyway, because the reason this rule exists is that
       the print edition must not follow the screen edition by accident. */
    .display,
    .headline,
    .subhead,
    .quote,
    .guide h1 {
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
            "Helvetica Neue", Arial, sans-serif;
    }
}

/* ============================================================
   WONDERLAND + PLUM — the owner's pick, 2026-09-25, from a clickable
   preview of three brand-derived themes: "go with 1 wonderland for
   colours but plum for contrast".

   Colours come from the Clicker Wonderland lockup: pink #c93a86 ->
   violet #8e4bd0 -> periwinkle #5a6fd0. The light page carries a faint
   lavender cast. Contrast comes from Plum: the soft chapters and the
   footer become deep violet-plum bands, so the page alternates light
   and dark as it scrolls. Tokens are redefined inside the bands, so
   every component that reads them flips without its own rule.

   Contrast, measured:
     --ink #1c1830 on --bg #fdfcff ........... 16.4:1
     --pink-text #7a3fc4 on #fdfcff .......... 6.3:1
     #ffffff label on #c93a86 (button start).. 4.6:1  (#8e4bd0 end 5.6:1)
     band --ink #f7f2ff on #231433 ........... 15.8:1
     band --ink-2 #cbbfdf on #231433 ......... 9.6:1
     band --pink-text #e3b6ff on #231433 ..... 9.9:1
   ============================================================ */

:root {
    --bg: #fdfcff;
    --bg-soft: #f4f1fc;
    --ink: #1c1830;
    --ink-2: #605b76;
    --hairline: rgba(60, 40, 120, 0.14);
    --hairline-soft: rgba(60, 40, 120, 0.07);
    --pink-text: #7a3fc4;
    --wl-accent: #8b5cf6;
    --wl-grad: linear-gradient(120deg, #c93a86 0%, #8e4bd0 55%, #5a6fd0 100%);
    --wl-band: radial-gradient(120% 90% at 85% 0%, #3a1f5c 0%, #231433 55%, #170c24 100%);
    --wl-ease: cubic-bezier(0.22, 1, 0.36, 1);
    --wl-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
html, body { background: var(--bg); }

/* ---------- Plum bands ---------- */
.chapter--soft,
.foot {
    --bg-soft: #231433;
    --ink: #f7f2ff;
    --ink-2: #cbbfdf;
    --pink-text: #e3b6ff;
    --hairline: rgba(255, 255, 255, 0.18);
    --hairline-soft: rgba(255, 255, 255, 0.08);
    background: var(--wl-band);
    color: var(--ink);
}

/* ---------- Wonderland colour ---------- */
.display {
    background: var(--wl-grad);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
}
.eyebrow::before { background: var(--wl-grad); }
.nav {
    background: rgba(253, 252, 255, 0.8);
    -webkit-backdrop-filter: saturate(1.4) blur(14px);
    backdrop-filter: saturate(1.4) blur(14px);
}

/* ---------- Buttons: gradient, lift, press, ripple ---------- */
.btn {
    position: relative;
    overflow: hidden;
    isolation: isolate;
    transition: transform 0.3s var(--wl-spring), box-shadow 0.3s ease,
                background-position 0.5s var(--wl-ease), background 0.25s ease, color 0.2s ease;
}
.btn:not(.btn--quiet) {
    background: var(--wl-grad);
    background-size: 160% 100%;
    background-position: 0 0;
    color: #ffffff;
    box-shadow: 0 6px 18px -8px rgba(123, 79, 214, 0.6);
}
.btn:not(.btn--quiet):hover {
    background-position: 100% 0;
    box-shadow: 0 14px 30px -10px rgba(123, 79, 214, 0.7);
}
.btn:hover { transform: translateY(-3px); }
.btn:active { transform: translateY(1px) scale(0.97); transition-duration: 0.08s; }
.btn--quiet { border-color: rgba(123, 79, 214, 0.35); }
.btn--quiet:hover { background: #ffffff; border-color: var(--wl-accent); }
.chapter--soft .btn--quiet { color: var(--ink); border-color: rgba(255, 255, 255, 0.4); }
.chapter--soft .btn--quiet:hover { background: rgba(255, 255, 255, 0.1); }
.chapter--soft .btn:not(.btn--quiet):hover { box-shadow: 0 14px 34px -8px rgba(201, 58, 134, 0.6); }
.wl-ripple {
    position: absolute;
    z-index: -1;
    border-radius: 50%;
    pointer-events: none;
    background: rgba(255, 255, 255, 0.45);
    transform: scale(0);
    animation: wl-ripple 0.6s var(--wl-ease) forwards;
}
@keyframes wl-ripple { to { transform: scale(2.6); opacity: 0; } }
.more::after { transition: transform 0.25s var(--wl-spring); }
.more:hover::after { transform: translate(6px, -1px); }

/* ---------- Nav underline ---------- */
.nav__links a { position: relative; }
.nav__links a::after {
    content: "";
    position: absolute;
    left: 0; right: 0; bottom: -3px;
    height: 2px;
    border-radius: 2px;
    background: var(--wl-grad);
    transform: scaleX(0);
    transform-origin: right;
    transition: transform 0.35s var(--wl-ease);
}
.nav__links a:hover::after,
.nav__links a[aria-current="page"]::after { transform: scaleX(1); transform-origin: left; }

/* ---------- Photos and cards ---------- */
.media,
.media:has(img) {
    border-radius: 22px;
    background: #ffffff;
    transition: transform 0.45s var(--wl-ease), box-shadow 0.45s ease;
}
.media > img:not(.px) { transition: transform 0.8s var(--wl-ease); }
.media:hover { transform: translateY(-6px); box-shadow: 0 24px 44px -20px rgba(123, 79, 214, 0.45); }
.media:hover > img:not(.px) { transform: scale(1.05); }
.chapter--soft .media:hover { box-shadow: 0 22px 50px -14px rgba(0, 0, 0, 0.7); }
.card:active .media { transform: translateY(-2px) scale(0.99); }
.card__title { transition: color 0.2s ease; }
.card:hover .card__title { color: var(--pink-text); }

/* ---------- Arrival ---------- */
.reveal-on .reveal { transition: opacity 0.8s ease, transform 0.8s var(--wl-ease); }
.reveal-on .reveal:not(.is-in) { transform: translateY(26px); }
.hero > *:not(.hero__media) { animation: wl-rise 0.9s var(--wl-ease) both; }
.hero > *:nth-child(2) { animation-delay: 0.08s; }
.hero > *:nth-child(3) { animation-delay: 0.16s; }
@keyframes wl-rise { from { opacity: 0; transform: translateY(20px); } }

@media (hover: none) {
    .media:hover { transform: none; }
    .media:hover > img:not(.px) { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
    .btn, .media, .media > img:not(.px), .nav__links a::after,
    .reveal-on .reveal, .card__title { transition: none; }
    .hero > *:not(.hero__media) { animation: none; }
    .btn:hover, .btn:active, .media:hover, .media:hover > img:not(.px) { transform: none; }
    .reveal-on .reveal:not(.is-in) { transform: none; }
    .wl-ripple { display: none; }
}

/* ============================================================
   PIXEL HEADINGS THAT MOVE LIKE A GAME — the owner's pick, 2026-09-25,
   from three previewed options: "the dialogue when we first came to the
   page, [then] the title screen continuous slight jumping".

   site.js splits every .display / .headline / .guide h1 into one span
   per letter (.rc, index --i, total --n on the heading; the heading
   keeps its words in aria-label and the spans are aria-hidden).

   1. Dialogue: when a heading scrolls into view, letters appear one per
      55ms frame, like an RPG text box, then a block cursor blinks 4
      times and goes.
   2. Idle: once typed, a one-frame hop ripples through the letters on a
      3.2s loop, like a title-screen sprite idling.

   Every timing is steps(), so it moves at a low frame rate on purpose.
   The hidden state lives behind .rc-wait, which only site.js adds, so
   with JS off or reduced motion the headings are simply there.
   ============================================================ */

.rc { display: inline-block; white-space: pre; }
.rc-word { white-space: nowrap; }

/* The gradient headline is painted per letter and lined up across the
   whole heading with offsets from site.js (--gw, --gx). */
.display.rc-split { background: none; color: var(--ink); }
.display .rc {
    background: var(--wl-grad);
    background-size: var(--gw, 100%) 100%;
    background-position: calc(var(--gx, 0px) * -1) 0;
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
}

.rc-wait .rc { visibility: hidden; }

@keyframes rc-on { from { visibility: hidden; } to { visibility: visible; } }
@keyframes rc-hop {
    0%, 6%, 100% { transform: translateY(0); }
    3%           { transform: translateY(-0.1em); }
}
@keyframes rc-blink { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 0; } }

.rc-go .rc {
    animation:
        rc-on 1ms steps(1, jump-start) calc(var(--i) * 55ms) both,
        rc-hop 3.2s steps(1) calc(var(--n) * 55ms + 1.4s + var(--i) * 60ms) infinite;
}
.rc-go::after {
    content: "";
    display: inline-block;
    width: 0.45em;
    height: 0.8em;
    margin-left: 0.08em;
    vertical-align: -0.02em;
    background: currentColor;
    animation:
        rc-on 1ms steps(1, jump-start) calc(var(--n) * 55ms) both,
        rc-blink 0.5s steps(1) calc(var(--n) * 55ms) 8 both;
}
.display.rc-go::after { background: #8e4bd0; }

@media (prefers-reduced-motion: reduce) {
    .rc-wait .rc { visibility: visible; }
    .rc-go .rc, .rc-go::after { animation: none; }
    .rc-go::after { display: none; }
}

/* ============================================================
   STICKER BUTTONS + OUTLINED FRAMES — owner's pick, 2026-09-25, from the
   Playful add-ons preview ("i like number 2 & 3"). Brought back from the
   rejected Playful direction, recoloured for Wonderland + Plum.

   Outlines and hard shadows use var(--ink), which the plum bands already
   flip to #f7f2ff, so both read on the light page and on the dark bands.

   Contrast: #ffffff label on the #7b4fd6 violet fill is 5.4:1.
   ============================================================ */

/* ---------- Sticker buttons: solid violet on a hard shadow ---------- */
.btn {
    border: 2px solid var(--ink);
    border-radius: 12px;
    box-shadow: 5px 5px 0 var(--ink);
    transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s ease;
}
.btn:not(.btn--quiet),
.btn:not(.btn--quiet):hover {
    background: #7b4fd6;
    color: #ffffff;
    box-shadow: 5px 5px 0 var(--ink);
}
.btn:hover,
.btn:not(.btn--quiet):hover {
    transform: translate(-2px, -2px);
    box-shadow: 7px 7px 0 var(--ink);
}
.btn:active,
.btn:not(.btn--quiet):active {
    transform: translate(5px, 5px);
    box-shadow: 0 0 0 var(--ink);
}
.btn--quiet,
.btn--quiet:hover { background: #ffffff; color: #1c1830; border-color: var(--ink); }
.chapter--soft .btn--quiet,
.chapter--soft .btn--quiet:hover { background: transparent; color: var(--ink); }
.chapter--soft .btn:not(.btn--quiet):hover { box-shadow: 7px 7px 0 var(--ink); }

/* ---------- Outlined frames: outline + hard offset shadow ---------- */
/* A 2px shadow ring, not a border: a border would add 4px to the frame,
   and check-pixel-companions requires the frame to be exactly the
   photograph's height. */
.media,
.media:has(img) {
    border-radius: 18px;
    box-shadow: 0 0 0 2px var(--ink), 8px 8px 0 0 var(--ink);
    transition: transform 0.25s var(--wl-spring), box-shadow 0.25s ease;
}
.media:hover {
    transform: translate(-3px, -3px) rotate(-0.6deg);
    box-shadow: 0 0 0 2px var(--ink), 11px 11px 0 0 #8e4bd0;
}
.chapter--soft .media:hover { box-shadow: 0 0 0 2px var(--ink), 11px 11px 0 0 #e3b6ff; }
.card:active .media,
.media:active {
    transform: translate(4px, 4px);
    box-shadow: 0 0 0 2px var(--ink), 3px 3px 0 0 var(--ink);
}

@media (hover: none) {
    .media:hover { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
    .btn, .media { transition: none; }
    .btn:hover, .btn:active, .media:hover, .media:active { transform: none; }
}

/* ============================================================
   THE WALKTHROUGH'S PLAY BUTTON IS A .btn, AND THE LATER .btn RULES MUST
   NOT MOVE IT. Fixed 2026-09-25 after the owner saw the button vanish.

   The Wonderland block gave every .btn `position: relative` (so the
   ripple stays inside), and the sticker block gave .btn hover/press
   transforms. Both came later in this file than .vid__play, so they won:
   the button fell out of its absolute spot on the poster into normal
   flow, below the 16:9 box, where .vid's overflow:hidden clipped it
   away. `.btn.vid__play` outranks both, puts it back on the poster, and
   keeps its translateY(-50%) centring inside every hover/press state.
   ============================================================ */
.btn.vid__play {
    position: absolute;
    z-index: 2;
    transform: translateY(-50%);
}
.btn.vid__play:hover { transform: translateY(-50%) translate(-2px, -2px); }
.btn.vid__play:active { transform: translateY(-50%) translate(5px, 5px); }
@media (prefers-reduced-motion: reduce) {
    .btn.vid__play:hover,
    .btn.vid__play:active { transform: translateY(-50%); }
}
