/* ==========================================================================
   LA BRACE v2 — tokens, then sections in DOM order.
   The token block is the contract in ../DESIGN.md. Do not add a value here
   that is not in that file; add it there first.
   ========================================================================== */
:root {
  --ground:    oklch(96.5% 0.018 78);
  --ground-2:  oklch(92.0% 0.036 62);
  --ground-3:  oklch(91.5% 0.024 24);
  --ink:       oklch(18.0% 0.014 62);
  --ink-70:    oklch(42.0% 0.016 62);
  --ink-45:    oklch(60.0% 0.014 62);
  --red:       oklch(58.0% 0.196 34);
  --red-deep:  oklch(46.0% 0.165 32);
  --green:     oklch(29.0% 0.055 142);
  --on-dark:   oklch(95.0% 0.020 80);

  --font-display: "Big Shoulders Display", "Arial Narrow", sans-serif;
  --font-text:    "Piazzolla", Georgia, "Times New Roman", serif;

  --t-hero: clamp(3rem, 7.2vw, 8rem);
  --t-h2:   clamp(2.25rem, 4.6vw, 5rem);
  --t-h3:   clamp(1.75rem, 3vw, 2.75rem);
  --t-num:  clamp(7.5rem, 18vw, 19rem);
  --t-lede: clamp(1.0625rem, 1.15vw, 1.3125rem);
  --t-body: clamp(1rem, .95vw, 1.0625rem);
  --t-meta: .8125rem;

  --s1: 4px;  --s2: 8px;  --s3: 12px; --s4: 16px; --s5: 24px;
  --s6: 32px; --s7: 48px; --s8: 64px; --s9: 96px; --s10: 144px;
  --section-y: clamp(96px, 12vw, 200px);
  --gut:     clamp(20px, 5vw, 64px);
  --max:     min(1340px, 92vw);
  --col-gap: clamp(16px, 2vw, 32px);
  --header-h: 72px;

  --r-pill: 999px;
  --r-img:  4px;

  --e-out:   cubic-bezier(0.16, 1, 0.30, 1);
  --e-out-q: cubic-bezier(0.22, 1, 0.36, 1);
  --d-fast:  180ms; --d-base: 420ms; --d-slow: 900ms;

  /* The carousel's radius. Read by BOTH .pizze__orbit and .pizze__orbit .slot,
     which must rotate about the SAME centre or the pizzas never land on the
     board.

     There used to be an --orbit-step here too, with a comment insisting it
     MUST equal CONFIG.stage.orbitStep. Nothing ever read it: no rule in this
     file referenced var(--orbit-step), so the 'contract' was unenforced and
     free to drift. The step angle lives in script.js alone now.

     900vh was the old value, and it is why the carousel used to sweep the
     copy: at that radius a step is an almost perfectly straight horizontal
     line, so pizzas slid across .pizze__info instead of arcing away from it.
     100vh puts the hub just off the right edge and gives the arc real
     curvature. Tune with stage.orbitStep -- the two are read together. */
  --orbit-r: 100vh;
}

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

html {
  -webkit-text-size-adjust: 100%;
  /* `clip`, NOT `hidden`: hidden on an ancestor breaks position:sticky.
     html is the scrolling element, so clipping body alone does nothing. */
  overflow-x: clip;
}

body {
  margin: 0;
  color: var(--ink-70);
  font-family: var(--font-text);
  font-size: var(--t-body);
  line-height: 1.6;
  /* The field. Painted once, here, and never repeated per section, so
     section boundaries are invisible and the page reads as one sheet. */
  background-color: var(--ground);
  background-image:
    radial-gradient(120% 90% at 12% 0%,   var(--ground-3) 0%, transparent 55%),
    radial-gradient(120% 100% at 100% 30%, var(--ground-2) 0%, transparent 60%);
  background-attachment: fixed;
  /* `clip`, NOT `hidden`. `overflow: hidden` on an ancestor silently breaks
     `position: sticky`, which is the mechanism both stages depend on. */
  overflow-x: clip;
}

h1, h2, h3, .brand span, .nav a, .btn, .marquee, .num {
  font-family: var(--font-display);
  font-weight: 900;
  text-transform: uppercase;
  letter-spacing: -0.015em;
  color: var(--ink);
}

.wrap { width: var(--max); margin-inline: auto; }

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

/* The section numeral: this page's grammar, standing in for the tracked
   uppercase eyebrow labels v1 repeated above every heading. Shared by
   Le Pizze, Storia, Come si fa and Visita, so it lives here, not in a
   section block. */
.num {
  font-size: var(--t-num); line-height: .8;
  -webkit-text-stroke: 2px var(--ink-45); color: transparent;
  display: block;
}

/* Scroll-reveal animations for sections below the hero. */
.reveal { clip-path: inset(0 0 100% 0); transform: translateY(24px); opacity: 0; }
.reveal.is-in {
  clip-path: inset(0 0 0 0); transform: none; opacity: 1;
  transition: clip-path var(--d-slow) var(--e-out),
              transform var(--d-slow) var(--e-out),
              opacity   var(--d-base) var(--e-out);
}
.no-motion .reveal { clip-path: none; transform: none; opacity: 0; }
.no-motion .reveal.is-in { opacity: 1; transition: opacity 120ms linear; }

:focus-visible { outline: 2px solid var(--red-deep); outline-offset: 3px; }

.header {
  position: fixed; inset: 0 0 auto 0; z-index: 50; height: var(--header-h);
  display: flex; align-items: center;
  transition: background-color var(--d-base) var(--e-out),
              border-color    var(--d-base) var(--e-out);
  background-color: transparent;
  border-bottom: 1px solid transparent;
}
.header.is-stuck {
  background-color: color-mix(in oklch, var(--ground) 92%, transparent);
  backdrop-filter: blur(10px);
  border-bottom-color: color-mix(in oklch, var(--ink-45) 30%, transparent);
}
.header__in, .footer__in {
  width: var(--max); margin-inline: auto;
  display: flex; align-items: center; justify-content: space-between; gap: var(--s6);
}
.brand { display: inline-flex; align-items: center; gap: var(--s3); text-decoration: none; }
.brand svg { width: 18px; height: 24px; color: var(--red-deep); }
.brand span { font-size: 1.25rem; letter-spacing: 0.02em; }

.nav { display: flex; align-items: center; gap: var(--s6); }
.nav a { font-size: .9375rem; letter-spacing: .04em; text-decoration: none; font-weight: 800; }
.nav a:not(.btn):hover { color: var(--red-deep); }

/* Filled --red-deep, never --red: --on-dark on --red is 3.84:1 and fails AA
   for a label at this size. See DESIGN.md contrast rules. */
.btn {
  display: inline-flex; align-items: center; justify-content: center;
  padding: var(--s3) var(--s6); font-size: 1rem; letter-spacing: .06em;
  text-decoration: none; border: 0; cursor: pointer;
  background: var(--red-deep); color: var(--on-dark);
  transition: background-color var(--d-fast) var(--e-out),
              transform        var(--d-fast) var(--e-out);
}
.btn--pill { border-radius: var(--r-pill); }
.btn:hover { background: var(--ink); transform: translateY(-2px); }

.footer { background: var(--ink); color: var(--on-dark); padding: var(--s8) 0; margin-top: 0; }
.footer .brand span, .footer .brand svg { color: var(--on-dark); }
.footer__meta { font-size: var(--t-meta); color: color-mix(in oklch, var(--on-dark) 70%, transparent); margin: 0; text-align: right; }
/* Quieter than the address above it, but still readable: 55% of --on-dark on
   --ink measures 6.4:1, comfortably past AA for this size. A disclosure that
   has to be hunted for is not a disclosure. */
.footer__demo {
  display: inline-block; margin-top: var(--s3);
  color: color-mix(in oklch, var(--on-dark) 55%, transparent);
  font-style: italic; max-width: 46ch;
}

/* ---------- 01 HERO ------------------------------------------------------
   .hero is tall; .hero__stage is sticky inside it. ONE ScrollTrigger reads
   the section's progress (Task 4). There is no pin anywhere.              */
.hero { position: relative; height: 300vh; }
.hero__stage {
  position: sticky; top: 0; height: 100vh;
  display: grid; align-items: center;
  grid-template-columns: repeat(12, 1fr);
  column-gap: var(--col-gap);
  width: var(--max); margin-inline: auto;
}

.hero__copy { grid-column: 1 / span 6; grid-row: 1; position: relative; z-index: 2; }
.hero__title { margin: 0; font-size: var(--t-hero); line-height: 0.84; }
.hero__title .line { display: block; }
.hero__title .line--red { color: var(--red); }   /* 3.93:1, legal at this size only */
.hero__lede {
  margin: var(--s6) 0 var(--s7); max-width: 46ch;
  font-size: var(--t-lede); color: var(--ink-70);
}
.hero__cta { display: flex; gap: var(--s4); flex-wrap: wrap; }
.btn--ghost {
  background: transparent; color: var(--ink);
  box-shadow: inset 0 0 0 2px var(--ink); border-radius: var(--r-pill);
}
.btn--ghost:hover { background: var(--ink); color: var(--ground); }

.hero__art {
  grid-column: 6 / span 7; grid-row: 1;
  position: relative; height: 100vh;
  display: grid; place-items: center;
}

/* The clip is a FLAT cream. The page under it is NOT: body paints --ground
   plus two radial washes, so the ground beneath .hero__art measures about
   249,241,228 at the bottom left and 247,229,212 on the right, while the clip
   renders a uniform 249,228,203 everywhere. That is a delta of up to
   (1,14,27) -- a rectangle you can plainly see, and one no edge feather can
   touch, because feathering fixes the SEAM and this is the FILL.

   So the hero gets a wash of the clip's OWN colour to sit on: solid where the
   canvas is, faded over a long distance so it joins the two washes already in
   the design instead of reading as a panel. The outer stop is written
   rgb(... / 0) and not 'transparent', which in sRGB interpolates through
   transparent BLACK and greys the falloff. */
.hero__art::before {
  content: ""; position: absolute; z-index: 0;
  /* Horizontal reach is generous on purpose: the art column is only ~359px
     wide on a phone, so a small percentage leaves the falloff ON screen and the
     wash reads as a panel of its own. -26% carries it past the viewport edge at
     mobile widths and merely under the headline at desktop, where it is a soft
     cream-on-cream that the existing washes already imply. */
  inset: -16% -26%;
  background: radial-gradient(ellipse at 50% 46%,
              rgb(249 228 203) 0%,
              rgb(249 228 203) 46%,
              rgb(249 228 203 / 0) 80%);
  pointer-events: none;
}
#heroCanvas { position: relative; z-index: 1; display: block; }
#heroPoster {
  position: absolute; max-width: 100%; max-height: 100%;
  height: auto; display: none; z-index: 1;
}

/* The clip is OPAQUE now -- rendered on the page cream instead of matted -- so
   each frame lands as a hard-edged square of near-background colour. Close is
   not invisible: the render sits at ~#FBE6CD and the ground under .hero__art
   is --ground-2 ~#F7E0CD, so the seam reads as a faint rectangle. This feathers
   it out.

   Sized to --hero-sq, NOT to the element. The canvas box is the whole art
   column but drawImage() paints a CONTAINed square inside it, so a mask sized
   to the element would feather transparent margin and leave the real edge
   standing. measureHero() writes --hero-sq; the 100% fallback is what the
   no-motion poster uses, whose own box already is the square.

   Two intersecting linear gradients, not one radial. A radial closest-side
   hits full transparency at the edge midpoints, which means it has already
   cut the corners off entirely -- and the burst throws basil into the corners.
   Intersecting linears feather all four edges and keep them. */
#heroCanvas, #heroPoster {
  --feather: 4.5%;
  -webkit-mask-image:
    linear-gradient(to right,  transparent 0, #000 var(--feather), #000 calc(100% - var(--feather)), transparent 100%),
    linear-gradient(to bottom, transparent 0, #000 var(--feather), #000 calc(100% - var(--feather)), transparent 100%);
  -webkit-mask-size: var(--hero-sq, 100%) var(--hero-sq, 100%);
  -webkit-mask-position: center;
  -webkit-mask-repeat: no-repeat;
  -webkit-mask-composite: source-in;
  mask-image:
    linear-gradient(to right,  transparent 0, #000 var(--feather), #000 calc(100% - var(--feather)), transparent 100%),
    linear-gradient(to bottom, transparent 0, #000 var(--feather), #000 calc(100% - var(--feather)), transparent 100%);
  mask-size: var(--hero-sq, 100%) var(--hero-sq, 100%);
  mask-position: center;
  mask-repeat: no-repeat;
  mask-composite: intersect;
}
/* No canvas without scroll to drive it: the poster IS the fallback. */
.no-motion #heroCanvas { display: none; }
.no-motion #heroPoster { display: block; position: static; }

/* .hero__shadow is GONE. It existed because matting removed the background and
   a cast shadow IS background, so the render's own shadow went with it. The
   opaque clip keeps its shadows -- one per floating piece, correctly placed --
   which no single CSS ellipse could have faked once the pizza broke apart. */

/* ---------- 02 MARQUEE ---------------------------------------------------
   Full-bleed --red drench, skewed so it cuts the page rather than sitting
   on it. Type is 800 at 40px+, which is what makes --on-dark on --red legal
   at 3.84:1. A smaller marquee would have to drop to --red-deep.          */
.marquee {
  position: relative; overflow: hidden;
  background: var(--red); color: var(--on-dark);
  padding: var(--s5) 0;
  transform: skewY(-1.2deg);
  margin-block: var(--s9);
}
.marquee__track { display: flex; width: max-content; will-change: transform; }
.marquee__item {
  font-family: var(--font-display); font-weight: 800; text-transform: uppercase;
  font-size: clamp(40px, 4.4vw, 74px); line-height: 1; letter-spacing: .01em;
  color: var(--on-dark); white-space: nowrap; padding-inline: var(--s5);
  display: inline-flex; align-items: center; gap: var(--s5);
}
.marquee__item::after {
  content: "\2733"; font-size: .6em;
  color: color-mix(in oklch, var(--on-dark) 70%, transparent);
}

/* ---------- 03 LE PIZZE --------------------------------------------------
   Its OWN sticky stage, not chained to the hero. Two sequential sticky
   stages cannot collide the way two pins did, because sticky needs no
   spacer measurement. This is what lets the mechanic stay as-is.         */
.pizze { position: relative; height: 460vh; }
.pizze__stage {
  position: sticky; top: 0; height: 100vh;
  display: grid; grid-template-columns: repeat(12, 1fr);
  column-gap: var(--col-gap); align-items: center;
  width: var(--max); margin-inline: auto;
}
.pizze__info { grid-column: 1 / span 5; z-index: 2; }
.pizze__name  { margin: 0; font-size: var(--t-h2); line-height: .88; }
.pizze__desc  { margin: var(--s5) 0; max-width: 40ch; color: var(--ink-70); }
.pizze__price {
  font-family: var(--font-display); font-weight: 900;
  font-size: var(--t-h3); color: var(--red); margin: 0 0 var(--s7);
}
/* The peel asset is 900x2109. Its round head is 97.9% of the image width and
   its centre sits at 50% x / 18.8% y -- NOT at the image's own centre, because
   the handle takes up everything below it. Centring the image would park every
   pizza far below the head.

   The previous asset was 461x649 and CROPPED: solid pixels ran along its top
   and bottom edges, so the head showed a flat cut and the handle a sawn-off
   end. The old comment here claimed 466x662 and said the -90deg rotation
   'hides the asset's cropped handle end' -- it only moved the cut off-centre,
   and at 1440x900 both cuts were plainly visible. The asset was outpainted to
   a complete peel and re-keyed. The cropped original was kept beside it as
   peel-cropped-ORIGINAL.webp until the 2026-09-09 cleanup; it is gone now, and
   the numbers above are the record of why it had to be replaced.

   Rotated -90deg the head swings left and the (now complete, rounded) handle
   exits stage right past the viewport. Under that rotation the head centre
   lands (50% - 18.8%) * 2109/900 = 0.731 image-widths to the LEFT of the img's
   own centre, so pushing the img right by exactly --peel-head-dx puts the head
   on the board's centre. That is already where .pizze__orbit delivers each
   pizza, so the carousel maths needs no change: do not also move the orbit,
   or the two corrections cancel. */
.pizze__board {
  grid-column: 6 / span 7; grid-row: 1; position: relative; height: 100vh;
  --peel-w:       min(74vh, 50vw);
  --peel-head-dx: calc(var(--peel-w) * 0.731);
  /* Of the HEAD's diameter. The head is now 0.979 of --peel-w where the old
     cropped asset's was 0.954, so 0.86 became 0.84 to hold the SAME pie-to-head
     ratio on screen. The ~20% transparent padding referred to here lives in the
     pizza images, not the peel. */
  --pizza-w:      calc(var(--peel-w) * 0.84);
}
.pizze__peel {
  position: absolute; left: 50%; top: 50%;
  width: var(--peel-w);
  /* rotate() is last, so the translates above it stay in screen space. */
  transform: translate(-50%, -50%) translateX(var(--peel-head-dx)) rotate(-90deg);
}

/* The orbit and its slots MUST share a rotation centre. The slots sit at the
   orbit's centre point and pre-rotate about a point --orbit-r away; the orbit
   rotates about that same point, which is its own centre plus --orbit-r. Give
   the orbit no origin and it defaults to its own centre, the two rotations
   stop composing, and the carousel silently does nothing while the labels
   keep advancing.

   The hub is to the RIGHT now, not below. That is the whole fix for pizzas
   covering the copy: with the centre below, a rotation moves the pizza
   horizontally -- straight across .pizze__info. With it to the right, the
   board centre sits at the wheel's 9 o'clock and rotation moves the pizza
   vertically, up and away from the text, receding toward the hub as it goes.
   BOTH origins swap axis together; change one alone and the centres part. */
.pizze__orbit {
  position: absolute; inset: 0; will-change: transform;
  transform-origin: calc(50% + var(--orbit-r)) 50%;
}
.pizze__orbit .slot {
  position: absolute; left: 50%; top: 50%;
  /* Zero-size anchor: a percentage transform-origin resolves against the
     element's own box, so a sized slot would resolve --orbit-r from a
     different point than the orbit does. */
  width: 0; height: 0;
  transform-origin: var(--orbit-r) 50%;
  will-change: transform;
}
/* Sized off the peel, not the viewport, so the pizza-to-head ratio holds at
   every breakpoint. Was min(52vh,36vw) against a min(74vh,52vw) head = 69%,
   which read small; --pizza-w is 76% of the head. One number to dial. */
/* THREE nested transforms, one writer each. They are on separate elements on
   purpose -- putting any two on the same element makes the later writer clobber
   the earlier one's transform wholesale:

     .slot          rotate(i * step)          written once, at build
     .pizza-holder  scale + opacity           per scroll frame, gsap.quickSetter
     .slot img      centring + idle bob       one looping GSAP tween

   The holder exists ONLY to carry the scroll-driven scale. The first cut of
   this put that scale on the img alongside the idle tween, and the tween
   re-rendered the transform from its own cache every tick and silently dropped
   the scale -- opacity (not a transform) kept working, which is what made it
   look fine until the values were actually read back.

   Zero-sized, so its transform-origin is the slot's own point and scaling it
   scales the pizza about that point rather than sliding it. */
.pizze__orbit .slot .pizza-holder {
  position: absolute; left: 0; top: 0; width: 0; height: 0;
  will-change: transform, opacity;
  /* The vanishing point for .pizza-tilt below. perspective is its OWN
     property, not part of transform, so it coexists with the scale this
     element already gets written every scroll frame. The box is 0x0, so
     perspective-origin resolves to the pizza's own centre. */
  perspective: 900px;
}

/* FOURTH transform layer, written only by the pointer handler.

   It exists because the other three are taken -- .slot has the orbit angle,
   .pizza-holder the falloff scale, .slot img the idle bob -- and this file
   has already been bitten twice by two writers sharing one transform and
   silently dropping a property. A hover tilt on any of those would be the
   third time. */
.pizze__orbit .slot .pizza-tilt {
  position: absolute; left: 0; top: 0; width: 0; height: 0;
  transform-style: preserve-3d;
  will-change: transform;
}

/* GSAP OWNS THIS ELEMENT'S TRANSFORM once MOTION is true: initPizze() re-states
   the centring as xPercent/yPercent and then runs the idle bob on it. The
   translate below is the base for the paths GSAP never runs on (reduced motion,
   <900px -- where .pizze__board is display:none anyway). Do NOT write .slot
   img's transform from anywhere else; two writers on one transform is how the
   centring silently disappears. */
.pizze__orbit .slot img { width: var(--pizza-w); height: auto; display: block; transform: translate(-50%, -50%); }
/* The lift shadow is a CSS transition, NOT tracked per pointer frame:
   drop-shadow on an image this size is expensive enough to cost frames if
   recomputed on every pointermove. It fades in once and the tilt does the
   rest of the work. */
.pizze__orbit .slot img { transition: filter 220ms var(--e-out); }
.pizze__orbit .slot img.is-touched {
  filter: drop-shadow(0 26px 28px oklch(18% .014 62 / .30));
}

/* Both of these are the small-screen face of Le Pizze and are OFF by default.
   The fallback query turns them on and turns the carousel's own furniture --
   #sigName, #sigDesc, .pizze__price, .thumbs -- off, so the section reads as
   a wheel with a live caption above 900px and as a plain menu below it.
   .pizze__list is built by script.js on every path (see initPizze), so this
   pair of rules is the ONLY thing that decides which face is showing; nothing
   depends on the width the page happened to load at. */
.pizze__heading { display: none; margin: 0; font-size: var(--t-h2); line-height: .88; }
.pizze__list    { display: none; }
.pizze__list img { width: 100%; height: auto; display: block; }
.pizze__row .pizze__name { margin-top: var(--s5); }

.thumbs { display: flex; gap: var(--s4); }
.thumbs button {
  background: none; border: 0; padding: 0 0 var(--s3); cursor: pointer;
  border-bottom: 3px solid transparent; transition: border-color var(--d-fast) var(--e-out);
}
.thumbs button[aria-selected="true"] { border-bottom-color: var(--red); }
.thumbs img { width: clamp(54px, 4.4vw, 76px); height: auto; border-radius: var(--r-img); display: block; }

/* `.num` itself is defined in Task 2's shell CSS: it is page grammar used by
   Tasks 7, 8, 9 and 10, so it does not belong to any one section. Only its
   Le Pizze positioning modifier lives here. */
.num--bg {
  position: absolute; left: 0; top: 6vh; z-index: 0;
  opacity: .5; pointer-events: none;
}

/* ---------- 04 STORIA ---------------------------------------------------- */
.storia { padding-block: var(--section-y); }
.storia__in {
  width: var(--max); margin-inline: auto;
  display: grid; grid-template-columns: repeat(12, 1fr);
  /* --s10, not --s9. --fig--d hangs 96px up into this gap (see below); at
     --s9 the gap WAS 96px, so it hung exactly into --fig--b. */
  column-gap: var(--col-gap); row-gap: var(--s10);
  align-items: start;
}
.storia .num { grid-column: 1 / span 3; grid-row: 1; }
.storia__quote { grid-column: 4 / span 6; grid-row: 1; margin: 0; align-self: center; }
.storia__quote p {
  font-family: var(--font-text); font-style: italic;
  font-size: var(--t-h3); line-height: 1.25; color: var(--ink); margin: 0;
}
/* These four photos used to sit in the grid above on explicit rows, with four
   hand-tuned data-rate values and a -96px hang on figure d that had to stay
   smaller than the row-gap. All of that is gone -- they are a horizontal track
   now. The old geometry is in git; it was hard-won and is not worth
   re-deriving from scratch if the editorial version is ever wanted back. */
.storia__scroller { position: relative; }   /* height written by JS */
.storia__stage {
  position: sticky; top: 0; height: 100vh;
  overflow: hidden; display: flex; align-items: center;
}
.storia__track {
  display: flex; align-items: center; gap: var(--s8);
  padding-inline: max(var(--s8), calc((100vw - var(--max)) / 2));
  will-change: transform;
}
.gal { margin: 0; flex: 0 0 auto; width: min(46vw, 620px); }
.gal:nth-child(even) { width: min(34vw, 460px); }

/* The frame CLIPS and the image inside it is oversized, so the image can slide
   against the frame as the track moves. That counter-motion is the whole
   effect: the photos read as windows onto the room rather than tiles going
   past. 118% wide with -9% margin leaves ~9% of overhang each side, which is
   what caps PARALLAX in script.js -- raise one without the other and the
   image edge walks into frame. */
.gal__frame { overflow: hidden; border-radius: var(--r-img); aspect-ratio: 4 / 3; }
.gal:nth-child(even) .gal__frame { aspect-ratio: 3 / 4; }
.gal__frame img {
  width: 118%; height: 100%; margin-left: -9%;
  object-fit: cover; display: block; will-change: transform;
}
.gal figcaption {
  margin-top: var(--s3); font-size: var(--t-meta);
  color: var(--ink-70);
  font-family: var(--font-text);
}

/* ---------- 05 COME SI FA ------------------------------------------------ */
.craft { position: relative; padding-block: var(--section-y); }
.craft__in {
  width: var(--max); margin-inline: auto; position: relative; z-index: 1;
  /* Named, because the two header claw-backs below subtract it. Hard-coding
     --s10 in three places meant the phone override could not shrink the step
     rhythm without silently changing the header spacing too. */
  --craft-gap: var(--s10);
  display: grid; row-gap: var(--craft-gap);
}
.step { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap: var(--s7); align-items: center; }
/* nth-of-TYPE, not nth-child. The zig-zag is what makes this section read as
   a sequence, and nth-child counts EVERY sibling -- so adding the section's
   05 numeral as the first child of .craft__in renumbered all four steps and
   silently flipped the entire alternation. Counting divs only means a
   non-step sibling can never shift the parity again. */
.step:nth-of-type(even) { direction: rtl; }
.step:nth-of-type(even) > * { direction: ltr; }
.step h3 { margin: 0 0 var(--s4); font-size: var(--t-h3); }
.step p  { margin: 0; max-width: 46ch; color: var(--ink-70); }
/* .num--bg parks at left:0 / top:6vh, which works for Le Pizze because its
   stage keeps that corner empty. Craft's steps run the full column width AND
   already carry four big outline numerals of their own, so the default put 05
   straight through step 01's heading -- 53,321px2 of overlap, measured.

   Absolute positioning cannot solve it: the numeral is ~207px tall and the
   section's top padding is ~173px, so lifting it into that band still left it
   50px into step 01's glyph -- measured 13,002px2 against the 01 itself, not
   just its box. It has to RESERVE space rather than float over it, so it is a
   flow item here, with most of the row-gap clawed back so it reads as a header
   for the sequence instead of a fifth entry in it. Dropped to .28 so it cannot
   compete with the 01-04 it introduces.

   Safe as a grid child only because .step alternation now keys off
   nth-of-TYPE; as nth-child this span would renumber every step. */
.craft .num--bg {
  position: static;
  /* The ~120px that used to separate 05 from step 01 now sits under the
     TITLE instead: the numeral and the words it introduces are one header, so
     the numeral claws the row-gap almost all the way back and .craft__title
     carries the gap to the sequence. */
  margin-bottom: calc(var(--s3) - var(--craft-gap));
  opacity: .28;
}
/* Sized like every other section heading (.pizze__heading, .visita__title) so
   05 reads as the same grammar as 03 and 06, not as a step's h3. */
.craft__title {
  margin: 0 0 calc(var(--s9) - var(--craft-gap));
  font-size: var(--t-h2); line-height: .88;
}

.craft__line {
  position: absolute; inset: 0; width: 100%; height: 100%;
  color: color-mix(in oklch, var(--ink-45) 55%, transparent);
  pointer-events: none;
}

/* ---------- 06 VISITA ---------------------------------------------------- */
.visita { background: var(--green); color: var(--on-dark); padding-block: var(--section-y); }
.visita__in { width: var(--max); margin-inline: auto; }
.visita__title { margin: var(--s5) 0 var(--s7); font-size: var(--t-hero); line-height: .84; color: var(--on-dark); }
.num--light { -webkit-text-stroke-color: color-mix(in oklch, var(--on-dark) 40%, transparent); }
.btn--onGreen { box-shadow: inset 0 0 0 2px var(--on-dark); }
.btn--onGreen:hover { background: var(--ink); }

/* --red-deep on --green is ~1.9:1: the global focus ring is invisible here.
   This is the first section with real focusable controls on the drench, so
   the bug only becomes reachable now. */
.visita :focus-visible { outline-color: var(--on-dark); }

.visita__cols {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--s7); margin-top: var(--s9);
  font-family: var(--font-text); line-height: 1.66;
  color: color-mix(in oklch, var(--on-dark) 82%, transparent);
}
.visita__cols strong { font-family: var(--font-display); font-weight: 800; letter-spacing: .06em; color: var(--on-dark); }

/* ---------- PRENOTA ------------------------------------------------------
   The container hugs whichever beat is active: the active beat stays in flow,
   the others are taken out of it, so .prenota__beats is exactly as tall as
   what is on screen. They still share a top-left origin, so the cross-fade is
   unchanged and stays opacity-and-transform only, per DESIGN.md.

   This replaced a shared grid cell, where every beat contributed height and
   the container was pinned to the TALLEST of them. That held the address block
   and the footer perfectly still, but it left 302px of dead green under beat 0
   and 238px under beat 2 -- which reads as a hole in the section, not as
   spacing. Hugging the active beat trades that away: advancing from beat 0 to
   beat 1 grows the container ~300px and pushes what follows down the page.
   That is an ordinary expanding-form reflow and it is the deliberate choice.
   The height change is instant and never transitioned, so no layout property
   is animated.

   A beat's own height still grows as JS fills #dayStrip and #slotGrid; the
   min-height reservation on .prenota__slots below is what keeps picking a day
   from adding a second jump on top of the first. */
.prenota { margin-top: var(--s7); max-width: 780px; }

.prenota__beats { position: relative; }
.prenota__beat,
.prenota__done {
  /* Out of flow, so an inactive beat contributes NO height and the container
     hugs whichever beat is active. The active one is switched back to
     `relative` below and is what sizes the container. */
  position: absolute; top: 0; left: 0; width: 100%;
  /* Kept from the grid era and still load-bearing at mobile, where
     .prenota__days is flex-wrap: nowrap and grows to ~2002px: without a
     min-width floor the beat can be sized by that content instead of by its
     container, defeating overflow-x: auto on the day strip. */
  min-width: 0;
  margin: 0; padding: 0; border: 0;
  opacity: 0; visibility: hidden; pointer-events: none;
  transform: translateY(10px);
  transition: opacity var(--d-base) var(--e-out),
              transform var(--d-base) var(--e-out),
              visibility 0s linear var(--d-base);
}
.prenota__beat.is-on,
.prenota__done.is-on {
  position: relative;
  opacity: 1; visibility: visible; pointer-events: auto;
  transform: none;
  transition-delay: 0s;
}
/* `hidden` is the no-JS resting state and is removed on init. No rule here
   sets `display` on .prenota__beat itself -- `display: grid` above is on the
   PARENT .prenota__beats -- so this rule cannot actually lose to anything;
   it is belt-and-braces, harmless if JS never runs. */
.prenota__beat[hidden],
.prenota__done[hidden] { display: none; }

.prenota__legend {
  padding: 0; margin-bottom: var(--s5);
  font-family: var(--font-display); font-weight: 800;
  font-size: var(--t-h3); line-height: .95; color: var(--on-dark);
}

.prenota__choices,
.prenota__days,
.prenota__slots { display: flex; flex-wrap: wrap; gap: var(--s3); }
/* min-height reserves two rows of chips (measured at 1440x900: two real rows
   of slots, each containing a two-line "completo" chip, run 101px + a 12px
   --s3 gap + 101px = 214px) so picking a day does not visibly jerk the page
   before #slotGrid has anything in it. It is a floor, not a cap: a day with
   more than two rows of slots still grows past it. */
.prenota__slots { margin-top: var(--s5); min-height: 214px; }
.prenota__days  { gap: var(--s2); }

/* One chip shape for party size, day and slot. The input is the control;
   the span is the skin. Radios, not buttons, so arrow keys work for free. */
.chip { position: relative; display: inline-flex; cursor: pointer; }
.chip input {
  position: absolute; inset: 0; margin: 0;
  opacity: 0; cursor: pointer;
}
.chip span {
  display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 2px; min-width: 56px; padding: var(--s3) var(--s4);
  border-radius: var(--r-img);
  box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--on-dark) 40%, transparent);
  font-family: var(--font-display); font-weight: 800; font-size: 1rem;
  letter-spacing: .04em; color: var(--on-dark);
  transition: background-color var(--d-fast) var(--e-out),
              color            var(--d-fast) var(--e-out),
              box-shadow       var(--d-fast) var(--e-out);
}
.chip:hover span { box-shadow: inset 0 0 0 2px var(--on-dark); }
.chip input:checked + span {
  background: var(--on-dark); color: var(--green);
  box-shadow: inset 0 0 0 2px var(--on-dark);
}
.chip input:focus-visible + span { outline: 2px solid var(--on-dark); outline-offset: 3px; }

/* Closed days and full slots. Struck through rather than merely dimmed:
   dimming alone reads as low emphasis, not as unavailable. */
.chip input:disabled + span {
  /* 60%, not 45%: 45% computed to 3.71:1 on the green, conformant (WCAG
     exempts inactive components, and the state is not carried by colour
     alone -- see line-through below) but hard to read on a phone. 60% is
     ~5.0:1. Nothing else about the disabled styling changes. */
  color: color-mix(in oklch, var(--on-dark) 60%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--on-dark) 18%, transparent);
  text-decoration: line-through;
  text-decoration-thickness: 1px;
}
.chip input:disabled { cursor: not-allowed; }
.chip:has(input:disabled):hover span { box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--on-dark) 18%, transparent); }

.chip__sub {
  font-family: var(--font-text); font-weight: 400;
  font-size: .6875rem; letter-spacing: 0; text-transform: lowercase;
  text-decoration: none;
}

.prenota__days .chip span { min-width: 52px; }

.prenota__fields {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: var(--s5);
}
/* Three EXPLICIT rows -- label, input, message -- and the message row is
   reserved at a fixed height whether or not a message is in it. `hidden` sets
   display:none, which removes the message from grid flow, but an explicit row
   keeps its size with no item in it, so validating a field cannot change the
   field's height and nothing shifts.

   align-content: start is the other half. A grid's default align-content
   behaves as stretch, and auto-sized rows absorb free space, so when one field
   grew a message row the OTHER field -- stretched to match by the parent grid
   -- distributed that extra height into its own two rows. That is why the
   error made Nome's input visibly taller than Telefono's, and pushed it down
   the box, rather than simply adding a line under one of them. */
.field {
  margin: 0;
  display: grid;
  grid-template-rows: auto auto 1.25rem;
  align-content: start;
  gap: var(--s2);
}
.field label {
  font-family: var(--font-display); font-weight: 800;
  font-size: var(--t-meta); letter-spacing: .08em; text-transform: uppercase;
  color: var(--on-dark);
}
.field input {
  width: 100%; padding: var(--s3) var(--s4);
  background: transparent; border: 0; border-radius: var(--r-img);
  box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--on-dark) 40%, transparent);
  font-family: var(--font-text); font-size: var(--t-body); color: var(--on-dark);
  transition: box-shadow var(--d-fast) var(--e-out);
}
.field input::placeholder { color: color-mix(in oklch, var(--on-dark) 45%, transparent); }
.field input:hover { box-shadow: inset 0 0 0 1px var(--on-dark); }
/* --red is 3.84:1 on this drench and forbidden here (DESIGN.md). Invalid
   fields are marked by ring weight and a message, not by hue. */
.field input[aria-invalid="true"] { box-shadow: inset 0 0 0 2px var(--on-dark); }
.field__err {
  font-family: var(--font-text); font-style: italic; font-size: var(--t-meta);
  color: color-mix(in oklch, var(--on-dark) 82%, transparent);
}

.prenota__actions {
  display: flex; align-items: center; gap: var(--s5);
  margin-top: var(--s7);
}
.prenota__back {
  padding: 0; border: 0; background: none; cursor: pointer;
  font-family: var(--font-display); font-weight: 800;
  font-size: var(--t-meta); letter-spacing: .08em; text-transform: uppercase;
  color: color-mix(in oklch, var(--on-dark) 70%, transparent);
  transition: color var(--d-fast) var(--e-out);
}
.prenota__back:hover { color: var(--on-dark); }
.prenota .btn--onGreen[disabled] {
  opacity: .4; cursor: not-allowed;
}
.prenota .btn--onGreen[disabled]:hover { background: var(--red-deep); transform: none; }

.prenota__aside {
  margin: var(--s5) 0 0;
  font-family: var(--font-text); font-style: italic;
  color: color-mix(in oklch, var(--on-dark) 82%, transparent);
}
.prenota__aside a { color: var(--on-dark); }

.prenota__summary {
  margin: 0 0 var(--s3);
  font-family: var(--font-display); font-weight: 800;
  font-size: var(--t-h3); line-height: 1.05; color: var(--on-dark);
}
.prenota__done .prenota__back { margin-top: var(--s6); }

/* No lift needed: the container now hugs the active beat, so the dots already
   sit directly under whatever is on screen. An earlier version translated them
   up by the dead space a fixed-height container left behind. */
.prenota__dots {
  display: flex; gap: var(--s2); margin: var(--s7) 0 0; padding: 0; list-style: none;
}
.prenota__dots li {
  width: 28px; height: 2px;
  background: color-mix(in oklch, var(--on-dark) 30%, transparent);
  transition: background-color var(--d-base) var(--e-out);
}
.prenota__dots li.is-on { background: var(--on-dark); }

/* Width-only: a desktop user with reduced motion keeps the full nav. */
@media (max-width: 900px) {
  .header__in { gap: var(--s3); }
  .brand span { white-space: nowrap; font-size: 1.05rem; }
  /* Four links plus a pill do not fit 375px. The links go, the one action
     stays, per the brand kit's single-action rule. No burger menu: four
     anchors on a one-page site do not earn that machinery. */
  .nav a:not(.btn) { display: none; }
  .nav { gap: var(--s3); }
  .header .btn { padding: var(--s2) var(--s5); font-size: .8125rem; }

  /* The 21-day strip becomes one swipeable row rather than a wall of chips. */
  .prenota__days {
    flex-wrap: nowrap; overflow-x: auto; scroll-snap-type: x mandatory;
    padding-bottom: var(--s3); margin-inline: calc(var(--gut) * -1);
    padding-inline: var(--gut);
    scrollbar-width: none;
  }
  .prenota__days::-webkit-scrollbar { display: none; }
  .prenota__days .chip { scroll-snap-align: start; flex: 0 0 auto; }
  /* Same reservation as the base rule, re-measured for the 3-up mobile
     grid: at 390x844 two rows (each again containing a "completo" chip) also
     measure 101px + 12px gap + 101px = 214px. */
  .prenota__slots { display: grid; grid-template-columns: repeat(3, 1fr); min-height: 214px; }
  .prenota__slots .chip span { min-width: 0; width: 100%; }
  .prenota__actions { margin-top: var(--s6); }
}

/* ==========================================================================
   FALLBACKS. Under 900px or with reduced motion there is no scroll driving
   anything, so the stages stop being stages: they collapse to their final
   arrangement and the page becomes an ordinary stack.
   ========================================================================== */
@media (max-width: 900px), (prefers-reduced-motion: reduce) {
  .hero, .pizze { height: auto; }
  .hero__stage, .pizze__stage {
    position: static; height: auto;
    display: block; padding-block: var(--section-y);
  }

  /* One still, the sequence's last frame. No canvas, no preload gate, no
     frames fetched at all -- the poster is a separate <img> precisely so the
     small-screen path never pays for the sequence. */
  .hero__art {
    grid-column: auto; grid-row: auto;
    height: auto; margin-top: var(--s7);
  }

  /* Le Pizze becomes what it looks like without scroll: a list. */
  .pizze__board { display: none; }
  .pizze__info  { grid-column: auto; }
  .pizze__list  { display: grid; gap: var(--s8); margin-top: var(--s7); }

  /* The static heading in, the carousel's live caption and its thumbnail
     switcher out. Leaving them in showed the Margherita's name, description
     and price immediately above a list whose first row is the Margherita's
     name, description and price, with a row of thumbnails that select a
     carousel this breakpoint has already hidden. */
  .pizze__heading { display: block; }
  .pizze__info #sigName,
  .pizze__info #sigDesc,
  .pizze__info .pizze__price,
  .pizze__info .thumbs { display: none; }

  /* .num--bg is positioned absolute for the desktop stage, where the board
     sits beside the copy and the outlined numeral has its own empty column to
     stand in. Stacked, there is no such column and it landed directly on top
     of the heading. It becomes an ordinary block in the flow. */
  .num--bg { position: static; margin-bottom: var(--s4); }

  .marquee { transform: none; }
  .marquee__track { animation: none; }

  .storia__in { display: block; }
  /* No sticky, no track: the gallery collapses to a plain column, the same
     shape .pizze__list takes. transform is cleared with !important because JS
     writes it inline every frame on the motion path. */
  .storia__scroller { height: auto !important; }
  .storia__stage { position: static; height: auto; overflow: visible; display: block; }
  .storia__track {
    flex-direction: column; gap: var(--s8);
    padding-inline: 0; transform: none !important;
  }
  .gal, .gal:nth-child(even) { width: 100%; }
  .gal__frame, .gal:nth-child(even) .gal__frame { aspect-ratio: auto; }
  .gal__frame img {
    width: 100%; height: auto; margin-left: 0; transform: none !important;
  }
  .step { grid-template-columns: 1fr; direction: ltr; }
  .step:nth-child(even) { direction: ltr; }
}

/* ==========================================================================
   PHONE. Width only -- deliberately NOT sharing the query above.

   The block above is "there is no scroll driving anything", which is equally
   true of a 1440px desktop with reduced motion. This block is "the viewport
   is 390px wide", which is not. Everything here trades horizontal room for
   vertical room, and doing that to a wide screen would only make it worse.
   It sits AFTER that block so it wins on the widths where both apply.
   ========================================================================== */
@media (max-width: 900px) {
  /* clamp(96px, 12vw, 200px) floors at 96px, and 12vw only reaches 96 at
     800px wide -- so every phone got the desktop's maximum-density spacing
     with none of the desktop's room. Stacked, that padding is spent twice at
     every seam (one section's bottom plus the next one's top), which put 192px
     -- 23% of a 844px screen -- of empty ground between the hero still and the
     marquee, and again between the marquee and Le Pizze. 64px a side reads as
     the same rhythm at this scale and gives back most of a screen. */
  :root { --section-y: clamp(64px, 9vw, 88px); }

  /* Same arithmetic: the marquee's own --s9 margins land against section
     padding on both sides, so they are the other half of those 192px gaps.
     The skew still needs clearance, which is why this is --s7 and not 0. */
  .marquee { margin-block: var(--s7); }

  /* 144px between steps is a desktop figure -- it reads as air beside a
     two-column step, and as a hole under a one-column one. The two header
     claw-backs are written against --craft-gap, so they follow this down and
     the 05/title/step-01 spacing is unchanged. */
  .craft__in { --craft-gap: var(--s9); }

  /* Collapsed to one column, .step's --s7 stops being the gutter between a
     numeral and its text and becomes a 48px band between all THREE children --
     numeral, heading, paragraph -- so a single step spent 96px separating
     parts that belong together. That, more than the section gap, is what read
     as empty. The h3's own bottom margin goes with it or it stacks on top. */
  .step { gap: var(--s3); }
  .step h3 { margin-bottom: 0; }

  /* ---- Live hero clip -------------------------------------------------
     `.has-clip` is written by initHeroMobile() and only after it has decided
     the phone should have the sequence at all -- not reduced motion, not
     Save-Data, not 2g/3g. Without the class this breakpoint is exactly what
     it was: a poster in the flow. So the runway can never open under a
     player that is not going to run.

     The runway is an EMPTY ELEMENT after the art, not padding on the stage.
     That distinction is the whole reason this works: a sticky element's
     travel is bounded by its containing block's CONTENT box, and the art is
     the stage's last in-flow child. Padding-bottom sits outside that content
     box, so a 132svh padding gave the art exactly zero travel and it scrolled
     away without ever sticking -- computed style read `position: sticky` the
     whole time, which is what made it look like a cascade problem. A real
     element extends the content box, so the travel is genuinely there.
     min-height would also have worked, but only while the copy stayed shorter
     than the figure; a spacer is additive at any content height.

     svh, not vh: the art must hold its place while a mobile browser's
     toolbars slide in and out, and vh sizes against the tallest state.

     Header height is subtracted before centring because the header is fixed
     and overlaps the top of the viewport; centring in the full height would
     tuck the art's top edge under it. */
  :root { --hero-art-h: min(84vw, 46svh); }

  .hero__runway { height: 132svh; }
  .hero__stage.has-clip .hero__art {
    position: sticky;
    top: calc(var(--header-h) + (100svh - var(--header-h) - var(--hero-art-h)) / 2);
    height: var(--hero-art-h);
  }

  /* Beats `.no-motion #heroCanvas { display: none }` on source order alone --
     the two selectors weigh the same, and the whole reason this block sits at
     the bottom of the file is so the later one wins. `.no-motion` stays set:
     it is still true of every other section on this breakpoint, and the hero
     is the one exception it now has. */
  .hero-live #heroCanvas { display: block; }
  .hero-live #heroPoster { display: none; }

  /* Both of these are percentages of a box that is now a third of its desktop
     size, so both shrink to a fraction of what they were tuned to be. The
     feather was 4.5% of a ~700px square, ~32px; here 4.5% of 328px is 15px and
     the clip's cream square regained a visible edge against the page's own
     cream. The wash behind it lost its reach the same way. Restated against
     the smaller box rather than left to scale down with it. */
  #heroCanvas, #heroPoster { --feather: 9%; }
  .hero__art::before { inset: -45% -60%; }

  /* ---- Gallery: a swipeable rail, not a column ------------------------
     Stacked, the four photos ran 2380px -- nearly three phone screens of
     uninterrupted image, with no text between them to break the fall, and
     every frame the same portrait shape because aspect-ratio: auto let all
     four 820x1230 sources render at their native ratio. The desktop's
     landscape/portrait alternation, which is what gave the row its rhythm,
     was gone.

     Horizontal keeps the section's own idea -- photos travelling past a
     window -- and costs one screen instead of three. Native scroll-snap does
     it, so there is still no ScrollTrigger and no Lenis on this path: the
     mechanic is the finger. The card is 78vw so the next one always peeks,
     which is what tells a thumb the rail moves at all. */
  /* .storia__in stops being a grid up in the fallback block, which takes its
     row-gap with it -- the quote then sat directly on the first photo. */
  .storia__quote { margin-bottom: var(--s7); }

  .storia__track {
    flex-direction: row; gap: var(--s4);
    overflow-x: auto; overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory;
    /* The same inset .storia__in gets from `width: var(--max); margin: auto`,
       so the first card's left edge lines up with the quote above it rather
       than sitting 5px inside it. --gut is a different number at this width. */
    padding-inline: calc((100% - var(--max)) / 2);
    scroll-padding-inline: calc((100% - var(--max)) / 2);
    scrollbar-width: none;
  }
  .storia__track::-webkit-scrollbar { display: none; }
  .gal, .gal:nth-child(even) {
    width: 78vw; flex: 0 0 auto; scroll-snap-align: center;
  }
  /* One shape for all four. Alternating ratios inside a snap rail makes the
     snap points land at uneven heights, which reads as the row wobbling. */
  .gal__frame, .gal:nth-child(even) .gal__frame { aspect-ratio: 4 / 5; }
  .gal__frame img {
    width: 100%; height: 100%; margin-left: 0;
    object-fit: cover; transform: none !important;
  }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important; animation-iteration-count: 1 !important;
    transition-duration: 120ms !important;
  }

  /* Beats swap on opacity alone: no slide. The `visibility 0s` written here
     is overridden by the `transition-duration: 120ms !important` above, so
     it actually runs at 120ms, not 0s -- which is the correct behaviour, not
     a bug: it holds the outgoing beat visible for exactly the length of the
     opacity fade, same as the non-reduced path's explicit
     `visibility 0s linear var(--d-base)` does for --d-base. */
  .prenota__beat, .prenota__done { transform: none; transition: opacity 120ms linear, visibility 0s; }
  .prenota__beat.is-on, .prenota__done.is-on { transform: none; }
}
