/* ============================================================================
   SENS — placeholder landing page
   Brand tokens live here. Change these three and the whole page follows.
   ============================================================================ */

/* --- Geist, self-hosted ----------------------------------------------------
   NOT loaded from Google Fonts. Loading a font from Google's CDN sends every
   visitor's IP address to Google — a third-country transfer with no legal basis
   and no consent, which a Munich court has held to breach GDPR precisely
   because self-hosting is a readily available alternative. Self-hosting removes
   the transfer entirely, which is why this page needs no consent banner.
   DO NOT reintroduce a font CDN. See Legal/decisions/.

   Geist is licensed under SIL Open Font License 1.1, which permits this as long
   as the copyright notice and licence travel with the files — they do, at
   assets/fonts/LICENSE-Geist.txt. Keep them together.
   Variable font: one file covers weights 100-900, so 300/400/500 all work.
   --------------------------------------------------------------------------- */
@font-face {
  font-family: "Geist";
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url("assets/fonts/geist-latin-ext-wght-normal.woff2") format("woff2-variations");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}

@font-face {
  font-family: "Geist";
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url("assets/fonts/geist-latin-wght-normal.woff2") format("woff2-variations");
  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;
}
:root {
  /* --- SENS brand palette -----------------------------------------------
     Alu Silver     #D9D9D9
     Deep Green     #154315
     Sens Gold      #D19C2C
     Washed Black   #1E1E1E
     Bourbon Brown  #2C1915
     All White      #FFFFFF
     --------------------------------------------------------------------- */

  /* Active scheme — swap these to re-skin the whole page.
     Text: Alu Silver / accent: Sens Gold.

     --bg is pure #000000 by choice, not by constraint. It was briefly #000000 to
     hide a seam against the art's baked-in black surround, then returned to
     Washed Black once the art was cropped. Andy then chose pure black on its own
     merits. Either value is safe now — the art no longer contains flat black. */
  --bg: #0f0a0a;        /* near-black, warmed toward the wood (baked 2026-08-13) */
  --ink: #D9D9D9;       /* Alu Silver — text color */
  --accent: #D19C2C;    /* Sens Gold — accent */
  --muted: #7c7a76;     /* quiet text (privacy line, status) */
  --on-accent: #1E1E1E; /* text on the gold button */

  --maxw: 1250px;       /* max width of the magazine image */

  /* Vertical breathing room between wordmark / magazine / footer.
     NOTE: this used to be inert on desktop and was recorded as such — but that
     was a symptom of the magazine sizing bug fixed on 2026-08-13, not a
     property of the layout. Now that the art is genuinely height-constrained,
     every pixel here is a pixel taken from the magazine. It is a real lever.
     Kept as a clamp so it stays responsive; --gap-vh is the tunable part. */
  --gap-vh: 4;
  --gap: clamp(1.5rem, calc(var(--gap-vh) * 1vh), 3rem);

  /* Width of the footer block in the split / left / row layouts. Deliberately
     its own dial rather than --maxw — see the note above .foot. */
  --footw: 1600px;

  /* Row layout: the consent block's width, and the type size it uses. Changing
     --consent-w changes how many lines the consent runs to, which is what
     height-matches it to the input — see the note by .foot-row .consent.
     Raised 430 -> 480 when the fine print moved INSIDE the consent block: the
     same three-line target now has ~20% more text to hold. Measured at 1440px,
     not estimated — 480 gives 48px against the 45px input, while 560 drops it
     to two lines and 32px, which is the same mismatch in the other direction. */
  --consent-w: 480px;
  --fine-size: 0.72rem;

  /* The stage's horizontal padding, hoisted out of the .stage shorthand so the
     full-bleed footer can cancel it exactly (negative margins of the same
     size). Safe-area insets are added at the point of use, not baked in here —
     the bleed has to cancel those too. */
  --stage-pad-x: clamp(1.25rem, 5vw, 3rem);

  /* The stage's bottom padding, hoisted out of the .stage shorthand for the
     same reason as --stage-pad-x: so the full-bleed band can cancel it and run
     to the absolute bottom of the window instead of leaving a strip of field
     showing beneath it. Safe-area inset is added at the point of use, not here.
     The top padding stays inline in .stage — only the bottom is a lever. */
  --stage-pad-bottom: clamp(1.5rem, 4vh, 3rem);

  /* Inset from the band's own edge to its content. Normally the bar padding;
     the full-bleed band overrides it so the content still lines up with
     --footw while the band itself runs to the window edges. Children read it
     (see .foot-row .status) so they follow either mode without knowing which. */
  --foot-inset: var(--bar-pad);

  /* --- Film grain and field ----------------------------------------------
     These are tuned live with dev-tuner.js (dev-only slider panel, injected
     by dev-server.py on localhost — never deployed). Tune, hit Save, and the
     resulting token block gets pasted back in here. Editing by hand works
     too; --grain-tex is the only one that is awkward to write by hand.

     ⬛ BAKED 2026-08-13 — this is Andy's `Wood (A)` look, pressed Save in the
     tuner and pasted in here. It is no longer "the look as shipped plus an
     inert tuner": these values ARE the design. The tuner still reads and
     writes them, and `dev-tuner-state.json` holds the same numbers plus the
     source values (freq / octaves / contrast / warmth) that the grain data URI
     was generated from — those cannot be read back out of the URI, so reopen
     the tuner from that file rather than trying to reverse-engineer it.
     --------------------------------------------------------------------- */
  --grain: 0.1;         /* grain opacity BEHIND the art */
  --grain-top: 0.06;    /* grain opacity ACROSS the art — the pass that makes
                           paper and field read as one photograph rather than
                           a sticker on a background. Live now that the art
                           carries real alpha. */
  --grain-size: 560px;  /* tile size — stitchTiles makes the repeat seamless */
  --grain-blur: 0.5px;  /* softness. Applied as a CSS filter on the whole
                           layer, deliberately NOT as feGaussianBlur inside
                           the tile: blurring inside the tile samples past its
                           edges and turns the seamless repeat into a grid. */
  --vignette: 0.35;     /* 0 = flat field. >0 darkens the edges. */

  /* The noise itself: inline SVG feTurbulence as a data URI, so no extra file
     and no request. Warmed toward the gold (warmth 0.45, tint #D19C2C) and
     given a flat alpha, which is what stops it reading as speckle. Generated —
     regenerate it with the tuner rather than editing it by hand. */
  --grain-tex: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'560'%20height%3D'560'%3E%3Cfilter%20id%3D'n'%20x%3D'0'%20y%3D'0'%20width%3D'100%25'%20height%3D'100%25'%3E%3CfeTurbulence%20type%3D'fractalNoise'%20baseFrequency%3D'0.82'%20numOctaves%3D'4'%20stitchTiles%3D'stitch'%2F%3E%3CfeColorMatrix%20type%3D'saturate'%20values%3D'0'%2F%3E%3CfeComponentTransfer%3E%3CfeFuncR%20type%3D'linear'%20slope%3D'1.05'%20intercept%3D'-0.025'%2F%3E%3CfeFuncG%20type%3D'linear'%20slope%3D'1.05'%20intercept%3D'-0.025'%2F%3E%3CfeFuncB%20type%3D'linear'%20slope%3D'1.05'%20intercept%3D'-0.025'%2F%3E%3C%2FfeComponentTransfer%3E%3CfeColorMatrix%20type%3D'matrix'%20values%3D'0.919%200%200%200%200%20%200%200.825%200%200%200%20%200%200%200.628%200%200%20%200%200%200%200%201'%2F%3E%3C%2Ffilter%3E%3Crect%20width%3D'100%25'%20height%3D'100%25'%20filter%3D'url(%23n)'%2F%3E%3C%2Fsvg%3E");

  /* --- The set: surface + light -------------------------------------------
     The magazine art now carries real alpha, so the page can behave like a
     photographic set rather than a picture on a colour: a surface, a light
     falling across it, and shadows the art casts onto it.

     --surface-tex is `none` by default, which leaves the flat field colour —
     i.e. exactly the look we have today. Point it at an image and the same
     light and shadow controls apply, unchanged, over that image instead.
     --------------------------------------------------------------------- */
  /* Andy's own photograph of his table — see Legal/asset-provenance.md. It
     tiles horizontally (measured seamless); the vertical join is mild but real,
     so if a horizontal band ever shows in the wood, raise --surface-size or
     switch to surface-mirror.webp. */
  --surface-tex: url("assets/surface/surface.webp");
  --surface-fade: 15%;      /* 0% = full bleed. Higher fades the surface out
                               toward the top/bottom edges, into the field —
                               i.e. a lit panel with darkness around it. */
  --surface-size: 260px auto; /* px + --surface-repeat: repeat tiles the texture
                               at a fixed size, which stays sharp on any screen
                               from a small file. `cover` would stretch one
                               photo to the viewport instead. */
  --surface-repeat: repeat;
  --surface-darken: 0.2;    /* Knocks the surface back so the paper separates
                               from it. Measured on the first reference: the
                               wood averaged luminance 171/255 against the
                               magazine's 188 — only 17 apart, which reads as
                               the spread dissolving into the table. Darkening
                               the wood is the cheap half of the fix; the
                               shadows below are the other half. */

  /* Light pool. Blended with `screen`, which ADDS light: over black it lifts a
     warm pool out of the dark, over a wood photo it brightens it warmly. Not
     overlay/soft-light — those preserve #000 exactly, so over a black field
     they do nothing at all. Same trap as the grain. */
  --light: 0.3;
  --light-tint: #E8C88A;
  --light-x: 36%;           /* upper-left, and the cast shadow below points
                               away from it — that pairing is the whole
                               illusion; change one and change the other */
  --light-y: 28%;
  --light-size: 85%;        /* radius of the pool */
  --light-feather: 75%;     /* where the falloff finishes — soft vs pointy */

  /* --- Shadows the art casts ----------------------------------------------
     Two, because real shadows are two things: a tight dark CONTACT shadow
     where the paper meets the surface, and a wider softer CAST shadow thrown
     in the direction the light is coming from. One shadow doing both jobs is
     the classic giveaway of a fake composite.

     drop-shadow (not box-shadow) follows the alpha channel, so these trace the
     paper's real silhouette. --cast-x/-y are written by the tuner from an
     angle + distance, so the shadow can be pointed opposite the light.
     --------------------------------------------------------------------- */
  /* Scrim: a soft darkening behind the wordmark and behind the copy + form.
     Unnecessary over flat black — the text already has all the contrast it
     needs. Over a wood photo it is what keeps the copy and the form legible
     without having to dim the whole surface. Both live now — the bottom one
     heavily, because that is where the copy and the form sit. */
  --scrim-top: 0.25;
  --scrim-bottom: 0.8;
  --scrim-height: 42%;

  /* Wordmark on/off — a DESKTOP decision only. Narrow screens override this
     below and always show it, because a phone has no spare vertical room to
     hand the magazine anyway, and the wordmark is the only brand mark above
     the fold there. Set to `none` (and --wordmark-sr to `block`) to drop it. */
  /* OFF on desktop (baked 2026-08-13): the spread gets the vertical room
     instead, worth ~13.6% on a laptop-shaped screen. The .wordmark-sr span
     carries the brand name for screen readers and machines in its place —
     exactly one of the two is ever exposed. */
  --wordmark-display: none;
  --wordmark-sr: block;

  /* Sheen on the spread — see the .magazine picture::after block. Warm, a
     narrow band low across the paper, masked to the INK: the highlight falls on
     the printed areas and leaves the bare stock alone, which is how a gloss
     actually behaves on a printed spread. `none` (unmasked) and
     mag-mask-paper.webp are the two alternatives and cost nothing until
     selected. Masked by --sheen-mask alone — the alignment comes from
     mask-size: contain on the same box the img contains itself in, so there is
     no geometry to keep in sync. */
  --sheen: 0.31;
  --sheen-mask: url("assets/magazine/mag-mask-ink.webp");
  --sheen-color: #d09d32;
  --sheen-angle: 15deg;
  --sheen-width: 6%;        /* half-width of the band */
  --sheen-pos: 43%;         /* slides the highlight across the spread */

  /* --- Footer bar ---------------------------------------------------------
     An optional panel behind the copy + signup, so the text sits ON something
     instead of floating on the surface. Same texture machinery as the wood:
     point --bar-tex at a seamless texture and it tiles.
     --bar-darken exists because a bright texture (chrome especially) destroys
     the contrast of Alu Silver text — the panel has to be knocked back before
     it is legible, and that is a requirement, not a preference.

     ⬛ ON as of 2026-08-13, as a FLAT COLOUR — not the chrome texture.
     --bar-tex stays `none` deliberately: chrome under Alu Silver text was a
     legibility problem however far it was knocked back (see CHANGELOG), and
     its licence is still unconfirmed (Legal/asset-provenance.md). Two separate
     reasons, either one sufficient. The bar needs `has-bar` on <body>. */
  --bar-bg: #1a1a1a;
  --bar-tex: none;
  --bar-size: 520px auto;
  --bar-repeat: repeat;
  --bar-darken: 0.55;
  --bar-pad: 42px;
  --bar-radius: 2px;        /* inert while the band is full-bleed — a band that
                               touches both edges has no corners to round */
  --bar-shadow-opacity: 0.45; /* contact shadow — the 3D separation */
  --bar-shadow-blur: 30px;
  --bar-shadow-y: 10px;

  --shadow-rgb: 20 11 4;    /* warm, to match the wood it falls on */
  --contact-opacity: 0.95;  /* very heavy, and deliberately so — it is what
                               plants the paper on the table rather than
                               floating it above one */
  --contact-blur: 22px;
  --contact-y: 20px;
  --cast-opacity: 0.56;
  --cast-blur: 90px;
  /* Written by the tuner from angle 164° + distance 44px, i.e. thrown away
     from the light at --light-x/y. Editing x/y by hand breaks that pairing. */
  --cast-x: 12.1px;
  --cast-y: 42.3px;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: "Geist", system-ui, -apple-system, "Segoe UI", sans-serif;
  font-weight: 400;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* --- Film grain -----------------------------------------------------------
   A fixed noise overlay across the whole viewport, to take the flatness off.
   The noise is an inline SVG feTurbulence (a data URI), so there is no extra
   file, no request, and nothing to keep optimized.

   Scoped to the BACKGROUND only — the magazine art stays untouched. That is
   done with stacking order, not masking: this overlay sits at z-index 1 and
   .stage (wordmark, magazine, copy, form) is lifted to z-index 2 above it.

   Not mix-blend-mode: overlay/soft-light — those preserve #000 exactly, so the
   grain would be invisible in the black, which is most of the page.
   All the knobs are the --grain* tokens in :root.
   -------------------------------------------------------------------------- */
body::after {
  content: "";
  position: fixed;
  /* Slightly oversized: --grain-blur pulls in transparency at the edges of
     the layer, which would show as a soft light rim around the viewport. */
  inset: calc(-2px - var(--grain-blur) * 3);
  z-index: 1;
  pointer-events: none; /* never let it eat clicks on the form */
  opacity: var(--grain);
  background-image: var(--grain-tex);
  background-size: var(--grain-size) var(--grain-size);
  filter: blur(var(--grain-blur));
}

/* --- The set --------------------------------------------------------------
   Three layers under everything else, because they have to blend differently
   and one background cannot do that:
     .set         the surface (a photo, or nothing = the flat field colour)
     .set::before the light falling on it   — `screen`, adds light
     .set::after  the vignette              — normal alpha, subtracts light
   All fixed and pointer-events: none, so they never affect layout or clicks.
   -------------------------------------------------------------------------- */
.set {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  /* The darkening rides as a background layer rather than a CSS `filter`,
     because a filter on .set would also apply to .set::before and ::after —
     it would dim the light layer along with the surface, which is the exact
     opposite of what it is for. */
  background-image:
    linear-gradient(rgb(0 0 0 / var(--surface-darken)),
                    rgb(0 0 0 / var(--surface-darken))),
    var(--surface-tex);
  /* Per-layer: the darkening gradient always covers; the texture either covers
     (one photo stretched to the viewport) or TILES at its own size.

     Tiling is the answer to resolution. A stretched photo has to be as wide as
     the widest screen or it goes soft — a seamless texture repeats at its
     native pixel size, so it is pin-sharp on any screen from a small file. The
     catch is that it must genuinely be seamless, or the joins show as a grid. */
  background-size: cover, var(--surface-size);
  background-repeat: no-repeat, var(--surface-repeat);
  background-position: center center;
  /* The fade lets the surface be a full-bleed tabletop or a lit panel with the
     field falling away above and below it — one dial between the two. */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 var(--surface-fade),
                                      #000 calc(100% - var(--surface-fade)), transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000 var(--surface-fade),
                              #000 calc(100% - var(--surface-fade)), transparent 100%);
}

.set::before,
.set::after {
  content: "";
  position: absolute;
  inset: 0;
}

/* --- On banding ------------------------------------------------------------
   Both gradients below are eased with intermediate stops and interpolated
   `in oklab` rather than sRGB.

   Why: a two-stop gradient across a dark field is where banding is worst. The
   screen is 8-bit, so a fade from #000 to #0b0b0b has about eleven distinct
   values to work with across hundreds of pixels — the steps are unavoidable
   arithmetic, not a rendering fault. Two things reduce it and neither removes
   it: interpolating in a perceptually even space (oklab) spends those few
   levels where the eye actually looks, and extra stops stop the browser
   spreading one linear ramp across the widest possible distance.

   The real cure is DITHER, and on this page the film grain is it — it sits
   above these layers (z-index 1 vs 0) and breaks the steps up with noise.
   That is why the grain is load-bearing here rather than decoration: turning
   it to 0 will make the banding worse, not neutral.
   -------------------------------------------------------------------------- */
.set::before {
  opacity: var(--light);
  mix-blend-mode: screen;
  background: radial-gradient(var(--light-size) var(--light-size)
                              at var(--light-x) var(--light-y)
                              in oklab,
                              var(--light-tint) 0%,
                              color-mix(in oklab, var(--light-tint), transparent 45%)
                                calc(var(--light-feather) * 0.45),
                              color-mix(in oklab, var(--light-tint), transparent 80%)
                                calc(var(--light-feather) * 0.75),
                              transparent var(--light-feather));
}

.set::after {
  opacity: var(--vignette);
  background: radial-gradient(120% 90% at 50% 45% in oklab,
                              transparent 40%,
                              rgb(0 0 0 / 0.25) 62%,
                              rgb(0 0 0 / 0.55) 82%,
                              rgb(0 0 0 / 0.85) 100%);
}

/* The second grain pass — the one that crosses the art rather than stopping
   at it, so paper and field read as one photograph instead of a sticker on a
   background. Nested in .stage (z-index 2) at z-index 3, so it lands above
   the content. Only worth turning up once the art has real transparency. */
.stage::after {
  content: "";
  position: fixed;
  inset: calc(-2px - var(--grain-blur) * 3);
  z-index: 3;
  pointer-events: none;
  opacity: var(--grain-top);
  background-image: var(--grain-tex);
  background-size: var(--grain-size) var(--grain-size);
  filter: blur(var(--grain-blur));
}

/* The scrim. z-index: -1 keeps it inside .stage's stacking context but behind
   .stage's own content — so it darkens the surface under the text without
   dimming the text, the magazine, or the form. Two separate gradients so the
   top (wordmark) and bottom (copy + form) can be judged independently: they
   sit on quite different parts of the picture. */
.stage::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  /* Eased and in oklab for the same banding reasons as the light and vignette
     — a scrim is a long fade over a dark field, i.e. the worst case. */
  background:
    linear-gradient(to bottom in oklab,
                    rgb(0 0 0 / var(--scrim-top)) 0%,
                    rgb(0 0 0 / calc(var(--scrim-top) * 0.4)) calc(var(--scrim-height) * 0.5),
                    transparent var(--scrim-height)),
    linear-gradient(to top in oklab,
                    rgb(0 0 0 / var(--scrim-bottom)) 0%,
                    rgb(0 0 0 / calc(var(--scrim-bottom) * 0.4)) calc(var(--scrim-height) * 0.5),
                    transparent var(--scrim-height));
}

/* Full-height centered column, respecting notches / safe areas.
   position/z-index lift the content above the grain overlay (see body::after). */
.stage {
  position: relative;
  z-index: 2;
  /* A definite height, not just a minimum: the magazine's max-height: 100%
     has to resolve against something real. min-height alone leaves the column
     content-sized, which is what let the art overflow the viewport. */
  height: 100svh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap);
  padding: clamp(1.5rem, 4vh, 3rem)
           calc(var(--stage-pad-x) + env(safe-area-inset-right, 0px))
           calc(var(--stage-pad-bottom) + env(safe-area-inset-bottom, 0px))
           calc(var(--stage-pad-x) + env(safe-area-inset-left, 0px));
}

/* Bottom bleed: with the band running edge to edge, the stage's own bottom
   padding was the one gap left — a strip of field showing under the band.
   Zeroing it drops the band flush to the window bottom; the band keeps its
   own --bar-pad inside, so the text never touches the glass. The reclaimed
   space goes to the magazine (flex: 1 1 0), which is where it is wanted.
   Phones restore it in the 640px block — the bleed is off there, so a flush
   footer would only delete the margin that keeps the phone text off the edge. */
body.foot-bleed .stage { padding-bottom: 0; }

/* --- Wordmark -------------------------------------------------------------
   The wordmark can be dropped on desktop so the magazine gets its vertical
   room. `display: none` (not visibility/opacity) is deliberate: it takes the
   header out of the flex column entirely, so the row AND its --gap collapse
   and the magazine actually gains the space. Hiding it any other way would
   leave the hole behind.

   Exactly one of the image and the screen-reader span is ever exposed, which
   is why there are two tokens rather than one — see index.html.
   -------------------------------------------------------------------------- */
.masthead {
  flex: 0 0 auto;
  display: var(--wordmark-display);
}

.wordmark-sr { display: var(--wordmark-sr); }

.wordmark {
  display: block;
  height: clamp(22px, 3.4vh, 40px);
  width: auto;
}

/* --- Magazine -------------------------------------------------------------
   `flex-basis: 0`, not `auto`, and it matters. With `auto` the figure's height
   comes from its own content — the image — so the image's `max-height: 100%`
   resolves against a height the image itself defined. That is circular, so the
   browser ignores it and the art ends up sized by --maxw ALONE, with height
   never constraining it at all. Two consequences, both measured:
     · on a short screen the spread overflowed and pushed the signup form below
       the fold, despite the page claiming to be contained to the viewport;
     · freeing vertical space (e.g. hiding the wordmark) changed the art's size
       by exactly 0%, because height was never the binding constraint.
   With `flex-basis: 0` the figure's height is distributed by the flex column
   instead — definite — so `max-height: 100%` resolves and the art fits.
   -------------------------------------------------------------------------- */
.magazine {
  flex: 1 1 0;
  margin: 0;
  width: 100%;
  max-width: var(--maxw);
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 0; /* lets the image shrink inside the flex column */
}

/* The art used to carry a black surround baked in, which fought the grained
   background (a flat #000 rectangle against a lifted #0b0b0b page). That was
   briefly hidden with a CSS crop; the source files are now cropped for real, so
   the image is pure paper and no crop hack is needed. See CHANGELOG.md. */
/* The <picture> is the link in the chain that has to carry a real height too.
   Left at its default auto height it is content-sized by the image inside it,
   so the image's max-height: 100% resolves against a height the image itself
   set — circular again, and silently ignored. Giving picture height: 100% of
   the (now definite) figure is what finally makes the art fit its box. */
/* The <picture> is the full box and the image is contained inside it. An
   earlier version tried to make the picture box *equal* the art box with
   aspect-ratio + height:100%, so the sheen overlay could simply cover it.
   That breaks: as soon as the height-derived width exceeds max-width, the
   width is clamped, the ratio is violated, and the box stops matching the art
   — which is exactly the drift Andy caught, the overlay sitting proud of the
   spread at some sizes and not others.

   The overlay is aligned by `mask-size: contain` instead (see ::after below),
   which reproduces object-fit: contain's geometry for free at every size. */
.magazine picture {
  display: flex;               /* centres the art now that the art is smaller */
  align-items: center;
  justify-content: center;
  position: relative;
  width: 100%;
  height: 100%;                /* stays DEFINITE — the img's max-height needs it */
  min-height: 0;
}

/* The img box now IS the spread, not a box the spread is contained inside.
   -------------------------------------------------------------------------
   It used to be `width: 100% / height: 100% / object-fit: contain`, which made
   the element the size of the available space and drew the art somewhere
   inside it — so on a wide window the element measured 1250px against a spread
   that was really ~800px, and stretching the window moved the two apart.
   Andy caught it by inspecting the img: the highlight box stood well proud of
   the paper on both sides.

   `width/height: auto` + `max-width/max-height: 100%` is the fix. With both
   dimensions auto the image starts at its intrinsic size and the two max
   constraints shrink it *preserving the ratio* (CSS 2.1 §10.4), which is the
   same geometry `contain` was computing — except the box comes out equal to
   the art instead of around it, in BOTH regimes (height-bound and width-bound).
   No aspect-ratio anywhere: that is what drifted last time, because a clamped
   width silently violates it.

   The centring moved to the picture, because an auto-width block no longer
   fills the row it sits in. Nothing else needs to change: the shadows follow
   the paper's alpha, and the sheen mask is sized `contain` against this same
   box, so it lands identically whether the box hugs the art or not.
   -------------------------------------------------------------------------- */
.magazine img {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
}

/* --- Sheen: light landing on the printed page -----------------------------
   A moving highlight, masked so it falls on the paper or on the ink but not
   uniformly across both. That distinction is the point: printed ink and
   uncoated stock genuinely do not reflect alike — ink is usually the glossier
   — so a single highlight laid flat over the whole spread reads as a sticker
   of light rather than light on a printed page.

   The masks are derived from the art itself by dev-assets.py, so no extra maps
   from Photoshop are needed:
     mag-mask-ink.webp    highlight rides the printed areas
     mag-mask-paper.webp  highlight rides the stock, ink stays matte
     the art itself       highlight covers the whole silhouette

   `screen` blend because it ADDS light; the same reason the grain and the
   light pool use it. Ships at --sheen: 0, and the masks are only fetched when
   a mask is actually selected.
   -------------------------------------------------------------------------- */
.magazine picture::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: var(--sheen);
  mix-blend-mode: screen;
  background-image: linear-gradient(var(--sheen-angle),
                    transparent calc(50% - var(--sheen-width)),
                    var(--sheen-color) 50%,
                    transparent calc(50% + var(--sheen-width)));
  background-size: 300% 300%;
  background-position: var(--sheen-pos) 50%;
  background-repeat: no-repeat;
  /* `contain` + `center`, NOT `100% 100%`. The mask image and the artwork are
     the same picture, so scaling the mask with contain inside the same box
     reproduces object-fit: contain exactly — the mask lands on the art at
     every size, and follows it when --maxw or the viewport changes, with no
     geometry to keep in sync. Stretching it to 100% 100% only agreed with the
     art when the box happened to match the art's ratio. */
  -webkit-mask-image: var(--sheen-mask);
  mask-image: var(--sheen-mask);
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}

/* Contact shadow then cast shadow, chained. Both follow the paper's alpha, so
   they trace its real silhouette rather than a bounding box. Invisible while
   both opacities are 0, which is how this ships. */
.magazine img {
  filter: drop-shadow(0 var(--contact-y) var(--contact-blur)
                      rgb(var(--shadow-rgb) / var(--contact-opacity)))
          drop-shadow(var(--cast-x) var(--cast-y) var(--cast-blur)
                      rgb(var(--shadow-rgb) / var(--cast-opacity)));
}

/* --- Footer: copy + signup ------------------------------------------------
   Three layouts, switched by a class on <body>. The default (no class) is the
   centred stack this page shipped with.

   Why the layout matters beyond taste: the footer is a fixed block of vertical
   space in a column where the magazine gets whatever is left. The centred
   stack is ~216px tall on a laptop; laying the same content out horizontally
   returns most of that to the art. So this is a size lever as much as a
   composition choice.

     (default)   centred stack, 420px wide, locked to the middle
     .foot-split copy line left, capture block right, spanning --footw
     .foot-left  the centred stack, moved to the left edge of --footw

   ⚠️ --footw does NOT track the spread's rendered width, and cannot. Since the
   layout fix the art is usually HEIGHT-constrained, so its width depends on
   the viewport's height and is narrower than --maxw. No CSS value can follow
   that. --footw therefore defaults to --maxw and is a separate dial: set it by
   eye to sit under the spread, or leave it wide for a deliberately full-width
   footer. Do not assume the two edges line up — measured at 1440x900 the art
   was 896px wide against a --maxw of 1300px.

   Phones ignore all of it and stay a centred stack — see the media query at
   the end of this file.
   -------------------------------------------------------------------------- */
.foot {
  flex: 0 0 auto;
  width: 100%;
  max-width: 420px;
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.9rem;
}

/* No box of its own in the default layout, so the centred stack renders
   exactly as it did before this wrapper existed. */
.foot-capture { display: contents; }

/* --- The bar --------------------------------------------------------------
   Turned on with `has-bar` on <body>. Composes with any of the three layouts
   rather than being a fourth one. Inert until a padding and a colour or
   texture are set, so it costs nothing while off. */
body.has-bar .foot {
  background-color: var(--bar-bg);
  background-image:
    linear-gradient(rgb(0 0 0 / var(--bar-darken)), rgb(0 0 0 / var(--bar-darken))),
    var(--bar-tex);
  background-size: cover, var(--bar-size);
  background-repeat: no-repeat, var(--bar-repeat);
  background-position: center center;
  padding: var(--bar-pad);
  border-radius: var(--bar-radius);
  /* The contact shadow is what makes it read as a physical object lying on the
     surface rather than a coloured rectangle drawn on top of it. */
  box-shadow: 0 var(--bar-shadow-y) var(--bar-shadow-blur)
              rgb(var(--shadow-rgb) / var(--bar-shadow-opacity));
}

/* --- Split: copy left, capture right -------------------------------------- */
body.foot-split .foot {
  max-width: var(--footw);
  flex-direction: row;
  align-items: flex-end;         /* sit both blocks on the same baseline */
  justify-content: space-between;
  text-align: left;
  gap: clamp(1.5rem, 5vw, 4rem);
}

body.foot-split .line {
  flex: 1 1 auto;
  max-width: 24ch;               /* a measure, so it breaks like a pull-quote */
  text-align: left;
  font-size: clamp(1.05rem, 2vw, 1.5rem);
}

body.foot-split .foot-capture {
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
  flex: 0 1 420px;
  min-width: 0;
}

body.foot-split .consent { text-align: left; }

/* --- Row: everything on one horizontal band -------------------------------
   The lowest-profile option, and the one intended to be wrapped in the bar.
   The consent text sits BESIDE the email + button rather than under it, and
   the two are height-matched by `align-items: center` plus a measure that
   lands the consent on about three lines — which is roughly the height of the
   input itself, so the two read as one band rather than two rows.

   `flex-wrap: wrap` is not decoration: four items in a row need somewhere to
   go on a narrow desktop window, and wrapping degrades to two tidy rows
   instead of overflowing. The phone breakpoint collapses it fully.
   -------------------------------------------------------------------------- */
body.foot-row .foot {
  position: relative;
  max-width: var(--footw);
  flex-direction: row;
  /* Top-align, not centre. The three columns are different heights (copy ~2
     lines, the input 1 line, the consent ~3 lines); centring floats the short
     input in the middle of the tallest column and reads as misaligned. Anchoring
     them all to the same top line reads as a Swiss grid — the fix for the wide
     band feeling "taller" without growing the input (Andy, 2026-08-14). */
  align-items: flex-start;
  /* nowrap, not wrap. The old fallback let the band wrap when it ran out of
     room, which dropped the form+consent onto a second row still jammed to the
     RIGHT — a dead gap under the copy, and the transition Andy called out. It
     is replaced by a real breakpoint (the --stack-at media query below): above
     it the horizontal band; at/below it a clean left-aligned column. Between
     the comfortable width and that break the items shrink rather than wrap,
     which is why min-width:0 is on the flex children. */
  flex-wrap: nowrap;
  justify-content: space-between;
  text-align: left;
  gap: clamp(0.75rem, 2vw, 1.75rem);
}

/* Flips which side the copy line sits on. */
body.foot-row.foot-reverse .foot { flex-direction: row-reverse; }

/* 16ch, not 20ch. With flex-wrap on, items wrap when their BASE sizes overflow
   — they do not shrink first — so the three bases plus gaps have to fit inside
   --footw or the band silently becomes two rows and the height saving is lost.
   At 1100px the budget is: copy 200 + form 300 + consent 430 + gaps ≈ 1014. */
body.foot-row .line {
  flex: 0 1 auto;
  /* 42ch, widened from 30ch when the copy got its deliberate <br> (2026-08-14):
     the measure now has to hold the LONGER of the two sentences ("Sign up to be
     notified when it's ready.", ~37ch at this font) on one line, or the <br>
     would only split the sentences and then the second one would wrap again and
     re-orphan its last word. 42ch fits it with a little slack; the band still
     holds one row at --footw 1600 (copy ~400px + capture ~790px + gaps well
     inside the ~1277px band at the 1360px break). If the copy changes, re-check
     against the longer sentence. */
  max-width: 42ch;
  margin: 0;
  text-align: left;
  font-size: clamp(0.95rem, 1.5vw, 1.2rem);
}

body.foot-row .foot-capture {
  display: flex;
  flex-direction: row;
  align-items: stretch;
  flex: 1 1 auto;
  justify-content: flex-end;
  gap: clamp(0.75rem, 1.5vw, 1.5rem);
  min-width: 0;
}

/* align-items: stretch is what makes the email + button box grow to the SAME
   height as the consent block, so email · Notify · consent read as one even
   strip (Andy, 2026-08-14). The consent is the tallest (its sentence + the
   "No spam" line = ~3 lines); the email field stretches up to meet it. When the
   error message opens, the email field's own content grows past the consent and
   the consent stretches to match instead — so the two stay equal height in every
   state and the error never breaks the alignment, it only makes the whole strip
   a little taller. The box restores centre alignment in the 920px block. */
body.foot-row .signup-form {
  flex-direction: row;
  align-items: stretch;
  width: auto;
  flex: 1 1 auto;
  justify-content: flex-end;
  gap: clamp(0.75rem, 1.5vw, 1.5rem);
  min-width: 0;
}

/* The email area is now the .email-field wrapper (status stacked over the
   input), so it carries the flex basis that .signup used to. .signup fills it. */
body.foot-row .email-field {
  flex: 0 1 300px;
  min-width: 0;
}

/* Sized to land the consent on THREE lines, which is what makes it the same
   height as the input beside it (3 x 1.4 x 0.72rem ≈ 48px against a 45px
   field). At the 306px it got first it ran to four lines and stood 19px
   taller, which is exactly the mismatch that makes a row look like a mistake
   rather than a band. Measured, not guessed.

   The block now carries the fine print too, so the three-line target needs a
   wider measure than it did (see --consent-w). That is the cost of merging the
   two, and it is why the bleed exists: three lines of the longer text want
   ~480px, and 200 + 300 + 480 + gaps stops fitting a 1100px band — measured at
   1440px, the row wrapped at --footw 1100 and was a single band at 1300. */
body.foot-row .consent {
  flex: 0 1 var(--consent-w);
  min-width: 0;
  font-size: var(--fine-size);
  line-height: 1.4;
  text-align: left;
}

/* The status message is no longer positioned against the band. It now lives in
   .email-field, stacked directly ABOVE the email input, so it appears right over
   the field in every layout — beside the copy in the wide band, under the copy
   in the copy-on-top range, and inside the box on tablet/phone. .email-field
   collapses its row to zero until there is a message and grows to fit it, so the
   band or box only gets taller when there is actually something to say. See
   `.email-field` and `.status` further down; nothing needs to be positioned
   against --foot-inset any more. */

/* --- Left: the same stack, off the centre line ---------------------------- */
body.foot-left .foot {
  max-width: var(--footw);
  align-items: flex-start;
  text-align: left;
}

body.foot-left .foot-capture {
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
  width: 100%;
  max-width: 420px;
}

/* --- Full-bleed band ------------------------------------------------------
   `foot-bleed` on <body>. Runs the footer to both window edges instead of
   stopping at --footw. Composes with any of the layouts and with the bar,
   exactly like `has-bar` does — though it is only worth turning on WITH the
   bar, since a band you cannot see has no edges to run to.

   Two separate widths, and keeping them apart is the whole point:
     · the BAND goes edge to edge — negative margins that cancel the stage's
       own padding, so the paint reaches the window;
     · the CONTENT stays at --footw — via padding, so --footw remains the one
       dial for "how wide does the text sit", bleeding or not. Turning the
       bleed on therefore never moves the copy, and turning it off is a real
       fallback rather than a different design.

   Why negative margins and not `width: 100vw`: 100vw includes the scrollbar
   gutter, so on any window that has one the band would overhang and create a
   horizontal scrollbar of its own. Cancelling the padding we set ourselves is
   exact. `width: auto` + `align-self: stretch` is what lets those margins
   WIDEN the box — against the inherited `width: 100%` they would only shift it
   sideways, which looks like a bug and measures like one.

   Must stay AFTER the three layout blocks: same specificity as
   `body.foot-row .foot`, so `max-width: none` only wins on source order. Same
   reason its `padding-inline` beats the `padding` shorthand in
   `body.has-bar .foot` — and only the inline sides, so vertical padding is
   still --bar-pad, which is what we want.

   Phones opt out entirely; see the 640px media query.
   -------------------------------------------------------------------------- */
body.foot-bleed .foot {
  /* max() so a --footw wider than the window degrades to the bar's own padding
     instead of asking for a negative inset. */
  --foot-inset: max(var(--bar-pad),
                    calc((100% - var(--footw)) / 2 + var(--stage-pad-x)));
  align-self: stretch;
  width: auto;
  max-width: none;
  margin-left: calc(-1 * (var(--stage-pad-x) + env(safe-area-inset-left, 0px)));
  margin-right: calc(-1 * (var(--stage-pad-x) + env(safe-area-inset-right, 0px)));
  padding-inline: var(--foot-inset);
  /* The band now runs to the window bottom (see body.foot-bleed .stage), so its
     bottom padding also has to clear any safe-area inset itself — the stage no
     longer does. Top stays --bar-pad, inherited from has-bar. */
  padding-bottom: calc(var(--bar-pad) + env(safe-area-inset-bottom, 0px));
  /* A band that touches every edge has no corners left to round. */
  border-radius: 0;
}


/* --- Row break: the copy lifts ABOVE the capture, capture stays horizontal ---
   When the band can no longer hold copy · email · Notify · consent on one line,
   it does NOT wrap-and-jam right (see the flex-wrap note above). Only the COPY
   moves — up onto its own line — while the capture stays a single horizontal
   row, packed to the band's outer (left) edge:

       We're building and preparing…                              (copy, on top)

       [ email ........ ] [ Notify me ]  ☐ I agree that SENS…      (capture row)

   Andy's refinement (2026-08-14): keep email · Notify · consent on ONE line with
   EVEN gaps — the notify↔consent gap matched to the email↔notify gap (both
   0.5rem, the gap he liked) — so the three read as evenly spaced, and let the
   copy sit above with air between. The horizontal capture is much shorter than a
   stacked one, so the band reads less vertically congested. The email + button
   stay side by side until the phone breakpoint (640px), where the whole thing
   collapses to the centred stack.

   1360px is where the single-line band (copy beside capture) stops being
   comfortable at --footw 1600 — above it the copy sits left of the capture,
   below it the copy lifts on top. A viewport query, not a container query,
   because in bleed mode the band IS the window width, so the two are identical
   here — and it stays consistent with the rest of the file. If --footw is
   lowered a lot, lower this with it. Must sit BEFORE the 640px phone block so
   the phone's centred stack still wins on the narrowest screens.
   -------------------------------------------------------------------------- */
@media (max-width: 1360px) {
  /* Copy on top, everything left-aligned to the outer edge. The gap between the
     copy and the capture row is opened up here (from the band's ~1.75rem to a
     taller, viewport-scaled clamp) so the copy region breathes — Andy's note
     2026-08-14: the top padding above the copy is a good size, the room to grow
     is the middle. Band grows taller as a result, which is intended. */
  body.foot-row .foot {
    flex-direction: column;
    align-items: flex-start;
    justify-content: flex-start;
    text-align: left;
    gap: clamp(1.5rem, 4.5vh, 3rem);
  }
  /* Full width available now, so the copy can breathe past its 30ch band
     measure — but keep a measure so it stays a two/three-line block, not one
     ruler-wide line. */
  body.foot-row .line { max-width: 46ch; }
  /* The capture drops below the copy and packs LEFT (was flex-end / right).
     flex: 0 0 auto so it does not grow to fill the column's vertical space. */
  body.foot-row .foot-capture {
    flex: 0 0 auto;
    width: 100%;
    min-width: 0;
    justify-content: flex-start;
  }
  /* Still a horizontal row — email + Notify + consent inline — but left-packed
     and with the notify↔consent gap matched to the email↔notify gap (.signup's
     0.5rem) so the three are evenly spaced. flex: 0 1 auto + min-width:0 lets
     the row SHRINK to fit on the narrowest desktop windows (720–900px) before
     the phone break, rather than overflowing into a horizontal scrollbar — the
     consent (flex: 0 1 --consent-w) gives width first and wraps to more lines,
     then the input narrows. Below 640px the phone block takes over entirely. */
  body.foot-row .signup-form {
    flex: 0 1 auto;
    width: auto;
    min-width: 0;
    justify-content: flex-start;
    gap: 0.5rem;
  }
}

.line {
  margin: 0;
  font-weight: 300;
  font-size: clamp(1rem, 2.6vw, 1.25rem);
  letter-spacing: 0.02em;
  /* Backstop for the deliberate <br> in the copy (see index.html): if a line
     measure is ever narrower than the second sentence, this stops the wrap from
     leaving a single word alone on the last line. */
  text-wrap: pretty;
}

.signup {
  display: flex;
  width: 100%;
  gap: 0.5rem;
}

.signup input {
  flex: 1 1 auto;
  min-width: 0;
  background: transparent;
  border: 1px solid color-mix(in srgb, var(--ink) 28%, transparent);
  color: var(--ink);
  font: inherit;
  padding: 0.7rem 0.9rem;
  border-radius: 2px;
  transition: border-color 160ms ease;
}
.signup input::placeholder { color: var(--muted); }
.signup input:focus {
  outline: none;
  border-color: var(--accent);
}

.signup button {
  flex: 0 0 auto;
  background: var(--accent);
  color: var(--on-accent);
  font: inherit;
  font-weight: 500;
  border: 0;
  padding: 0.7rem 1.1rem;
  border-radius: 2px;
  cursor: pointer;
  transition: opacity 160ms ease, transform 160ms ease;
}
.signup button:hover { opacity: 0.9; }
.signup button:active { transform: translateY(1px); }
.signup button:disabled { opacity: 0.5; cursor: default; }

.signup-form {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
}

/* Honeypot: off-screen, never announced, never focusable. Not display:none —
   some bots skip hidden fields, and we want them to fill this one in. */
.hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.consent {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  text-align: left;
  font-size: 0.78rem;
  line-height: 1.45;
  color: var(--muted);
  cursor: pointer;
}

.consent input {
  flex: 0 0 auto;
  margin: 0.15rem 0 0;
  accent-color: var(--accent);
  cursor: pointer;
}

/* Everything the checkbox is not. One text flow, so the consent sentence and
   the fine print wrap into each other rather than sitting in two boxes.
   min-width: 0 because it is a flex item and long text otherwise refuses to
   shrink below its longest word's line. */
.consent-copy {
  flex: 1 1 auto;
  min-width: 0;
}

/* The "|" divider stays hidden: the fine print sits on its OWN line under the
   consent sentence in every layout (Andy likes it that way), and the email +
   button grow to match this block's height instead — see align-items: stretch
   on the foot-row capture. A spaced "|" only makes sense between two things on
   one line, so leading/trailing a line it just looks like a stray mark. Kept in
   the markup (aria-hidden) so nothing else has to change. */
.consent-sep {
  display: none;
}

/* The non-consent half, on its own line under the consent sentence. Its own
   element only so the consent span stays exactly the sentence that gets recorded
   (never fold this into #consent-text). */
.fine {
  display: block;
  margin-top: 0.35em;
  color: var(--muted);
}

/* The email field and its message, stacked. The status is the FIRST grid row so
   it sits ABOVE the input; that row is collapsed to 0fr until a message shows,
   then animates open to 1fr — which is what makes the band/box grow only when
   there is something to say, and gives the message its slide-open/close. The
   input row stays `auto`. (0fr→1fr in an auto-height grid resolves to the
   content height — the standard collapse trick; .status carries overflow:hidden
   so it clips cleanly while collapsed.) */
.email-field {
  position: relative;   /* anchor for the absolutely-placed status */
  display: flex;
  flex-direction: column;
  min-width: 0;
}
/* The input fills the field, so when the field is stretched to the consent's
   height in the desktop band (align-items: stretch on .signup-form) the input
   grows to that full height instead of sitting short at the top. */
.email-field .signup {
  flex: 1 1 auto;
}

/* The message is positioned ABSOLUTELY above the input, so it NEVER takes part
   in the strip's layout — the email · Notify · consent band keeps its exact
   even height whether or not the message is showing (Andy, 2026-08-14: the error
   must not break the alignment, only pop above the email). It slides up + fades
   in on .show and slides down + fades out when cleared. Anchored to the field's
   top edge (bottom: 100%) so it sits directly over the input in every layout.
   In the ≤920px box it becomes in-flow instead (see the box block) so the box
   grows to fit it rather than overlapping the copy above. */
.status {
  position: absolute;
  bottom: 100%;
  left: 0;
  right: 0;
  margin-bottom: 0.3rem;
  min-height: 0;
  font-size: 0.9rem;
  color: var(--accent);
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.28s ease, transform 0.28s ease;
  pointer-events: none;
}
.status.show {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}

/* --- Legal dialog ---------------------------------------------------------
   Native <dialog>: opens over the page, closes back to it. ::backdrop dims the
   page behind. No library involved. */
.legal-link {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: var(--ink);
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}
.legal-link:hover { color: var(--accent); }

.legal-dialog {
  border: 1px solid color-mix(in srgb, var(--ink) 20%, transparent);
  border-radius: 3px;
  background: var(--bg);
  color: var(--ink);
  padding: 0;
  max-width: 46ch;
  width: calc(100% - 2rem);
  max-height: 82svh;
  overflow: auto;
}

.legal-dialog::backdrop {
  background: rgb(0 0 0 / 0.72);
}

.legal-inner {
  padding: clamp(1.25rem, 4vw, 2rem);
  font-size: 0.82rem;
  line-height: 1.6;
  text-align: left;
}

.legal-inner h2 {
  margin: 0 0 1rem;
  font-size: 1rem;
  font-weight: 500;
  letter-spacing: 0.01em;
}

.legal-inner p { margin: 0 0 0.85rem; color: var(--muted); }
.legal-inner strong { color: var(--ink); font-weight: 500; }
.legal-inner a { color: var(--accent); }

.legal-close {
  margin-top: 0.5rem;
  background: var(--accent);
  color: var(--on-accent);
  font: inherit;
  font-weight: 500;
  border: 0;
  padding: 0.55rem 1.1rem;
  border-radius: 2px;
  cursor: pointer;
}
.legal-close:hover { opacity: 0.9; }

/* .privacy is gone on purpose — the fine print it held now lives inside the
   consent block as .fine, on its own line under the consent sentence (the "|"
   divider is hidden; see .consent-sep). Do not reintroduce .privacy as a
   separate paragraph, or the page ends up saying the same thing in two places. */

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Narrow screens: stack the signup so the button isn't cramped. */
@media (max-width: 460px) {
  .signup { flex-direction: column; }
  .signup button { width: 100%; }
}

/* Tablet + phone keep the wordmark, whatever the desktop setting is. Once the
   footer drops to the centred box (≤920px, the tablet/phone layout), the brand
   mark should be back at the top — so this matches that 920px cutover (Andy,
   2026-08-14), not just true phones. Delete this block to let the desktop toggle
   apply everywhere. */
@media (max-width: 920px) {
  .masthead { display: block; }
  .wordmark-sr { display: none; }
}

/* Tablet + phone: drop to the centred box under the magazine. Raised from 640
   to 920px (Andy, 2026-08-14) to SKIP the squeezed intermediate state — between
   ~640 and ~910px the horizontal capture row can no longer hold the consent at
   its ~3-line measure beside the email+button, so the consent shrank and towered
   over the input (measured: consent 480px / 3 lines at 880px → 434px / 4 lines /
   68px at 820px, against a 45px input). Rather than degrade through that, the
   footer jumps straight to the centred, non-bleed box it uses on phones. 920px
   so the cutover lands just above the squeeze even at the baked --bar-pad of
   42px (the row needs ~826px of content + 2×42px padding ≈ 910px of window).
   The wordmark is deliberately NOT forced on here — that stays the ≤640 phone
   concern above; on tablets it follows the desktop setting. */
@media (max-width: 920px) {
  /* Footer layouts are a DESKTOP decision. Below here there is no room to put
     the copy beside the form or the consent beside the input, so every layout
     collapses to the centred box rather than degrading into a cramped row. */
  body.foot-split .foot,
  body.foot-left .foot,
  body.foot-row .foot,
  body.foot-row.foot-reverse .foot {
    max-width: 420px;
    flex-direction: column;
    flex-wrap: nowrap;
    align-items: center;
    text-align: center;
    gap: 0.9rem;
  }
  body.foot-split .line,
  body.foot-left .line,
  body.foot-row .line { max-width: none; text-align: center; font-size: clamp(1rem, 2.6vw, 1.25rem); }
  body.foot-split .foot-capture,
  body.foot-left .foot-capture,
  body.foot-row .foot-capture { display: contents; }
  body.foot-row .signup-form { flex-direction: column; width: 100%; align-items: center; }
  body.foot-row .email-field { flex: none; width: 100%; }
  body.foot-row .signup { flex: none; width: 100%; }
  body.foot-row .consent { flex: none; font-size: 0.78rem; }
  /* In the stacked box the message goes back IN-FLOW above the input (not
     absolute), so the box simply grows to fit it instead of the floating version
     overlapping the copy above. Centred to match the box. Margin only when shown
     so the hidden empty line reserves nothing. */
  body.foot-row .status {
    position: static;
    transform: none;
    margin: 0;
    text-align: center;
  }
  body.foot-row .status.show { margin-bottom: 0.35rem; }
  body.foot-split .consent { text-align: left; }

  /* The full-bleed band is a desktop decision too, and the same one in
     reverse: a phone footer is already the full width of the screen, so
     bleeding it would only delete the margins that keep the text off the
     glass. Andy signed off on the phone layout as it stands — this keeps the
     bleed from touching it. */
  body.foot-bleed .foot {
    --foot-inset: var(--bar-pad);
    align-self: center;
    width: 100%;
    max-width: 420px;    /* the phone stack's width, same as every layout here */
    margin-inline: 0;
    padding: var(--bar-pad);
    border-radius: var(--bar-radius);
  }
  /* Bleed is off on phones, so the flush-to-bottom trick is too: restore the
     stage's bottom padding so the centred phone footer keeps its margin off
     the glass (Andy signed off on the phone layout as it stands). */
  body.foot-bleed .stage {
    padding-bottom: calc(var(--stage-pad-bottom) + env(safe-area-inset-bottom, 0px));
  }
}

@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; }
}
