:root {
  --bg: #fbf4ea;
  --surface: #fffdf9;
  --ink: #3b2a1f;
  /* ★ v313 — the same move as the backoffice: this is the shop's quiet line (a
     product's second line, a collection Point's address, money labels), and she
     reads it on a phone in daylight more than anything else in the app.
     ⚠️ IT IS THE SAME VALUE AS THE BACKOFFICE'S TOKEN, and it is the same value
     for the same reason: set by the WORST background it lands on, not the nicest.
     The two files define their own tokens, so moving one does not move the
     other — change them together or the two screens disagree about one word. */
  --muted: #7b6552;
  --line: #f0e2d2;
  /* ⚠️⚠️ ONE STEP DEEPER (v583): #c4552f carried white at **4.48:1**, a hair under the 4.5 floor — ⚠️ **every
     solid terracotta control (track, apply, step numbers, the product stamp) was just under it.** ⭐ #c1502b
     measures **4.72:1** and is the same hue. */
  --brown: #c1502b;
  --brown-dark: #a84322;
  /* ★★ THE SHOP'S DELIVERY GREEN (v510) — **the backoffice's own `--green`, written out here**, for the
     same reason `--muted`, `--red` and `--amber` are: the two files define their own tokens, so moving
     one does not move the other.
     ⚠️⚠️ AND UNTIL v510 THIS TOKEN DID NOT EXIST AT ALL. The shop's calendar asked for
     `var(--green, #6b8f4e)` — **a token this file never defined** — so it quietly took the FALLBACK,
     and the two calendars drew **two different greens** while the comment under the grid said they were
     the same one. ⚠️ **A fallback is not a declaration, and nothing compared them**: a test that reads
     declarations sees `var(--green, …)` in both files and agrees.
     ⚠️⚠️ AND THE FALLBACK WAS UNDER THE FLOOR: **#6b8f4e is 3.65:1** on `--surface` — ⭐ **so the day
     number of every bake day the customer could choose was below the 4.5:1 body floor**, at 15px bold,
     on a phone in Penang daylight. ⭐ `--green` is **5.47:1** there and **5.09:1** on `--bg`, and the
     ring clears the 3:1 UI-edge floor either way.
     ⭐ Her ask is what found it: __"i want it to be oversize like the one at app orders"__ — ⭐ **putting
     the shop's calendar beside the app's is what made "the same green" worth measuring at all.** */
  --green: #3b7449;
  /* ★★ THE SHOP'S ONE RED (v426). Her words: __"can you flash the only available on friday in red
     color?"__
     ⚠️⚠️ THE SHOP HAD NO RED AT ALL BEFORE THIS, and that is why it needs its own token rather than a
     borrowed colour: **the whole page is terracotta** (`--brown` #c4552f, `--brown-dark` #a84322), so a
     "red" that is really a warmer terracotta would flash and read as the brand colour arriving — the
     opposite of standing out. ⭐ This is a genuinely redder red at the same weight as the rest.
     ⚠️ AND IT CLEARS THE APP'S OWN BAR: **5.35:1** on the card, over the 4.5 every accent here is held
     to (v369) — a flash nobody can read is not a warning. */
  --red: #c0392b;
  /* ★★ AND THE SHOP'S OWN AMBER, TAKEN FROM THE BACKOFFICE'S "A NEWER VERSION" STRIP (v501).
     ⚠️⚠️ Her words: __"it is rather small. it should be a strip like what is having in the backoffice"__ —
     ⭐ **"like the backoffice" means the PATTERN, not an approximation of it:** the amber pair, a full-width
     strip, and a real press. ⚠️ **The two files define their own tokens — moving one does not move the
     other — which is the same reason `--muted` is written out here at the admin's value.** Change them
     together or the two screens disagree about one colour.
     ⚠️ MEASURED: `--amber` (#975a13) on `--amber-bg` (#fdf1dc) is **4.96:1** — over the 4.5 every word of
     this page is held to. */
  --amber: #975a13;
  --amber-bg: #fdf1dc;
  /* ★★ HOW THE RED WARNING FLASHES, IN ONE PLACE (v427). Her words: __"can you flash the red twice or
     trice"__.
     ⚠️⚠️ THESE TWO AND THE TIMER IN `flashRow` ARE ONE FACT, SPLIT ACROSS TWO FILES. The mark comes
     off on a TIMER — not on the animation's end event, because `animation: none` (which is what a
     reduced-motion reader gets) never fires one — so **a flash longer than the timer would be cut off
     mid-blink.** ⭐ `flashRow` READS THESE TWO rather than carrying a number of its own, so the
     flashing and the moment it stops cannot drift apart. ⚠️ Three blinks used to be impossible to ask
     for: the timer said 1200 and a three-blink flash is 1500. */
  /* ⚠️⚠️ AND THESE TWO ARE THE ONLY THINGS TO CHANGE WHEN THE FLASH FEELS WRONG. Her words, twice:
     __"the red is too short time"__ and then, after the first longer setting, __"still too short"__.
     ⭐ At 500ms the red was on for only **215ms** of each blink; at 900ms it was ~590ms — **and she
     said still too short, so the eye wants more than half a second to take a line in and use it.**
     ⚠️ It is **1125ms of red per blink now, five times the original.**
     ⭐ **The timer follows, because `flashRow` reads these** — a one-line change, nothing to drift. */
  /* ⚠️⚠️ AND THIS IS HER OWN CYCLE, WRITTEN OUT: **1500ms = 1000ms of red + 500ms of pale**, which is
     __"on 1sec, off 0.5sec"__ to the millisecond. Her words, three times over: __"the red is too short
     time"__, __"still too short"__, and then the number itself. */
  --flash-blink: 1500ms;  /* how long ONE red blink lasts: 1s on + 0.5s off */
  --flash-blinks: 3;      /* how many of them there are */
  --brown-soft: #fbe7dc;
  --shadow: 0 2px 12px rgba(120, 66, 32, .08);
  --shadow-lg: 0 12px 30px rgba(120, 66, 32, .18);
  --radius: 16px;
  --serif: "Iowan Old Style", "Palatino Linotype", Georgia, "Times New Roman", serif;
  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --hand: "Shantell Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

/* The chalk hand for the bakery's own sentence in the shelf strip (v274). It is
   a Latin face - it carries no Chinese characters at all - so a sentence written
   in Chinese falls through to the system font, character by character, exactly
   as it looked before this file existed. That is the honest limit, not a fault.

   Shipped from our own folder rather than fetched from a font service, so the
   shop has no third-party request and the letters are the same on every phone.
   The file is the Latin subset of Shantell Sans Regular, kerning intact; the
   licence (OFL) sits beside it in fonts/OFL.txt. `swap` shows the system face
   first and swaps in the hand when it arrives, so a slow download never blanks
   the line. */
@font-face {
  font-family: "Shantell Sans";
  src: url("fonts/ShantellSans-Regular-latin.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-text-size-adjust: 100%;
}

/* ---------- Hero banner ---------- */
.hero {
  color: #fff;
  padding: 26px 20px calc(20px + env(safe-area-inset-top));
  padding-top: calc(26px + env(safe-area-inset-top));
  /* ★★★ WHITE TEXT ON THIS BANNER NOW MEASURES 4.6:1 OR MORE (v565).
     ⚠️⚠️ MEASURED BEFORE THE FIX, over the gradient AND its highlight: the eyebrow was **3.75:1**, the
     tagline **3.62:1**, and the "Our homepage" pill **3.58:1 at its darkest and 2.10:1 at the light end** —
     all under the 4.5:1 a small white line needs. ⭐ Three measured pairs do the work, and no new colour
     enters the palette: the same hues, one step deeper; the highlight at a quarter of its strength; and
     the frosted pills turned dark instead of light. ⭐ **Measured after: 4.61:1 at the worst point of the
     gradient, 6.3:1 on the pills.** ⚠️ The highlight had to shrink because it lands exactly where the
     language pills and the eyebrow sit — a 40% cream wash over terracotta cannot carry white text. */
  background:
    radial-gradient(120% 150% at 90% -10%, rgba(255, 220, 160, .10), transparent 60%),
    linear-gradient(130deg, #b04c2a, #a4551d 55%, #8d601b);
  box-shadow: var(--shadow-lg);
}
.hero-eyebrow {
  margin: 0 0 6px;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .6px;
  text-transform: uppercase;
  /* ⚠️ NO TRANSPARENCY ON TEXT OVER A GRADIENT (v565) — .9 white measured 4.29:1 against the worst stop. */
  opacity: 1;
}

/* EN / 中文 / BM switch sits on the hero's top row, right of the eyebrow. */
.hero-top {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 10px;
}
.hero-top .hero-eyebrow { min-width: 0; }
.lang-switch {
  display: flex;
  gap: 4px;
  flex: 0 0 auto;
  margin: -3px -2px 0 0;
}
.lang-pill {
  appearance: none;
  border: 1px solid rgba(255, 255, 255, .45);
  /* ⚠️ A WHITE WASH UNDER WHITE TEXT LOWERS THE CONTRAST (v565): 14% white measured 3.6:1. A dark wash
     measures 6.3:1. Same pill, same border, inverted fill. */
  background: rgba(0, 0, 0, .18);
  color: #fff;
  font: inherit;
  font-size: 11px;
  /* ⚠️ AND A PILL IS A TAP TARGET: it measured about 23px tall, and 36px is this app's own floor. */
  min-height: 36px;
  display: inline-flex;
  align-items: center;
  font-weight: 700;
  padding: 4px 10px;
  border-radius: 999px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.lang-pill.is-on {
  background: #fff;
  border-color: #fff;
  color: var(--brown-dark);
}
.lang-pill:not(.is-on):active { background: rgba(0, 0, 0, .34); }
.hero-title {
  margin: 0 0 6px;
  font-family: var(--serif);
  font-size: 28px;
  line-height: 1.15;
}
.hero-sub { margin: 0; font-size: 14px; opacity: 1; }

/* The way back to the brand page. Its own line under the tagline, so it belongs
   to the banner and never competes with the language pills on the top row —
   that row's eyebrow already wraps to two lines on a phone. Same pill shape as
   those pills, and a full 36px tall so it is a real tap target. */
.hero-home {
  display: inline-flex;
  align-items: center;
  min-height: 36px;
  margin-top: 12px;
  padding: 6px 14px;
  border: 1px solid rgba(255, 255, 255, .45);
  border-radius: 999px;
  background: rgba(0, 0, 0, .18);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  text-decoration: none;
  -webkit-tap-highlight-color: transparent;
}
.hero-home:active { background: rgba(0, 0, 0, .34); }

/* ---------- Layout ---------- */
.view {
  max-width: 540px;
  margin: 0 auto;
  /* ⚠️⚠️ AND THE UPDATE STRIP'S ROOM IS RESERVED HERE, WHICH IS WHAT MAKES THE BOTTOM POSITION WORK (v504).
     ⭐ Her instruction: __"i want it to be consistant with the app"__ — and the app's strip is pinned above
     its tab bar **and the screens reserve room for it**, so a control can always be brought clear.
     ⚠️ **`--strip-h` is the strip's own measured height, and it is 0 while the strip is hidden** — so a shop
     with no notice is exactly the height it always was, and nothing moves when the notice is not there.
     ⭐ Growing the page's foot never scrolls or shifts what the customer is reading: it only adds room
     BELOW the content, which is precisely the room they scroll into. */
  padding: 14px 14px calc(var(--bar-h, 92px) + var(--strip-h, 0px) + 20px);
}
.section {
  font-family: var(--serif);
  font-size: 18px;
  color: var(--ink);
  margin: 20px 2px 10px;
}
.section:first-child { margin-top: 6px; }
.muted { color: var(--muted); }

/* ---------- Info card ---------- */
.card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 15px;
  margin-bottom: 12px;
  box-shadow: var(--shadow);
}
.info { padding: 12px 15px; }
/* ★★ ONE LINE, ONE SENTENCE (v433). Her words: __"customer might confused that it is 2 group of
   word, one left one right"__ — ⚠️ AND SHE WAS RIGHT: this was `display: flex;
   justify-content: space-between`, which pinned the word to the left edge and its answer to the
   right edge with **nothing joining them**. A customer reading down the left and then down the
   right gets the pairing wrong, and the card is the one place on the shop that states the two
   rules an order depends on.
   ⭐ The colon lives in the MARKUP, not in the translated string, so all three languages keep their
   own wording and none of them has to carry punctuation it never had (see store-lang.js). */
.info-row {
  margin: 4px 0;
  font-size: 14px;
}
.info-val { font-weight: 600; }

/* ---------- Delivery-day calendar ---------- */
/* The month grid the customer picks their delivery day from. A day the bakery
   delivers, with room left, is a green circle (the same green the app's own
   calendar uses for a delivery date); today breathes a soft halo; a full day is
   struck through; a standard day the bakery marked carries that mark in the
   bakery's own calendar's language — a translucent band across a run of days, a
   solid box for a single one — and its name comes out in a bubble when the day is
   tapped. */
.cal {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 12px 10px 10px;
  box-shadow: var(--shadow);
}
/* ★★★ THE ARROWS SIT BESIDE THE DATE RANGE, NOT AT THE CARD'S EDGES (v512).
   Her words: __"the 2 arrows are too far away from centre, they should appear just beside the date
   range"__.
   ⚠️ BOTH CALENDARS USED A THREE-COLUMN GRID — `36px 1fr 36px` — which pinned one arrow to the left
   edge of the card and the other to the right, **with the date range stranded in the middle of a
   300px gap.** ⭐ It was built that way to keep the TITLE centred whether or not an arrow was drawn,
   and it does that perfectly — ⚠️ **but nobody is looking at the middle track; they are looking at the
   month, and the two controls that move it were as far from it as the card allows.**
   ⭐ A centred flex row puts the whole group together: `‹  4 Oct – 7 Nov  ›`, read as one thing.
   ⚠️ An empty slot is still a plain span (`cal-slot`, no styles, 0 wide), so at the ends of what she
   has published the group simply shifts by an arrow's width rather than leaving a hole. */
.cal-head {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  margin: 0 0 8px;
}
.cal-title {
  font-family: var(--serif);
  font-weight: 700;
  font-size: 16px;
  color: var(--ink);
  text-align: center;
}
.cal-nav {
  appearance: none;
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--ink);
  font: inherit;
  font-size: 17px;
  line-height: 1;
  min-width: 36px;
  /* ⚠️⚠️ THE SHOP'S OWN 36px FLOOR, ON THE HEIGHT TOO (v583): these arrows were ~31px tall, ⚠️ **under the
     target size this shop sets for every other control.** */
  min-height: 36px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 6px 0;
  border-radius: 9px;
  cursor: pointer;
}
.cal-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 2px;
  position: relative; /* anchor for the absolutely-placed occasion bands */
}
/* Paging moves the window one whole week, so the grid arrives the way the dates
   moved: going later the rows travel up, coming back down. The grid is rebuilt on
   every paint, so what moves is the new grid coming in rather than the old one
   sliding out — short and shallow on purpose, because the customer is reading
   dates, not watching motion. Only a tap on an arrow sets this. */
@keyframes cal-week-up { from { opacity: 0; transform: translateY(10px); } }
@keyframes cal-week-down { from { opacity: 0; transform: translateY(-10px); } }
.cal-grid[data-slide="up"] { animation: cal-week-up 180ms ease-out; }
.cal-grid[data-slide="down"] { animation: cal-week-down 180ms ease-out; }
@media (prefers-reduced-motion: reduce) {
  .cal-grid[data-slide] { animation: none; }
}
.cal-dow {
  text-align: center;
  font-size: 11px;
  font-weight: 700;
  color: var(--muted);
  padding: 2px 0 4px;
}
.cal-cell {
  position: relative;
  /* Above the occasion bands (see .occ-paper) so the numbers and the delivery ring
     stay legible on top of a mark. The cell's own background is transparent, so a
     band behind still shows through around the number. */
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  /* ★★★ THE DAY BOX IS 44px, NOT 38 (v510). ⚠️ Measured: at 38px the 32px delivery ball left **3px**
     above and below it, so the chooser's new terracotta wire frame — which is the DAY's box, like the
     app's own Orders calendar — had **1px** of clearance from the green ring and read as a second ring
     rather than as a frame with air in it. ⭐ **Her words, seeing that: __"i want it to be oversize like
     the one at app orders"__.** ⭐ 44px gives the ball 6px of margin, so the frame is a box AROUND the
     day rather than a band against it — and ⭐ **44px is also the web skill's floor for anything a thumb
     hits**, which a 38px day was quietly under. */
  min-height: 44px;
  border: 0;
  background: none;
  font: inherit;
  font-size: 15px;
  color: var(--ink);
  border-radius: 50%;
  padding: 0;
  appearance: none;
}
/* A day already gone. The window's first row is always the week just gone, so
   these are deliberately quiet: they are context for "today", not something to
   read. They stay faint but legible — a day that vanished completely would read
   as a hole in the week. */
.cal-cell.past { color: #ded6cd; }
.cal-cell.avail { font-weight: 700; }
.cal-cell.avail.tappable { cursor: pointer; -webkit-tap-highlight-color: transparent; }
.cal-cell.avail.tappable:active { background: var(--brown-soft); }
/* The number always sits on its own 32px circle; on a plain day the circle is
   invisible. It used to be drawn only for a delivery day, which is why today's
   ring hugged the bare text and read as a small box. */
.cal-num {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 50%;
}
/* A day the bakery delivers still has room: a green ring (or a solid green disc
   once chosen). The ring draws on the number, not the whole cell, so a day the
   bakery also marked keeps its band or its box around the ring. */
.cal-cell.avail .cal-num {
  box-shadow: inset 0 0 0 2px var(--green, #6b8f4e);
  color: var(--green, #6b8f4e);
}
.cal-cell.avail.tappable:active .cal-num { background: var(--brown-soft); }
/* ★★★ THE CHOSEN DAY WEARS AN OVERSIZE TERRACOTTA WIRE FRAME ROUND THE WHOLE DAY, AND THE GREEN BALL
   KEEPS ITS RING (v510).
   Her words: __"i want it to be highlighted by terracotta wire frame only, the green outlined ball dont
   have to turn solid"__ — and then, on seeing the first attempt: __"the terracotta wireframe shape i want
   it to be oversize like the one at app orders"__ / __"rounded recangular"__.
   ⚠️⚠️ BEFORE THIS the ONLY mark on a chosen day was the ball **filling in** solid green — ⭐ **so "the
   bakery bakes this day" and "this is the day I chose" were the same disc, and the customer lost the one
   that told her the day was a bake day at all.** ⭐ **Choosing a day does not un-deliver it.**
   ⭐⭐ SO THE TWO FACTS ARE TWO SHAPES, AND THEY ARE DELIBERATELY DIFFERENT SHAPES: **the ball keeps the
   green ring it wears on every delivery day (unchanged), and the whole day gets a terracotta rounded
   rectangle** — the same wire frame the app's own Orders calendar draws (v510), so "the day I chose"
   looks the same wherever she meets it.
   ⚠️⚠️ AND THE FRAME IS THE DAY'S OWN BOX, NOT A RING ROUND THE NUMBER: my first attempt put the
   terracotta on the ball, 2px out from the green — and **it read as one thicker ring rather than as one
   day being chosen.** ⭐ **A selection has to be a different SHAPE from the thing it selects, or the eye
   has nothing to compare.**
   ⚠️ MEASURED: `--brown` is **4.10:1** on `--bg` and **4.41:1** on `--surface` — clear of the 3:1 WCAG
   floor for UI edges — and the day's number keeps `--green` at **5.09:1** on `--bg`.
   ⚠️ Drawn as an INSET shadow rather than a border, so **nothing on the grid moves by 2px the moment a
   day is chosen**, and the cell's own `border-radius: 50%` is replaced by the app's rounded-rect so the
   frame is a rectangle and not an ellipse. */
.cal-cell.sel {
  background: none;
  color: var(--brown-dark);
  font-weight: 700;
  border-radius: 10px;
  box-shadow: inset 0 0 0 2px var(--brown);
}
/* ⚠️ AND THE BALL IS LEFT EXACTLY AS IT WAS on a day with no choice made: green ring, green number, no
   fill. There is deliberately no rule here — `.cal-cell.avail .cal-num` above already draws it, and a
   chosen day now wears the same ball an unchosen one does. ⭐ **The note is the point: a future reader
   asking "what happens to the ball when the day is chosen?" gets the answer in words.** */
/* Today is not a box — today breathes. The halo lives on a pseudo-element so it
   can never overwrite the number's own shadow: a box-shadow animation would erase
   the green ring on a day that is both today and a day the bakery delivers. */
.cal-cell.today:not(.sel) .cal-num::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  pointer-events: none;
  animation: today-glow 2.6s ease-in-out infinite;
}
.cal-cell.today:not(.sel):not(.avail) .cal-num { color: var(--brown-dark); font-weight: 700; }
@keyframes today-glow {
  0%, 100% { box-shadow: 0 0 5px 2px rgba(196, 85, 47, .16); }
  50% { box-shadow: 0 0 11px 5px rgba(196, 85, 47, .30); }
}
@media (prefers-reduced-motion: reduce) {
  .cal-cell.today:not(.sel) .cal-num::before {
    animation: none;
    box-shadow: 0 0 8px 3px rgba(196, 85, 47, .24);
  }
}
/* A delivery day with no room left: struck through and plainly not tappable. */
.cal-cell.full .cal-num { color: #a99e91; text-decoration: line-through; }
/* A standard day the bakery marked is drawn exactly as it is in the bakery's own
   calendar: a see-through wash in the mark's own colour, so the date number and
   the green ring stay readable on top of it. A mark running over several days is
   one rounded band across those days; a single-day mark is a box nearly the whole
   day wide, a touch narrower than a band so a longer band still shows around its
   edges. Neither is ever a dot, a bar or a ring — a ring or a filled disc is what
   "a day you can order" looks like, so the mark sits BEHIND the number and the
   delivery ring always reads first.

   The eight colours are the office's own, so a day looks the same on both sides
   of the shop. --occ-solid is the mark's own colour; the bands and the box are it
   mixed down (--occ-wash), so there are no per-colour tints to keep in step. */
.occ-red    { --occ-solid: #c04538; }
.occ-orange { --occ-solid: #d1641a; }
.occ-yellow { --occ-solid: #e0a80c; }
.occ-green  { --occ-solid: #1f7a47; }
.occ-blue   { --occ-solid: #2a63a6; }
.occ-purple { --occ-solid: #73408f; }
.occ-pink   { --occ-solid: #b23a6e; }
.occ-grey   { --occ-solid: #6b6f76; }

/* One multi-day mark = one rounded band per week row it crosses, an absolutely-
   placed grid child: grid-column/grid-row (set in JS) pick its week-row area
   without disturbing the auto-placed day cells, and it sits UNDER the day cells
   (z-index 0 against the cells' 1). The 1px side inset keeps neighbouring bands
   visually continuous while letting a single-day box on top still show around
   its edges. Bands only ever cover today and the future. */
.occ-paper {
  position: absolute;
  grid-area: var(--gr) / var(--gc1) / var(--gr) / var(--gc2);
  inset: 2px 1px;
  z-index: 0;
  pointer-events: none;
  border-radius: 9px;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--occ-solid, #8a7a6b) 22%, transparent);
}
/* One wash per strength: the shorter the run, the deeper the wash, so a short
   holiday still stands out inside a long school break. The band and the box both
   read their depth from here, so the two shapes cannot drift apart. */
.occ-strong { --occ-wash: 32%; }
.occ-mid    { --occ-wash: 22%; }
.occ-soft   { --occ-wash: 14%; }
.occ-paper { background: color-mix(in srgb, var(--occ-solid, #8a7a6b) var(--occ-wash, 22%), transparent); }

/* A single-day mark is that same see-through wash, drawn as a box nearly the whole
   day wide so a longer band underneath still peeks out at its sides. It is never a
   solid block, whatever the day holds: a solid block would swallow the green ring,
   and a holiday must look the same whether or not the bakery delivers that day. */
.cal-cell.sol { font-weight: 700; }
.cal-cell.sol::before {
  content: "";
  position: absolute;
  z-index: -1; /* in the cell's own stacking context → above the bands, under the number */
  left: 50%;
  top: 50%;
  width: calc(100% - 8px);
  height: 30px;
  border-radius: 9px;
  transform: translate(-50%, -50%);
  background: color-mix(in srgb, var(--occ-solid, #8a7a6b) var(--occ-wash, 32%), transparent);
}
.cal-cell.tippable { cursor: pointer; -webkit-tap-highlight-color: transparent; }
/* The marked day's name, only on request: a small bubble above the day. A tap (or a
   mouse resting on it) shows it; a tap anywhere else puts it away. It is out of flow,
   so it can never shift the grid. */
.cal-tip {
  position: absolute;
  left: 50%;
  bottom: calc(100% - 5px);
  transform: translateX(-50%);
  z-index: 3;
  pointer-events: none;
  /* ⚠️⚠️ A LONG OCCASION NAME PUSHED THE PAGE SIDEWAYS AT 375px (v583): the bubble never wrapped and was
     never clipped, ⚠️ **so its width became the page's.** ⭐ It wraps inside the screen now. */
  white-space: normal;
  width: max-content;
  max-width: min(200px, 70vw);
  overflow-wrap: anywhere;
  text-align: center;
  background: var(--ink);
  color: #fff;
  font-size: 11px;
  font-weight: 600;
  line-height: 1.2;
  padding: 4px 8px;
  border-radius: 8px;
  box-shadow: var(--shadow);
}
/* Beats the browser's own [hidden] rule; a mouse is a desktop pointer, and a tap
   goes through the handler above. */
.cal-cell:hover .cal-tip { display: block; }
/* The chosen day in words, and the one short note under the grid. */
.cal-chosen {
  margin: 10px 2px 0;
  font-size: 14px;
  font-weight: 700;
  color: var(--ink);
}
/* ★★★ WHAT THE GREEN CIRCLE MEANS (v512). Her words: __"simple note that date in green circle are bake
   dates"__.
   ⚠️⚠️ IT DRAWS THE SYMBOL RATHER THAN ONLY DESCRIBING IT — a real green ring, the same colour and the
   same 2px weight as the one on a bake day — so the sentence and the thing it explains cannot drift
   apart, which is the fault the v510 green turned out to be (a fallback nobody had compared). ⭐ **A
   legend that names a colour has to show it.**
   ⚠️ Quiet on purpose: 12px `--muted`, **MEASURED at 5.40:1 on the card** — over the 4.5 the shop is held
   to, and still plainly quieter than the line below it — because it is reference material, not a second
   answer. */
.cal-key {
  display: flex;
  align-items: center;
  gap: 7px;
  margin: 8px 2px 0;
  font-size: 12px;
  line-height: 1.4;
  color: var(--muted);
}
.cal-key-dot {
  flex: 0 0 auto;
  width: 13px;
  height: 13px;
  border-radius: 50%;
  box-shadow: inset 0 0 0 2px var(--green);
}
.cal-note {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 2px 10px;
  margin: 4px 2px 0;
  font-size: 12px;
  line-height: 1.4;
  color: var(--muted);
}

/* ---------- Menu ---------- */
/* The card is a ROW: the photo, when there is one, is the left column and the
   body is everything else. `align-items: stretch` is the whole point — it is
   what makes the photo take the card's full height rather than a box of its
   own (the baker asked for the fullest height the card has). A product with no
   photo is the same shape with the body as the only column. */
.menu-item {
  display: flex;
  align-items: stretch;
  gap: 12px;
}
.card-body {
  display: flex;
  flex-direction: column;
  gap: 12px;
  flex: 1 1 auto;
  min-width: 0;
}
/* `margin-top: auto` drops the stepper to the card's foot when the photo has
   made the body taller than its own words need. On a card with no photo the
   body is exactly its content, so the margin resolves to nothing and the card
   is drawn exactly as it always was. */
.menu-item .stepper { align-self: flex-end; margin-top: auto; }
.card-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 10px;
}
.card-title { font-size: 16px; font-weight: 700; margin: 0 0 2px; font-family: var(--serif); }
.card-sub { font-size: 13px; color: var(--muted); margin: 0; }
/* The name/price/description column, beside the stamp. `min-width: 0` is what
   lets a long product name wrap inside the head instead of shoving the stamp
   off the right of the card. */
.card-words { flex: 1 1 auto; min-width: 0; }

/* ONE standard photo window, and it is the SAME window in the three places she
   meets a product picture: the shop's card here, her app's products list, and
   the editor where she picks it. 120 x 120, and SQUARE. Before this version each
   place had its own size — 96 wide here following the card's own height, 64 in
   the app — so one picture showed three different sizes and none of them big
   enough to make out the product.

   `cover`, and the window is square. The photo is cropped to a square when she
   chooses it (readPhoto, 360 px), so it fills this window edge to edge with no
   bars. `cover` is here for the photos already stored from before that change: an
   old wide or tall one is trimmed to the square's middle rather than letterboxed,
   so every card looks the same and no window colour shows through. She asked for
   the crop by name — "photo upload can be auto crop to fit the window" — which
   reverses the no-crop of v220-v223. */
.menu-thumb {
  width: 120px;
  height: 120px;
  flex: 0 0 auto;
  align-self: flex-start;
  object-fit: cover;
  border-radius: 10px;
  background: #f1e9de;
}

/* The shop's category headings. A heading belongs to the cards under it, so it
   sits close to them and far from the group above — otherwise a full-width
   heading reads as a divider between two groups rather than the name of one.
   A nested heading is indented by its own depth (the `--depth` set inline) and
   wears the whole path down to it, so it still says where it sits. */
.menu-cat {
  margin: 22px 0 8px;
  font-family: var(--serif);
  font-size: 17px;
  font-weight: 700;
  color: var(--ink);
  letter-spacing: .2px;
}
.menu-cat:first-child { margin-top: 2px; }
.menu-cat-sub {
  font-size: 15px;
  font-weight: 600;
  margin: 16px 0 6px;
  padding-left: calc(var(--depth, 1) * 12px);
  color: var(--muted);
}
/* The tail heading over the products she has not filed yet. It is a plain
   heading, not a warning: nothing is ever hidden for want of filing. */
.menu-cat-tail {
  font-size: 15px;
  font-weight: 600;
  color: var(--muted);
  border-top: 1px solid var(--line);
  padding-top: 14px;
}

/* The owner's short product description, under the name + price. Keeps its
   line breaks (textarea) but stays quiet next to the title. */
.prod-desc {
  margin: 3px 0 0;
  font-size: 12.5px;
  color: var(--muted);
  line-height: 1.45;
  white-space: pre-wrap;
}

/* Per-product "Only N left" / "Sold out" stamps on the menu cards. */
.prod-stamp {
  flex: 0 0 auto;
  font-size: 10px;
  font-weight: 800;
  letter-spacing: .5px;
  text-transform: uppercase;
  color: #fff;
  background: var(--brown);
  border-radius: 6px;
  padding: 3px 7px;
  transform: rotate(-4deg);
  box-shadow: 0 1px 3px rgba(80, 50, 30, .25);
}
.prod-stamp.soldout { background: #c0392b; }
.menu-item.soldout .card-title { color: #75634f; } /* 5.38:1 — was 2.18 (v583) */
.menu-item.soldout .step-btn { background: #f1e9de; color: #6e5c49; cursor: default; } /* 5.3:1 — was 1.6 (v583) */

/* Small notes under a product's stepper: why the card is unavailable on this
   date (a value pack's advance window, a from–to window, orders closed, or a day
   the baker doesn't sell it), and the baker's change/cancel window for the
   product when one is stated. */
.prod-note {
  margin: -2px 0 0;
  font-size: 12px;
  color: var(--muted);
  line-height: 1.4;
}

/* The customer's own note for one item (v236), under that item's stepper.

   It opens on a press rather than standing open, so the row is usually just the
   link. The link is deliberately quiet — it is an invitation, not an instruction,
   and it must not compete with the stepper for the thumb — but it is a real
   target: 36px tall, the smallest thing on this page that is meant to be tapped.

   When it opens, the box is the full width of the card's body so it lines up with
   the product's own words above it, and it keeps the shop's ordinary input look. */
.line-note-row { margin: 8px 0 0; }
.line-note-add {
  display: inline-flex;
  align-items: center;
  min-height: 36px;
  padding: 0 2px;
  background: none;
  border: 0;
  font: inherit;
  font-size: 13px;
  color: var(--brown-dark);
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}
.line-note-add:active { color: var(--brown-dark); }
.line-note {
  width: 100%;
  font-size: 13px;
}

/* On a greyed card, the one line that says what to do: the next date this
   product can be ordered, and how many are left that day. It wears the brand
   colour and pulses, so it reads through the dimming and draws the eye on a
   card that is otherwise telling the customer "not today". A slow brighten and
   dim, not a hard blink — a fast on/off is hard to read and a seizure risk.
   Reduced-motion gets the same chip, lit steadily. */
.prod-next {
  /* The card is a flex column, so a plain block would stretch the chip across the
     full width; hugging its own text keeps it reading as a label, not a bar. */
  align-self: flex-start;
  display: inline-block;
  color: var(--brown-dark);
  font-weight: 600;
  background: var(--brown-soft);
  border-radius: 8px;
  padding: 3px 9px;
  animation: next-pulse 1.8s ease-in-out infinite;
}
@keyframes next-pulse {
  0%, 100% { background: var(--brown-soft); box-shadow: 0 0 0 0 rgba(196, 85, 47, 0); }
  50% { background: #f6d3c0; box-shadow: 0 0 0 4px rgba(196, 85, 47, .20); }
}
@media (prefers-reduced-motion: reduce) {
  .prod-next { animation: none; box-shadow: 0 0 0 3px rgba(196, 85, 47, .16); }
}
/* The chip is a control now (v99) — it orders for the day it names. A button
   arrives with the browser's own chrome, so the chip's look is restated for it,
   and the arrow says out loud what the pulse was only hinting at. */
button.prod-next {
  font-family: inherit;
  font-size: 12px;
  line-height: 1.2;
  border: none;
  cursor: pointer;
  text-align: left;
}
button.prod-next::after { content: " →"; }
button.prod-next:active { background: #f6d3c0; }
/* With a basket already in hand the line is a label again (v99): it still names
   the day, but tapping it would move the customer's whole order, so it wears no
   arrow, takes no pointer, and sits still rather than pulsing at them. */
button.prod-next.off { animation: none; cursor: default; box-shadow: 0 0 0 3px rgba(196, 85, 47, .16); }
button.prod-next.off::after { content: ""; }

/* The baker's policies text on the shop — line breaks preserved exactly as
   typed in the app (white-space: pre-line collapses spaces but keeps newlines). */
.policy-text {
  margin: 0;
  font-size: 14px;
  color: var(--ink, #3b2a1f);
  white-space: pre-line;
}

/* The privacy notice, moved to the ORDER BAR (v350). One quiet line under Place
   order, and this panel behind it. It was a block at the point of collection (v348);
   she asked for it moved because the block read as something LOUD sitting in the
   middle of the form.

   ⚠️ DELIBERATELY QUIET, NOT HIDDEN — and the two are not the same thing. The
   Commissioner's guide says a notice "hidden behind too many links" is one nobody
   reads, and warns against a font "so small that it results in the data subject not
   reading the PDP Notice". The panel opens in place, from the bar itself, so the
   notice is one press from the button rather than a page away.
   ⚠️ IT IS A NOTICE, NEVER A GATE: no tick box, and nothing here can refuse an order.
   ⚠️ THE LINE IS THE ONLY PLACE THE NOTICE IS GIVEN, so 12.5px is its FLOOR, not a
   starting point. It matches the size the v348 block used. */
/* .order-notice-wrap carries NO styling of its own. It exists to be the ZONE for "click away
   to close" (see store/app.js): the words are inside it, and the panel — which used to be — is
   now checked separately, because it has moved out to the bar. */

/* ⚠️ THE PANEL FLOATS ABOVE THE WHOLE BAR (v357), AND IT DOES NOT PUSH.
   Its parent is .order-bar, which is `position: fixed` and so is the containing block; that
   makes `bottom: 100%` land the panel's lower edge exactly on the bar's top edge.
   ⚠️ IT USED TO SIT INSIDE .order-notice-wrap, directly above the words — which put it ON TOP
   OF THE ROW, and that row is the Place order button. Her words: "can the pop up be open
   higher not to block the Placae Order button". The button must stay in sight while the
   notice is read.
   left/right are 16px because that is the bar's own horizontal padding, so the panel lines up
   with the button and the words rather than with the bar's outer edges.
   max-height keeps it on screen on a short phone; it scrolls inside rather than running off the
   top. ⚠️ The cap is MEASURED from the tallest language, not picked: at 375px wide the notice is
   English 405px, Chinese 371px, BAHASA MALAYSIA 518px, so a 360px cap was cutting 45px off the
   Malay one and making a 4-point notice scroll. 520px shows all three whole, and the
   `calc(100vh - 160px)` term is what stops it running off the top on a short phone (160px covers
   the bar's own height plus the gap and a little air). */
.privacy-sheet {
  position: absolute;
  left: 16px;
  right: 16px;
  bottom: calc(100% + 6px);
  z-index: 1;
  max-height: min(520px, calc(100vh - 160px));
  overflow-y: auto;
  padding: 12px 14px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow-lg);
}
.privacy-sheet-head {
  margin: 0 0 7px;
  font-size: 12.5px;
  font-weight: 700;
  color: #6b5b4a;
}
.privacy-sheet-list {
  margin: 0;
  padding: 0 0 0 16px;
  list-style: disc;
  font-size: 12.5px;
  line-height: 1.5;
  color: #6f6052;
}
.privacy-sheet-list li { margin: 0 0 5px; }
.privacy-sheet-list li:last-child { margin-bottom: 0; }
/* The line naming the Act. It CLOSES the notice rather than opening it (v350): the
   Commissioner's own template opens in plain language, and so do the large
   platforms. The date is the last reviewed date the guide asks to be recorded. */
.privacy-sheet-fine {
  margin: 8px 0 0;
  font-size: 12px;
  line-height: 1.45;
  color: var(--muted);
}
/* The number, not a word — so it carries the ink and a tap target worth having. */
.privacy-wa {
  color: var(--brown-dark);
  font-weight: 700;
  text-decoration: none;
  white-space: nowrap;
  padding: 3px 0;
}
/* One line of small print under the button, and the press that opens the panel.
   The press carries padding with a matching NEGATIVE margin: that grows the tap
   target to a thumb's size without moving the words or changing the line's height. */
.order-notice {
  margin: 0;
  text-align: center;
  font-size: 12.5px;
  line-height: 1.45;
  color: var(--muted);
}
/* ⚠️ NOT THE TERRACOTTA. It carries `--brown-dark` until v352, which read as a RED link
   sitting under a sentence of grey small print — she asked for the underline to stay and
   the red to go. `inherit` takes the colour of the line it lives in, so the trigger can
   never drift away from the small print around it, in any language or on any background.
   The affordance is the underline and the weight; the hover thickens it rather than
   changing colour, so nothing here goes red again. */
.privacy-link {
  appearance: none;
  background: none;
  border: 0;
  font: inherit;
  font-size: inherit;
  color: inherit;
  font-weight: 700;
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
  display: inline-block;
  vertical-align: middle;
  padding: 6px 4px;
  margin: -6px -4px;
  min-height: 24px;
}
/* A press that shows nothing when it is hovered reads as a dead control, and the colour
   is no longer the tell — so the underline carries it, and thickens on hover. */
.privacy-link:hover { text-decoration-thickness: 2px; }
/* ⚠️ IT HAD NO FOCUS RING OF ITS OWN until v352, and with `appearance:none; border:0` it
   was relying on whatever the browser felt like drawing. Same shape as the shop's other
   focus ring (.promo-dot), in the small print's own colour rather than the terracotta. */
.privacy-link:focus-visible { outline: 2px solid var(--muted); outline-offset: 2px; }

/* Notice shown when a live refresh had to fix the customer's order — e.g. an
   item sold out (or only a few left) after they added it. Sits above the menu. */
.menu-note {
  margin: 0 0 12px;
  padding: 11px 13px;
  border-radius: 12px;
  background: #fdf1dc;
  border: 1px solid #e8c98a;
  color: #8a5a12;
  font-size: 13px;
  line-height: 1.45;
}
.menu-note p { margin: 0 0 2px; }
.menu-note p:last-child { margin-bottom: 0; }

.stepper {
  display: inline-flex;
  align-items: center;
  border: 1px solid var(--line);
  border-radius: 999px;
  overflow: hidden;
  background: var(--surface);
}
.step-btn {
  width: 40px;
  height: 40px;
  font-size: 19px;
  font-weight: 700;
  border: none;
  background: var(--brown-soft);
  color: var(--brown-dark);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.step-btn:active { background: #f5d6c4; }
/* ★★★ AND IT IS A FIELD NOW, NOT A LABEL (v458). Her words: __"for qty field i should be allow to key in
   qty also instead of just press the + & -"__. ⚠️ So it is stripped back to exactly the look the number
   already had — no border, no background, the shop's own ink — because a boxed number between two round
   buttons would read as a third, different control. ⭐ And a number box brings its own little up/down
   spinners on a desktop browser: those are removed, because **the + and − either side ARE that control**
   and two ways to do one thing on one row is the fault this project has a rule about. */
.stepper-val {
  width: 52px;
  min-width: 44px;
  text-align: center;
  font-size: 16px;
  font-weight: 700;
  font-family: inherit;
  color: var(--ink);
  border: none;
  background: transparent;
  padding: 0;
  -moz-appearance: textfield;
  appearance: textfield;
}
.stepper-val::-webkit-outer-spin-button,
.stepper-val::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
/* ⚠️ AND A DISABLED ONE IS DRAWN AS INERT, as the two buttons beside it are — otherwise a sold-out card
   would show a live-looking number that cannot be changed. */
.stepper-val:disabled { opacity: .45; }
/* ★★★ AND A BOX THAT IS NOT OPEN YET WEARS IT (v461). Her report: __"i do step 3 skipping step 2, it just
   dont allow, but no warning"__. ⚠️⚠️ **The box was switched off, and it looked entirely normal** — the
   worst of both, because he taps a box that looks ready and is told nothing at all.
   ⭐ It is NOT switched off any more: it can be tapped, and the tap is answered with the sentence. But it
   must still LOOK shut, or the customer taps expecting to type and the answer reads as a refusal he did
   not earn. ⭐ Colours are the shop's own tokens — the page's background, and the muted ink it already
   uses for a quiet second line — so this cannot drift from the rest of the page. */
.input.locked {
  background: var(--bg);
  color: var(--muted);
  border-color: var(--line);
}
/* ⚠️ NO OPACITY ON THIS ONE, AND THAT IS MEASURED RATHER THAN PREFERRED (v461). With a `.75` fade over
   the locked background the hint bottomed out at about **3.1:1**; at full strength `--muted` on `--bg` is
   **5.03:1**, clear of the 4.5 the shop holds its words to. ⭐ A locked box is exactly where a customer is
   straining to read what he is being told he cannot do yet. */
.input.locked::placeholder { color: var(--muted); }

/* ---------- Forms ---------- */
.field { margin-bottom: 12px; }
.field label, .field .field-label {
  display: block;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  margin-bottom: 5px;
}
.field label .req { font-style: normal; color: var(--c); }
.input {
  width: 100%;
  font-size: 16px;
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--surface);
  color: var(--ink);
  min-height: 46px;
}
.input:focus { outline: 2px solid var(--brown); outline-offset: -1px; border-color: transparent; }
/* The delivery note is a hint, never content (v237). Set explicitly rather than
   left to the browser, whose default grey is dark enough to read as typed text
   and differs between Safari, Chrome and Firefox. `opacity: 1` for Firefox. */
.note-input::placeholder { color: var(--muted); opacity: 1; }

/* Self collect / courier picker.
   ★ ONE ROW PER WAY, EACH SAYING WHAT IT IS (v407). It was two bare words side by side in a
   segmented control, and her customer chose a courier when he wanted to collect — the page
   named both ways and never said what either meant or what it cost. So it is one full-width
   row per way, a bold name over a plain line, which is the same shape as the collect-from
   list right below it: same kind of question, same answer to "which one am I picking".
   ⚠️ STACKED, NOT SIDE BY SIDE, and that is the point rather than a style choice — two
   columns leave a phone about 160px per line, where a sentence wraps to five ragged lines and
   nobody reads it. Full width is what lets both lines be read before anything is tapped. */
.seg { display: grid; gap: 8px; }
/* ★★ THE CARD IS THE CHOICE **AND EVERYTHING IT CONTAINS** (v410). Her words: __"there is 2 main
   selection, select self collect and a drop down appear, should not be in present interface"__.
   ⚠️ The edge therefore went on the WRAPPER rather than on the press: the places a customer can
   collect from are part of choosing to collect, and a border around only the words would draw a
   line between one decision and the thing it decides. */
/* ⚠️⚠️ NOT `--surface` (v412). Her words: __"even after i expend it, there should not be hole"__ —
   and the hole was this. **The picker sits INSIDE the white "Your details" card**, so a
   `--surface` card here is white on white: its own edge is the only thing visible, its place rows
   are white too, and an expanded card is a **white void with thin outlines floating in it.** ⭐ The
   page's own background colour instead, so an unopened card reads as a soft well on the card, and
   the white place rows sit ON something rather than in nothing. */
.seg-opt {
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--bg);
  overflow: hidden;
}
/* The chosen one takes the brand's orange edge — the same "this one is on" the promo box and the
   chosen day already use, so nothing here has to be learned. */
.seg-opt.active {
  border-color: var(--brown);
  border-width: 2px;
  background: var(--brown-soft);
}
.seg-btn {
  display: block;
  width: 100%;
  appearance: none;
  text-align: left;
  padding: 10px 12px;
  border: none;
  background: transparent;
  font: inherit;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.seg-opt.active .seg-name { color: var(--brown-dark); }
/* ★★ THE SELF-COLLECT CARD FOLDS, AND THE CARET IS THE WHOLE HINT (v412). Her words: __"i want the
   self collect card at store to be folded as default"__, and __"when it expend the unselected with a
   white background is like a hole, and like confusing"__ — ⚠️ which is exactly what an open card full
   of white place rows does while it is NOT the chosen way: it reads as a white hole in the page at
   the moment the customer has already decided against it. */
.seg-head-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.seg-caret { flex: 0 0 auto; color: var(--muted); font-size: 13px; }
/* ★★ THE LINE UNDER THE QUESTION (v416). Her words: __"you need a 'click to choose' after the
   title"__. ⚠️ With NEITHER card edged, the two of them read as two descriptions rather than as two
   things to pick — so this says what they are. Same voice as the instruction over the places, because
   it is the same kind of help. */
.seg-prompt {
  margin: 0 0 8px;
  font-size: 13px;
  font-weight: 600;
  color: var(--brown-dark);
}
.seg-prompt[hidden] { display: none; }
/* ⚠️ THE FOLDED LIST TAKES NO ROOM. `hidden` on the element itself, so a shop with no places draws
   exactly the card it drew before — the rule is keyed on the attribute rather than on `:empty`
   because an EMPTY list and a FOLDED one are different things and only one of them is hidden. */
#point-list[hidden] { display: none; }
/* ⚠️ 12px is the FLOOR for the explanation, not a starting point — the whole fault being
   repaired is a line the customer did not read. */
/* ★★ AND IT IS A TITLE (v434). Her words: __"Self collect should be in bigger font being a title"__.
   ⚠️ It was 14px sans — the size of body text — while every other card heading on this shop is
   `.card-title`: 16px, 700, in the serif. ⭐ **So it does not get a new size invented for it; it wears
   the shop's own title**, which is why 'Self collect' and 'Courier delivery' now read exactly like
   'Focaccia' on the menu above them. ⚠️ The serif is the whole of the change in voice: a heading on
   this page is set in it, and nothing else is. The active colour below is untouched. */
.seg-name { display: block; font-weight: 700; font-size: 16px; font-family: var(--serif); color: var(--ink); }
.seg-sub { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; line-height: 1.45; }
.seg-btn.active .seg-name { color: var(--brown-dark); }
/* ⚠️ THE PLACES INSIDE THE SELF-COLLECT CARD (v410). Inset and quiet, so the card still reads as
   one choice: the name and its line first, and the places under a hairline. An empty list draws
   nothing at all (store/app.js leaves it empty when she has no Points), which is why the hairline
   lives on the rows rather than on the container. */
.point-list {
  padding: 0 10px 10px;
  border-top: 1px solid var(--line);
  margin-top: 2px;
  padding-top: 8px;
}
.seg-opt.active .point-list { border-top-color: var(--brown-soft); }
/* ★★ AND THE COURIER'S ADDRESS SITS IN ITS CARD THE SAME WAY (v412). Her words: __"courier delivery
   and address should be one piece"__ — ⭐ the exact mirror of the places inside the collect card, and
   **the same reasoning: the thing a choice needs is PART of the choice, not a field that appears
   under it.** ⚠️ The rules are deliberately the same shape as `.point-list`, because two panels doing
   the same job differently is how the next one gets built wrong. */
.addr-body { padding: 8px 10px 10px; border-top: 1px solid var(--line); margin-top: 2px; }
.seg-opt.active .addr-body { border-top-color: var(--brown-soft); }
.addr-body[hidden] { display: none; }

/* ---------- The door pin (optional) ---------- */
/* Two controls side by side, because the two customers are different people: one is
   standing at the door the bread is going to, the other is ordering from somewhere
   else entirely and needs to point at it. */
/* ★★ THE PIN IS AN OFFER, NOT A STEP (v437). Her words: __"Pin a map can be confusion that is not
   mandatory to be faced by customer, especially when we have auto complete… maybe we just fade that
   function"__.
   ⚠️⚠️ IT WAS A WHOLE PARAGRAPH AND A FULL-WIDTH BORDERED BUTTON at the same weight as the address
   field above them, so it read as the next thing to do. **Closed, it is now one quiet line** — and
   the long explanation, which is the part that says skipping is FINE, moved into the map.
   ⚠️ IT DISAPPEARS WHILE THE MAP IS OPEN — see `openPinBox`. The link does not close the box, so a
   line still offering the thing already on screen would be a control that does nothing. */
.pin-ask { margin: 6px 0 0; }
/* ⚠️ A LINK, NOT A BUTTON. ⭐ A press inside a sentence says "you can"; a bordered button the width
   of the card says "now do this", and one of those was the whole complaint. */
.pin-link {
  appearance: none;
  border: 0;
  background: none;
  font: inherit;
  font-size: inherit;
  color: var(--brown-dark);
  font-weight: 700;
  text-decoration: underline;
  text-underline-offset: 3px;
  padding: 0 2px;
  cursor: pointer;
}
.pin-link:hover { text-decoration-thickness: 2px; }
.pin-link:focus-visible { outline: 2px solid var(--brown); outline-offset: 2px; }
/* What the address lookup found (v202). A box of doors under the address box, each one
   a row you can tap. It is not a floating dropdown: the shop's own page scrolls, and a
   list positioned over the keyboard fights it on a phone. */
.addr-list {
  margin-top: 6px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--surface);
  overflow: hidden;
}
.addr-note {
  margin: 0;
  padding: 9px 12px;
}
/* The instruction is gone once a door has been chosen, and then the first row sits
   directly under the list's own border — where its own top border would draw a second
   line a pixel inside the first. */
.addr-list > .addr-hit:first-child { border-top: 0; }
.addr-hit {
  /* Two lines rather than one: the whole point of this list is telling two houses on
     the same street apart, and the part that tells them apart is often at the end. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
  width: 100%;
  appearance: none;
  border: 0;
  border-top: 1px solid var(--line);
  background: transparent;
  color: var(--brown-dark);
  font: inherit;
  font-size: 14px;
  line-height: 1.35;
  text-align: left;
  padding: 11px 12px;
  min-height: 44px; /* a thumb target, not a mouse target */
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.addr-hit:active { background: #f5ecdf; }
/* ⚠️ `.pin-row` IS GONE (v437) — it was the flex line holding the full-width button, and the press
   is a link inside a sentence now. ⭐ Dead CSS is the layer of this the screen never shows. */
.pin-btn {
  flex: 1;
  appearance: none;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--surface);
  color: var(--brown-dark);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  padding: 11px 8px;
  min-height: 44px; /* a thumb target, not a mouse target */
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.pin-btn:active { background: #f5ecdf; }
.pin-btn:disabled { opacity: .55; cursor: default; }
.pin-status {
  margin: 8px 0 0;
  color: var(--brown-dark);
}
.pin-box {
  margin-top: 8px;
  padding: 8px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: #fbf7f1;
}
.pin-note { margin: 2px 0 8px; }
.pin-canvas {
  /* Leaflet fills whatever box it is given, so the box has to have a height. */
  height: 240px;
  border-radius: 10px;
  background: #e8e0d5; /* the map's own colour before the tiles arrive */
}
.pin-actions {
  display: flex;
  gap: 8px;
  margin-top: 8px;
}
.pin-actions .track-btn { flex: 1; }

/* ---------- Track your order ---------- */
.track-row {
  display: flex;
  gap: 8px;
}
.track-row .input { flex: 1; min-width: 0; }
/* ⚠️ THE PHONE ROW SITS UNDER THE CODE ROW (v558): two boxes and a button on one line is 375px of nothing. */
.track-row + .track-row { margin-top: 8px; }
.track-btn {
  appearance: none;
  border: none;
  border-radius: 12px;
  background: var(--brown);
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  padding: 12px 18px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.track-btn:active { background: var(--brown-dark); }
.track-btn:disabled { opacity: .55; cursor: default; }
.track-result {
  margin-top: 12px;
  font-size: 14px;
  line-height: 1.5;
}
/* Journey progress line (New → Confirmed → Paid → Baked → Packed → Delivered) */
.tj { display: flex; margin: 4px 0 10px; }
.tj-step {
  position: relative;
  flex: 1 1 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  min-width: 0;
}
/* connector from this step's node across to the next step's node */
.tj-step::after {
  content: "";
  position: absolute;
  top: 12px;
  left: 50%;
  width: 100%;
  height: 3px;
  border-radius: 2px;
  background: #e9dfd0;
}
.tj-step:last-child::after { display: none; }
.tj-step.done::after { background: #3e8e5a; }
.tj-track {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 26px;
}
.tj-node {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: #d8cbbb;
  display: flex;
  align-items: center;
  justify-content: center;
}
.tj-step.done .tj-node { background: #3e8e5a; }
.tj-step.now .tj-node {
  width: 24px;
  height: 24px;
  background: #c9641f;
  animation: tj-pulse 1.4s ease-in-out infinite;
}
/* pulsing ring draws the eye to the live step (for a Packed order that is the
   final Delivered node — the delivery is the only thing left to happen) */
@keyframes tj-pulse {
  0%, 100% { box-shadow: 0 0 0 4px rgba(201, 100, 31, .22); }
  50%      { box-shadow: 0 0 0 10px rgba(201, 100, 31, 0); }
}
@media (prefers-reduced-motion: reduce) {
  .tj-step.now .tj-node { animation: none; box-shadow: 0 0 0 4px rgba(201, 100, 31, .18); }
}
.tj-check { color: #fff; font-size: 11px; font-weight: 800; line-height: 1; }
.tj-dot { width: 7px; height: 7px; border-radius: 50%; background: #fff; }

/* A step the order went straight past — this customer pays when she collects, so Paid is
   skipped. The step keeps its place so the line reads like every other order's, but it wears
   an X and is never green while the money is outstanding (17 Sep 2026). */
.tj-step.skipped .tj-node { background: #fbe7d8; box-shadow: inset 0 0 0 2px #c9641f; }
.tj-cross { color: var(--amber); font-size: 11px; font-weight: 800; line-height: 1; }
.tj-step.skipped .tj-label { color: var(--muted); }
.tj-label {
  margin-top: 6px;
  font-size: 9.5px;
  font-weight: 600;
  line-height: 1.25;
  text-align: center;
  color: var(--muted);
}
.tj-step.done .tj-label { color: #2f6f46; }
.tj-step.now .tj-label { color: var(--amber); font-weight: 800; }
.track-details { margin-top: 2px; }
.track-code { margin: 0 0 4px; font-weight: 700; }
.track-result p { margin: 2px 0; }
.track-note { color: var(--muted); font-size: 13px; }
/* ---------- An order's money, as a receipt (v277) ---------- */
/* Her report, 2 Oct 2026: "the format still not as clear as a receipt, the money have to
   align up ... line up in one column". Every money line on this card is now one ROW —
   the words on the left, the figure flush right — so the figures stack into a single
   column instead of each one starting again where its own label happens to end. The
   same shape her own screens draw (the .receipt block in admin/css/app.css), so the
   card she is shown and the card she reads back cannot be read two ways.
   `tabular-nums` is what makes the column true rather than approximate: the figures are
   digits of one width, so 1s and 8s keep the decimal points on one vertical line. */
.track-details .money { margin-top: 2px; }
.money-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin: 2px 0;
  font-size: 13px;
}
.money-row .money-label { flex: 1 1 auto; min-width: 0; color: var(--muted); }
.money-row .money-val {
  flex: 0 0 auto;
  white-space: nowrap;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
/* The total, under the workings it was added up from (v199). The same emphasis the
   customer's WhatsApp message gives it with a blank line and a bold pair — done here
   the way a page does it, and set off from the muted workings above by a hairline, by
   weight and by a little air, so the figure they are being asked for is the one their
   eye lands on and the rule says the column above it was added up into it. */
.track-details .money-row.track-total {
  margin-top: 6px;
  padding-top: 5px;
  border-top: 1px dashed var(--line);
  font-weight: 800;
}
.track-details .money-row.track-total .money-label,
.track-details .money-row.track-total .money-val { color: var(--ink); }
/* The courier's tracking number, on its own line on the customer's card: the
   number face, spaced so it reads digit by digit when they are comparing it with
   a courier's site. Specific enough to beat .track-result p's tight margin. */
.track-result .track-no {
  margin: 8px 0 0;
  font-weight: 600;
  letter-spacing: .3px;
  font-variant-numeric: tabular-nums;
}
/* ---------- The delivery itself, on the customer's card (v190) ---------- */
/* Where the parcel has got to. This is the question the customer opened this page to
   ask, so it wears the card's own ink instead of the muted grey the notes around it
   use — the only line on the card that is news rather than detail. */
.track-trip { color: var(--ink); font-weight: 600; }
/* Who is bringing it, and the plate to look for at the gate. */
.track-driver { color: var(--ink); font-weight: 500; }
/* Every link on this card is one a customer taps on a phone — the driver's number and
   the courier's own tracking page — so none of them is left in the browser's default
   blue, the one colour on this page the bakery never chose.
   AND THE HEIGHT IS THE PAGE'S OWN. Measured on the drawn card at 375 pixels, a pill
   built from padding alone came out 30 pixels tall while every other thing a customer
   taps on this page — the delivery day, the collect / courier pair, Track, Place order —
   is 37 to 48. The two controls that matter most at the door were the two smallest
   targets on the page, found by measuring rather than by reading the rule. So the pill
   takes the page's own floor and centres its word in it, instead of growing with the
   text.

   AND THE WORD ITSELF MAY BREAK (v284). Her report, 3 Oct 2026: *"the lalamove link is
   very long and go out of bound."* A courier's share link is one unbroken run of about
   150 characters, and an inline-flex box cannot break one — the pill simply grew past
   the card and off the screen. `overflow-wrap: anywhere` is the rule that fixes it, and
   it is the same one her own backoffice already carries on `.job-link`, so the customer's
   card and her own screen stop a long link the same way. `anywhere` and not `break-word`:
   it is the one that also lowers the box's minimum width, which is what lets the pill fit
   the card at all. The vertical padding exists so that a wrapped word has room inside the
   pill; a one-line pill is still exactly the 38 pixels measured above, because min-height
   wins and the word stays centred. */
.track-result a {
  display: inline-flex;
  align-items: center;
  min-height: 38px;
  padding: 6px 14px;
  box-sizing: border-box;
  overflow-wrap: anywhere;
  border-radius: 999px;
  background: var(--brown-soft);
  color: var(--brown-dark);
  font-weight: 600;
  letter-spacing: 0;
  font-variant-numeric: normal;
  text-decoration: none;
}
.track-result a:active { opacity: .75; }
/* The customer arrived by tapping "Track your order" in their WhatsApp message:
   the card is lit so it cannot be missed on a page they may not know, and the
   glow only goes when their pointer reaches it (revealTrack, store/app.js) — the
   same rule the backoffice uses when it jumps to an order. Reduced-motion gets the
   same lit card, held steady. */
#track-section.hit .card { animation: track-glow 1.4s ease-in-out infinite; }
@keyframes track-glow {
  0%, 100% { box-shadow: var(--shadow); }
  50% { box-shadow: 0 0 0 3px rgba(196, 85, 47, .45), var(--shadow); }
}
@media (prefers-reduced-motion: reduce) {
  #track-section.hit .card { animation: none; box-shadow: 0 0 0 3px rgba(196, 85, 47, .45), var(--shadow); }
}

/* ---------- Social ---------- */
.social {
  display: flex;
  gap: 10px;
  justify-content: center;
  margin-top: 6px;
}
.social a {
  font-size: 13px;
  font-weight: 600;
  color: var(--brown-dark);
  text-decoration: none;
}

/* The small "Website by …" credit under the social links. Shown only when the
   baker has set a developer name in the app and republished: the name line, a
   WhatsApp link (primary, opens with a ready "Hi!") and the email(s) smaller
   underneath. */
.dev-foot {
  text-align: center;
  margin: 14px 0 4px;
  font-size: 12px;
  color: var(--muted);
}
.dev-foot .dev-note {
  margin: 0 0 3px;
  font-weight: 500;
}
.dev-foot a {
  display: inline-block;
  margin: 2px 8px 0;
  color: var(--brown-dark);
  text-decoration: none;
  font-weight: 600;
}
.dev-foot a.dev-mail {
  color: var(--muted);
  font-weight: 500;
}
/* Which engine build the shop is running (v252), in the smallest type on the page and the
   same muted grey as the credit it sits under — the developer's corner, not the shop's. */
.dev-foot .dev-engine {
  margin: 4px 0 0;
  font-size: 11px;
  opacity: 0.75;
}

/* The suggestion box under the "Website by" credit (v247). It appears only when
   the baker has published a developer email, because it has nowhere to send the
   words without one — a box that sends nowhere is worse than no box. */
.fb-foot {
  max-width: 460px;
  margin: 14px auto 4px;
  text-align: left;
}
.fb-input {
  flex: 1 1 auto;
  min-width: 0;
  box-sizing: border-box;
  font: inherit;
  font-size: 13px;
  line-height: 1.45;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 9px 11px;
  /* One line, and the JS opens it as the words need it (see renderFeedback). Not
     resizable by hand: a dragged corner would fight the growing it does itself,
     and with a ceiling it scrolls rather than swallowing the whole footer. */
  resize: none;
  overflow-y: auto;
  max-height: 200px;
}
.fb-input::placeholder { color: var(--muted); }
.fb-input:focus {
  outline: none;
  border-color: var(--brown);
  box-shadow: 0 0 0 3px rgba(196, 85, 47, .18);
}
/* A field nobody should ever reach. Moved off-screen rather than `display:none`:
   a bot that fills every input still fills this one, and a hidden field is the
   first thing a slightly cleverer one skips. */
.fb-trap {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  opacity: 0;
}
.fb-row {
  display: flex;
  align-items: flex-end;
  gap: 8px;
}
/* There is no Send button: Enter is the send (see renderFeedback). .fb-input still
   fills this row, so the box keeps its full width. */
.fb-say {
  margin: 6px 0 0;
  font-size: 12px;
  line-height: 1.45;
  color: var(--muted);
}
.fb-say.fb-done {
  color: var(--brown-dark);
  font-weight: 600;
}
.fb-say.fb-bad { color: var(--brown-dark); }

/* ---------- Sticky order bar ---------- */
/* The bar is TWO ROWS since v350: the total and the button, then one line of small
   print. It was a single flex row; the row itself now lives in .order-bar-row, so
   everything that used to be a direct child of the bar keeps its arrangement.
   ⚠️ THE BAR IS FIXED, so its height is a cost paid on every screen: .bar-spacer
   must cover the CLOSED height in the LONGEST language, or the last thing on the
   page hides behind the bar. Measured, not guessed — see .bar-spacer below. */
.order-bar {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 30;
  padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
  background: var(--surface);
  border-top: 1px solid var(--line);
  box-shadow: 0 -4px 16px rgba(120, 66, 32, .08);
  max-width: 540px;
  margin: 0 auto;
}
.order-bar-row {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 0 0 8px;
}
.order-summary {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.bar-count { font-size: 12px; color: var(--muted); }
.bar-total { font-size: 20px; font-weight: 800; }
.order-bar .btn {
  flex: 1;
  appearance: none;
  border: none;
  border-radius: 999px;
  /* ⚠️⚠️ THE TOP STOP WAS 3.81:1 AGAINST WHITE (v583). ⭐ #bd5127 measures **4.81:1**; the bottom is unchanged. */
  background: linear-gradient(180deg, #bd5127, #b34a28);
  color: #fff;
  font-size: 15px;
  font-weight: 700;
  padding: 14px 18px;
  cursor: pointer;
  box-shadow: 0 3px 10px rgba(140, 70, 40, .28);
  min-height: 48px;
  -webkit-tap-highlight-color: transparent;
}
.order-bar .btn:active { transform: translateY(1px) scale(.99); }
.order-bar .btn[disabled] { opacity: .55; box-shadow: none; cursor: default; }

/* ⚠️ MEASURED, NOT GUESSED. The bar is fixed over the page, so this is the only thing
   stopping its height from swallowing the last row of the shop. It was 78px for the old
   one-row bar. The notice line made it two rows, and two things set the number, not one:

     · the TALLEST LANGUAGE, and
     · the NARROWEST SCREEN.

   Measured on the closed bar: at 375px wide English 100 / Chinese 101 / Bahasa Malaysia
   100; at **320px wide Bahasa Malaysia wraps to a second line and reaches 118** — the
   tallest of the six readings. So 118 is the floor and 126 gives it headroom. Set from
   English at 375px (100) the bar would cover the last row of the shop for a customer on
   a small phone reading in Bahasa Malaysia. ⚠️ The panel does NOT count here: it floats
   (see .privacy-sheet), so opening the notice does not change this height at all. */
.bar-spacer { height: 126px; }

/* ★ THE CODE, SAID AGAIN ABOVE THE BUTTON (v408). It only exists on an order that actually
   carries a code, so an ordinary bar is unchanged. Small print, the same weight and colour as
   the notice under the button — it is the same kind of thing: a fact the customer needs, never
   a control. ⚠️ 12px is the floor, not a starting point: the line's whole job is to be read
   before the button is pressed, and it is the second thing on this page to be set that small. */
.promo-bar {
  margin: 0 0 8px;
  font-size: 12px;
  line-height: 1.45;
  color: var(--brown-dark);
}
.promo-bar b { font-weight: 700; }

.confirm-msg {
  margin: 0 0 14px;
  padding: 12px 14px;
  border-radius: var(--radius, 14px);
  background: #eaf6ea;
  border: 1px solid #bfe0bf;
  color: #2f6b2f;
  font-weight: 600;
  font-size: 14px;
  text-align: center;
  line-height: 1.45;
}
.confirm-msg.warn {
  background: #fdf1dc;
  border: 1px solid #e8c98a;
  color: #8a5a12;
}
.confirm-title { margin: 0 0 4px; font-size: 15px; font-weight: 700; }
/* ★★ THE SHOP'S ONE QUESTION (v436). Her words: __"a confirmation pop up, asking whether customer
   intend to change to delivery? the point selection will be cleared, vise versa"__.
   ⚠️⚠️ IT IS THE FIRST TIME THIS PAGE ASKS ANYTHING. `#confirm-msg` is a NOTICE — a banner with no
   buttons — and the privacy sheet is read-only, so nothing here has ever needed an answer before.
   ⭐ It covers the whole screen on purpose: a question that can be answered by a press on the page
   behind it is not a question. */
.ask-scrim {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  background: rgba(59, 42, 31, .45);
}
/* ⚠️⚠️ THIS LINE IS LOAD-BEARING: `display: flex` above BEATS the `hidden` attribute, so without
   it the question would sit over the shop from the moment the page loads. */
.ask-scrim[hidden] { display: none; }
.ask-card {
  width: 100%;
  max-width: 340px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 16px 16px 14px;
}
.ask-card .confirm-title { font-size: 16px; }
.ask-card .confirm-body { margin: 4px 0 0; line-height: 1.45; }
/* ⚠️ THEY WRAP RATHER THAN SQUEEZE (v436). Measured on a 317px viewport: two labels side by side at
   `flex: 1` came out 109px wide and 59px tall — **the primary over two lines**. ⭐ The minimum is
   what makes them stack on a narrow phone and sit side by side when there is room, which is the fix
   the admin's own `.btn-row` needed for the same reason. */
.ask-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
/* ⚠️⚠️ THE SHOP'S `.btn` IS SCOPED TO THE ORDER BAR — it is written `.order-bar .btn`, so a button
   carrying `btn` in this card would be COMPLETELY UNSTYLED, and every test would still pass. These
   two carry their own look; the primary is the bar's own terracotta pill, so the question and the
   button it leads to read as one page. ⭐ The quiet one comes FIRST and the primary LAST — the
   order this shop already puts two choices in.
   ⚠️ 44px, above the 36px floor the app holds itself to, because this one is answered on a phone
   with a thumb. */
.ask-actions button {
  flex: 1 1 140px;
  appearance: none;
  border-radius: 999px;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  padding: 12px 14px;
  min-height: 44px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.ask-quiet { background: var(--surface); color: var(--brown-dark); border: 1px solid var(--line); }
.ask-go { border: none; background: linear-gradient(180deg, #d0632f, #b34a28); color: #fff; }
.ask-actions button:active { transform: translateY(1px) scale(.99); }
.ask-actions button:focus-visible { outline: 2px solid var(--brown); outline-offset: 2px; }
.confirm-body { margin: 2px 0; font-size: 13px; font-weight: 500; }
.confirm-sub { margin: 6px 0 0; font-size: 12px; font-weight: 600; opacity: .85; }
.confirm-link {
  display: inline-block;
  margin-top: 8px;
  font-size: 14px;
  font-weight: 700;
  color: var(--brown-dark);
}

/* A referral-link visitor (?via=…) sees this amount-free banner at the very top
   of the page. Green to match the "welcome" feel; the amount is quoted by the
   bakery on WhatsApp, never here. */
.referral-banner {
  margin: 0 0 14px;
  padding: 10px 14px;
  border-radius: 12px;
  background: #eaf6ea;
  border: 1px solid #bfe0bf;
  color: #2f6b2f;
  font-size: 13px;
  font-weight: 600;
  line-height: 1.45;
  text-align: center;
}

/* ---------- Promo code (v269) ---------- */

/* The standing offer, at the top of the shop. A STATEMENT of what is on right
   now, not a control, so it wears the referral strip's shape. Amber rather than
   green: green is already the referral's "you were given something", and a
   referred customer can see both strips at once. */
/* ★ v318: THE WINDOW, NOT THE BOX. The amber panel used to live here — one box that never
   moved. She asked for "each message" to be boxed, so the box moved onto every `.promo-slide`
   and this element became the frame you look through: no background, no border, no padding, so
   nothing is drawn twice and the window adds no height of its own.
   ⚠️ It still carries the COLOUR and the TYPE the panels inherit, and it is still the element
   the pointer listeners are bound to — so "rest your pointer on it and it holds" still means
   the whole strip, boxes and all. */
.promo-today {
  margin: 0 0 14px;
  color: #8a5a12;
  font-size: 13px;
  font-weight: 600;
  line-height: 1.45;
  text-align: center;
}
.promo-today p { margin: 0; }
/* ---------- The offers turn, and the strip holds its height (v292, v295) ----------

   EVERY offer is in the strip at once, stacked on top of each other in ONE grid cell,
   and only the current one is lit. A grid row is as tall as its tallest item, so the
   strip is exactly as tall as the TALLEST message and stays that height for ever — the
   browser works it out, at whatever font, language and text size the customer actually
   has, with no measurement of ours that can go stale.

   WHY IT IS BUILT THIS WAY. v292 wrote each offer into ONE pair of lines as the turn
   came round, so the strip was as tall as whichever message it happened to be showing.
   Two codes where only one carries a sentence of her own are two different heights, so
   every turn made the whole page below jump. Her words: "when the message switch, the
   page is like jumping up and down repeatedly… The window should be fix, base on the
   tallest message."

   The turn is these rules and nothing else — no timer in the script, so a repaint landing
   mid-turn cannot leave a blank strip behind.

   ---------- And the turn is a 3D FLIP (v296, made properly 3D in v297) ----------
   Her asks, in order: "can the flip be an animation", then "the flip should be 3D flip".

   An offer nobody is reading waits a quarter-turn AWAY — `rotateX(90deg)`, edge-on to the
   reader — and being lit brings it round to face them. That much was v296.

   ★ ★ v318: THE OFFERS SCROLL **UP**, AND EACH MESSAGE IS ITS OWN BOX. Her words, and they
   are the whole spec: __"Maybe you box up each message, when 1st message start to scroll up,
   the 2nd message is following, So effectively you see 2 message, one follow by another, it
   scroll up, stop 2sec, scroll again until mouse over."__

   ⚠️ **THE AMBER BOX MOVED OFF THE STRIP AND ONTO EACH MESSAGE.** Until now `.promo-today` WAS
   the box; there was one of it and it never moved. She asked for each message to be boxed, so
   the strip becomes the WINDOW (transparent, no border, no padding) and every `.promo-slide`
   carries the amber panel instead. That is the swap this rule makes, and it is why the strip's
   own background and border are gone from the rule above.

   ⚠️ **AND THE TWO MESSAGES ARE THE POINT, NOT A SIDE EFFECT.** The waiting one sits a full
   panel BELOW (`translateY(calc(100% + 10px))`) and the replaced one leaves through the TOP
   (`translateY(calc(-100% - 10px))`). The `+ 10px` is not decoration: it is the GAP that passes
   between the two panels as they move, which is what makes it read as one message following
   another rather than as one block of text sliding.

   ⚠️ **THE FLIP (v297) AND THE SIDEWAYS SCROLL (v316) ARE BOTH MINE, NOT HERS, AND BOTH ARE
   GONE.** v292 chose a fade, v296/v297 a 3D flip, v316 a slide off to the left — each with
   reasoning written into this file about what "reads as a glitch". She looks at this every day.
   **Do not restore either on the strength of those arguments.**

   ⚠️ **AND THERE IS NO FADE — a scroll shows BOTH messages.** Fading either would put a
   half-visible one on screen, which is what the old fades existed to prevent.

   ⚠️ `overflow: hidden` IS LOAD-BEARING: without it the waiting panels, parked below the
   window, would hang under the strip. It cannot clip a focus ring — a panel holds only `<p>`,
   so there is nothing focusable inside one.

   `transform` is paint-only, so NONE of this can change the strip's height — the guarantee
   above holds with the animation as it did without it. Measured on the live page across four
   steps: **unchanged on every one.** */
.promo-rotor { display: grid; overflow: hidden; }
.promo-slide {
  grid-area: 1 / 1;      /* every slide lands in the same cell, so the tallest sets the height */
  /* THE MESSAGE'S OWN BOX — this is what used to be on `.promo-today`. */
  padding: 8px 12px;
  border-radius: 10px;
  background: #fdf3e3;
  border: 1px solid #f0dcb8;
  /* The gap at the end of the travel is what separates the two panels as they pass. */
  transform: translateY(calc(100% + 10px));   /* waiting its turn, a panel BELOW */
  transform-origin: center center;
  pointer-events: none;  /* a waiting panel sits outside the window; it must never catch a tap */
  /* LEAVING: the ease the house skill names for anything going out, inside the 250–400ms band
     that reads as correct for a page-level state change. */
  transition: transform .4s cubic-bezier(.4, 0, 1, 1);
}
.promo-slide.is-on {
  transform: translateY(0);
  pointer-events: auto;
  /* ARRIVING: an ease-out to settle on, the same duration so the pair travel together. */
  transition: transform .4s cubic-bezier(0, 0, .2, 1);
}
/* The message that has just been replaced: up and out through the top. This rule must stay
   AFTER `.promo-slide` and is more specific than it, so it wins on its own. */
.promo-slide.is-left { transform: translateY(calc(-100% - 10px)); }

/* ⚠️⚠️ THE PARKED SLIDE, AND IT IS LOAD-BEARING (v319). `showSlide` puts this on every
   waiting message for one frame before it hands out `is-on` / `is-left`, so each of them
   lands back in its place BELOW with no transition.

   ⚠️ WITHOUT IT, A MESSAGE TRAVELS BACK THROUGH THE WINDOW. A slide that has just left
   through the top sits at `-100%`; when its turn comes round again it has to be at `+100%`
   to arrive from below — and MOVING IT THERE WITH A TRANSITION SLIDES IT THE WHOLE WAY
   THROUGH THE WINDOW, so the message the customer just read sweeps back across the strip
   in the wrong direction. A jump with the transition off paints only its ends.

   ⚠️ IT MUST COME LAST, or `.is-on` and `.is-left` (same specificity) would win on source
   order and the park would not take. */
.promo-slide.is-parked { transition: none; }

/* One dot per offer, under the box (v297). Her ask: "there should be 2 dot if there is 2
   message, 3 dot if 3 message."

   ⚠️ SIZED AND COLOURED TO THE HOUSE RULES, not to hand. The hit area is 28px because WCAG
   2.5.8's floor is 24×24 CSS px and the first draft of this shipped 22px — under the floor.
   The dot itself is 10px, matching the reviews carousel on her homepage, so nothing about
   the look changes; the button around it is what carries the size.

   The colours are picked to clear 3:1 against the strip, because a dot is a UI element and
   3:1 is the standard for one — the soft cream the homepage uses does not reach it. And the
   lit dot is BOTH darker and larger, because a position shown by colour alone is one of the
   mistakes the house skill names. */
.promo-dots { display: flex; justify-content: center; gap: 4px; margin-top: 6px; }
.promo-dots:empty { display: none; }

/* ⚠️ `?debug=offers` ONLY — one line saying why the strip is or is not turning. It is
   built by the script only when the shop's address carries that flag, so a customer
   never sees it; the rule exists so that when it IS there it reads as what it is —
   a reading, not part of the shop. */
.promo-debug {
  margin: 6px 0 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 11px;
  line-height: 1.4;
  color: #6b5545;
  text-align: center;
  word-break: break-word;
}
.promo-dot {
  /* ⚠️ 36, THE APP'S OWN FLOOR (v593): the homepage's identical dot is 36, and two dots that look alike must
     be as easy to hit as each other. WCAG's 24 is a minimum, not this app's standard. */
  width: 36px; height: 36px;
  padding: 0; border: 0; background: none; cursor: pointer;
  display: grid; place-items: center;
  border-radius: 999px;
}
.promo-dot::before {
  content: "";
  width: 10px; height: 10px; border-radius: 999px;
  background: #a8763a;                 /* 3.6:1 on the strip — over the 3:1 a UI element owes */
  transition: transform .18s ease, background-color .18s ease;
}
.promo-dot.is-on::before { background: #8a5a12; transform: scale(1.5); }   /* 5.3:1, and bigger */
/* A ring of at least 2px at 3:1, which the house skill requires on everything interactive
   and which these buttons had none of. */
.promo-dot:focus-visible { outline: 2px solid #8a5a12; outline-offset: 2px; }

/* The setting is about MOTION, not about information: a customer who asks for less
   movement still gets every offer — it simply arrives facing them, with no turn. */
@media (prefers-reduced-motion: reduce) {
  /* ⚠️⚠️ "REDUCE MOTION" MEANS NO *MOVEMENT* — NOT NO *EFFECT* (v321).
     This used to be `transition: none` and nothing else, which was wrong in a way that took
     a screen recording to find: the offers still changed, but they changed **INSTANTLY**,
     with nothing to see at all. She recorded it, and one turn came out as a snap between
     two frames a tenth of a second apart — and `transition: none` was on MY machine too,
     which is why every check I made looked fine.

     The house skill is explicit: under `prefers-reduced-motion: reduce`, drop the transforms
     and parallax, and allow **at most a ≤200ms opacity crossfade**. The panels therefore keep
     their places and only their OPACITY changes — no travel at all, but the message still
     visibly arrives rather than blinking. */
  /* ⚠️⚠️ **THE TRANSFORMS STAY. ONLY THEIR ANIMATION GOES.** The panels keep their places —
     the waiting ones below, the replaced one above — so the strip cannot be left blank by
     this rule, and if `is-on` were ever missing the failure is the one it already had rather
     than a new one. What changes is that the panel ARRIVING fades in over 180ms instead of
     appearing in a single frame. No travel, and something to see.

     ⚠️ AND THE SPECIFICITY MATTERS: `.promo-slide` alone is (0,1,0) and CANNOT beat the
     `transition: transform` that `.promo-slide.is-on` (0,2,0) carries — so the arriving
     panel has to be named here too, or it keeps animating its transform and never fades. */
  .promo-slide { opacity: 0; transition: opacity .18s ease; }
  .promo-slide.is-on { opacity: 1; transition: opacity .18s ease; }
  .promo-slide.is-parked { transition: none; }
  .promo-dot::before { transition: none; }
}
/* The bakery's own words, inside the same strip but quieter than the offer and
   the code it sits under — those are what a customer has to act on.

   Her sentence is set in the chalk hand at 16px (v274), not the 13px the strip's
   own text uses: it is a sentence a customer reads, and at 13px a handwriting
   face on a phone loses the strokes that make it a hand at all. The offer line
   above it keeps the strip's 13px system lettering, so the thing to act on still
   leads. */
.promo-today .promo-words {
  margin-top: 5px;
  font-family: var(--hand);
  font-size: 16px;
  font-weight: 400;
  line-height: 1.4;
}
.promo-words[hidden] { display: none; }

/* ---------- Where a Self collect order is collected from (v299) ----------

   One row per place, the kitchen first, the Points below it. The chosen row takes the
   brand's orange edge — the same "this one is on" the buttons and the promo box already
   use, so nothing here needs learning. Each row is a button and is comfortably taller than
   a thumb's target, because on a phone this is the last thing a customer taps before
   paying. */
.point-list { display: grid; gap: 8px; }
/* ★★ THE CHOSEN PLACE IS THE ONE THAT STANDS OUT (v413), NOT THE OTHERS.
   ⚠️⚠️ IT WAS THE OTHER WAY ROUND, AND THAT WAS THE "HOLE" AND THE CONFUSION TOGETHER. Every row was
   `--surface` (white) and the chosen one was `--brown-soft` — so inside a card that is ITSELF
   `--brown-soft`, **the chosen place dissolved into the card while every place the customer had NOT
   chosen stood out as a bright white slab.** Her words: __"you see the white, that is a hole when
   the self collect card unfold. It confuse user of actually which is the selected"__ — ⭐ **a customer
   reads the loudest row as the selected one, and it was pointing at the wrong place.**
   ⭐ So the flat row is the resting state and the filled one is the choice: **the selected place is
   the only thing wearing a panel.** */
/* ⚠️⚠️ AND THE UNCHOSEN ONES KEEP A FRAME YOU CAN ACTUALLY SEE (v413). Her words: __"can the
   unselected be frame as well with a very thin frame, so it is clear that custoerr is selecting from
   only 2 available choices"__ — and the frame was already there, which is the point: **`--line` is
   `#f0e2d2`, a cream chosen to sit on WHITE, and on the card's `#fbe7dc` it is invisible.** ⭐ So an
   unchosen place read as a bare line of text rather than as the other thing you may pick. A
   translucent brand brown instead, so every row is visibly ONE OF the choices — ⚠️ hairline-thin and
   still quieter than the chosen one, which keeps the 2px edge and the panel. */
.point-opt {
  display: block;
  width: 100%;
  text-align: left;
  padding: 10px 12px;
  border: 1px solid rgba(196, 85, 47, .38);
  border-radius: 12px;
  background: transparent;
  font: inherit;
  cursor: pointer;
}
.point-opt.active {
  border-color: var(--brown);
  border-width: 2px;
  padding: 9px 11px;
  background: var(--surface);
}
/* A Point whose smallest basket this basket has not reached (v306). PARKED, NOT HIDDEN: it
   stays on the page with the reason in its own line, because a choice that vanished below RM30
   would read as a broken page rather than as the baker's own rule — and a customer can act on a
   shortfall they can see. It is drawn quiet rather than dimmed to nothing, so it still reads. */
.point-opt.short {
  /* ⚠️⚠️ NO BACKGROUND FILL — THE GREY-OUT IS THE WORDING, NOT A SLAB (v425). Her words: __"the
     unavailable point grey out is not the background grey out but wording grey out"__.
     ⚠️ It filled with `--bg`, the PAGE cream, so a row inside the card's tint became a flat
     page-coloured patch — **the same "hole" she caught at v413**, in a different place.
     ⭐ The row keeps the card's own surface and is set apart by its words and its dashed frame —
     the dashes are the SHAPE and the pale text is the TONE, and either alone is a colour-only cue. */
  border-style: dashed;
}
/* The SAME PARKED LOOK for a place that is not served on the bake day he has chosen (v420).
   ⚠️ Deliberately identical to `.short` above, and written out rather than shared, so the
   stylesheet names BOTH reasons a place can be listed-but-not-open. They are one look on purpose:
   to a customer, "this place will not take your basket" and "this place is not open that day" are
   the same fact — you cannot collect here — and two different looks would invent a difference. */
/* ⚠️ AND THE SAME FOR A PLACE NOT SERVED THAT DAY — one look, two reasons, because two rows that look
   alike must behave alike ([[feedback-affordances]]). See the note above for why the fill went. */
.point-opt.off { border-style: dashed; }

/* ★★ HOW PALE, AND WHY IT IS NOT ONE VALUE (v425). ⚠️ Her second telling: __"the grey not obvious"__.
   ⭐ She was right, and the first attempt proved it — `--muted` is a WARM BROWN at 5.40:1, which at
   13px reads as ordinary text beside the body copy, so the row did not look unavailable at all.
   ⚠️ THE FADE HAS TO BE HARD TO BE SEEN, so it is `opacity` on the text rather than a colour swap, and
   the numbers were measured against the card before choosing them:
     · 42% — **2.40:1**, unmistakably pale. This is the NAME and the ordinary description, which are
       not the thing the customer has to read.
     · 70% — **5.21:1**, clearly paler than normal text (13.45:1) and still comfortably readable. This
       is the line that carries the REASON, because **a reason nobody can read is not a reason.**
   ⚠️⚠️ AND THE REASON IS NOT THE SAME LINE IN BOTH CASES, which is why these rules are named by
   reason and not by row: a place not served today says it on `.point-days`, while a place his basket
   has not reached says it ON `.point-sub` — because that sentence replaced the description.
   ⚠️ `:not(.point-days)` IS LOAD-BEARING: the days line wears `.point-sub point-days` too, so a plain
   `.point-sub` rule would fade the very line this is keeping readable. The `:not()` also outranks the
   plain rule on its own, so the outcome does not depend on the order these appear in. */
/* ⚠️⚠️ THE REASON LINE CARRIES NO FADE AT ALL, AND THAT IS A CORRECTION, NOT AN OMISSION (v426).
   Her words: __"it is abit too fade"__.
   ⚠️ v425 FADED THIS LINE BY 70% ON TOP OF ITS COLOUR — **AND ITS COLOUR IS ALREADY THE FADED ONE.**
   It inherits `--muted` from `.point-sub`, so the row's warning was **2.94:1**, not the 5.21 I wrote
   down: **I measured the colour and then applied a second fade over it, and the two compound.**
   ⭐ **`--muted` at full strength IS 5.40:1** — over the 4.5 this app holds its accents to, and the
   right answer was simply to stop folding it twice. **She found it by reading the page.**
   ⚠️ `opacity: 1` is written out rather than left to inheritance so the intent is on the page: an
   element that must NOT be faded says so. */
.point-opt.off .point-days,
.point-opt.short .point-days,
.point-opt.short .point-sub { opacity: 1; }

/* ⚠️ AND THE NAME AND THE ORDINARY DESCRIPTION still fade hard — that IS her "obviously pale", and
   they are not the thing the customer has to read. ⚠️ The name is `--ink` at 42% = **2.40:1**; the
   description is `--muted` at 42%, which is very faint indeed — deliberately, because it is the
   sentence that says what the place IS rather than why he cannot pick it. */
.point-opt.off .point-name,
.point-opt.short .point-name,
.point-opt.off .point-sub:not(.point-days) { opacity: .42; }

/* ★★ THE FLASH ON A PARKED PLACE (v423; REFINED AT v424). Her words — the second, sharper telling:
   __"i mean just flash the word Only available on Wed, Friday"__.
   ⚠️⚠️ SO IT IS THE WORDS THAT FLASH, NOT THE ROW. Her "just" IS the instruction: the row is already
   greyed and dashed to say *not this one*, and what he actually needs told is WHEN it IS open — so the
   LINE CARRYING THAT goes dark and back, and nothing else on the page changes at all.
   ⚠️ ONLY THE LINE THAT CARRIES THE REASON, and the two parked reasons carry it in different places:
   a place not served today says it on its own `.point-days` line, while a place his basket has not
   reached says it ON the `.point-sub` line, because that sentence REPLACED the description.
   ⚠️⚠️ Flashing `.point-sub` for BOTH would blink a place's ordinary description — a sentence that
   says nothing about why he cannot pick it — which is the opposite of pointing at the warning.
   ⚠️ A BLINK, NOT A JUMP: opacity only. No movement, no size, no position, so the words stay exactly
   where they are on the line he is reading. ⭐ Measured: the page's scroll position and the row's
   height are both identical before and after the tap. */
/* ★★ THE FLASH IS RED (v426). Her words: __"can you flash the only available on friday in red
   color?"__ — so the line no longer dims and returns: **it turns red and comes back.**
   ⚠️⚠️ `0%, 100%` IS `var(--muted)`, AND THAT IS THE WHOLE CRAFT OF IT. The line it animates INHERITS
   its colour from `.point-sub`, which is `--muted` — so a keyframe that ended on any other value
   would flash red and then **snap** the moment the animation let go. ⭐ **The end value has to be the
   resting value**, and the resting value here is a colour, not `1`.
   ⚠️ AND THE FADE FROM v425 IS UNTOUCHED: this animates `color` only, so the line keeps its 70%
   opacity throughout — the row stays as pale as she chose, and the red is what moves. */
/* ⚠️⚠️ THE TWO COLOURS ARE WRITTEN OUT, AND THAT IS NOT LAZINESS (v426). They began as
   `var(--muted)` and `var(--red)` — and the red **never arrived**: the animation ran, its name was on
   the element, and the computed colour did not move. An unregistered custom property is substituted
   as tokens, and a `var()`-to-`var()` pair is not something every engine will interpolate. ⭐ **A
   literal always interpolates**, and these two are pinned against the tokens by a test so the words
   and the numbers cannot drift apart. */
@keyframes point-flash-word {
  /* ★★ THE CYCLE, IN HER OWN NUMBERS (v428). Her words: __"on 1sec, off 0.5sec cycle"__ — ⭐ so the
     blink length is 1500ms and the two stretches are cut from it:
       · `0%, 67%`   — the RED, held for **1005ms**, which is her one second
       · `70%, 100%` — the pale, **450ms**, which is her half second
     ⚠️ AND THE 3% BETWEEN THEM IS A CROSSFADE, NOT SLACK: a truly square edge at this size reads as a
     rendering glitch rather than a light going on and off. **45ms is fast enough to feel like a switch
     and slow enough not to strobe.**
     ⚠️⚠️ THE RED IS NOT FADED IN — the cycle OPENS on it. "On for a second" means it is already on
     when it starts, and a fade-up would spend part of her second arriving.
     ⚠️ The pale stretch is not decoration: **a line that simply STAYS red is a colour change; a line
     that BLINKS is something happening.** */
  0%, 67% { color: #c0392b; }     /* --red, held for a full second */
  70%, 100% { color: #7b6552; }   /* --muted, the half-second off */
}
.point-opt.off.flash .point-days,
.point-opt.short.flash .point-sub {
  /* ⚠️ BOTH NUMBERS COME FROM THE TOKENS ABOVE, so the count and the length of the whole flash are
     read in one place — by this rule and by `flashRow`'s timer. */
  animation: point-flash-word var(--flash-blink) ease-out var(--flash-blinks);
}
/* ⚠️ AND FOR A READER WHO HAS ASKED FOR LESS MOVEMENT, the same line is simply made to STAND OUT while
   the class is on — a blink is precisely what that setting is about, and the warning is on the row
   either way. ⚠️⚠️ AND NOTE WHAT THIS RULE DOES *NOT* RELY ON: the class now comes off on a TIMER
   (see `flashRow`), not on `animationend` — because **`animation: none` means no animation runs, and
   an animation that never runs never ends.** v423 relied on the event and a reduced-motion reader was
   left with the mark stuck on that row for good. */
@media (prefers-reduced-motion: reduce) {
  .point-opt.off.flash .point-days,
  .point-opt.short.flash .point-sub { animation: none; color: var(--red); font-weight: 700; }
}

.point-name { display: block; font-weight: 700; font-size: 14px; color: var(--ink); }
.point-sub { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; }
/* ★★ THE AREA HEADING OVER A GROUP OF PLACES (v410). Her words: __"top level is Penang Island &
   Prai. Under Penang Island will be Area like Sg Ara, Balik Pulau, Farlim, Georgetown"__.
   ⚠️ IT IS DRAWN AS HER OWN WORDS AND IS NOT A CHOICE — no press, no hover, no border. It is a
   signpost on a list, and anything that looked tappable would have a customer trying to collect
   from "Penang Island". Small caps and quiet, so the places under it stay the loudest thing here. */
.point-area {
  margin: 10px 0 0;
  padding-left: calc(var(--depth, 0) * 12px);
  font-size: 11px;
  font-weight: 800;
  letter-spacing: .08em;
  /* ★★ BLACK, NOT THE BRAND RED (v434). Her words: __"area should not be in red, put as black"__.
     ⚠️ **A heading is not a warning and not a link.** The terracotta is this shop's voice for things
     the customer must NOTICE — an instruction to tap, a day that is closed. An area name is a
     signpost on a list: it says where the places below it are, and it is not asking for anything.
     ⭐ In `--ink` it still leads its group without competing with the places, which stay the loudest
     thing here. */
  color: var(--ink);
  /* ★★ AND IT IS WRITTEN AS SHE WRITES IT (v434). Her words: __"why capitalised the AREA?"__ — ⚠️
     **The capitals were never in the data: the name is stored as 'Penang Island' and this line drew
     it as 'PENANG ISLAND'.** ⭐ `text-transform` is a display style, and it was shouting a word that
     is only a signpost. Removed — **the small size, the weight and the wide spacing are what keep it
     a quiet heading**, and none of them needed the capitals to work. */
  text-transform: none;
}
.point-area + .point-opt { margin-top: 6px; }
/* ★★ THE INSTRUCTION OVER THE PLACES (v415). It stands exactly where the highlight used to sit the
   moment the card opened, so the card still SAYS something — it simply stops claiming a choice
   nobody made. Quiet and small, the same voice as a Point's own description, because it is help
   rather than a warning. */
.point-pick {
  margin: 0 0 2px;
  font-size: 12px;
  color: var(--brown-dark);
}
.point-pick[hidden] { display: none; }
/* ⚠️ THE ADDRESS IS SHOWN ONLY ON THE POINTS SHE TICKED, and it keeps the description's size and
   colour rather than being set apart — it is one more fact about the place, not a warning. */
.point-addr { margin-top: 1px; }
/* The days a Place is served (v419) — "Only available on Fri". A line of its own under the
   description, for the same reason the address got one: it is a fact about WHEN, and folding it
   into her own sentence would make her words look as if they said it. */
.point-days { margin-top: 1px; }

.promo-box { margin: 0 0 16px; }
.promo-box .field { margin-bottom: 0; }

/* The box and its button on one line, the box taking whatever room is left so a
   long code is never clipped off the right. */
.promo-row { display: flex; gap: 8px; align-items: stretch; }
.promo-row .input { flex: 1 1 auto; min-width: 0; text-transform: uppercase; }
.promo-apply {
  flex: 0 0 auto;
  min-height: 46px;
  padding: 0 16px;
  border: 1px solid var(--brown);
  border-radius: 12px;
  background: var(--brown);
  color: #fff;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
}
.promo-apply:active { transform: translateY(1px); }

/* The answer under the box. Green when the code is on, the brand red when it is
   not — the same yes/no pairing the referral strip and the suggestion box use,
   so "no" reads as "no" at a glance instead of having to be read out. */
.promo-say.good { color: #2f6b2f; font-weight: 600; }
.promo-say.bad { color: var(--brown-dark); }

/* Removing a code is a quiet undo, not a second action, so it is a text link at
   the same 36px target as the per-item note's. */
.promo-clear {
  display: inline-flex;
  align-items: center;
  min-height: 36px;
  padding: 0;
  margin-top: 2px;
  background: none;
  border: 0;
  font: inherit;
  font-size: 13px;
  color: var(--brown-dark);
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}
/* `hidden` has to win here. A rule of ours that sets `display` beats the browser's
   own rule for [hidden], so without this the Remove link is drawn on a page where
   no code has been used — a control offering to remove nothing. */
.promo-clear[hidden] { display: none; }

/* ★★ THE ANSWER, AND THE THREE STEPS (v405) ──────────────────────────────────────────────────────
   Her report, after her first customer ordered alone: __"End up they enter a wrong baking day and the
   select courier instead of self collect… Checking with the customer confirm that he actually want the
   bread today, not knowing order close 6pm one day before."__

   ⚠️⚠️ THE PAGE STATED THE RULES AND NEVER THE ANSWER. "Delivery days Mon, Wed, Fri" and "Order by
   18:00 the day before" are both true and neither tells a stranger whether HE can have bread, or when
   the earliest he can get it is. He had to combine today's date, three weekdays, the clock and "the day
   before" — in his head, on a phone, while not being confident online. So he picked a day that looked
   fine and it was wrong.

   ⚠️ EVERYTHING HERE IS UNDER `body.trial` UNTIL SHE HAS TRIED IT ON HER OWN PHONE. The markup ships
   but the box stays `hidden`, so a customer cannot see a half-judged wording. */

/* The answer box — a card with a coloured edge, so it reads as the page speaking to him rather than as
   one more panel of small print. */
.answer { background: var(--surface); border: 1px solid var(--brown); border-left: 4px solid var(--brown);
  border-radius: var(--radius); padding: 13px 15px; margin-bottom: 12px; }
.answer-kicker { font-size: 11px; letter-spacing: .09em; text-transform: uppercase; font-weight: 700;
  color: var(--brown-dark); margin: 0 0 4px; }
.answer-big { font-family: var(--serif); font-size: 17px; margin: 0 0 4px; line-height: 1.32; }
.answer-sub { font-size: 13px; color: var(--muted); margin: 0; line-height: 1.45; }
/* ⚠️ GREEN WHEN THERE IS STILL TIME, the same green the backoffice uses for a settled thing. The two
   states are the SAME words with a different first line, so a customer never has to work out which one
   they are looking at. */
.answer.ok { border-color: #3b7449; border-left-color: #3b7449; }
.answer.ok .answer-kicker { color: #3b7449; }

/* The three steps. ⚠️ BIG AND FILLED ON PURPOSE — a step number is read at a glance or not at all, and
   a small grey numeral beside a heading is decoration rather than a signpost. */
.steps-lede { font-size: 13.5px; color: var(--muted); margin: 0 0 4px; line-height: 1.45; }
.step-head { display: flex; align-items: center; gap: 10px; margin: 18px 0 8px; }
.step-head .section { margin: 0; }
/* ★★ WHAT A BAKE DAY IS (v434). Her words: __"I think customer 1st confusion is that we never
   explain what is a bake day to them."__ ⚠️⚠️ **The words were used FIVE TIMES on this page and
   explained none of them, and the only hint about WHEN the bread is ready lived inside the Self
   collect option — so a courier customer never met it at all.**
   ⭐ It is indented PAST the circle so it sits under the heading's own words and reads as part of
   step 1: the step-head is a 30px circle plus a 10px gap.
   ⚠️ Small and quiet, in `--muted` — it ANSWERS a question, and an answer set loud reads as a
   warning. ⭐ `--muted` on this card measures well clear of the 4.5 floor for body text. */
.bake-day-what {
  margin: 0 0 8px 40px;
  font-size: 12.5px;
  line-height: 1.45;
  color: var(--muted);
}
/* ★★ THE FOURTH BALL, BESIDE THE BUTTON (v438). Her words: __"the number 4 should not be inside
   the the Place order button, it should sit beside, same size with other number to be consistant,
   make the place order button smaller to make roon for the 4 ball"__.
   ⚠️⚠️ v435 DREW IT INSIDE THE BUTTON — `#order-btn::before`, a 20px white disc — so the fourth
   number **did not match the three he had just followed.** ⭐ It is now the same ball as the other
   three, at the same 30px, in the same place in the sequence: after the details, before the press.
   ⚠️ AND IT IS DELIBERATELY NOT `:before` ON THE BUTTON ANY MORE. The button's label is written by
   `renderBar` on every repaint, so anything inside it is wiped or has to be rebuilt in script; a
   ball standing BESIDE it is markup that nothing rewrites. */
.bar-step {
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--brown);
  color: #fff;
  font-weight: 700;
  font-size: 16px;
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* ★★ AND IT TURNS GREEN WHEN THE STEP IS DONE (v451) — THE SAME GREEN AS ①②③.
   ⚠️⚠️ v439 SAID "THE BALL IS A LABEL, NOT A STATE", AND THAT WAS TOO BROAD. What v438/v439 were right
   about is that the ball must not follow the BASKET: an empty basket must not dim it, because the basket
   is not a step. **But ④ IS a step — the fourth one — so when the customer places the order, the step is
   DONE, and ①②③ all say so by turning green.** The ball was left as the one step number that could never
   report itself, which is the fault her own words name: __"the number 4 never have the chance to turn
   green"__. ⭐ Same colour, same meaning, one more class. */
.bar-step.done { background: #3b7449; }
/* ⚠️⚠️ THE BALL NEVER DIMS (v439). v438 gave it `.bar-step.off { opacity: .55 }` for an empty
   basket, so the bar would not show a full-strength ball beside a greyed press. **Her phone
   showed what that actually does: ④ turns pale pink while ② and ③ stay solid terracotta** — her
   question, off a picture of the bar: __"is the color 1234 consistant?"__ ⭐ **It was not, and the
   ball is a STEP MARKER: ①②③ mark a step and never change with the basket, so ④ must not
   either.** ⚠️ The button carries its own disabled state; a label is not a state. */
.step-num { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 50%;
  background: var(--brown); color: #fff; font-weight: 700; font-size: 16px;
  display: flex; align-items: center; justify-content: center; line-height: 1; }
/* A step she has finished: the circle turns green, so "where am I" is answered without reading a word. */
.step-head.done .step-num { background: #3b7449; }
/* ⚠️ THE TRIAL GUARD THAT USED TO SIT HERE WENT WHEN THE FLAG DID (v406) — it read
   `body:not(.trial) … { display: none }` for the answer box, the lede and the step circles. */

/* ★★★ THE STEP'S OWN LINE (v455). ⚠️ Her words: __"the message show in the wrong place... it should shown
   up at the appropriate place, or the missing field turn red embossed"__ ⭐ **A form marks what is missing
   where it sits** — so this is a line in the flow of the page, beside the step it is about, and never a
   pop-up over the whole shop. It reads as a correction: a quiet edge and the shop's own attention colour,
   not a shout, because nothing is broken — one step is simply not done yet. */
.step-note {
  margin: 10px 0 4px;
  padding: 9px 12px;
  border-left: 3px solid var(--brown-dark);
  background: var(--brown-soft);
  border-radius: 0 10px 10px 0;
  font-size: 13.5px;
  line-height: 1.5;
  color: var(--ink);
}
.step-note strong { color: var(--brown-dark); }

/* ★★★ AND IT BLINKS, EXACTLY AS EVERY OTHER WARNING ON THIS PAGE DOES (v461). Her question, and it is the
   right one: __"did you blink the warning?"__ ⚠️ **It did not — the line simply appeared.** Her words a
   moment earlier were __"i do step 3 skipping step 2, it just dont allow, but no warning"__, and a sentence
   that arrives silently part-way down a long page is a sentence that can still be missed.
   ⭐⭐ IT IS THE SAME FLASH AS THE PARKED PLACE, DOWN TO THE NUMBERS: `--flash-blink` and `--flash-blinks`
   above are read by this rule AND by `flashRow`'s timer, **so there is one blink in this shop and not two.**
   ⚠️ AND ITS OWN KEYFRAME, because of the trap the CSS above already records: **the last keyframe has to be
   the colour the element RESTS at, or the words flash and then snap.** The parked place rests on `--muted`;
   this line's title rests on `--brown-dark`, so a shared keyframe would have snapped it. ⚠️ Both ends are
   written as LITERALS for the same reason the ones above are — a `var()`-to-`var()` pair does not
   interpolate in every engine, and the red silently never arrived. */
@keyframes step-note-flash {
  /* ⚠️ THE LINE FILLS WITH RED AND EMPTIES AGAIN — a whole-line pulse, not a tint on a word. */
  0%, 67% { background-color: #c0392b; color: #fbf4ea; }     /* --red, and the page's own light ground */
  70%, 100% { background-color: #fbe7dc; color: #3b2a1f; }   /* --brown-soft and --ink, where it rests */
}
@keyframes step-note-flash-word {
  0%, 67% { color: #fbf4ea; }     /* the words go to the light ground while the red is behind them */
  70%, 100% { color: #a84322; }   /* --brown-dark, where this line's title rests */
}
/* ⚠️⚠️ A FLASH HAS TO MOVE SOMETHING A EYE CAN SEE, AND THAT IS THE WHOLE OF v462. Her words:
   __"all warning so far not flashing"__ — ⭐ **and she was right, though the animation WAS running.**
   ⚠️⚠️ MEASURED: the first version blinked `#c0392b` against `#a84322`, **and those two are 1.10:1 apart —
   both are reds.** The animation ran perfectly and did nothing anybody could see. ⭐⭐ **This is the SAME
   shape as v426's fault, in the same file: a colour that was checked and a colour that arrived are not the
   same thing.** ⭐ The parked place gets away with a hue-only blink because it goes **grey-brown -> red**;
   this line's title is ALREADY red, so a red-to-red blink was invisible.
   ⭐ So the pulse is now a whole-line one: **the pale line fills with red, the words go light, and it comes
   back** — and every end value is the colour that element actually rests at, or it would snap. */
/* ★★★ AND THE REASON UNDER A MISSING BOX WEARS THE SAME LINE, AND BLINKS WITH IT (v462). Her report:
   __"i skip step 3, go for step4, not warning"__ — ⚠️ **the warning WAS there: the box turned red with its
   reason underneath it.** ⭐ **But it was a still red edge, a screen up from the button she had just
   pressed, and nothing about it moved.** ⭐⭐ **So her two reports are one report: the warnings exist and do
   not catch the eye.** ⚠️ It had no styling of its own at all before this — a bare paragraph — so there was
   nothing for a flash to return TO; it wears the step line's own clothes now, **which also means it can use
   the very same keyframes and there is still only ONE blink in this shop.** */
.field-note {
  background: var(--brown-soft);
  color: var(--ink);
  border-left: 3px solid var(--red);
  border-radius: 0 8px 8px 0;
  padding: 7px 10px;
  margin: 6px 0 0;
  font-size: 13.5px;
  line-height: 1.45;
}
.field-note.flash { animation: step-note-flash var(--flash-blink) ease-out var(--flash-blinks); }
@media (prefers-reduced-motion: reduce) {
  .field-note.flash { animation: none; background-color: var(--red); color: #fbf4ea; }
}
.step-note.flash {
  animation: step-note-flash var(--flash-blink) ease-out var(--flash-blinks);
}
.step-note.flash strong {
  animation: step-note-flash-word var(--flash-blink) ease-out var(--flash-blinks);
}
/* ⚠️ AND FOR A READER WHO ASKED FOR LESS MOVEMENT, the line simply STAYS filled while the class is on — the
   same bargain the parked place makes, and still unmissable. ⚠️ The class comes off on `flashRow`'s TIMER
   and never on `animationend`, because `animation: none` fires no such event (v423's fault). */
@media (prefers-reduced-motion: reduce) {
  .step-note.flash { animation: none; background-color: var(--red); color: #fbf4ea; }
  .step-note.flash strong { animation: none; color: #fbf4ea; }
}


/* ★★★ THE UPDATE NOTICE IS A STRIP NOW, LIKE THE BACKOFFICE'S (v501).
   ⚠️⚠️ HER WORDS: __"now see the warning, it is rather small. it should be a strip like what is having in the
   backoffice"__ — ⭐ **and "like the backoffice" means the pattern, not a near-miss.** What was here was a
   pale `--surface` bar with a 14px sentence and an underlined link for the press: **it read as another line
   of the page, which is exactly how a customer — or the baker — scrolls past the one thing that says the
   page is out of date.**
   ⭐ The backoffice's own strip is: **an amber bar across the whole width, with its message and a real
   press**, and this is that strip, with the shop's own words and the shop's own button.
   ⚠️ IT STAYS AT THE TOP AND IN FLOW. ⚠️ The backoffice fixes its strip to the bottom because it has a tab
   bar there; **this page has the Order bar at the bottom, and a second fixed strip would sit on top of the
   thing the customer is trying to press.** ⚠️ And the notice is about the PAGE, so the top is where it
   belongs — the same reasoning the v492 comment gives. ⭐ Nothing here changes the layout's height under
   anything, so nothing jumps when it appears. */
.build-notice {
  /* ★★★ PINNED ABOVE THE ORDER BAR — WHERE THE BACKOFFICE PUTS ITS OWN (v504).
     ⚠️⚠️ HER WORDS: __"i want it to be consistant with the app"__ — ⭐ **and consistency is the right thing
     to want: the same warning should live in the same place wherever she looks at it.** I had moved it to the
     top at v503 on the strength of a measurement I later admitted was dishonest (below), and this puts it
     back where the app's is.
     ⚠️⚠️ AND THE COVER IS ANSWERED BY RESERVING ITS ROOM, WHICH IS WHAT THE BACKOFFICE DOES TOO. ⭐ A bar
     pinned to the bottom covers the bottom of the screen at all times — **but the page's own foot padding was
     grown by the strip's measured height in the same version, so there is always room to bring a control
     clear of it.** ⚠️ **MEASURED at 375x812 with step 3 on screen: the customer scrolls the last of the form
     up and "Self collect" and "Courier delivery" are both fully clear** — which is the same bargain the
     admin's strip makes, and the reason it works there.
     ⚠️⚠️ AND WHY I WRONGLY MOVED IT: my first check said the strip "still covered 97px" — **because I had
     forced the offer to the bottom of the screen with `scrollIntoView`, a position no customer ends up in.**
     ⭐ **A control can always be scrolled clear of a reserved bar; it could never be scrolled clear of a bar
     whose room nobody reserved.** The reservation is the fix, not the edge it hangs from. */
  position: fixed;
  left: 0; right: 0;
  bottom: var(--bar-h, 92px);
  z-index: 31;                    /* ⚠️ just above `.order-bar`'s 30 */
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 10px 12px;
  padding: 10px 12px;
  margin: 0;
  background: var(--amber-bg);
  color: var(--ink);
  border-top: 1px solid #ecd4a5;
  box-shadow: 0 -2px 8px rgba(120, 66, 32, .12);
  font-size: 16px;
  line-height: 1.35;
  text-align: center;
}
.build-notice[hidden] { display: none; }
.build-say { margin: 0; font-weight: 600; }
.build-say[hidden] { display: none; }
/* ⚠️⚠️ A REAL PRESS, NOT A LINK IN A SENTENCE (v498's lesson, and she has now reported the smallness
   twice). ⭐ 44px, filled in the shop's terracotta, which reads as the ACTION on an amber strip.
   ⚠️ `--surface` on `--brown-dark` is **5.92:1** — over the 4.5 every word here is held to. */
.build-btn {
  appearance: none;
  border: 0;
  border-radius: 10px;
  min-height: 44px;
  padding: 10px 16px;
  background: var(--brown-dark);
  color: var(--surface);
  font: inherit;
  font-size: 16px;
  font-weight: 700;
  text-decoration: none;
  cursor: pointer;
}
.build-btn:hover { background: var(--brown); }
.build-btn:focus-visible { outline: 2px solid var(--brown); outline-offset: 3px; }
.build-btn[hidden] { display: none; }
