:root {
  --paper: #f5f4f2; /* near-white ground, sampled from the client's own reference art (not a generic reach) */
  --ink: #1c1714; /* near-black, sampled from the ume-branch ink — text and every structural mark */
  --ink-soft: #6b625d;
  --rose: #dc7578; /* dusty rose — sampled median of both reference images' line art/blossom color */
  --rose-text: #a13d44; /* AA-safe darkened rose — 5.85:1 on Paper, 6.43:1 on white */
  --card: #ffffff;
  --blush: #f5cdd0;

  --font-display: "Taviraj", serif; /* Thai serif with brush-like stroke contrast; also covers Latin */
  --font-jp: "Shippori Mincho", serif; /* real Japanese mincho serif, used only for actual Japanese text */
  --font-body: "Noto Sans Thai", -apple-system, "Segoe UI", sans-serif;

  --radius-tag: 8px;
  --column-max: 28rem;
  --column-pad: 1.25rem;
}

* { box-sizing: border-box; }

html { scroll-behavior: smooth; }

/* Only on <html>. Putting overflow-x on <body> as well made body a scroll
   container, and a sticky element's scrollport is its nearest scrolling
   ancestor — so .site-header stuck to a box that never scrolls and simply rode
   the page out of view. The language toggle lives in that header and is the
   only route to English, so a shopper who scrolled lost it entirely until they
   scrolled back to the top. Clipping on <html> alone still contains the float
   stickers that bleed past the right edge. */
html {
  overflow-x: hidden;
}

body {
  margin: 0;
  background-color: var(--paper);
  color: var(--ink);
  font-family: var(--font-body);
  line-height: 1.6;
  -webkit-tap-highlight-color: transparent;
}

::selection {
  background: var(--rose);
  color: var(--paper);
}

::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--paper); }
::-webkit-scrollbar-thumb { background: var(--ink); border-radius: 999px; border: 2px solid var(--paper); }

/* --rose is 2.79:1 on Paper, under the 3:1 a focus ring has to clear to be a
   focus ring at all. --rose-text is the same hue at 5.85:1 and costs nothing.
   No border-radius here: that property rounds the ELEMENT, not the outline, so
   a focus rule setting it reshapes whatever is focused for as long as it is
   focused. It only ever looked harmless because every rounded control declares
   its own radius further down this file and wins on order — move this rule
   below them and the circular .book__nav squares off under the finger. The
   outline already follows the element's own radius on its own. */
:focus-visible {
  outline: 2px solid var(--rose-text);
  outline-offset: 3px;
}

a { color: var(--rose-text); }

img { max-width: 100%; display: block; }

/* Text for a screen reader that must not take up any room: the teaser's live
   announcement of the fronted pack, and the product name inside the book
   counter, which has to keep the width of "รุ่นที่ 2 / 8" between the arrows.
   Not display:none and not visibility:hidden — both of those take the element
   out of the accessibility tree, and an element that is not in that tree cannot
   announce anything. `clip` is the superseded spelling of clip-path and is kept
   ahead of it for the same reason every color-mix() here has a plain value
   first: an un-updated Android System WebView is what an in-app browser runs
   on, and an in-app browser is how a QR sticker is opened. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Floating parallax stickers — real cut elements from the client's reference
   sheet, never redrawn. Each sits in a spacious "atmosphere" zone (never
   inside the product book, whose pages stay scannable for a
   10-30s shelf glance) and is nudged at its own scroll speed via JS for
   the layered-depth "designer portfolio" feel. */
.float-el {
  position: absolute;
  pointer-events: none;
  opacity: 0.9;
  z-index: 1;
}

/* The scroll-linked parallax drift (yPercent, set by GSAP/ScrollTrigger) lives
   on .float-el itself; the ambient idle bob below lives on the image inside
   it. Two elements, two independent transforms, so neither fights the other
   for the same property. */
@keyframes float-bob {
  0%, 100% { transform: translateY(0) rotate(0deg); }
  50% { transform: translateY(-16px) rotate(5deg); }
}

.float-el__img {
  width: 100%;
  height: auto;
  animation: float-bob 5s cubic-bezier(0.77, 0, 0.175, 1) infinite;
}

.hero__float--tulip .float-el__img { animation-duration: 4.5s; }
.hero__float--catface .float-el__img { animation-duration: 6.2s; animation-delay: -3s; }
.site-footer__float--vase .float-el__img { animation-duration: 5.2s; animation-delay: -2.4s; }
.site-footer__float--ramen .float-el__img { animation-duration: 6.6s; animation-delay: -4.1s; }
.teaser__float--koi .float-el__img { animation-duration: 5.6s; animation-delay: -1.2s; }
.teaser__float--fan .float-el__img { animation-duration: 4.9s; animation-delay: -0.6s; }

@media (prefers-reduced-motion: reduce) {
  .float-el__img { animation: none; }
}

/* Scroll progress — a hairline read of "how much catalog is left", on a page
   that is ~6500px tall on a phone. Sits above the sticky header (z-index 20).
   Starts at scaleX(0) and is only ever driven by GSAP, so with JS off or
   reduced-motion on it stays invisible rather than showing a false full bar. */
.scroll-progress {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  z-index: 30;
  background: var(--rose-text);
  transform: scaleX(0);
  transform-origin: 0 50%;
  pointer-events: none;
}

/* Header */
.site-header {
  position: sticky;
  top: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.75rem 1.25rem;
  /* index.html asks for viewport-fit=cover, which lets the layout run under a
     display cutout. In landscape on a notched phone that is exactly where the
     language toggle sits — the only route to English on this page — so the side
     padding grows to clear the inset where there is one. Written as two
     longhand overrides rather than into the shorthand above so that an engine
     without env() or max() still gets the 1.25rem. Portrait is unchanged.
     The footer deliberately gets no such treatment: its 3rem bottom padding
     already clears a 34px home indicator, so an inset there would be a no-op. */
  padding-right: max(1.25rem, env(safe-area-inset-right));
  padding-left: max(1.25rem, env(safe-area-inset-left));
  /* Opaque, and the backdrop-filter is gone with the translucency.
     `backdrop-filter: saturate(140%) blur(6px)` on a sticky element re-samples and
     blurs a full-width band on every scroll frame — 2-6ms of GPU on the Mali/Adreno
     class of chip a shopper is actually holding, and among the most expensive things
     a mobile page can ask for. It was buying nothing: the ground behind it is #F5F4F2
     at 92% over a #F5F4F2 page, so there was no colour to saturate and almost nothing
     to blur. The one case where it did show — a product photo passing underneath —
     is the case an 8% ghost hurts rather than helps, so the band goes solid instead
     of committing harder to the bleed. Nothing else on the page changes.
     The background no longer needs the color-mix fallback pair it used to carry —
     it is a flat token now. The border below still does: every color-mix() on this
     page is preceded by a plain value, because an engine that does not know the
     function drops the whole declaration rather than part of it, and an un-updated
     Android System WebView is what an in-app browser runs on. */
  background: var(--paper);
  border-bottom: 1px solid rgba(28, 23, 20, 0.1);
  border-bottom: 1px solid color-mix(in srgb, var(--ink) 10%, transparent);
}

.site-header__brand {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
}

.site-header__logo {
  height: 1.6rem;
  width: auto;
  flex-shrink: 0;
}

.lang-toggle {
  flex-shrink: 0;
  display: inline-flex;
  border: 1px solid rgba(28, 23, 20, 0.25);
  border: 1px solid color-mix(in srgb, var(--ink) 25%, transparent);
  border-radius: 999px;
  padding: 3px;
  background: var(--card);
  font-family: var(--font-body);
}

.lang-toggle__opt {
  min-width: 2.75rem;
  min-height: 2.75rem;
  padding: 0.3rem 0.7rem;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  border: none;
  border-radius: 999px;
  background: transparent;
  color: var(--ink-soft);
  font-family: inherit;
  cursor: pointer;
  transition: background 0.2s ease, color 0.2s ease;
}

.lang-toggle__opt[aria-pressed="true"] {
  background: var(--rose-text);
  color: var(--paper);
}

/* Hero — asymmetric: the ume branch bleeds top-right, content sits lower-left */
/* Capped on the hero itself, not on the branch inside it. Capping the branch
   fixed the 882px-tall desktop hero and broke the composition doing it: the wrap
   keeps `margin-left: auto`, so a capped branch parks against the viewport's
   right edge while .hero__content stays centred, and the two stop overlapping
   entirely — measured at 1440px, a 43px gutter where the design wants the copy
   sitting under the branch. Capping the hero keeps the branch's bleed relative
   to the column exactly as it is on a phone. No-op below 488px, so every phone
   renders byte-identically. The px floor inside min() is load-bearing for the
   same reason it is on .book__stage: --column-max is in rem, so a large system
   font would otherwise lift the cap past the point of having one. */
.hero {
  position: relative;
  max-width: min(calc(var(--column-max) + 2 * var(--column-pad)), 488px);
  margin-inline: auto;
  padding: 0 1.25rem 2.5rem;
  overflow: hidden;
}

/* The 84% is a phone measurement. Uncapped it keeps scaling: on a 1440px
   desktop the branch renders 1176x882 and the headline starts ~850px down a
   page that has no fold to speak of. Capped at the same --column-max the rest
   of the page uses, so the branch stays in proportion with the content it sits
   over — and never renders wider than the 539px source webp. The largest phone
   in portrait is still under the cap (84% of 430px is 328px), so nothing about
   the mobile hero moves. */
.hero__illustration-wrap {
  position: relative;
  width: 84%;
  margin: 0 -1.25rem 0 auto;
  aspect-ratio: 4 / 3;
  -webkit-mask-image: linear-gradient(200deg, #000 55%, transparent 92%);
  mask-image: linear-gradient(200deg, #000 55%, transparent 92%);
  pointer-events: none;
}

.hero__illustration {
  width: 100%;
  height: 100%;
  object-fit: contain;
  object-position: top right;
  transform-origin: top right;
  animation: branch-sway 8s cubic-bezier(0.77, 0, 0.175, 1) infinite;
}

/* The branch "grows" from the top-right, where it bleeds off-screen, so it
   sways from that fixed point rather than its own center. */
@keyframes branch-sway {
  0%, 100% { transform: rotate(0deg); }
  50% { transform: rotate(-1.5deg); }
}

@media (prefers-reduced-motion: reduce) {
  .hero__illustration { animation: none; }
}

.hero__content {
  position: relative;
  max-width: var(--column-max);
  margin: -2rem auto 0;
  text-align: center;
}

.hero__glow-wrap {
  position: absolute;
  left: 50%;
  top: 0.5rem;
  width: 130px;
  height: 130px;
  transform: translateX(-50%);
  pointer-events: none;
}

.hero__glow {
  width: 100%;
  height: 100%;
  object-fit: contain;
  animation: glow-breathe 6s cubic-bezier(0.77, 0, 0.175, 1) infinite;
}

@keyframes glow-breathe {
  0%, 100% { transform: scale(1) translateY(0); }
  50% { transform: scale(1.08) translateY(-6px); }
}

@media (prefers-reduced-motion: reduce) {
  .hero__glow { animation: none; }
}

.hero__mascot {
  position: relative;
  margin: 0 auto 1rem;
  border-radius: 50%;
  animation: mascot-bob 4s cubic-bezier(0.77, 0, 0.175, 1) infinite;
}

@keyframes mascot-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-10px); }
}

@media (prefers-reduced-motion: reduce) {
  .hero__mascot { animation: none; }
}

.hero__headline {
  position: relative;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(1.75rem, 6vw, 2.5rem);
  line-height: 1.2;
  color: var(--ink);
  margin: 0 0 0.5rem;
}

/* Nudged up from 1rem. This line now carries the brand name and the ranking
   claim rather than a soft tagline, so it earns more weight than a subtitle
   usually would — but it stays clearly under the headline (28px vs 17.6px on a
   phone) so the hero still reads as one statement with one follow-up, not two
   competing lines. */
.hero__sub {
  position: relative;
  margin: 0;
  font-size: 1.1rem;
  color: var(--ink-soft);
}


/* Masked slide reveal — the signature move on jp.marugame.com, where a
   clip-path window holds still while the copy slides into it. Their mask
   bleeds far past the text on the axis it is NOT revealing along (theirs
   is -80px vertically for a horizontal slide); here the slide is vertical,
   so the bleed goes sideways and well above, which also keeps Thai tone
   marks and ascenders from ever being clipped by the mask edge. */
.hero__headline,
.hero__sub {
  -webkit-clip-path: polygon(-20% -120%, 120% -120%, 120% 100%, -20% 100%);
  clip-path: polygon(-20% -120%, 120% -120%, 120% 100%, -20% 100%);
}

.hero__reveal-inner {
  display: block;
  animation: mask-rise 0.67s cubic-bezier(0.33, 0, 0.24, 1) 0.15s both;
}

.hero__reveal-inner--delayed {
  animation-delay: 0.23s; /* ~0.08s behind the headline, matching their stagger step */
}

@keyframes mask-rise {
  from { transform: translateY(118%); opacity: 0; }
  to { transform: translateY(0); opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .hero__reveal-inner { animation: none; }
  .hero__headline,
  .hero__sub { -webkit-clip-path: none; clip-path: none; }
}

.hero__float--tulip {
  width: 3.4rem;
  top: 3rem;
  left: -1rem;
  opacity: 0.85;
}

.hero__float--catface {
  width: 2.6rem;
  bottom: 0.5rem;
  left: 0.5rem;
  opacity: 0.8;
}

/* Teaser */
.teaser {
  position: relative;
  padding: 0.5rem 1.25rem 2rem;
  text-align: center;
}

.teaser__illustration {
  position: absolute;
  top: -0.25rem;
  left: 0.25rem;
  width: 4rem;
  height: auto;
  opacity: 0.9;
}

.teaser__hint {
  position: relative;
  margin: 0 0 1.25rem;
  font-size: 0.78rem;
  /* 400, not 500. Weight 500 of Noto Sans Thai was pulled down for this rule and
     .leaf__pill alone — two rules, ~58 KB of font files across a Thai and a Latin
     subset. Both were re-pointed at weights the page already loads: a hint line is
     the last thing that should compete for attention, so it takes 400. */
  font-weight: 400;
  color: var(--ink-soft);
}

.teaser__stage {
  position: relative;
  /* Tall enough for the enlarged card plus the vertical offset the fanned
     neighbours take (yStep x ~3 cards before they fade out). */
  height: 17.5rem;
  max-width: var(--column-max);
  margin: 0 auto;
  overflow: hidden;
  /* renderTeaserFrame() writes z-index straight onto the cards, up to 100, so
     the fan stacks front-to-back correctly. Without a stacking context here
     those values compete in the root context and beat the sticky header's 20:
     scrolling the teaser up under the header drew the cards over the top of it,
     wordmark, language toggle and all. position:relative alone does not open a
     context and neither does overflow:hidden — isolation does, and it changes
     nothing about how the cards stack against each other. */
  isolation: isolate;
}

.teaser__drag {
  position: absolute;
  inset: 0;
  z-index: 10;
  cursor: grab;
  /* pinch-zoom is listed explicitly because pan-y on its own withdraws it.
     This layer covers the whole card stage, and the book below covers the
     smallest type on the page; a shopper who needs to zoom to read a pack name
     is exactly the shopper this page is for, and the aisle audience skews
     older. The viewport meta has never blocked zoom — touch-action was. */
  touch-action: pan-y pinch-zoom;
  /* Inward, uniquely on this control. #teaser-drag is inset:0 inside a stage
     that clips (overflow: hidden), so their boxes are identical and the global
     +3px offset puts the entire ring outside the clip rect — the teaser's only
     keyboard-focusable control had no visible focus indicator at all, in any
     colour. Pulling the ring inside the box is the only way it survives the
     clip; every other focusable element keeps the outward offset. */
  outline-offset: -3px;
}

.teaser__drag:active { cursor: grabbing; }

/* Three stacked bands, not overlays: tag strip, photo, name. The tag used to
   float over the top-right corner of the product shot, which put type across
   the one thing a shopper is trying to recognise — the pack itself. Giving it
   its own full-width band costs a little height and buys an unobstructed photo. */
.teaser__card {
  position: absolute;
  top: 50%;
  left: 50%;
  display: flex;
  flex-direction: column;
  width: 9rem;
  height: 12.5rem;
  border-radius: var(--radius-tag);
  overflow: hidden;
  background: #faf9f8;
  background: color-mix(in srgb, var(--paper) 55%, var(--card));
  border: 1px solid rgba(28, 23, 20, 0.12);
  border: 1px solid color-mix(in srgb, var(--ink) 12%, transparent);
  pointer-events: none;
}

/* Promoted only while the fan is actually moving. will-change is a hint about an
   imminent change, not a permanent state, and holding it on all eight cards kept
   eight compositor layers alive for the whole visit — roughly 8 MB of GPU texture
   at DPR 3, on a phone, for a widget a shopper may never touch. It cannot simply be
   deleted: these cards are moved by per-frame JS style writes, not by a CSS
   animation, so Chrome will not promote them on its own and dropping the hint costs
   real smoothness. app.js adds .teaser__stage--live on pointerdown and when a spring
   starts, and takes it off once the spring settles. */
.teaser__stage--live .teaser__card {
  will-change: transform, opacity;
}

.teaser__card img {
  flex: 1;
  min-height: 0; /* without this a flex child refuses to shrink below its image */
  width: 100%;
  object-fit: contain;
  padding: 0.45rem;
  box-sizing: border-box;
}

/* The strip: a fixed band that holds its height whether the tag runs to one
   line or two, so the cards stay the same shape as they fan. It centres its
   text rather than letting it sit against the top edge. */
.teaser__card-badge {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 2.35rem;
  box-sizing: border-box;
  padding: 0.25rem 0.4rem;
  background: var(--rose-text);
  color: var(--paper);
  text-align: center;
}

/* The text: clamped to two lines so a long tag cannot push the strip open. It
   is the text that fades on the fanned-out cards, never the strip — fading the
   strip would leave a blank band above every card but the front one. A tag cut
   short here is readable in full on that product's own page; this is the index,
   not the entry. */
.teaser__card-badge-text {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  /* 0.7rem is this page's floor for Thai. Two clamped lines at 1.3 leading is
     29.1px, which with the badge's 0.25rem padding still lands inside its
     2.35rem min-height — so the strip does not open and the fan keeps its
     shape. Raise the leading or the clamp and that stops being true. */
  font-size: 0.7rem;
  font-weight: 600;
  line-height: 1.3;
}

.teaser__card-name {
  width: 100%;
  box-sizing: border-box;
  margin: 0;
  padding: 0.3rem 0.45rem 0.4rem;
  font-family: var(--font-display);
  font-size: 0.75rem;
  font-weight: 600;
  line-height: 1.3;
  text-align: center;
  color: var(--ink);
  /* No opacity transition here or on .teaser__card-badge-text, deliberately.
     renderTeaserFrame() writes style.opacity on both every animation frame, so a
     0.2s transition was restarted sixteen times a frame and retargeted before it
     could finish: the text lagged the card it belongs to by ~200ms and kept fading
     for another 200ms after the spring had already stopped. The rAF loop is the
     interpolation; a transition on top of it is a second one fighting the first. */
}

.teaser__float--koi {
  width: 4rem;
  bottom: -0.5rem;
  right: -0.75rem;
  opacity: 0.8;
}

.teaser__float--fan {
  width: 3rem;
  top: -0.5rem;
  right: 1rem;
  opacity: 0.75;
}

/* StPageFlip's own structural CSS, vendored verbatim from
   page-flip@2.0.7/src/Style/stPageFlip.css (815 bytes) rather than pulled as a
   second CDN request. It is pure geometry — no color, no type — so nothing here
   competes with the design below. Re-copy it if the pinned version in
   index.html ever moves. */
.stf__parent {
  position: relative;
  display: block;
  box-sizing: border-box;
  transform: translateZ(0);
  -ms-touch-action: pan-y;
  touch-action: pan-y;
}

/* Restores pinch-zoom over the book, which the vendored rule above withdraws:
   touch-action: pan-y grants the vertical pan and nothing else, so two-finger
   zoom dies on the element carrying the densest type on the page. The rule is
   written against the id rather than by correcting the block above, for two
   reasons — that block is a verbatim copy and is worth keeping verbatim, and
   editing it would not be enough anyway: page-flip.browser.js injects its own
   copy of .stf__parent into <head> at runtime, so only a higher-specificity
   selector survives. Vertical scrolling is untouched, and initBookSwipe's own
   axis lock still claims horizontal drags with preventDefault. */
#book-pages {
  touch-action: pan-y pinch-zoom;
}

.sft__wrapper {
  position: relative;
  width: 100%;
  box-sizing: border-box;
}

.stf__parent canvas {
  position: absolute;
  width: 100%;
  height: 100%;
  left: 0;
  top: 0;
}

.stf__block {
  position: absolute;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  perspective: 2000px;
}

.stf__item {
  display: none;
  position: absolute;
  transform-style: preserve-3d;
}

.stf__outerShadow,
.stf__innerShadow,
.stf__hardShadow,
.stf__hardInnerShadow {
  position: absolute;
  left: 0;
  top: 0;
}

/* Book — the orihon rebound. The accordion's folded leaves became bound pages:
   one pack per page, turned by hand. Eight near-identical red boxes are the
   whole problem this page exists to solve, and a scrolling list lets a hurried
   eye slide past all eight at once. A page can only show one. */
.book {
  position: relative;
  padding: 0.5rem 0 1.25rem;
}

.book__hint {
  max-width: var(--column-max);
  margin: 0 auto 0.6rem;
  padding: 0 var(--column-pad);
  text-align: center;
  font-size: 0.82rem;
  line-height: 1.5;
  color: var(--ink-soft);
}

/* initBook() adds .book--flip only once page-flip.browser.js is actually
   running. Until then — a blocked CDN, a slow one, a stripped-down host — the
   eight products are still all there as a plain readable stack, which is the
   point of building them at parse time. But three pieces of chrome describe an
   interaction that does not exist in that state: a hint telling the shopper to
   swipe left and right, prev/next arrows wired to a turnBook() that returns
   immediately, and two decorative cover pages that are only covers when
   something can be opened. Hide them rather than let the fallback promise a
   book it cannot deliver. */
.book:not(.book--flip) .book__hint,
.book:not(.book--flip) .book__controls,
.book:not(.book--flip) .book__page--cover,
.book:not(.book--flip) .book__page--end {
  display: none;
}

/* The cap is what keeps the book in portrait, and portrait is what makes the
   page counter mean anything. StPageFlip picks its orientation from
   `blockWidth < 2 * minWidth` (500px here) and, in landscape, pairs the pages
   and reports the LEFT one from getCurrentPageIndex() — so the counter walks
   1, 3, 5, 7 and never names the product actually under the reader's thumb. A
   phone turned sideways is ~844px, so this was never a desktop-only problem.

   Capping the parent rather than .book__pages is deliberate: the library writes
   `max-width: 840px` as an inline style on its own host element, and an inline
   style beats a stylesheet rule. The parent is the one surface it does not own.

   The px floor in min() is load-bearing, not belt-and-braces: --column-max is in
   rem, so at a 20px root font — Android's larger font settings, Chrome's
   "Large" — the rem-only cap measures 560px, clears 500px, and the whole fix
   silently reverts for exactly the readers who most need one big page. */
.book__stage {
  display: flex;
  justify-content: center;
  padding: 0 var(--column-pad);
  max-width: min(calc(var(--column-max) + 2 * var(--column-pad)), 488px);
  margin: 0 auto;
}

.book__pages {
  width: 100%;
  max-width: var(--column-max);
}

/* Until page-flip.browser.js arrives and sets .book--flip, the pages are just a
   readable stack. A shopper on a network that blocks the CDN still gets all
   eight products — they lose the page turn, not the catalog. */
.book__page {
  position: relative;
  background: var(--card);
  border: 1px solid rgba(28, 23, 20, 0.14);
  margin-bottom: 0.85rem;
}

.book--flip .book__page {
  margin-bottom: 0;
  border: 0;
}

.book__page-inner {
  position: relative;
  box-sizing: border-box;
  height: 100%;
  padding: 1.1rem 1.15rem 1.25rem;
  display: flex;
  flex-direction: column;
  /* `safe` matters on a narrow phone, where the page scales down with the column
     but rem-sized text does not, so the content can outgrow the page. Plain
     `center` then overflows both ends at once and scrollTop cannot go below 0,
     which put the top of the product photo out of reach entirely. `safe` falls
     back to flex-start the moment it would overflow, so nothing is unreachable;
     it still centres on every page that fits.
     Written as a plain value followed by the enhanced one, the same pattern the
     header uses for color-mix, and for the same reason: an engine that does not
     know the `safe` keyword drops the whole declaration rather than part of it,
     and an un-updated Android System WebView is what an in-app browser runs —
     which is how a QR sticker gets opened. Without the plain line first such an
     engine would fall back to `normal`, top-aligning every page that currently
     centres. The narrow-phone tier below means content now fits, so the two
     declarations render identically; this only guards the look. */
  justify-content: center;
  justify-content: safe center;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}

/* The cut-out sticker that used to sit on the fold between two leaves. There is
   no fold to sit on any more, so it stamps the page corner instead — still one
   real element cut from the client's own reference sheet, never redrawn. */
.book__mark {
  position: absolute;
  top: 0.7rem;
  left: 0.8rem;
  width: 30px;
  height: 30px;
  object-fit: contain;
  opacity: 0.62;
}

/* Photo and text are re-centred inside a page. In the accordion each leaf was
   deliberately offset toward the edge it leaned to; a bound page has no lean,
   and an off-centre photo inside a rectangle just reads as a mistake. */
.book__page .leaf__media {
  width: 53%;
  max-width: 8.6rem;
  margin: 0 auto 0.7rem;
}

.book__page .leaf__beat--2 {
  margin-top: 0.65rem;
}

.book__page .leaf__desc {
  font-size: 0.86rem;
}

.book__page--cover,
.book__page--end {
  background: var(--blush);
  border-color: rgba(28, 23, 20, 0.14);
}

.book__cover-inner {
  position: relative;
  box-sizing: border-box;
  height: 100%;
  padding: 2rem 1.4rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: 0.55rem;
  overflow: hidden;
}

.book__cover-orb {
  position: absolute;
  top: -18%;
  left: -22%;
  width: 78%;
  object-fit: contain;
  opacity: 0.5;
  pointer-events: none;
}

.book__cover-mascot {
  position: relative;
  width: 5.2rem;
  height: auto;
  margin-bottom: 0.3rem;
}

.book__cover-title {
  position: relative;
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.5rem;
  line-height: 1.3;
  color: var(--ink);
}

/* --ink, not --ink-soft. This line sits on --blush, not on paper, and
   --ink-soft against blush is 4.11:1 at 13.76px — under the 4.5:1 AA floor for
   text this size. --ink on the same ground is 12.29:1. It is the same element
   on both the front cover and the end page, so the soft grey was failing twice. */
.book__cover-sub {
  position: relative;
  margin: 0;
  font-size: 0.86rem;
  line-height: 1.55;
  color: var(--ink);
  max-width: 15rem;
}

.book__cover-logo {
  position: relative;
  width: 7rem;
  height: auto;
  margin-top: 0.9rem;
  opacity: 0.8;
}

.book__end-branch {
  position: relative;
  width: 9rem;
  aspect-ratio: 539 / 395; /* ume-branch.webp, so the end page holds its layout before the image decodes */
  object-fit: contain;
  margin-bottom: 0.4rem;
}

.book__controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1.1rem;
  margin-top: 0.85rem;
}

/* 44px is the floor for a finger, per both Apple's and Google's touch-target
   guidance. This is a page a shopper taps one-handed while holding a cold pack
   in the other, so the arrows sit exactly on it rather than just under. */
.book__nav {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border: 1px solid var(--ink);
  border-radius: 50%;
  background: transparent;
  color: var(--ink);
  cursor: pointer;
  transition: opacity 0.2s ease, background-color 0.2s ease;
}

/* A spent arrow must not light up under the finger — the hover state reads as
   "this will do something" on the one press where nothing will. */
.book__nav:not([aria-disabled="true"]):hover { background: rgba(28, 23, 20, 0.06); }

/* Keyed off aria-disabled rather than :disabled — see syncBookNav. The button
   stays focusable and keeps carrying the arrow keys; only its appearance and
   its announcement say "this end of the book". */
/* 0.45, not 0.28. WCAG exempts inactive controls, but these are deliberately
   aria-disabled rather than disabled — they stay focusable and stay in the tab
   order, so a keyboard user lands on one and has to be able to see what they
   landed on. 0.28 put the arrow at roughly 1.84:1 against paper. It still reads
   clearly as the spent end of the book. */
/* The fade is withheld from the focused state on purpose. opacity composites
   the whole element, outline included, so fading a focused arrow multiplied the
   focus ring down with it — 2.04:1 against paper, under the 3:1 that the
   :focus-visible rule above was just raised to clear, and on exactly the two
   controls a keyboard reader needs to escape either end of the book. Splitting
   the rule leaves the ring at its full 5.85:1 the moment focus lands. */
.book__nav[aria-disabled="true"]:not(:focus-visible) {
  opacity: 0.45;
}

.book__nav[aria-disabled="true"] {
  cursor: default;
}

.book__counter {
  margin: 0;
  min-width: 7rem;
  text-align: center;
  font-family: var(--font-display);
  font-size: 0.92rem;
  /* Stated rather than inherited. This was the only element reaching for Taviraj
     at anything but 600: it inherited 400 from body, and with only 500 and 600
     loaded the browser resolved that to 500 — one weight, ~22 KB of font files,
     pulled down for a single counter. Naming 600 lets the 500 weight go. */
  font-weight: 600;
  color: var(--ink);
}

/* Without a running book there is no page to turn, so the arrows would be dead
   controls. The stacked fallback needs no navigation — it is just scrolled. */
.book:not(.book--flip) .book__controls { display: none; }

/* Pausing the ambient keyframe loops during a page turn was tried here and
   removed: an alternated A/B over ~1000 frames on a 6x-throttled CPU showed no
   effect the run-to-run variance did not already cover, and toggling a class on
   <body> forces a whole-document style recalc at the exact moment the turn
   starts. If flip smoothness is revisited, measure on real hardware — a
   throttled desktop Chromium does not model a phone's compositor, which is
   where StPageFlip's soft-fold cost actually lands. */

/* Narrow phones. The page keeps its aspect ratio as it scales down with the
   column, so at 360px it is ~310x492 instead of ~340x540 — but rem-sized text
   does not scale with it, and the same paragraph then overflows a shorter page.
   English is the binding case: it runs longer than Thai in every product.
   Photo and spacing give up room first; type gives up the least, because this
   is read at arm's length in a chilled aisle. */
@media (max-width: 400px) {
  .book__page-inner {
    padding: 0.85rem 0.95rem 0.7rem;
  }

  .book__page .leaf__media {
    width: 47%;
    max-width: 7.2rem;
    margin-bottom: 0.5rem;
  }

  /* Vertical air goes before the photo does: the photo is what a shopper holds
     the box up against, so it is the last thing that should shrink. */
  .book__page .leaf__pills {
    margin-bottom: 0.6rem;
  }

  .book__page .leaf__grain {
    margin-bottom: 0.65rem;
  }

  .book__page .leaf__name {
    font-size: 1.05rem;
  }

  .book__page .leaf__beat--2 {
    margin-top: 0.5rem;
  }

  .book__page .leaf__desc {
    font-size: 0.8rem;
  }
}

/* Below 390px the page stops being 340x540 and starts scaling down with the
   column — 325x516 at 375px, 311x494 at 361px, 270x429 at 320px — while rem-sized
   text does not scale with it. So the tightening tiers are keyed to exactly that:
   they run for as long as the page is smaller than the size it was designed at.
   This first one buys back air and leading only, and deliberately leaves the
   photo and the grain swatch alone: 361-389px covers 375px and other very common
   phones, and those two carry the things a shopper is actually comparing.
   It exists because of a measured near-miss. With the 360px tier below as the
   only one, 361px — one pixel past its edge, where the page is short but the
   tier has switched off — cleared by 6px. Six pixels is not a margin; a fallback
   font or one longer word erases it. */
@media (max-width: 389px) {
  .book__page-inner {
    padding: 0.7rem 0.9rem 0.55rem;
  }

  .book__page .leaf__media {
    margin-bottom: 0.4rem;
  }

  .book__page .leaf__pills {
    margin-bottom: 0.35rem;
  }

  .book__page .leaf__grain {
    margin-bottom: 0.35rem;
  }

  /* The description is the tallest text block, so its leading is where pixels are
     cheapest to find: 1.45 against the body's 1.6 saves about two a line across
     four or five lines, and is not perceptible at this size. Taken instead of a
     bite out of the photo or the grain swatch. */
  .book__page .leaf__desc {
    margin-bottom: 0;
    line-height: 1.45;
  }

  .book__page .leaf__beat--2 {
    margin-top: 0.4rem;
  }
}

/* A third tier, where air alone stops being enough. At 320px English overflowed
   40-60px against the tier above, so here the photo and the grain swatch do give
   way. Type is still untouched: the 400px tier already took the description to
   0.8rem, and this is read at arm's length in a chilled aisle. */
@media (max-width: 360px) {
  .book__page-inner {
    padding: 0.6rem 0.8rem 0.45rem;
  }

  .book__page .leaf__media {
    max-width: 6rem;
    margin-bottom: 0.35rem;
  }

  .book__page .leaf__pills {
    margin-bottom: 0.25rem;
  }

  .book__page .leaf__grain {
    margin-bottom: 0.25rem;
  }

  .book__page .leaf__grain-swatch {
    width: 2.5rem;
    height: 2.5rem;
  }

  /* The vertical Japanese caption is the single tallest element on a 320px page —
     88px against the 45px of the product name beside it — so it, not the name,
     sets the height of that whole row. It is also the one thing there a Thai
     shopper at a chilled shelf does not read, which makes it the cheapest place
     left to find height once air, the photo and the swatch have all given.
     `max-height` is the lever, and this is worth spelling out because the obvious
     move does not work: the caption is `writing-mode: vertical-rl` and already
     pinned against the base rule's `max-height: 5.5rem`, so it renders at exactly
     88px whatever the font size — dropping font-size alone was measured to change
     nothing at all. Lowering the cap is what shortens it (88px to 70px here).
     The smaller font-size earns its place alongside: a shorter cap makes the text
     wrap into more vertical columns, and the smaller size fits more characters
     per column, which holds the caption's width down as its height comes off.
     The caption stays — tategaki has been part of this design since pass one — it
     is only set shorter where the page itself is shortest. */
  .book__page .leaf__name-ja {
    font-size: 0.66rem;
    max-height: 4.4rem;
  }

  .book__page .leaf__beat--2 {
    margin-top: 0.35rem;
  }
}

.leaf__beat--1 {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.leaf__media {
  position: relative;
  width: 60%;
  max-width: 11.5rem;
  aspect-ratio: 1 / 1;
  margin: 0 0 0.85rem auto;
  margin-right: -0.9rem;
  overflow: visible;
}

.leaf__glow {
  position: absolute;
  inset: -10%;
  object-fit: contain;
  z-index: 0;
}

.leaf__photo {
  position: relative;
  z-index: 1;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.leaf__name-row {
  display: flex;
  align-items: flex-start;
  justify-content: center;
  gap: 0.5rem;
}

.leaf__name {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.15rem;
  line-height: 1.35;
  text-align: center;
  margin: 0;
  max-width: 20ch;
}

.leaf__name-ja {
  font-family: var(--font-jp);
  font-weight: 500;
  writing-mode: vertical-rl;
  text-orientation: mixed;
  font-size: 0.74rem;
  color: var(--ink-soft);
  letter-spacing: 0.05em;
  max-height: 5.5rem;
  flex-shrink: 0;
  padding-top: 0.1rem;
}

.leaf__beat--2 {
  margin-top: 1.1rem;
}

.leaf__pills {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin-bottom: 0.9rem;
}

.leaf__pill {
  font-size: 0.72rem;
  /* 600, not 500 — the other half of dropping Noto Sans Thai's 500 weight. A pill
     is a label read at a glance at 0.72rem, so it goes up rather than down; 400
     measured too faint against the Blush fill at that size. */
  font-weight: 600;
  letter-spacing: 0.02em;
  padding: 0.25rem 0.6rem;
  border-radius: var(--radius-tag);
  border: 1px solid rgba(28, 23, 20, 0.2);
  border: 1px solid color-mix(in srgb, var(--ink) 20%, transparent);
  background: var(--card);
}

.leaf__pill--tag {
  background: var(--blush);
  border-color: transparent;
}

/* Grain size — the one spec on this page a shopper can judge by eye, so it
   gets a real photo of the beans rather than only a name. Circular to echo the
   blossom-orb marks already in the system. */
.leaf__grain {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  margin: 0 auto 1rem;
}

/* Nudged up from 2.75rem. Grain size is the only difference between these eight
   packs a shopper can actually see, so the swatch earns a little more of the
   page than a decorative thumbnail would — but only a little: it sits inside a
   fixed-height book page whose vertical budget is already tight in English on a
   360px phone, and every millimetre here comes out of the description. */
.leaf__grain-swatch {
  width: 3.1rem;
  height: 3.1rem;
  flex-shrink: 0;
  border-radius: 50%;
  object-fit: cover;
}

.leaf__grain-text {
  display: flex;
  flex-direction: column;
  text-align: left;
  line-height: 1.3;
}

.leaf__grain-label {
  font-size: 0.7rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.leaf__grain-value {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 0.95rem;
  color: var(--ink);
}

.leaf__desc {
  max-width: 60ch;
  margin: 0 auto 1rem;
  font-size: 0.92rem;
  text-align: center;
}


/* Preparation + storage — stated once for the whole range, after the last
   product and before the footer's outbound link. Set on a faint card so it
   reads as a shared note about all eight products rather than a ninth one. */
/* Toppings — a row the thumb pushes sideways, snapping card to card. The
   photos are cut-outs on transparency like every other photo here, so they sit
   straight on the paper with no frame and no shadow. Capped to the same width
   as the book so the page keeps one column. */
.toppings {
  position: relative;
  padding: 1.75rem 0 0.5rem;
}

.toppings__title,
.menus__title {
  max-width: var(--column-max);
  margin: 0 auto 0.25rem;
  padding: 0 var(--column-pad);
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.2rem;
  line-height: 1.35;
  text-align: center;
  color: var(--ink);
}

.toppings__note,
.menus__note {
  max-width: var(--column-max);
  margin: 0 auto 1rem;
  padding: 0 var(--column-pad);
  text-align: center;
  font-size: 0.82rem;
  line-height: 1.5;
  color: var(--ink-soft);
}

/* Three across, two down — the whole set answers at once. A scrolling row was
   built first and rejected: it hides half the toppings behind a gesture, and a
   shopper who does not swipe simply never learns the other three exist. Six
   items is small enough to show whole, so showing them whole is the honest
   layout. The cost is a narrow column, which the type sizes below are cut to
   fit rather than pretending it is not there. */
.toppings__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1.1rem 0.5rem;
  max-width: min(calc(var(--column-max) + 2 * var(--column-pad)), 488px);
  margin: 0 auto;
  padding: 0 var(--column-pad) 0.5rem;
}

.topping {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  min-width: 0; /* a grid item will not shrink below its content without this */
}

/* Thai carries no spaces inside a phrase, so Chrome breaks it wherever its
   own dictionary says a word ends — which in a 98px column split words down
   the middle ("ความ|มัน", "ไม่ต้องปรุง|เพิ่ม"). word-break: keep-all does not
   fix this: measured against word-break: normal on the same string it produced
   an identical 2-line, 32px box, because Chrome only applies keep-all to CJK.
   What does work is denying the break structurally — fillPhrases() in app.js
   wraps every space-delimited run in one of these, so the only place a line
   can break is a real space. Safe only while no run is wider than the column;
   at .topping__note's size that is about 15 Thai characters on a 320px phone. */
.nowrap {
  white-space: nowrap;
}

.topping__photo {
  width: 100%;
  /* 6.4rem, up from 5.4rem. The photo is what a shopper actually recognises in this
     grid — six cut-outs on transparency, read at arm's length — and the cap, not the
     column, was holding it small: at 360px the column is ~101px while the photo
     stopped at 86.4px. Raising the cap lets it fill the column on the common phone
     and changes nothing at 320px, where the 88px column is already the binding
     limit. Card width is set by the 1fr grid track, so nothing here can widen a
     card or reach the nowrap runs that rule 10 protects. */
  max-width: 6.4rem;
  aspect-ratio: 1 / 1;
  object-fit: contain;
  margin-bottom: 0.3rem;
}

.topping__name {
  margin: 0 0 0.25rem;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 0.86rem;
  line-height: 1.3;
  color: var(--ink);
}

.topping__note {
  margin: 0 0 0.5rem;
  font-size: 0.72rem;
  line-height: 1.5;
  color: var(--ink);
}

/* Two labelled facts, label above value, so the eye can compare "when" and
   "how much" straight down a column of cards rather than hunting in prose. */
.topping__facts {
  margin: 0;
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.15rem;
  width: 100%;
}

/* 0.7rem is the floor this page holds for Thai — the marks that stack above
   and below the baseline are the first thing to go as size drops, and 0.6rem
   was the smallest type on the page. It buys no width from anything: these two
   labels are set with textContent, not fillPhrases, so they are not inside a
   nowrap run and can still break where they like. Measured in Noto Sans Thai,
   "ใส่เมื่อไหร่" is 41.7px at 0.7rem against an 85px column at 320px. */
.topping__facts dt {
  font-size: 0.7rem;
  letter-spacing: 0.03em;
  color: var(--ink-soft);
}

.topping__facts dd {
  margin: 0 0 0.3rem;
  font-size: 0.7rem;
  line-height: 1.35;
  font-weight: 600;
  color: var(--ink);
}

/* The one line on this page that is a safety instruction rather than a taste
   note, so it is the one that gets the accent colour. */
/* Also raised to the 0.7rem Thai floor, and this one is the line that had to be
   measured before it moved: it goes through fillPhrases, so each of its two
   space-delimited runs is a nowrap span that overhangs the card rather than
   wrapping if it outgrows the column. The longer run,
   "ที่ผ่านการฆ่าเชื้อ", is 69.4px in Noto Sans Thai at 0.7rem (63.4px at
   0.64rem) against an 85px column on a 320px phone, so it still clears. Nothing
   was traded for this — .topping__photo keeps its 5.4rem. Re-measure if this
   copy ever lengthens. */
.topping__warn {
  margin: 0.1rem 0 0;
  font-size: 0.7rem;
  line-height: 1.4;
  color: var(--rose-text);
}

/* Recipes — a short vertical list, not a carousel. Three items do not need a
   gesture, and a recipe is read rather than browsed. */
.menus {
  position: relative;
  padding: 1.25rem 0 0.5rem;
}

.menus__list {
  max-width: var(--column-max);
  margin: 0 auto;
  padding: 0 var(--column-pad);
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

.menu {
  display: flex;
  flex-direction: column;
}

/* Unlike every other photo here, the recipe shots are rectangular studio images
   with their own background, so they get a soft corner to sit as pictures
   rather than pretending to be cut-outs. */
/* The 320/213 is the shipped webp's real pixel size, not a tidy 3:2 — matching
   it exactly means the box the browser reserves is the box the image lands in,
   with no settle. Three full-column images with no reserved height is the worst
   case on this page: they are loading="lazy", so they arrive after
   initScrollEffects() has measured the document and built its ScrollTriggers,
   and .scroll-progress spends the rest of the session calibrated to a page that
   is three photos shorter than the one being scrolled — it reads 100% well
   before the footer. */
.menu__photo {
  width: 100%;
  height: auto;
  aspect-ratio: 320 / 213;
  border-radius: var(--radius-tag);
  margin-bottom: 0.6rem;
}

.menu__name {
  margin: 0 0 0.15rem;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.05rem;
  line-height: 1.35;
  color: var(--ink);
}

.menu__meta {
  margin: 0 0 0.7rem;
  font-size: 0.75rem;
  color: var(--ink-soft);
}

.menu__sub {
  margin: 0 0 0.25rem;
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.05em;
  color: var(--rose-text);
}

.menu__ingredients,
.menu__steps {
  margin: 0 0 0.8rem;
  padding-left: 1.1rem;
  font-size: 0.82rem;
  line-height: 1.65;
  color: var(--ink);
}

.menu__ingredients { list-style: disc; }
.menu__steps { list-style: decimal; }

.menu__ingredients li,
.menu__steps li { margin-bottom: 0.1rem; }

/* align-self did nothing here — .menu__body is a plain block, and an <a> is
   shrink-to-fit already. What the link actually needed was the 44px touch floor
   the rest of the page holds (.lang-toggle__opt and .book__nav are both exactly
   2.75rem): 0.78rem of Thai on the inherited 1.6 leading is ~20px, so 0.75rem
   above and below brings it to 44. inline-block is what makes that padding
   count on an inline element. Left padding stays 0 so the link still lines up
   with the steps above it.
   The underline offset is the same one the footer link uses: Thai's lower
   vowels (ุ ู) hang into where the default underline sits, and this is the one
   link on the page that already had an underline crossing them. */
.menu__source {
  display: inline-block;
  padding: 0.75rem 0.5rem 0.75rem 0;
  font-size: 0.78rem;
  text-underline-offset: 0.3em;
  color: var(--rose-text);
}

.care {
  position: relative;
  max-width: var(--column-max);
  margin: 0.5rem auto 0;
  padding: 1.75rem var(--column-pad) 1.5rem;
  text-align: center;
  border-top: 1px solid rgba(28, 23, 20, 0.14);
  border-top: 1px solid color-mix(in srgb, var(--ink) 14%, transparent);
}

.care__mark {
  width: 2.6rem;
  height: auto;
  aspect-ratio: 81 / 90; /* cat-teacup.webp — small, but it is lazy and it sits above the whole care block */
  margin: 0 auto 0.6rem;
  opacity: 0.85;
}

.care__title {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.2rem;
  line-height: 1.35;
  color: var(--ink);
  margin: 0 0 0.2rem;
}

.care__note {
  margin: 0 0 1.4rem;
  font-size: 0.76rem;
  color: var(--ink-soft);
}

.care__grid {
  display: grid;
  gap: 1.2rem;
  text-align: left;
}

.care__item-title {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 0.9rem;
  color: var(--rose-text);
  margin: 0 0 0.3rem;
}

.care__item-body {
  margin: 0;
  font-size: 0.9rem;
  line-height: 1.65;
}

/* Footer */
.site-footer {
  position: relative;
  text-align: center;
  padding: 1.5rem 1.25rem 3rem;
  overflow: hidden;
}

.site-footer__illustration-wrap {
  position: absolute;
  top: 0.5rem;
  left: -2rem;
  width: 5.5rem;
  opacity: 0.92;
  z-index: 0;
}

.site-footer__illustration {
  width: 100%;
  transform-origin: top left; /* mirrored, so the branch now "grows" from the top-left */
  animation: branch-sway-mirrored 8s cubic-bezier(0.77, 0, 0.175, 1) infinite;
}

@keyframes branch-sway-mirrored {
  0%, 100% { transform: scaleX(-1) rotate(0deg); }
  50% { transform: scaleX(-1) rotate(-1.5deg); }
}

@media (prefers-reduced-motion: reduce) {
  .site-footer__illustration { animation: none; transform: scaleX(-1); }
}

.site-footer__float--vase {
  width: 3.6rem;
  bottom: 4rem;
  right: -0.5rem;
  opacity: 0.8;
}

.site-footer__float--ramen {
  width: 3.2rem;
  bottom: 5.5rem;
  left: -0.25rem;
  opacity: 0.8;
}

.site-footer__logo {
  position: relative;
  z-index: 1;
  width: 12rem;
  height: auto;
  margin: 0 auto 1rem;
}

/* The one outbound link on the page, and with text-decoration: none it was
   announcing itself as a link by colour alone — nothing left for anyone who
   cannot separate rose from ink. The offset is why the underline was dropped in
   the first place: Thai hangs vowels below the baseline (ุ ู) and the default
   'auto' position draws straight through them. 0.3em clears them. */
.site-footer__link {
  display: inline-block;
  font-weight: 600;
  font-size: 0.9rem;
  text-decoration: underline;
  text-underline-offset: 0.3em;
  padding: 0.65rem 0.5rem;
  margin-bottom: 0.75rem;
}

/* Substantiation for the ranking claim in the hero. Fine print, in the place
   fine print belongs — a shopper scanning the QR code never has to read past it
   to reach the products, but it is plain text at a legible size for anyone who
   goes looking. Deliberately not hidden: a disclosure a consumer cannot
   perceive is worse than no disclosure, since it reads as concealment rather
   than compliance. Thai keeps 0.7rem as its floor — tone marks and vowels stack
   above and below the baseline, so this script loses legibility faster than
   Latin as size drops. */
.site-footer__note {
  max-width: 32rem;
  margin: 0 auto 0.7rem;
  font-size: 0.7rem;
  line-height: 1.6;
  /* No opacity wash. --ink-soft on this footer is 5.41:1, comfortably past the
     4.5:1 AA floor for 11px text; the 0.85 that used to sit here composited it
     down to 3.94:1, under the floor. It stays small and stays at the very
     bottom — a shopper still never has to read it — it just stops being faded
     past the point where someone who goes looking for it can. That matters
     more here than anywhere else on the page: this is the substantiation for
     the No.1 claim, and unreadable fine print is not a defence. */
  color: var(--ink-soft);
}

.site-footer__legal {
  max-width: 32rem;
  margin: 0 auto;
  font-size: 0.7rem;
  line-height: 1.6;
  color: var(--ink-soft);
}

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