/* ==========================================================================
   Astrocaffe — 3D hardcover book
   Phase 1a, Stage 4. Built 2026-08-04.

   Kept in its own file rather than folded into blocks.css because it is the
   only component in the project with its own 3D coordinate system, and
   because in Phase 1b it becomes its own Rails asset. Everything visual
   still comes from tokens.css — no literal brand colour is typed here.

   ---------------------------------------------------------------------
   GEOMETRY IS MEASURED, NOT GUESSED.

   Every dimension below is read off the supplied artwork at 300 dpi.
   Paths as of 2026-08-05, when the images moved into the per-product tree:

     products/<slug>/3d/front|back.webp   2475 x 2475  ->  8.25 in sq (boards)
     products/<slug>/3d/spine.webp         160 x 2475  ->  0.533 in    (13.5 mm)
     products/inside/NN.webp              2400 x 2400  ->  8.00 in sq  (pages)

   The covers are per product; the inner pages are shared by all of them,
   which is why `inside/` sits beside the product folders rather than inside
   one. The reading is the same book whatever cover it wears.

   From which the rest follows, and cross-checks against a real book:

     square (overhang)  = (8.25 - 8.00) / 2 = 0.125 in = 3.2 mm
         The board oversails the text block on head, tail and fore-edge.
         3 mm is the standard square on a cased-in hardcover. This single
         detail is what separates a hardback from a paperback on screen.

     board thickness    = 0.0984 in = 2.5 mm  (2 mm greyboard + cloth/print)
     text block         = 0.533 - (2 x 0.0984) = 0.336 in = 8.5 mm
         50 leaves of ~0.17 mm coated stock = 8.5 mm. The supplied spine
         width and the stated 100-page extent agree to within a hair, so
         these are the real numbers rather than a plausible invention.

   --in is the ONLY scale knob. Change it and the whole book resizes with
   its proportions intact.
   ========================================================================== */
/* Registered so the page-turn can animate a plain number and have every
   dependent calc() — stack thicknesses, lighting, shadow — recompute per
   frame. Without @property these snap instead of easing; nothing breaks. */
@property --tr {
    syntax: '<number>';
    inherits: true;
    initial-value: 0;
}

.book3d {
    /* Size. The book stands 80vh tall, but never wider than the container it
       sits in and never wider than the phone it is on — min() takes whichever
       limit bites first. The 24px is `.w-container`'s own left+right padding,
       which the book has to stay inside of. */
    --in: calc(min(80vh, 92vw, var(--container-w) - 24px) / 8.25);

    /* Measured book, in inches. Do not hand-tune these. */
    --i-cover: 8.25;
    --i-page: 8;
    --i-spine: 0.5333;
    --i-board: 0.0984;
    --i-square: 0.125;

    /* Derived */
    --cover: calc(var(--i-cover) * var(--in));
    --page: calc(var(--i-page) * var(--in));
    --spine: calc(var(--i-spine) * var(--in));
    --board: calc(var(--i-board) * var(--in));
    --square: calc(var(--i-square) * var(--in));
    --block: calc(var(--spine) - (2 * var(--board)));

    /* Distance from book centre-plane to the middle of each board. */
    --board-z: calc((var(--block) + var(--board)) / 2);

    /* Motion. One duration and one easing for geometry AND light, so the
       highlights track the rotation without a JS animation loop. */
    --dur-rot: 900ms;
    --dur-open: 1100ms;
    --dur-turn: 1150ms;
    --ease-book: cubic-bezier(0.22, 0.61, 0.36, 1);

    /* State. --ry / --rx / --zoom come from the state classes at the foot of
       this file; --spin and --tr are written by JS.

       --spin accumulates whole turns. The book is never wound backwards —
       going from the back cover to the front adds 360deg instead of
       subtracting 180, so repeated clicks read as one continuous rotation
       rather than a shuttle. */
    --ry: 0deg;
    --rx: 0deg;
    --zoom: 1;
    --spin: 0deg;
    --tr: 0;
    position: relative;
}

/* --------------------------------------------------------------------------
   Stage
   -------------------------------------------------------------------------- */
.book3d__stage {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: calc(var(--cover) * 1.08);
    perspective: calc(30 * var(--in));
    perspective-origin: 50% 44%;
    cursor: pointer;
    outline: none;

    /* Rotation happens around the book's own centre; the stage never moves. */
    touch-action: manipulation;
    -webkit-tap-highlight-color: transparent;
}

.book3d__stage:focus-visible {
    outline: 3px solid var(--c-orange);
    outline-offset: var(--space-4);
    border-radius: var(--radius);
}

/* Nothing on the stage carries spacing of its own — the book is centred by
   the flex container and the shadows by their own transforms, so a stray
   margin here would silently throw the whole scene off centre. */
.book3d__stage > * {
    margin: 0;
    padding: 0;
}

/* Ground shadow, in two layers. One element could not do this: a contact
   shadow is tight and dark, an ambient one is broad and soft, and they have
   to change shape independently as the book turns. Both are separate planes
   rather than a drop-shadow on the book, so they can be skewed and squashed
   to follow the rotation — which is what makes the book sit on something
   instead of hovering in front of it. */
.book3d__shadow,
.book3d__shadow-core {
    position: absolute;
    top: 50%;
    left: 50%;
    width: var(--cover);
    height: var(--cover);
    pointer-events: none;
    transform-origin: 50% 50%;
    transition: transform var(--dur-rot) var(--ease-book),
                opacity var(--dur-rot) var(--ease-book);
}

/* Ambient: the broad soft pool the whole object sits in.

   The gradient centre is pushed UP to 34%, not left at 50%. A shadow is
   densest where it meets the object and thins as it travels away from it, so
   an evenly-centred blob reads as a grey oval the book happens to be standing
   on. Off-centring it is most of what makes this look cast. */
.book3d__shadow {
    background: radial-gradient(ellipse at 50% 34%,
        rgb(0 0 0 / 0.4) 0%,
        rgb(0 0 0 / 0.22) 38%,
        rgb(0 0 0 / 0.07) 62%,
        rgb(0 0 0 / 0) 78%);
    filter: blur(calc(var(--in) * 0.42));
    opacity: var(--sh-o, 0.75);
    transform: translate(-50%, -50%)
               translateX(var(--sh-tx, 0%))
               translateY(var(--sh-ty, 42%))
               skewX(var(--sh-skew, 0deg))
               scale(var(--sh-x, 0.95), var(--sh-y, 0.22));
}

/* Contact: the hard dark line where the object actually meets the surface.
   Tight, barely blurred, and much flatter than the ambient layer. */
.book3d__shadow-core {
    background: radial-gradient(ellipse at 50% 42%,
        rgb(0 0 0 / 0.62) 0%,
        rgb(0 0 0 / 0.34) 44%,
        rgb(0 0 0 / 0) 74%);
    filter: blur(calc(var(--in) * 0.11));
    opacity: var(--shc-o, 0.7);
    transform: translate(-50%, -50%)
               translateX(var(--shc-tx, 0%))
               translateY(var(--shc-ty, 47%))
               skewX(var(--shc-skew, 0deg))
               scale(var(--shc-x, 0.7), var(--shc-y, 0.06));
}

/* --------------------------------------------------------------------------
   The book
   -------------------------------------------------------------------------- */
.book3d__book {
    position: relative;
    width: var(--cover);
    height: var(--cover);
    transform-style: preserve-3d;

    /* --shift comes first so it is a straight slide in stage space, applied
       after the rotations rather than along the book's own turning x-axis.
       It exists because the book hinges on its LEFT edge: opening it grows
       the spread leftwards, so the spine — the thing the eye centres on —
       ends up half a cover width left of the frame unless it is pushed back. */
    transform: translateX(var(--shift, 0px))
               scale(var(--zoom))
               rotateX(var(--rx))
               rotateY(calc(var(--spin) + var(--ry)));
    transition: transform var(--dur-rot) var(--ease-book),
                --tr var(--dur-turn) var(--ease-book);
}

.book3d.is-open .book3d__book {
    transition: transform var(--dur-open) var(--ease-book),
                --tr var(--dur-turn) var(--ease-book);
}

/* Shared face reset. Every face is laid out so its own centre already sits
   on the box centre, which means transform-origin can stay `center` and the
   3D transforms below are pure translate/rotate with no -50% correction. */
.book3d__book .f {
    position: absolute;
    left: 0;
    top: 0;
    margin: 0;
    backface-visibility: hidden;
    background-repeat: no-repeat;
    background-size: cover;
    background-position: center;
}

.book3d__book .f img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;

    /* The artwork is square and so is every face it lands on — nothing is
       being cropped here, `cover` only guards against rounding. */
}

/* --------------------------------------------------------------------------
   Cover boards — real six-sided boxes, 2.5 mm thick
   -------------------------------------------------------------------------- */
.book3d__board {
    position: absolute;
    left: 0;
    top: 0;
    width: var(--cover);
    height: var(--cover);
    transform-style: preserve-3d;
}

.book3d__board .f--out,
.book3d__board .f--in {
    width: var(--cover);
    height: var(--cover);
}

.book3d__board .f--out {
    transform: translateZ(calc(var(--board) / 2));
    border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
    overflow: hidden;
}

.book3d__board .f--in {
    transform: translateZ(calc(var(--board) / -2)) rotateY(180deg);

    /* Endpaper. Cream, with the faint gutter shading a pasted-down
       endpaper always has near the hinge. */
    background-color: var(--c-cream);
    background-image: linear-gradient(to left,
        rgb(0 0 0 / 0) 78%,
        rgb(0 0 0 / 0.16) 97%,
        rgb(0 0 0 / 0.3) 100%);
    border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}

/* Board edges. Head is lit, tail is in shadow — the key light sits high
   and front-left, and every surface below obeys that one decision. */
.book3d__board .f--top,
.book3d__board .f--bot {
    width: var(--cover);
    height: var(--board);
    top: calc((var(--cover) - var(--board)) / 2);
}

.book3d__board .f--top {
    transform: rotateX(90deg) translateZ(calc(var(--cover) / 2));
    background-image: linear-gradient(to bottom, #2b3d52, #16283a);
}

.book3d__board .f--bot {
    transform: rotateX(-90deg) translateZ(calc(var(--cover) / 2));
    background-image: linear-gradient(to bottom, #0a1725, #050d16);
}

.book3d__board .f--fore,
.book3d__board .f--hinge {
    width: var(--board);
    height: var(--cover);
    left: calc((var(--cover) - var(--board)) / 2);
}

.book3d__board .f--fore {
    transform: rotateY(90deg) translateZ(calc(var(--cover) / 2));
    background-image: linear-gradient(to right, #24354a, #101f30);
}

.book3d__board .f--hinge {
    transform: rotateY(-90deg) translateZ(calc(var(--cover) / 2));
    background-image: linear-gradient(to left, #24354a, #0c1826);
}

.book3d__board--front {
    transform: translateZ(var(--board-z));
}

.book3d__board--back {
    transform: translateZ(calc(var(--board-z) * -1));
}

/* The back board is the front board turned over, so its two faces swap.
   Printed side outward means facing AWAY from the camera when the book is
   sitting front-on; leave it at the front board's values and flipping the
   book shows you the endpaper where the back cover should be. */
.book3d__board--back .f--out {
    transform: translateZ(calc(var(--board) / -2)) rotateY(180deg);
    border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}

/* Keeps the inherited gutter gradient: this face is not turned over, so its
   local left is still the spine and the shading already falls correctly. */
.book3d__board--back .f--in {
    transform: translateZ(calc(var(--board) / 2));
    border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}

/* Specular sheen on the printed cover. Its position is transitioned on the
   same clock as the rotation, so a highlight appears to sweep across the
   board as the book turns — the single cheapest thing that reads as "this
   surface is catching light" rather than "this is a picture of a book". */
.book3d__board--front .f--out::after,
.book3d__board--back .f--out::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(105deg,
        rgb(255 255 255 / 0) 30%,
        rgb(255 255 255 / 0.13) 46%,
        rgb(255 255 255 / 0.22) 50%,
        rgb(255 255 255 / 0.13) 54%,
        rgb(255 255 255 / 0) 70%);
    background-size: 260% 100%;
    background-position: var(--sheen, 92%) 0;
    transition: background-position var(--dur-rot) var(--ease-book),
                opacity var(--dur-rot) var(--ease-book);
    opacity: var(--sheen-o, 0.55);
    pointer-events: none;
}

/* The French groove: the hinge channel between spine and board. A real one
   is a pressed valley, so it is a dark line with a lit lip on the board
   side, not a plain shadow. */
/* The joint has to be CRISP. A cased-in board is a stiff square-edged panel:
   the channel beside the spine is narrow and its outer corner — the arris —
   is a hard line catching light, not a soft roll.

   The previous version was a 0.34in gradient fading smoothly to nothing over
   its whole width, which is exactly how you draw a rounded shoulder. This is
   a third of that width and the stops are doubled up so the tones step
   rather than blend: dark valley, then a bright hairline lip, then the board
   face. The lip is what the eye reads as a sharp edge. */
.book3d__board--front .f--out::before,
.book3d__board--back .f--out::before {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    width: calc(var(--in) * 0.12);
    background: linear-gradient(to right,
        rgb(0 0 0 / 0.5) 0%,
        rgb(0 0 0 / 0.5) 30%,
        rgb(0 0 0 / 0.16) 55%,
        rgb(0 0 0 / 0.16) 70%,
        rgb(255 255 255 / 0.16) 82%,
        rgb(255 255 255 / 0.16) 94%,
        rgb(0 0 0 / 0) 94.01%);
    pointer-events: none;
    z-index: 2;
}

.book3d__board--back .f--out::before {
    left: auto;
    right: 0;
    transform: scaleX(-1);
}

/* Front board hinges open about the spine. The end state also drops it to
   table level, because a cover opened flat ends up UNDER the text block,
   not floating at its original height. */
.book3d__hinge {
    position: absolute;
    left: 0;
    top: 0;
    width: var(--cover);
    height: var(--cover);
    transform-style: preserve-3d;
    transform-origin: left center;
    transform: rotateY(var(--cover-ry, 0deg));
    transition: transform var(--dur-open) var(--ease-book);
}

/* Past flat, not short of it. At -178 the cover is tilted 2 degrees the wrong
   way, and across 8.25 in that lifts its fore edge higher than the board is
   thick — the endpaper then paints straight through the left-hand page.
   Going a couple of degrees beyond 180 drops the fore edge instead, which is
   also what a real cover does: it rests on the table while the text block
   sits proud of it. */
.book3d.is-open .book3d__hinge {
    --cover-ry: -182deg;
}

/* --------------------------------------------------------------------------
   Spine — the strip that wraps the two boards
   -------------------------------------------------------------------------- */
.book3d__spine {
    position: absolute;
    left: 0;
    top: 0;
    width: var(--spine);
    height: var(--cover);
    transform-origin: left center;

    /* -90, not +90. Both put the slab in the right place, but +90 turns its
       normal into the book and we end up reading the printed name in
       mirror-writing. */
    transform: translateZ(calc(var(--spine) / -2)) rotateY(-90deg);
    overflow: hidden;

    /* Square. Rounding the spine's ends softened the very corner this is
       meant to keep sharp. */
    border-radius: 0;
    transition: opacity var(--dur-open) var(--ease-book);
}

.book3d__spine img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: fill;
}

/* A spine is a rounded tube, not a flat card. This gradient across its
   width is what gives it that curve. */
.book3d__spine::after {
    content: "";
    position: absolute;
    inset: 0;

    /* Cylindrical shading across the middle, but the darkening at the two
       ends is pulled into the outer 7%. Spread wide, as it was before, it
       became a second soft smear sitting on top of the joint groove and the
       two together rounded the corner off. */
    background: linear-gradient(to right,
        rgb(0 0 0 / 0.45) 0%,
        rgb(0 0 0 / 0.12) 7%,
        rgb(255 255 255 / 0.09) 46%,
        rgb(0 0 0 / 0.14) 93%,
        rgb(0 0 0 / 0.42) 100%);
    pointer-events: none;
}

/* Once open, the spine is edge-on and its hollow faces away from us. */
.book3d.is-open .book3d__spine {
    opacity: 0;
}

/* --------------------------------------------------------------------------
   Text block — the sheets
   -------------------------------------------------------------------------- */
.book3d__block {
    position: absolute;
    top: var(--square);
    width: var(--page);
    height: var(--page);
    transform-style: preserve-3d;
}

/* Right-hand stack thins out as pages move across; left-hand stack thickens
   by the same amount. Both ease off --tr, so the book visibly redistributes
   its bulk as you read it. */
/* The stacks are BUILT at full thickness and squashed along z with a
   transform. They used to be rebuilt every frame instead — `--t` fed the
   `width`, `height`, `left` and `top` of the edge faces, so each frame of a
   page turn relaid out the entire 3D scene, and the fine repeating gradient
   on the page edges re-tiled as its box resized. That is what made the turn
   step and smear. scaleZ costs no layout, and because the gradient is now
   fixed in the element's own space and merely scaled, it cannot re-tile. */
.book3d__block--right {
    --t: var(--block);
    left: var(--square);
    transform: scaleZ(calc(1 - (0.9 * var(--tr))));
}

/* Hidden rather than transparent when the book is shut. `visibility` is
   deliberate: this element is `transform-style: preserve-3d` with 3D
   children, and an opacity below 1 makes WebKit give it a stacking context
   and flatten everything inside it. Nothing is lost — at the first spread
   this stack has no thickness anyway, so it has nothing to fade in. */
/* Same treatment. The 0.02 floor keeps scaleZ off zero — a degenerate matrix
   makes the browser drop the element's 3D context for a frame, which flickers
   at exactly the moment the first page lands. */
.book3d__block--left {
    --t: var(--block);
    left: calc(var(--page) * -1 - var(--square));
    transform: scaleZ(calc(0.02 + (0.88 * var(--tr))));
    visibility: hidden;
}

.book3d.is-open .book3d__block--left {
    visibility: visible;
}

.book3d__block .f--face,
.book3d__block .f--rear {
    width: var(--page);
    height: var(--page);
    background-color: var(--c-white-soft);
}

.book3d__block .f--face {
    transform: translateZ(calc(var(--t) / 2));
}

.book3d__block .f--rear {
    transform: translateZ(calc(var(--t) / -2)) rotateY(180deg);
    background-color: var(--c-cream);
}

/* The fore-edge. ~50 leaves is far too fine to draw honestly at this size —
   drawn literally it turns into moire. This reads as a dense sheaf instead,
   with the paper warm at the edge and shading into the gutter. */
.book3d__block .f--edge {
    width: var(--t);
    height: var(--page);
    left: calc((var(--page) - var(--t)) / 2);
    transform: rotateY(90deg) translateZ(calc(var(--page) / 2));
    background-color: #efece2;
    background-image: repeating-linear-gradient(to right,
            rgb(120 110 92 / 0.28) 0px,
            rgb(120 110 92 / 0) 0.7px,
            rgb(120 110 92 / 0) 1.6px,
            rgb(120 110 92 / 0.28) 2.3px),
        linear-gradient(to right,
            rgb(0 0 0 / 0.34) 0%,
            rgb(255 255 255 / 0.35) 26%,
            rgb(0 0 0 / 0.08) 70%,
            rgb(0 0 0 / 0.4) 100%);
}

.book3d__block--left .f--edge {
    transform: rotateY(-90deg) translateZ(calc(var(--page) / 2));
}

.book3d__block .f--head,
.book3d__block .f--tail {
    width: var(--page);
    height: var(--t);
    top: calc((var(--page) - var(--t)) / 2);
    background-color: #efece2;
    background-image: repeating-linear-gradient(to bottom,
            rgb(120 110 92 / 0.26) 0px,
            rgb(120 110 92 / 0) 0.7px,
            rgb(120 110 92 / 0) 1.6px,
            rgb(120 110 92 / 0.26) 2.3px),
        linear-gradient(to bottom,
            rgb(255 255 255 / 0.4),
            rgb(0 0 0 / 0.3));
}

.book3d__block .f--head {
    transform: rotateX(90deg) translateZ(calc(var(--page) / 2));
}

/* Darkened with an extra gradient rather than `filter: brightness()`. A
   filter on a 3D-transformed face forces it into its own rendering context,
   which is a reliable way to get WebKit sorting it against the rest of the
   book incorrectly. */
.book3d__block .f--tail {
    transform: rotateX(-90deg) translateZ(calc(var(--page) / 2));
    background-image: linear-gradient(rgb(0 0 0 / 0.3), rgb(0 0 0 / 0.3)),
        repeating-linear-gradient(to bottom,
            rgb(120 110 92 / 0.26) 0px,
            rgb(120 110 92 / 0) 0.7px,
            rgb(120 110 92 / 0) 1.6px,
            rgb(120 110 92 / 0.26) 2.3px),
        linear-gradient(to bottom,
            rgb(255 255 255 / 0.4),
            rgb(0 0 0 / 0.3));
}

/* Gutter shading on whichever page is showing — paper curves down into the
   spine and loses the light there. Mirrored for the left page. */
.book3d__block .f--face::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(to left,
        rgb(0 0 0 / 0) 76%,
        rgb(0 0 0 / 0.12) 93%,
        rgb(0 0 0 / 0.28) 100%);
    pointer-events: none;
}

.book3d__block--left .f--face::after {
    background: linear-gradient(to right,
        rgb(0 0 0 / 0) 76%,
        rgb(0 0 0 / 0.12) 93%,
        rgb(0 0 0 / 0.28) 100%);
}

/* The left-hand page is always blank. There are eleven photographed pages
   and each stands for a ten-page section, so the sheet that has just been
   turned is showing its reverse — which in this book is not one of the
   eleven. Putting the previous image there would claim the section had been
   printed twice, back to back. One page of text at a time, on the right.
   The element and its `img` stay in the markup so the stack still has a
   surface to be seen; the script never gives it a source. */
/* --------------------------------------------------------------------------
   The turning leaf
   -------------------------------------------------------------------------- */
/* --leaf-z lifts the sheet clear of BOTH stacks.

   It used to sit at z = 0 while the right-hand page it is meant to cover sat
   at +t/2 — in front of it — so which one painted on top came down to a
   `z-index` hint rather than to geometry. As the stacks changed thickness
   through a turn the depth order kept being re-decided, and the sheet tore
   and left fragments behind. Half a block clears the thickest either stack
   ever reaches; the hair on top keeps them off exactly equal.

   translateZ is written BEFORE rotateY here and in the keyframes, so it
   moves the hinge line rather than swinging the sheet around the spine — the
   leaf pivots at the top of the stack, which is where a real one pivots. */
.book3d__leaf {
    --leaf-z: calc(var(--block) / 2 + 0.5px);
    position: absolute;
    top: var(--square);
    left: var(--square);
    width: var(--page);
    height: var(--page);
    transform-style: preserve-3d;

    /* The hinge is the SPINE, at x = 0, not the sheet's own left edge.

       The sheet is inset by one square (the board's overhang), so
       `transform-origin: left center` put its axis a square inside the spine.
       The two page stacks, though, are mirror images about x = 0: the right
       spans [square, square+page] and the left spans [-(page+square),
       -square]. A sheet pivoting a square off that axis therefore landed
       2 x square — a quarter inch — to the right of the page it was becoming,
       and the whole spread jumped left the instant the finished sheet handed
       over to the real page underneath.

       A negative origin puts the axis back on the spine, where a page
       actually turns, so the sheet lands exactly on the left-hand page. */
    transform-origin: calc(var(--square) * -1) center;
    transform: translateZ(var(--leaf-z)) rotateY(0deg);
    visibility: hidden;
}

/* Split at the halfway line so the sheet can bow. A single flat plane
   rotating 180 degrees reads as a cereal packet; two panels hinged in the
   middle give a sheet of paper its silhouette for almost nothing. */
.book3d__leaf-half {
    position: absolute;
    top: 0;
    width: 50%;
    height: 100%;
    transform-style: preserve-3d;
}

.book3d__leaf-half--in {
    left: 0;
}

.book3d__leaf-half--out {
    left: 50%;
    transform-origin: left center;
}

.book3d__leaf-face {
    position: absolute;
    inset: 0;
    overflow: hidden;
    backface-visibility: hidden;
    background-color: var(--c-white-soft);
}

.book3d__leaf-face img {
    position: absolute;
    top: 0;
    width: calc(var(--page));
    height: var(--page);
    max-width: none;
    object-fit: cover;
    display: block;
}

/* Each half is a window onto the same full-size page image, so the seam
   between them is invisible. */
.book3d__leaf-half--in .book3d__leaf-face img {
    left: 0;
}

.book3d__leaf-half--out .book3d__leaf-face img {
    left: calc(var(--page) / -2);
}

.book3d__leaf-face--back {
    transform: rotateY(180deg);
}

/* The reverse of the turning sheet is blank paper — see the note on the
   left-hand stack. Its `img` is left in place but never given a source, so
   what shows is the face's own paper colour. */
/* Sheen on the turning sheet — a moving band of light is what makes paper
   look like paper mid-flight. */
/* The sheen spans the WHOLE sheet, not each half of it.

   Each face is only half a page wide, so a plain `to right` gradient ran its
   full sweep twice — restarting its dark end at the fold and drawing a second
   shadow straight down the middle of the turning page. The gradient is now
   sized to a full page and each half is a window onto its own portion of it,
   exactly as the page images are handled, so the two halves read as one sheet
   with one highlight running across them. */
.book3d__leaf-face::after {
    content: "";
    position: absolute;
    inset: 0;
    background-image: linear-gradient(to right,
        rgb(0 0 0 / 0.3) 0%,
        rgb(0 0 0 / 0.06) 14%,
        rgb(255 255 255 / 0.16) 52%,
        rgb(255 255 255 / 0.04) 100%);
    background-size: var(--page) 100%;
    background-repeat: no-repeat;
    opacity: 0;
    pointer-events: none;
}

.book3d__leaf-half--in .book3d__leaf-face--front::after,
.book3d__leaf-half--out .book3d__leaf-face--back::after {
    background-position-x: 0;
}

.book3d__leaf-half--out .book3d__leaf-face--front::after,
.book3d__leaf-half--in .book3d__leaf-face--back::after {
    background-position-x: calc(var(--page) / -2);
}

/* The reverse of the sheet carries the same gutter shading the left-hand page
   has. Without it the sheet landed as flat blank paper and the gutter shadow
   only appeared a moment later, when the finished sheet was swapped for the
   page underneath — which is the shadow "jumping in" after the turn. Painting
   it on the sheet itself means the shadow arrives WITH the page, deepening as
   it comes down, and the swap underneath changes nothing.

   Constant, so it lives on ::before — ::after is the sheen, whose opacity is
   animated. Same page-wide windowing as everything else on this sheet. */
.book3d__leaf-face--back::before {
    content: "";
    position: absolute;
    inset: 0;
    background-image: linear-gradient(to right,
        rgb(0 0 0 / 0) 76%,
        rgb(0 0 0 / 0.12) 93%,
        rgb(0 0 0 / 0.28) 100%);
    background-size: var(--page) 100%;
    background-repeat: no-repeat;
    pointer-events: none;
    z-index: 1;
}

.book3d__leaf-half--out .book3d__leaf-face--back::before {
    background-position-x: 0;
}

.book3d__leaf-half--in .book3d__leaf-face--back::before {
    background-position-x: calc(var(--page) / -2);
}

.book3d.is-turning .book3d__leaf {
    visibility: visible;
    animation: book3d-leaf var(--dur-turn) var(--ease-book) forwards;
}

.book3d.is-turning .book3d__leaf-half--out {
    animation: book3d-leaf-bend var(--dur-turn) var(--ease-book) forwards;
}

.book3d.is-turning .book3d__leaf-face::after {
    animation: book3d-leaf-sheen var(--dur-turn) var(--ease-book) forwards;
}

@keyframes book3d-leaf {
    from {
        transform: translateZ(var(--leaf-z)) rotateY(0deg);
    }

    to {
        transform: translateZ(var(--leaf-z)) rotateY(-180deg);
    }
}

/* The bow: flat at rest, deepest around the middle of the arc, flat again
   as it settles. */
@keyframes book3d-leaf-bend {
    0% {
        transform: rotateY(0deg);
    }

    18% {
        transform: rotateY(-13deg);
    }

    50% {
        transform: rotateY(-19deg);
    }

    82% {
        transform: rotateY(-11deg);
    }

    100% {
        transform: rotateY(0deg);
    }
}

@keyframes book3d-leaf-sheen {
    0% {
        opacity: 0;
    }

    30% {
        opacity: 0.85;
    }

    62% {
        opacity: 0.7;
    }

    100% {
        opacity: 0;
    }
}

/* --------------------------------------------------------------------------
   Controls
   -------------------------------------------------------------------------- */
.book3d__controls {
    margin-bottom: var(--space-6);
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: var(--space-3);
}

.book3d__controls button {
    margin: 0;
}

/* .book3d__progress removed 2026-08-04 — the counter was dropped along with
   its markup and its script. The turn button's own label still changes to
   "close the book" on the last spread, so the end of the book is still
   announced; there is simply no running count. */
/* The instruction line was removed 2026-08-04 — the book invites the hover
   on its own and the sentence only got in the way. */
/* The reset only earns its place on screen once the book has left home.
   `display: none` rather than a hidden-but-present button: the row is
   centred, so a reserved-but-empty slot would push the lone "open" button
   off centre while the book is still shut. */
.book3d__reset {
    display: none;
}

.book3d:not(.is-flat) .book3d__reset {
    display: inline-block;
}

/* --------------------------------------------------------------------------
   States. Geometry and light change together, on one clock.
   -------------------------------------------------------------------------- */
/* Flat — front cover square to camera. */
.book3d.is-flat {
    --ry: 0deg;
    --rx: 0deg;
    --zoom: 1;
    --sheen: 92%;
    --sheen-o: 0.35;

    /* The drops have to clear 50% — that is where the book's own bottom edge
       sits, as a fraction of a cover. At 42/47% both shadows fell almost
       entirely BEHIND the book, with the contact layer 100% hidden, which is
       why the shadow read as fixed: there was nothing visible left to change
       shape as the book turned. They now emerge from under the base. */
    /* Wider than the book, not narrower. A shadow that stops short of the
       object it belongs to is the thing that reads as "small" — a standing
       book lit from above and in front throws a pool that spreads well past
       its own footprint. */
    --sh-x: 1.26;
    --sh-y: 0.3;
    --sh-o: 0.85;
    --sh-skew: 0deg;
    --sh-tx: 0%;
    --sh-ty: 50%;
    --shc-x: 0.8;
    --shc-y: 0.08;
    --shc-o: 0.9;
    --shc-skew: 0deg;
    --shc-tx: 0%;
    --shc-ty: 50%;
}

/* Angled — the right-hand edge swings away so the spine comes into view.
   A touch of rotateX as well: nobody photographs a book dead level.

   38deg, not the "few degrees" of the brief, and the reason is arithmetic.
   The spine is 0.533 in against an 8.25 in board, so its share of the
   book's apparent width is only (0.533 / 8.25) x tan(angle). At 10deg the
   spine is under 1% of the cover and invisible; at 38deg it is about 5%,
   which is where the printed name on it becomes legible. Below roughly
   30deg the book reads as a flat picture that has been nudged. */
.book3d.is-angled {
    --ry: 38deg;
    --rx: 4deg;
    --zoom: 1;
    --sheen: 34%;
    --sheen-o: 0.9;

    /* Turned away, the book's footprint narrows by roughly cos(38) and the
       spine end swings toward the camera — so the shadow narrows and leans.
       The skew is the whole trick: an unskewed shadow under a turned object
       is the thing that reads as a sticker rather than a book. */
    --sh-x: 1.08;
    --sh-y: 0.32;
    --sh-o: 0.85;
    --sh-skew: -28deg;
    --sh-tx: 5%;
    --sh-ty: 50%;
    --shc-x: 0.6;
    --shc-y: 0.095;
    --shc-o: 0.9;
    --shc-skew: -34deg;
    --shc-tx: 4%;
    --shc-ty: 50%;
}

/* Back — carried on round the same way, never rewound. */
.book3d.is-back {
    --ry: 180deg;
    --rx: 0deg;
    --zoom: 1;
    --sheen: 8%;
    --sheen-o: 0.45;

    /* Square to camera again, so the footprint matches the flat state. */
    --sh-x: 1.26;
    --sh-y: 0.3;
    --sh-o: 0.85;
    --sh-skew: 0deg;
    --sh-tx: 0%;
    --sh-ty: 50%;
    --shc-x: 0.8;
    --shc-y: 0.08;
    --shc-o: 0.9;
    --shc-skew: 0deg;
    --shc-tx: 0%;
    --shc-ty: 50%;
}

/* Back, hovered — the same courtesy the front cover gets, so the spine is
   reachable from either side without winding the book back. */
.book3d.is-back-angled {
    --ry: 218deg;
    --rx: 4deg;
    --zoom: 1;
    --sheen: 34%;
    --sheen-o: 0.7;

    /* IDENTICAL to is-angled, not mirrored. A shadow is cast by the object's
       footprint on the ground, and a flat rectangle's footprint is unchanged
       by a half turn — 218 degrees and 38 degrees lay the book down along the
       same line. Only the face pointed at the camera differs, and the floor
       cannot see that. Mirroring these was wrong and made the shadow jump
       sideways when the book passed 180. */
    --sh-x: 1.08;
    --sh-y: 0.32;
    --sh-o: 0.85;
    --sh-skew: -28deg;
    --sh-tx: 5%;
    --sh-ty: 50%;
    --shc-x: 0.6;
    --shc-y: 0.095;
    --shc-o: 0.9;
    --shc-skew: -34deg;
    --shc-tx: 4%;
    --shc-ty: 50%;
}

/* Open — an open book is twice as wide, so it steps back to stay in frame,
   and tips forward as though lying on a table just below eye level. */
/* THE OPEN BOOK IS CROPPED, and that reverses a deliberate constraint.
   Zlatan, 2026-09-09: "make the whole image bigger so the right hand page is
   90% of the canvas — the left page is cropped and visible is something like
   10% of it." Then, having seen it: "a book is too big, scale it down 10% from
   centre — the right page needs to be visible whole, and we can see a bit more
   of the left one."

   What was here originally: `--zoom: 0.5` exactly, so an open spread was the
   same width as the closed book — two half-scale pages side by side, a constant
   footprint whatever the book was doing, and no way for the spread to outgrow
   the container it was sized to fit. That is a good rule for showing an OBJECT.
   It is the wrong rule for showing a PAGE: it spent half the frame on the verso
   and left the reader the thing they came to look at at half size.

   TWO KNOBS, AND THEY ARE INDEPENDENT. The first version derived the slide by
   pinning the page's outer edge to the frame's right edge, which was wrong
   twice over: it left the page flush against the crop with no margin at all,
   and it welded size to position — so asking for a smaller book slid it right
   instead of drawing it toward the middle. Size and place are separate
   questions and now have separate answers.

     --open-page    the right page's share of the frame. Sets the scale.
     --open-gutter  where the spine sits across the frame. Sets the slide.

   from which:

     zoom  = open-page x cover / page       = 0.81 x 8.25 / 8 = 0.835
     shift = cover x (open-gutter - (1 - zoom) / 2)

   At these values the right page runs 14% -> 95% of the frame: whole, with
   5% of air outside it. The verso keeps 11.5%. Scaling about the frame centre
   is exactly `zoom` and `shift` scaling together by the same factor, which is
   why 0.9 -> 0.81 on the first knob and 0.141 -> 0.127 on the second is a
   clean 10% step and not a re-derivation.

   NOT SEEN IN A BROWSER. The pane was hidden for both sessions, so transforms
   never resolved and getBoundingClientRect returned the untransformed layout in
   every state. The untransformed numbers matched this model exactly and the
   resolved geometry is measured, so the arithmetic is sound — but --open-page
   and --open-gutter are the two numbers to nudge if the eye disagrees. */
.book3d.is-open {
    --open-page: 0.81;
    --open-gutter: 0.127;
    --ry: 0deg;
    --rx: 14deg;
    --zoom: calc(var(--open-page) * var(--i-cover) / var(--i-page));
    --shift: calc(var(--cover) * (var(--open-gutter) - (1 - var(--zoom)) / 2));
    --sheen: 60%;
    --sheen-o: 0.2;

    /* An open book is a tent, not a slab. The ambient pool spreads wide and
       low; the contact layer becomes a long flat smear that is darkest in
       the middle, which puts the density under the gutter — where the spine
       actually presses on the table — and lets it lift away toward the two
       outer page edges. That gradient across the width is what gives the
       open book its depth. */
    /* These offsets are a percentage of the shadow's own (unscaled) size,
       while the open book is at half scale — so the drops have to be roughly
       halved too, or the shadow detaches and floats below the book. The book's
       bottom edge lands near 24% of a cover once the 14deg tilt is taken off. */
    --sh-x: 1.28;
    --sh-y: 0.2;
    --sh-o: 0.62;
    --sh-skew: 0deg;
    --sh-tx: 0%;
    --sh-ty: 23%;
    --shc-x: 0.98;
    --shc-y: 0.075;
    --shc-o: 0.82;
    --shc-skew: 0deg;
    --shc-tx: 0%;
    --shc-ty: 26%;
}

/* The frame that does the cropping, and it only exists while the book is open.

   Width is exactly --cover, so "the canvas" means the same thing at every
   viewport: the footprint the book was already sized to fit. That is what lets
   --open-page be a single number rather than something recomputed against a
   stage whose width floats with the container.

   `overflow-x: clip`, NOT `hidden` — the house rule, and load-bearing here:
   `hidden` forces the other axis to compute as `auto`, which would turn the
   stage into a scroll container and let the cropped verso be scrolled into
   view. `clip` leaves overflow-y genuinely visible, which the shadows need,
   and creates no scroll container at all.

   Perspective and the clip sit on the SAME element, and the preserve-3d chain
   starts below it at .book3d__book — an ancestor clip between a perspective
   and its 3D descendants is what flattens a scene, and this is not that. */
/* THE CLIP IS NOT ON THE STAGE, and that is the whole point of this rule.

   It was, and Safari sliced the right-hand page into vertical bands while
   Chrome rendered it correctly (Zlatan, 2026-09-09). The stage is the element
   carrying `perspective`, and putting a clip on the same element that
   establishes the perspective makes WebKit build its clipping layer inside the
   3D rendering context — the faces then rasterise in tiles that do not line up,
   which is the slicing.

   Moving it one level up to .book3d fixes it because .book3d has no 3D of its
   own: the perspective still starts at .book3d__stage and the preserve-3d chain
   still starts at .book3d__book, so the scene is untouched and only the
   finished result is cropped. Clipping OUTSIDE the 3D context rather than
   inside it is the general rule; it is Chromium being forgiving that hid it.

   .book3d__controls sits inside this box too and is now bounded by the cover
   width. It is two centred buttons, so it was already narrower than that. */
.book3d.is-open {
    width: var(--cover);
    margin-left: auto;
    margin-right: auto;
    overflow-x: clip;
}

/* The ground shadows go while the book is cropped. They are tuned as a
   percentage of their own size against a half-scale book, so at 0.93 they
   would sit too small and too high — and more to the point, you can no longer
   SEE the book's footprint. A shadow under a page that runs off both edges of
   the frame is not a book sitting on a table, it is a smudge. Untouched for
   the closed, rotated and flipped states, which is where they do their work;
   drop --open-page back toward 0.5 and they come back. */
.book3d.is-open .book3d__shadow,
.book3d.is-open .book3d__shadow-core {
    opacity: 0;
}

/* --------------------------------------------------------------------------
   Small screens
   -------------------------------------------------------------------------- */
/* No size override here. The 92vw term in --in already governs phones, and a
   second rule fighting it is how the book ended up smaller than it was asked
   to be. Likewise no --open-page override: the spread is now WIDER than the
   frame at every width by design, and what stops a phone scrolling sideways is
   the stage's `overflow-x: clip`, not the old 0.5 zoom. Updated 2026-09-09. */
@media (max-width: 767px) {
    /* Less tilt — foreshortening costs more when there is less width to
       give away. */
    .book3d.is-open {
        --rx: 10deg;
    }

    .book3d__controls {
        gap: var(--space-2);
    }
}

/* --------------------------------------------------------------------------
   Reduced motion — the book still works, it just stops performing.
   Every state remains reachable; only the tweening goes.
   -------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
    .book3d__book,
    .book3d__hinge,
    .book3d__shadow,
    .book3d__spine,
    .book3d__block--left,
    .book3d__board .f--out::after {
        transition-duration: 1ms !important;
    }

    .book3d.is-turning .book3d__leaf,
    .book3d.is-turning .book3d__leaf-half--out,
    .book3d.is-turning .book3d__leaf-face::after {
        animation: none !important;
    }

    .book3d.is-turning .book3d__leaf {
        visibility: hidden;
    }
}
