/* Page layout: reset, ground colours, the header/footer/main skeleton.
 *
 * Colour comes only from custom properties emitted by render_tokens_css
 * (assets/tokens.css) -- --board, --gold, --gold-text, --page/--band/
 * --band-deep, --ink/--ink-dim, --text, etc. Never a hex literal here: a
 * re-tone in pipeline/cover.py or web/context.py must move this stylesheet
 * too, and a literal would be the second copy that drifts.
 *
 * ART DIRECTION (spec 2026-08-31-storefront-rebuild-design.md, S13/S14,
 * 4A.4 -- RETONE, second pass): the first pass (R19, superseded) put the
 * page's own ground on a warm near-black stack. Bilal rejected the
 * rendered result outright: "everything is black and the website doesn't
 * look professionally done" -- and correctly named the root cause: a black
 * cover does not imply a black website. This pass measures Aesop instead:
 * their page is NEVER white, it is cream, and that single choice makes a
 * black-and-gold book out-darken every UI element by construction, with no
 * discipline required. The page's own ground is `--page` (the covers' own
 * `--page`/GROUND cream, #f5efe2 -- the two collapse into one value on
 * purpose). `--board` stays reserved for whatever DEPICTS the physical
 * book or wants to read as its spine.
 *
 * FIX ROUND 2, CRITICAL 3 (2026-08-31): that used to be the header, footer
 * AND sticky buy bar. The header and stickybar are OFF `--board` now --
 * measured, a solid-black bar at the top of every page (above the hero, on
 * every page load) read as the second-darkest thing on screen, fighting
 * the covers for the darkest pixel instead of ceding it, exactly the
 * failure 4A.1's whole mechanism exists to avoid. Both moved onto `--page`
 * with a hairline rule for separation instead (components.css for the
 * stickybar). `--board` now depicts the spine in exactly one place: the
 * footer, a single dark bookend at the FOOT of the page, which does not
 * compete with the covers the way a full-bleed top bar did. `--gold-text`
 * is proven legible (>=4.5:1) on `--board` only (10-11:1) -- it is NOT
 * legal on the light grounds (~1.65:1), which is why this file and
 * components.css use `--gold-label`/`--ink` there instead --
 * tests/test_art_direction.py.
 *
 * RULING R20 (fix round 1, still true) + RETONE 2026-08-31: reading text on
 * the ONE dark band (`--board`) is `--text`/`--text-dim` -- never
 * `--body`/`--body-dim`, the covers' own muted chrome tone, banned from
 * every site stylesheet for the reason recorded in
 * test_site_stylesheets_never_reach_for_the_covers_chrome_tones. Reading
 * text everywhere else -- the page and every light card -- is
 * `--ink`/`--ink-dim`.
 */

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--page);
  color: var(--ink);
  font-family: var(--t-body);
  font-size: var(--t-body-size);
  line-height: var(--leading-body);
  /* The page ground is light cream now, so the default focus ring is the
     darker label gold, legible there -- header/footer/drawer/stickybar
     override this back to --gold-text where they sit on --board. */
  --focus-color: var(--gold-label);
}

:focus-visible {
  outline: 2px solid var(--focus-color);
  outline-offset: 2px;
}

h1, h2, h3 {
  font-family: var(--t-display);
  font-weight: 400;
  margin: 0 0 var(--s-4);
  /* Large glyphs carry excess counter-space (4A.6). */
  letter-spacing: var(--track-display);
  /* THE 375PX FIX: an unbreakable display-scale word (the wordmark's
     "HANDWRITTEN" alone) is wider than a 375px viewport's .inner at
     --t-d-4/--t-d-5 sizes. Normal word-wrap only breaks BETWEEN words, so
     the word pushed the box 12px past the viewport edge on every page that
     renders it -- confirmed by measuring document.documentElement.scrollWidth
     (387) against window.innerWidth (375) in a headless build, then walking
     every element's getBoundingClientRect() until the one whose own
     scrollWidth (371) exceeded its clientWidth (343) turned up: the home
     page's <h1 class="display">. overflow-wrap lets a word break only when
     it has nowhere else to go, instead of forcing the box past the
     viewport. tests/test_art_direction.py holds this at 375px on every
     built page, not just the one that surfaced it. */
  overflow-wrap: break-word;
}

.display { font-size: var(--t-d-heading); line-height: 1.15; }
.display--sm { font-size: var(--t-d-title); line-height: 1.2; }

/* Container: max-width 1200px, a fixed 15px inner padding -> 1170px of
 * content (spec 4A.3). The page margin (135px at 1440, 15px at 390) falls
 * out of this one rule with no separate breakpoint -- see the --page-pad
 * comment in tokens.base.css. */
.inner {
  max-width: var(--w-page);
  margin-inline: auto;
  padding-inline: var(--page-pad);
}

/* RETONE 2026-08-31: the skip link is the FIRST thing a keyboard user
 * meets. --page/--ink is exactly right here -- it is the site's own
 * brightest surface and darkest ink, so it is never anything other than
 * legible, on either ground it might sit over.
 *
 * FIX ROUND 2, CRITICAL 3: it used to pop up over a solid-black header
 * (--board), where a cream box read as unmistakably brighter than what was
 * behind it. The header is --page now too (same fix), so that contrast-
 * against-the-header cue is gone -- both are the same cream. A 2px --ink
 * border (up from 1px --rule-strong) plus the default focus ring
 * (2px --gold-label, offset) is what still marks the box out as its own
 * element rather than blending into the header's own hairline bottom
 * edge. tests/test_art_direction.py checks the declared fg/bg pair clears
 * AA -- that half of the requirement is unchanged by any of this. */
.skip {
  position: absolute;
  left: -999px;
  top: 0;
  background: var(--page);
  color: var(--ink);
  border: 2px solid var(--ink);
  padding: var(--s-2) var(--s-4);
  z-index: 100;
}
.skip:focus { left: var(--s-2); top: var(--s-2); }

main { display: block; }

/* Section padding is 0 (spec 4A.3) -- a section's own height comes only
 * from its content, so heights are free to vary. Rhythm comes from the
 * CONSTANT gap between sections instead (--gap-section, 100px desktop /
 * 80px mobile), applied as margin so it never doubles up with padding. A
 * hairline sits at the trailing edge of that gap, never a shadow (4A.4). */
.band { padding-block: 0; }
.band + .band {
  margin-top: var(--gap-section);
  border-top: 1px solid var(--rule);
}

/* The Task 4 placeholder body every page still under construction shares --
 * Tasks 6-9 replace this with the real page. */
.placeholder { padding-block: var(--s-16); text-align: center; }

/* ---- header ------------------------------------------------------------
 * FIX ROUND 2, CRITICAL 3 (2026-08-31): the header used to sit on --board,
 * the SAME solid-black-bar treatment 4A.1's whole mechanism argues
 * against -- it read as the second-darkest thing on screen, fighting the
 * covers instead of ceding the darkest pixel to them. Moved onto --page
 * with a hairline bottom edge (Aesop's own treatment: chrome sits ON the
 * page ground, separated by a rule, not by a change of ground). --board
 * stays reserved for the ONE dark band that remains -- the footer -- which
 * this fix does not touch (not named in the fix brief, and a single dark
 * bookend at the foot of the page does not compete with the covers the way
 * a full-bleed bar at the TOP of every page, above the hero, did). */
/* THE MASTHEAD IS A PLATE, ON EVERY ROUTE. Chosen by Bilal on 2026-09-08 from
 * an impeccable live round on the home page, and taken site-wide here because
 * the alternative was two marks: the hero opening at 22px while every interior
 * route sat at 14px, which is exactly the split
 * test_the_brand_mark_is_the_same_mark_on_every_route was written to close.
 *
 * At the top of a page there is no bar -- the mark sits on the room. Once the
 * page moves, a brass-edged plate slides over it and carries the nav. The
 * wordmark is gold leaf with a light crossing it, continuously.
 *
 * `is-stuck` is added by assets/js/header-plate.js. Everything it drives is a
 * TRANSITION FROM A LEGIBLE RESTING STATE, never a reveal from nothing, so a
 * visitor with no JavaScript keeps the open masthead rather than an empty bar.
 */
.site-header {
  position: sticky;
  top: 0;
  z-index: 60;
  background: transparent;
  color: var(--ink);
  border-bottom: 0;
  transition: background 380ms ease, border-color 380ms ease;
}
.site-header .inner {
  position: relative;
  display: flex;
  align-items: center;
  /* WRAPS AT EVERY WIDTH, not just under 640px, and the reason is arithmetic
     rather than taste. The brand block (wordmark plus tagline) measures 780px
     and the five-link nav needs 370px on one row; `.inner` caps at 1200px with
     15px of padding, so the content box is 1170 and the two together want
     1150 plus this gap. Twenty pixels of slack is not a layout, it is a
     coincidence -- and it ran out at 1368px, where "Privacy" dropped onto a
     second line beside the tagline and the header read as broken.

     With the row allowed to wrap, the nav either sits beside the brand or
     takes a row of its own, and `margin-inline-start: auto` keeps it aligned
     right in both cases. No breakpoint decides it; the content does, which is
     what makes it hold when a label changes or a sixth link arrives. */
  flex-wrap: wrap;
  gap: var(--s-6);
  /* THE OPEN STATE COSTS FOLD SPACE, AND FOLD SPACE IS NOT FREE. At --s-6 the
     masthead stood 70px against the old bar's ~44px, and the 26px it took
     pushed the product page's buy control to y=825 against an 812 fold, and
     squeezed the home hero's book to 16px side margins against a 19px floor --
     the four-edge bleed this rebuild was scrapped for once. --s-4 keeps the
     open state visibly more generous than the stuck one (16px against 12px)
     without spending a viewport on it. */
  padding-block: var(--s-4);
  transition: padding-block 380ms cubic-bezier(.22,.61,.36,1);
}
.site-header.is-stuck {
  /* NO DROP SHADOW. The plate wanted one and DESIGN.md forbids it: shadows on
     this site depict a real object -- the book's cast, a photographed cover --
     and never elevate a piece of UI. A masthead is UI. The brass edge below is
     the separator, which is the hairline the same rule prescribes. Caught by
     test_no_box_shadow_is_used_for_separation_on_dark, which was right. */
  background: linear-gradient(180deg, #14141A, #0C0C10);
}
.site-header.is-stuck .inner { padding-block: var(--s-3); }
.site-header__edge {
  position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
  background: linear-gradient(90deg, rgba(196,162,76,0), var(--gold) 22%,
    var(--gold-hi, #F0DFA6) 50%, var(--gold) 78%, rgba(196,162,76,0));
  opacity: 0; transition: opacity 380ms ease;
}
.site-header.is-stuck .site-header__edge { opacity: 1; }
/* THE TAGLINE SITS UNDER THE WORDMARK, not beside it, and the reason is that
   beside it does not fit and never could. Measured on /faq/ 2026-09-13, with
   the five-link nav: the rail caps `.inner` at 1200px, so the content box is
   1170 at every width above it. Wordmark 600 + tagline 191 + gap 24 + nav 370
   comes to 1185. Fifteen pixels over, permanently -- there is no viewport wide
   enough to fix it, because the constraint is the rail and not the screen.

   So the row wrapped, and the nav landed right-aligned on a line of its own
   under a left-aligned wordmark. Bilal, correctly: "why are they below the rest
   of the nav bar? looks horrible."

   Stacked, the brand is 600 wide instead of 791 and the whole header needs 994
   against 1170 -- 176px of headroom rather than a 15px deficit. The tagline
   keeps its job (it is the only place "written out by hand" reaches someone who
   arrived on a policy page from a search result) and the nav gets its row back.
   The header grows by one short line, which these routes can afford: the fold
   pressure the wordmark's own note describes is the home hero's, and the home
   page does not use this header. */
.site-header__brand {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  min-width: 0;
}
.site-header .wordmark {
  font-family: var(--t-display);
  /* ONE MARK ON EVERY ROUTE. This was 24px, -0.015em and white while `/`
     drew the same string at 14px, +0.15em and gold -- two marks, and a
     visitor moving from the hero to /about/ watched the masthead change
     identity. Held by tests/test_site_home.py::
       test_the_brand_mark_is_the_same_mark_on_every_route.

     The white was a CREAM-ERA FOSSIL. The comment that stood here read
     "dark ink, not gold -- --gold-text fails AA outright on the new light
     ground (~1.65:1)" and cited
     test_gold_text_fails_on_the_light_grounds_so_gold_label_is_not_decorative.
     The light ground was replaced by the dark retone, that test no longer
     exists anywhere in tests/, and gold on the ground actually shipping
     measures 7.91:1 -- comfortably AA, and the same #C4A24C the hero has
     been using all along. CLAUDE.md rule 8: a confident comment is not
     evidence, it is a review-stopper; re-derive it. */
  /* Clamped, not picked. At 22px with .3em tracking the mark measures 432px,
     which runs 62px off a 390px phone -- measured. It opens where there is
     room and sits at 14px where there is not. */
  /* Raised 22px -> 30px and 400 -> 600 on 2026-09-12 ("seems too hidden
     away"). The reasoning, the measurements and the trap are written out once,
     beside the home page's copy of this rule, in vitrine.html.j2's
     `.mast--plate .mark`. The two must move together or
     test_the_brand_mark_is_the_same_mark_on_every_route fails, by design. */
  font-size: clamp(14px, 3.6vw, var(--t-d-heading));
  letter-spacing: clamp(.15em, .6vw, .3em);
  font-weight: 600;
  /* Contains the descender the gold is clipped to -- see the home page's
     copy of this rule for the measurement. */
  line-height: 1.2;
  text-decoration: none;
  white-space: nowrap;
  transition: font-size 380ms ease, letter-spacing 380ms ease;
  /* Gold leaf with a light crossing it. The numbers are measured: a first
     version ran a 300%-wide gradient with a 6% bright core -- gold travelling
     over gold -- and moved the mark's mean ink 6.3 levels of 255 across a whole
     sweep, which is to say invisibly. Widening the band to 30%, narrowing the
     gradient to 200% and dropping the base to --gold-foot took it to 29.4. */
  background: linear-gradient(100deg,
    var(--gold) 0%, var(--gold) 24%,
    #D8BA6A 38%, var(--gold-hi, #F0DFA6) 48%, #FFF6DC 52%,
    var(--gold-hi, #F0DFA6) 56%, #D8BA6A 66%,
    var(--gold) 80%, var(--gold) 100%);
  background-size: 200% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: mastGleam 6s linear infinite;
}
.site-header.is-stuck .wordmark {
  /* CLAMPED, not the flat token. `.mast--plate.is-stuck .mark` scores 0,3,0
     and the phone rule that is meant to hold the mark down is a bare `.mark`
     inside a media query -- 0,1,0. A media query adds no specificity, so the
     flat 24px won at every width and put the desktop size on a 390px
     masthead once the plate came down. Measured, not reasoned about: the
     phone arm of test_the_page_keeps_room_on_every_side_all_the_way_down
     went red on exactly that. */
  font-size: clamp(14px, 3.2vw, var(--t-d-title));
  letter-spacing: .15em;
}
@keyframes mastGleam { from { background-position: 140% 0; } to { background-position: -40% 0; } }
@media (prefers-reduced-motion: reduce) {
  .site-header .wordmark {
    animation: none; background: none; color: var(--gold);
    -webkit-background-clip: border-box; background-clip: border-box;
  }
  .site-header, .site-header .inner, .site-header .wordmark,
  .site-header__edge { transition: none !important; }
}
.site-header__tagline {
  font-family: var(--t-ui);
  font-size: var(--t-ui-xs);
  color: var(--ink-dim);
  white-space: nowrap;
}
.site-header nav { margin-inline-start: auto; }
.site-header nav ul {
  display: flex;
  gap: var(--s-6);
  list-style: none;
  margin: 0;
  padding: 0;
  font-family: var(--t-ui);
  font-size: var(--t-ui-sm);
}
.site-header nav a {
  color: var(--ink-dim);
  text-decoration: none;
}
.site-header nav a:hover,
.site-header nav a[aria-current="page"] { color: var(--ink); text-decoration: underline; }

/* ---- the reading routes: a publisher's two-column band ------------------- */
/* about, faq, terms, delivery-and-returns -- every page whose job is Read
   rather than Persuade.
 *
 * MEASURED BEFORE (2026-09-07, session 43): `.inner` is 1200px and headings
 * and full-bleed rules spanned it, while every paragraph was capped at
 * --w-text (440px, and correctly so -- it is 45-52 characters) and LEFT-
 * ALIGNED inside it. So the page promised a wide layout and delivered a
 * narrow one, with ~730px of void down the right of every section. The rules
 * did not bracket the content, they underlined the emptiness.
 *
 * Centring the 440px column alone was tried and measured symmetric (500px
 * each side) -- honest, but sparse: three sections filled a 900px viewport.
 * This puts the section heading in a left rail beside its prose instead. The
 * measure is untouched, the band fills 780px of the page, the rules now
 * bracket a real two-column structure, and four sections fit the same
 * viewport. It is also what the direction contract asks for in as many
 * words: type that is "a publisher's, not a mood's".
 *
 * The container is sized to its OWN columns (260 + 80 + 440 + padding), not
 * left at 1200px: measured, that left 200px unused inside the container and
 * pushed the band off-centre (330px left, 415px right). It is now 330/330.
 *
 * Held by tests/test_site_reading.py. */
.policy .inner {
  max-width: calc(260px + 80px + var(--w-text) + var(--page-pad) * 2);
}
.policy .band .inner {
  display: grid;
  grid-template-columns: 260px minmax(0, var(--w-text));
  column-gap: 80px;
  align-items: start;
}
.policy .band .inner > h1,
.policy .band .inner > h2 { grid-column: 1; margin: 0; }
.policy .band .inner > *:not(h1):not(h2) { grid-column: 2; }

/* One column below the point where the rail stops paying for itself: at
   860px the rail plus the measure no longer fit without squeezing the
   measure, and squeezing the measure is the one thing this layout exists to
   protect. */
@media (max-width: 860px) {
  .policy .band .inner { grid-template-columns: minmax(0, 1fr); }
  .policy .band .inner > h1,
  .policy .band .inner > h2,
  .policy .band .inner > *:not(h1):not(h2) { grid-column: 1; }
}

/* ---- footer -------------------------------------------------------------- */
.site-footer {
  background: var(--board);
  /* Fix round 1, ruling R20: site stylesheets use --text/--text-dim only.
     --body/--body-dim are the covers' own constants and stay in tokens()
     because tokens() mirrors pipeline/cover.py, but they are not the
     site's reading colours -- see the R20 comment on --text/--text-dim in
     web/context.py. --text clears 17.55:1 on --board (well above the
     10.19:1 --body measured here before) --
     tests/test_art_direction.py. */
  color: var(--text);
  --focus-color: var(--gold-text);
  margin-top: var(--s-16);
}
.site-footer .inner { padding-block: var(--s-8); }
.site-footer nav ul {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-4) var(--s-6);
  list-style: none;
  margin: 0 0 var(--s-4);
  padding: 0;
  font-family: var(--t-ui);
  font-size: var(--t-ui-sm);
}
.site-footer nav a { color: var(--gold-text); text-decoration: none; }
.site-footer nav a:hover { text-decoration: underline; }
.site-footer .seller,
.site-footer .seller-identity,
.site-footer .copyright {
  font-family: var(--t-ui);
  font-size: var(--t-ui-xs);
  /* R20: --text-dim, not --body-dim -- 9.47:1 on --board. */
  color: var(--text-dim);
  margin: var(--s-1) 0;
  /* MEASURE, not just size. Measured on the rendered home page 2026-09-09:
     the seller line ran 137 characters wide at a line height of 2.3 -- the
     worst pairing there is, because a long line needs the eye to find the
     start of the next one and loose leading is what makes it lose the thread.
     Capped to the reading band the rest of the site uses. */
  max-width: 68ch;
  line-height: 1.6;
}
.site-footer .seller-identity a { color: var(--gold-text); text-decoration: none; }
.site-footer .seller-identity a:hover { text-decoration: underline; }

/* Same 44px floor as `.mail`, and this one rides _base.html.j2 -- so at 199x15
   it was under-sized on EVERY route that extends base, not just the page it
   was found on. 15px needs +30 to clear the floor. See about.css's note for
   why this is padding on an inline box rather than a larger face. */
@media (pointer: coarse) {
  .site-footer .seller-identity a { padding-block: 15px; }
}

/* FIVE LINKS, NOT ONE. The policy set joined the top bar 2026-09-13 (Bilal:
   "i want faq, privacy and all the others in the top bar along with about").
   One link never needed to wrap; five do, and "Delivery & returns" is the
   widest label on the site's UI face.

   `flex-wrap` on the list, not just on `.inner`: without it the row cannot
   break at all and overflows the viewport horizontally, which is the one
   layout failure this site measures on every route. The label keeps its full
   wording rather than shrinking to "Delivery" -- a gift buyer judging an
   object they cannot hold is asking exactly whether they can send it back,
   and the answer should not be hidden behind a shortened word. */
.site-header nav ul { flex-wrap: wrap; row-gap: var(--s-2); align-items: baseline; }
/* If the row still has to break on a narrow screen, the nav takes the full
   width and starts at the wordmark's own left edge. Right-aligned under a
   left-aligned brand is the shape that read as broken. */
@media (max-width: 1040px) {
  .site-header nav { margin-inline-start: 0; width: 100%; }
}
/* NOWRAP ON THE ITEM is what makes the wrap tidy. Without it the widest
   label breaks INSIDE itself -- measured at 390px, "Delivery & returns"
   split across two lines and straddled the baseline the other four links
   sit on, so the row read as broken rather than as wrapped. Every
   width check still passed; it took looking at it. With nowrap the row
   breaks between links, which reads as deliberate. */
.site-header nav a { white-space: nowrap; }

@media (max-width: 640px) {
  .site-header .inner { flex-wrap: wrap; }
  .site-header nav ul { gap: var(--s-3); row-gap: var(--s-2); }
  /* The nav takes its own row once the brand and five links cannot share one.
     Wrapping rather than hiding: the adapt playbook's rule is that core
     navigation does not disappear on a phone, and a policy link a buyer
     cannot find is the same as one that does not exist. */
  .site-header nav { margin-inline-start: 0; width: 100%; }
  /* Dense chrome, not a second headline -- the hero's own subheading
     carries the same "written out by hand" fact at this width. */
  .site-header__tagline { display: none; }
}

/* ---- book3d (Task 4, fix round 2, R23) -----------------------------------
 * The canvas used to carry fixed width/height ATTRIBUTES (1200x1200), which
 * -- absent any CSS -- set its DISPLAYED size too: on a narrow viewport the
 * element itself was clipped by the page and took the book with it, even
 * though the object's own framing inside the canvas was already correct
 * (measured: touches no edge -- a layout bug, not a camera one). Sized here
 * instead; book3d.js matches the WebGL drawing buffer to this CSS size on
 * mount and on resize (syncCanvasSize()), so the object stays fully inside
 * the canvas at any viewport -- tests/test_book3d.py holds this at 375px
 * and 1280px. Square (aspect-ratio 1/1): the camera's three-quarter framing
 * (mount()'s camera comment) assumes a square canvas.
 *
 * min(var(--w-page), 100%): --w-page (1200px) is this site's own existing
 * "how wide can page content ever get" decision (.inner, above) -- reused
 * rather than picking a fresh number, and it happens to preserve the exact
 * on-screen size the hardcoded 1200x1200 attribute gave on any viewport
 * already wider than that. A smaller cap was tried first (600px) and
 * measured to be the wrong choice, not just an aesthetic one: at 600px the
 * Spine is only 3-4 real pixels wide on screen, which is what
 * test_the_spine_edge_is_neither_white_nor_neutral could not reliably
 * sample -- the same defect this fix round exists to make legible would
 * have shipped nearly invisible by default.
 */
/* Task 5: the pinned stage. ScrollTrigger pins [data-book3d], which is now
 * this element, and a pin holds it against the top of the viewport for the
 * whole scroll -- so it has to BE one viewport tall, and the square has to
 * fit inside it. Hence the 92svh term added to the square's width below:
 * the element is square, so its height is its width, and an uncapped
 * 1200px square on a 720px-tall viewport would have spent the entire pin
 * with the bottom 480px of the object cut off.
 *
 * 92svh and not less: the comment above records that a 600px cap was tried
 * and measured WRONG, because at 600px the Spine is 3-4 screen pixels and
 * the R24 defect this page exists to make visible would ship invisible.
 * 92svh keeps the square at 660px on the harshest realistic viewport (the
 * 720px-tall one the tests drive) and 900px+ on an ordinary desktop, both
 * comfortably clear of that floor -- and
 * test_the_spine_edge_is_neither_white_nor_neutral, which reads the
 * DISPLAYED pixels at whatever size CSS gives them, is what actually holds
 * this: it fails if this cap is ever tightened far enough to lose the
 * Spine. svh (small viewport height) rather than vh so a mobile browser's
 * retracting URL bar cannot make the stage taller than the space actually
 * available at rest. */
.book3d-stage {
  /* Not a bare 100svh: this element starts BELOW the site header, so a
   * full-viewport stage centred inside itself puts its last row a header's
   * height under the fold -- which is exactly where the binding switcher
   * landed, invisible, until you scrolled. Measured, not guessed: the
   * header is 60px here, the switcher 52px including its gap. The pin is
   * unaffected (ScrollTrigger holds whatever height this is against the
   * top of the viewport). */
  min-height: calc(100svh - 4rem);
  display: grid;
  place-items: center;
}
.book3d {
  /* calc(100svh - 6rem), not a bare 92svh: the stage is a COLUMN and the
   * binding switcher (G9) sits under the square inside it, so the square
   * plus the buttons plus their gap have to fit one viewport between them.
   * A bare 92svh left the pair ~4rem too tall, which pushed the top of the
   * book off the viewport for the whole pin and made clicking a button
   * scroll the page -- which, the switcher being inside the pinned stage,
   * turned the object as a side effect of switching its binding. 10rem,
   * not 6rem, so the square, the switcher and the header all fit one
   * viewport at scroll 0. Measured at 720px tall: a 560px square, where
   * the paperback's edge is still ~8 native pixels
   * (test_the_two_bindings_are_distinguishable... holds the floor at 6). */
  width: min(var(--w-page), 100%, calc(100svh - 10rem));
  aspect-ratio: 1 / 1;
  margin-inline: auto;
}
.book3d { position: relative; }
.book3d canvas { display: block; width: 100%; height: 100%; }
/* Absolutely positioned rather than `height: 100%`. Measured: as a grid
 * item in a `height: 100%` box, the image's own `height: 100%` computed to
 * 778.969px -- i.e. it resolved to AUTO and took the aspect ratio (560 x
 * 900/647), because the grid area's height is not a definite percentage
 * basis even though the box measures 560px. `inset: 0` gives a definite
 * containing block, so 100% is 100%. */
.book3d-fallback { position: absolute; inset: 0; }
.book3d-fallback img {
  /* width+height 100% with object-fit, NOT max-width/max-height. The
   * frames are portrait (647x900) and the stage is square: measured, a
   * `max-width: 100%; max-height: 100%` pair clamped the width to 560 and
   * then let the aspect ratio take the height to 779 inside a 560 box,
   * overflowing the pinned stage by 219px and cutting the bottom off the
   * book. Sizing the BOX to the container and letting object-fit letterbox
   * the picture inside it cannot overflow by construction. */
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* `hidden` is a UA-stylesheet rule (`[hidden] { display: none }`), and ANY
 * author `display` declaration outranks it -- so `.book3d-fallback
 * { display: grid }` above, `.book3d canvas { display: block }`, and
 * `.book3d-bindings { display: flex }` each silently un-hide their own
 * element. Every one of the three ships `hidden` and is revealed by JS,
 * so without this rule the page would show the canvas, the frames AND a
 * pair of dead binding buttons stacked on each other before any script
 * ran, and with JS disabled would show them forever. Scoped to the stage
 * rather than made global, so it cannot surprise anything else. */
.book3d-stage [hidden] { display: none !important; }

/* The DOM page the object hands over to (V16). Positioned from JS at the
 * handoff rect, then driven by TRANSFORM alone (V19): this element contains
 * a 1700x2200 image, and animating width or height would relayout and
 * repaint the largest thing on the page every frame, on the beat the visitor
 * is looking at hardest.
 *
 * `position: fixed` is measured, not assumed -- a transformed ancestor
 * becomes the containing block for a fixed descendant, and ScrollTrigger's
 * pin can apply one. test_the_dom_page_lands_exactly_where_the_3d_page_was
 * compares this element's own getBoundingClientRect against the projected
 * mesh, so that substitution shows up as a failure rather than as a drift.
 *
 * No `display` declaration: `.book3d-stage [hidden]` above needs to win, and
 * an author display rule here is exactly what beat it three times in stage 1.
 */
.page-fill {
  position: fixed;
  margin: 0;
  transform-origin: top left;
  will-change: transform;
  pointer-events: none;
}
.page-fill img { display: block; width: 100%; height: auto; }

/* The binding switcher (G9). Inside the pinned stage, so it travels with
 * the object rather than scrolling away from the thing it labels. */
.book3d-bindings {
  display: flex;
  gap: var(--s-2);
  justify-content: center;
  margin-block-start: var(--s-3);
}
.book3d-bindings button {
  font-family: var(--t-ui);
  font-size: var(--t-ui-xs);
  letter-spacing: var(--track-overline);
  text-transform: uppercase;
  padding: var(--s-2) var(--s-4);
  color: var(--ink);
  background: transparent;
  border: 1px solid var(--rule);
  border-radius: var(--r-sm);
  cursor: pointer;
}
.book3d-bindings button[aria-pressed="true"] {
  border-color: var(--rule-strong);
  background: color-mix(in srgb, var(--ink) 6%, transparent);
}

/* RETONE 2026-08-31: the mobile clamp() that used to live here shrank
 * --t-d-4 (57px)/--t-d-5 (76px) Cinzel down toward a 375px viewport,
 * because an unbreakable "HANDWRITTEN" at those sizes was wider than
 * .inner. Cinzel's whole scale is now 31px/24px (spec 4A.3) -- narrower
 * than the old MOBILE clamp ceiling ever was -- so that word no longer
 * comes close to overflowing at any width this site supports, and the
 * clamp step is gone as dead weight rather than kept as an unneeded
 * safety net. `overflow-wrap: break-word` on h1/h2/h3 above stays: it is
 * a general safety net for any future word or font substitution, not
 * specific to the size step this comment used to justify.
 * tests/test_site_home.py and tests/test_art_direction.py still hold "no
 * horizontal overflow at 375px" on the built site. */


/* ---- touch ergonomics ---------------------------------------------------- */
/*
 * TAP TARGETS, ON POINTERS THAT CANNOT BE PRECISE.
 *
 * Measured 2026-09-10 on the built site at 390, 768, 1024 and 1440 CSS px:
 * the same SEVEN links came back under 44px on every one of them -- the two
 * header links (29x20 at 390) and the five footer links (25x30 to 110x30).
 * Nothing else on any page did, and no page scrolls horizontally at any of
 * those widths, so this is the whole of the touch problem rather than a
 * symptom of a broken layout.
 *
 * `pointer: coarse` rather than a width breakpoint, deliberately. The
 * constraint is the INPUT, not the screen: a touch laptop at 1440px has the
 * same fingertip as a phone, and a 768px window on a desktop has a mouse and
 * wants the compact spacing. Sizing this off width would have got both wrong.
 *
 * Padding, not min-height, and no change to font-size or colour: the links
 * keep their exact look and only the hit area grows, so the header does not
 * re-flow and the footer's rhythm is untouched. `align-items: center` on the
 * flex row keeps the enlarged boxes centred on the same baseline.
 *
 * 44px is the figure both Apple's HIG and the WCAG 2.2 target-size rule
 * settle on; nothing here is tighter than that.
 */
@media (pointer: coarse) {
  .site-header nav ul,
  .site-footer nav ul { align-items: center; }
  .site-header nav a,
  .site-footer nav a {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    padding-inline: 6px;
  }
  /* The wordmark is a link too, and it is the one every visitor aims at
     first. It measured 238x22 on a phone. */
  .site-header .wordmark { display: inline-flex; align-items: center; min-height: 44px; }
}
