/* =============================================================================
   Konek — Customer Portal skin. Fintech-meets-arcade, LIGHT default.
   Layered on shared tokens.css. Everything brand-coloured derives from
   --accent / --accent-2 (runtime-injected) so an operator's colour reskins the
   whole surface. Mobile-first, ≥48px taps, reduced-motion safe.
   ========================================================================== */

/* --------------------- Portal-local token additions ---------------------- */
/* Placeholder ink. --ink-faint is --n-400 in BOTH schemes (#94a3b8 light,
   #3a4860 dark) and every input in this file fills with --surface-2, which lands
   the hint text at 2.28:1 / 2.00:1 — an effectively blank box in Philippine
   sunlight, on the fields where a customer types a voucher/buddy/transfer code.
   --n-600 is the one ramp step that inverts to a readable value on both sides
   (#475569 light, #8a99af dark): 6.7:1 / 6.4:1, no per-theme special-casing.
   (--ink-3 is NOT enough — #64748b on --surface-2 is only 4.24:1.) Aliased here
   so the placeholder rules below still read as semantic ink, not a ramp step. */
:root { --ink-placeholder: var(--n-600); }

/* ------------------------------ Page frame ------------------------------- */
body {
  min-height: 100vh;  /* fallback — dvh is Chrome 108+, kiosk is Chromium 87 */
  min-height: 100dvh;
  /* No phone should ever be pannable sideways. The ≤360px block at the bottom
     already guarded the smallest handsets, which left the whole 361–430px band
     (375/390/393/412/414/428) able to scroll horizontally off any stray overflow.
     Kept on <body> only: it propagates to the viewport, whereas putting it on
     .app would turn the column into its own scroll container. */
  overflow-x: hidden;
  background:
    radial-gradient(120% 70% at 50% -8%, var(--accent-soft), transparent 58%),
    radial-gradient(90% 60% at 100% 4%, color-mix(in srgb, var(--accent-2) 12%, transparent), transparent 60%),
    linear-gradient(180deg, var(--bg-grad-1), var(--bg-grad-2) 38%, var(--bg));
  background-attachment: fixed;
}

/* Tech-modern PCB circuit field — accent-coloured (theme-adaptive via mask) and
   very subtle, so it tints to whatever the active brand accent is. */
body::before {
  content: '';
  position: fixed; inset: 0; z-index: -1; pointer-events: none;
  background: var(--accent);
  /* Per-theme PATTERN FIELD: each brand's decoration swaps the masked field
     (Bubuyog honeycomb, Lawa waves, Kidlat circuit, Kislap dot-grid, Sinag
     sunburst rays, Bula bubbles, Sigla leaves, Fiesta confetti). This is the
     single biggest "beyond color" signal — the whole page wears the brand's
     texture, tinted by its accent. Falls back to the classic circuit field. */
  opacity: var(--theme-field-opacity, .14);
  -webkit-mask: var(--theme-field, url(/portal/img/circuit.svg)) 0 0 / var(--theme-field-size, 300px 300px) repeat;
  mask: var(--theme-field, url(/portal/img/circuit.svg)) 0 0 / var(--theme-field-size, 300px 300px) repeat;
}
/* A pool of accent light the device sits in — adds depth + makes the circuit
   glow brighter behind the centred portal. */
body::after {
  content: '';
  position: fixed; inset: 0; z-index: -1; pointer-events: none;
  background:
    radial-gradient(38% 30% at 50% 30%, color-mix(in srgb,#fff 10%,transparent), transparent 60%),
    radial-gradient(46% 54% at 50% 40%, color-mix(in srgb,var(--accent) 15%,transparent), transparent 70%);
}

.app {
  position: relative;
  max-width: var(--maxw);
  margin: 0 auto;
  min-height: 100vh;  /* fallback — dvh is Chrome 108+, kiosk is Chromium 87 */
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  padding-bottom: env(safe-area-inset-bottom);
}

/* Desktop: present the portal as a premium glass "device" centred on the circuit
   field, instead of a thin column floating in empty space. */
@media (min-width: 640px) {
  .app {
    max-width: 460px;
    min-height: auto;
    margin: var(--s-7) auto;
    border-radius: var(--r-2xl);
    overflow: hidden;
    background: color-mix(in srgb, var(--bg) 42%, transparent);
    -webkit-backdrop-filter: blur(8px);
    backdrop-filter: blur(8px);
    border: 1px solid color-mix(in srgb, var(--accent) 16%, var(--line));
    box-shadow: 0 40px 90px -28px #000, 0 0 70px -24px color-mix(in srgb, var(--accent) 36%, transparent), inset 0 1px 0 color-mix(in srgb,#fff 30%,transparent);
  }
  .app .portal-hero { border-radius: 0; }
  /* The desktop glass "device" is only ~460px wide — the same squeeze as a phone
     — so the ≤430px brand-priority fix must apply here too, or the shop name
     truncates to "Aling …". Give the brand block the row and compact the status
     pill to its dot. */
  .app .hero-row, .app .brand, .app .brand-text, .app .timer-center { min-width: 0; }
  .app .brand { flex: 1 1 auto; }
  .app .hero-right { flex: 0 0 auto; }
  .app .brand-name { white-space: normal; overflow: visible; text-overflow: clip; }
  .app .status-badge { font-size: 0; gap: 0; padding: 6px; }
  .app .status-badge::before { margin: 0; width: 8px; height: 8px; }
  /* Pin the opt-in Jukebox/Arcade launchers to the centred 460px card's own
     edges (230px = half its width) instead of the far corners of a wide screen,
     so they stay visually attached to the portal they belong to. */
  .jukebox { right: calc(50% - 230px + 14px); }
  .arcade-launch { left: calc(50% - 230px + 14px); }
}

/* ------------------------------- Hero ------------------------------------ */
.portal-hero {
  padding: calc(var(--s-6) + env(safe-area-inset-top)) var(--s-5) var(--s-7);
  border-radius: 0 0 var(--r-2xl) var(--r-2xl);
  /* Dark-glass header (overrides .tab-hero's bright accent block) — neon on dark. */
  background:
    var(--theme-texture, none) 0 0 / var(--theme-hero-tex-size, 46px 46px) repeat,
    radial-gradient(130% 90% at 50% -30%, color-mix(in srgb, var(--accent) 26%, transparent), transparent 62%),
    color-mix(in srgb, var(--bg) 58%, transparent);
  /* soft-light melts the brand's pattern into the header so the hero itself
     wears the theme's texture, not just its accent colour. */
  background-blend-mode: soft-light, normal, normal;
  -webkit-backdrop-filter: blur(16px);
  backdrop-filter: blur(16px);
  border-bottom: 1px solid color-mix(in srgb, var(--accent) 24%, transparent);
  box-shadow: 0 20px 50px color-mix(in srgb, #000 45%, transparent);
}
/* A second, brighter aura blob layered over the shared .tab-hero aurora.
   `aurora` animates transform, which is the cheap choice — but a `filter` puts the
   element behind its own render surface, and moving that surface every vsync
   re-runs the Gaussian on the GPU forever, across the full width of the header.
   The blur is dropped because it was doing nothing you can see: both stops already
   ramp to transparent over ~150px of a ~500px-wide blob, and a 10px Gaussian on a
   150px ramp is below the noise floor. Without it the layer is a plain composited
   texture that the compositor just slides. */
.portal-hero::after {
  content: '';
  position: absolute;
  inset: -30% -10% auto -10%;
  height: 120%;
  z-index: -1;
  background:
    radial-gradient(46% 60% at 78% 14%, color-mix(in srgb, var(--accent) 30%, transparent), transparent 62%),
    radial-gradient(50% 50% at 14% 78%, color-mix(in srgb, var(--accent-2) 26%, transparent), transparent 66%);
  opacity: .5;
  animation: aurora 22s var(--ease-in-out) infinite reverse;
}
/* Same treatment for the shared aurora underneath it (tokens.css .tab-hero::before,
   `filter: blur(6px)` + `aurora 18s`) — a SECOND animated blur surface on the same
   header, over an even bigger box (inset: -40% -20%). Its gradients fade out at
   60%/62% too, so the blur is equally invisible and equally expensive. Overridden
   here rather than in tokens.css because that primitive is shared with admin;
   this is the portal's own hero, where the two auroras stack. */
.portal-hero::before { filter: none; }
.hero-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--s-3);
}
.brand { display: flex; align-items: center; gap: var(--s-3); min-width: 0; }
.brand-logo {
  width: 40px; height: 40px; border-radius: 10px; object-fit: contain;
  /* Corporate KonekOS mark shows clean (no frame); an operator's own logo, if set,
     is contained rather than cropped. */
}
.brand-text { display: flex; flex-direction: column; min-width: 0; }
.brand-name {
  font-weight: var(--w-black);
  font-size: var(--fs-xl);
  letter-spacing: var(--track-tight);
  line-height: 1.05;
  color: var(--ink);                 /* adaptive: dark on light themes, light on dark */
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.brand-tagline {
  font-size: var(--fs-xs);
  font-weight: var(--w-medium);
  color: var(--ink-2);               /* was inherited white → invisible on light themes */
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

.hero-right { display: flex; align-items: center; gap: var(--s-2); flex-shrink: 0; }
.sound-toggle {
  position: relative;
  display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; padding: 0; flex-shrink: 0;
  border: 1px solid color-mix(in srgb, var(--ink) 20%, transparent);
  border-radius: 50%;
  background: color-mix(in srgb, var(--ink) 8%, transparent);
  color: var(--ink); cursor: pointer;
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  transition: background var(--dur) var(--ease), transform var(--dur) var(--ease-spring), opacity var(--dur) var(--ease);
}
/* Extend the sound toggle's hit area to >=44px without growing its visual size. */
.sound-toggle::after {
  content: ''; position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: 44px; height: 44px; border-radius: 50%;
}
.sound-toggle:hover { background: color-mix(in srgb, var(--ink) 14%, transparent); }
.sound-toggle:active { transform: scale(.9); }
.sound-toggle[data-muted="1"] { opacity: .68; }
.sound-toggle svg { display: block; }
.lang-toggle {
  position: relative;
  display: inline-flex; align-items: center; justify-content: center;
  height: 34px; width: 44px; padding: 0; flex-shrink: 0;
  border: 1px solid color-mix(in srgb, var(--ink) 20%, transparent);
  border-radius: var(--r-pill, 999px);
  background: color-mix(in srgb, var(--ink) 8%, transparent);
  color: var(--ink); cursor: pointer; font: var(--w-bold, 700) 12px/1 var(--font-ui, sans-serif);
  letter-spacing: .04em;
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  transition: background var(--dur) var(--ease), transform var(--dur) var(--ease-spring);
}
/* Extend the lang toggle's hit area to >=44px tall without growing its visual size. */
.lang-toggle::after {
  content: ''; position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: 100%; height: 44px; min-width: 44px;
}
.lang-toggle:hover { background: color-mix(in srgb, var(--ink) 14%, transparent); }
.lang-toggle:active { transform: scale(.92); }

/* Fiesta banderitas (party bunting) across the very top of the portal. */
.banderitas {
  width: 100%; line-height: 0; pointer-events: none;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, .14));
  transform-origin: top center;
}
.banderitas svg { display: block; }
@media (prefers-reduced-motion: no-preference) {
  .banderitas { animation: banderitas-sway 5.5s var(--ease-in-out, ease-in-out) infinite; }
}
@keyframes banderitas-sway {
  0%, 100% { transform: rotate(-0.5deg); }
  50%      { transform: rotate(0.5deg); }
}
.net-dot {
  width: 8px; height: 8px; border-radius: 50%;
  background: #fcbfbf;  /* fallback — color-mix is Chrome 111+, kiosk is Chromium 87 */
  background: color-mix(in srgb, #fff 55%, #f87171);
  transition: background var(--dur) var(--ease);
}
/* Same story as .timer-online: an expanding ring, but composited instead of an
   animated box-shadow spread (which repainted forever AND was built on color-mix,
   so it never ran on the kiosk's Chromium 87 in the first place). */
.net-dot { position: relative; }
.net-dot[data-up="1"] { background: #d1fae5; }
.net-dot[data-up="1"]::after {
  content: ''; position: absolute; inset: 0; border-radius: 50%;
  border: 2px solid #fff; pointer-events: none;
  transform-origin: 50% 50%;
  animation: netpulse 2.4s var(--ease) infinite;
}
@keyframes netpulse {
  0%   { transform: scale(.7); opacity: .6; }
  70%  { transform: scale(2.4); opacity: 0; }
  100% { transform: scale(2.4); opacity: 0; }
}
.status-badge {
  display: inline-flex; align-items: center; gap: 6px;
  white-space: nowrap; min-width: 0; flex-shrink: 1; overflow: hidden;
  font-size: var(--t-2xs);
  font-weight: var(--w-bold);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  padding: 5px 11px 5px 10px;
  border-radius: var(--r-pill);
  color: var(--ink);                 /* adaptive so 'ONLINE' is readable on light themes */
  background: rgba(127,127,127,.12); /* fallback — color-mix is Chrome 111+ */
  background: color-mix(in srgb, var(--ink) 8%, transparent);
  box-shadow: inset 0 0 0 1px rgba(127,127,127,.2); /* fallback — color-mix is Chrome 111+ */
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink) 18%, transparent);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  white-space: nowrap;
}
.status-badge::before {
  content: ''; width: 6px; height: 6px; border-radius: 50%;
  background: currentColor; opacity: .85;
}
/* The badge LABEL is compacted away (font-size:0) at ≤430px and again at ≥640px,
   so on every real device the dot is the only thing left of the pill. Colour it
   per state or it says nothing — main.js renderStatusBadge() already writes
   data-status, and index.html ships data-status="connected" as the default.
   Plain status tokens only (no color-mix) so the kiosk keeps the colour.
   "connected" is --info, NOT --accent: the operator's accent collides with the
   three fixed status hues whenever a brand ships an amber/lime/mint one (Sigla's
   accent vs --ok is 1.06:1, Bubuyog's vs --warn 1.20:1 — connected and paused
   were the same dot). --info is a fourth hue no brand config can reach. */
.status-badge[data-status="connected"]::before { background: var(--info); }
.status-badge[data-status="active"]::before    { background: var(--ok); }
.status-badge[data-status="paused"]::before    { background: var(--warn); }
.status-badge[data-status="expired"]::before   { background: var(--danger); }
/* Two channels, not just hue. (a) The dot alone is 1.4–2.5:1 against the pill's
   own ink-8% fill on the light presets, under the 3:1 non-text floor — a 1px ink
   ring lifts it off without changing its colour. (b) Shape survives sun,
   greyscale and colour-blindness where hue does not, so paused/expired differ
   geometrically too.
   ⚠ 65%, not 55%: the dot above carries `opacity: .85`, and opacity composites the
   WHOLE element including its box-shadow — so a 55% ring actually paints at
   .55 × .85 = .4675 and lands at ~2.98:1, i.e. still under the 3:1 floor it was
   added to clear. 65% × .85 = .5525 restores the intended ~3.8:1. Change the
   opacity and this number has to move with it. */
.status-badge::before {
  box-shadow: 0 0 0 1px rgba(127,127,127,.65);  /* fallback — color-mix is Chrome 111+, kiosk is Chromium 87 */
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--ink) 65%, transparent);
}
.status-badge[data-status="paused"]::before  { border-radius: 2px; }
.status-badge[data-status="expired"]::before { border-radius: 2px; transform: rotate(45deg); }

/* Big trust readout: time remaining lives in the hero.
   COLUMN, not row. moments.js appends the decorative ILAW strand into this same
   container (mountIlaw), and as a ROW sibling its width:100%/max-width:260px took
   the row from the pill: "2h 50m left" wrapped to two lines in English and three
   in Bikol/Ilocano, inside a --r-pill radius built for one — the single most
   money-relevant number on the page, rendered as a blob. The strand was always
   meant to sit UNDER the balance (see moments.js mountIlaw), and .ilaw-host's own
   `margin: 6px auto 0` is a block-centring margin that did nothing in a flex row.
   align-items:flex-start keeps the pill at content width instead of stretching it
   edge to edge; the gap is gone because .ilaw-host carries its own 6px. */
.hero-balance { margin-top: var(--s-6); display: flex; flex-direction: column; align-items: flex-start; }
.balance-pill {
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
  font-family: var(--font-num);
  font-weight: var(--w-black);
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
  font-size: var(--fs-xl);
  letter-spacing: var(--track-tight);
  padding: var(--s-3) var(--s-5);
  border-radius: var(--r-pill);
  position: relative;
  isolation: isolate;
  /* Fallbacks must be OPAQUE: where color-mix is unsupported the hero behind this
     pill also loses its dark-glass fill and falls back to .tab-hero's bright accent
     gradient, so a transparent pill would leave the balance at ~1.6:1 on it. */
  background: var(--surface-2);  /* fallback — color-mix is Chrome 111+, kiosk is Chromium 87 */
  background: linear-gradient(color-mix(in srgb,var(--bg) 60%,transparent), color-mix(in srgb,var(--surface) 40%,transparent));
  /* Lean on --ink (adaptive) so the "30 min left" balance reads on light-accent
     themes too — 64% accent was only 3.5:1 on Bubuyog amber. */
  color: var(--ink);             /* fallback — color-mix is Chrome 111+, kiosk is Chromium 87 */
  color: color-mix(in srgb, var(--accent) 46%, var(--ink));
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  text-shadow: 0 0 14px color-mix(in srgb, var(--accent) 55%, transparent);
  box-shadow: inset 0 2px 4px color-mix(in srgb,var(--accent) 14%,transparent),
    inset 0 -1px 0 color-mix(in srgb,#fff 40%,transparent),
    0 8px 22px color-mix(in srgb,var(--accent) 22%,transparent);
}
.balance-pill.is-rolling { animation: rollin var(--dur-slow) var(--ease-spring); }
/* No `filter: blur()` here. It repainted the pill every frame for a softening that
   is invisible at this size, and blur is the single most expensive thing to animate
   on the board's GPU. The overshoot alone reads as motion. */
@keyframes rollin {
  0% { transform: translateY(-14px); opacity: 0; }
  55% { transform: translateY(3px) scale(1.06); }
  78% { transform: translateY(-1px) scale(.99); }
  100% { transform: none; }
}
/* One accent flash-ring the instant the balance catches the value (reuses .is-rolling). */
.balance-pill.is-rolling::after {
  content: ''; position: absolute; inset: -3px; border-radius: inherit;
  border: 1.5px solid var(--accent); opacity: 0; pointer-events: none;
  animation: pill-catch .5s var(--ease);
}
@keyframes pill-catch {
  0% { opacity: .8; transform: scale(1); }
  100% { opacity: 0; transform: scale(1.08); }
}

/* ------------------------------- Main ------------------------------------ */
.main {
  flex: 1;
  padding: var(--s-6) var(--s-5) var(--s-7);
  display: flex;
  flex-direction: column;
}
.screen { display: flex; flex-direction: column; gap: var(--s-5); }
.screen:not([hidden]) { animation: screenIn var(--dur-slow) var(--ease); }
.screen[hidden] { display: none; }
@keyframes screenIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }

.muted { color: var(--ink-3); font-size: var(--fs-sm); margin: var(--s-2) 0 0; }

/* ----------------------- A: INSERT COIN panel ---------------------------- */
.insert-panel {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--s-3);
  padding: var(--s-9) var(--s-5) var(--s-8);
  border-radius: var(--r-2xl);
  background:
    radial-gradient(120% 90% at 50% -10%, var(--accent-softer), transparent 62%),
    color-mix(in srgb, var(--surface) 70%, transparent);
  -webkit-backdrop-filter: blur(16px);
  backdrop-filter: blur(16px);
  border: 1px solid color-mix(in srgb, var(--accent) 16%, var(--line));
  box-shadow: inset 0 1px 0 color-mix(in srgb,#fff 55%,transparent),
    0 2px 6px rgba(15,23,42,.05),
    0 18px 40px -14px color-mix(in srgb,var(--accent) 22%,rgba(15,23,42,.28)),
    0 40px 80px -40px rgba(15,23,42,.35);
  cursor: pointer;
  overflow: hidden;
  isolation: isolate;
  transition: transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.insert-panel:active { transform: scale(.985) translateY(1px); box-shadow: 0 8px 20px -12px color-mix(in srgb,var(--accent) 30%,transparent), inset 0 2px 6px color-mix(in srgb,#000 8%,transparent); }
/* A faint sweeping shine across the panel — the "idle shimmer".
   Same story as .net-dot / .timer-online: this used to slide a 240%-wide gradient
   with `background-position`, which is a PAINT property — so Blink re-rasterized
   the biggest box on the landing screen (this panel, inset 0) on every frame of
   the 55% of the cycle that interpolates, forever, on the one screen a customer
   sits and stares at before deciding to pay. The strip is now a real 240%-wide
   box that is TRANSLATED across: the panel rasterizes once and the compositor
   moves the strip.
   ⚠ THE LOOK IS NOT BIT-IDENTICAL, AND THAT IS A DELIBERATE CHOICE. The old rule
   never set `background-repeat`, and the `background:` shorthand resets it to
   `repeat` — so the 2.4W gradient TILED. A second band was therefore always in
   frame, and at the 55%→100% hold its peak parked almost dead-centre of the panel
   for 2.34s of every 5.2s cycle, then vanished in a single frame at the wrap. That
   is a tiling artefact, not a designed shimmer: a static accent wash sitting in the
   middle of the insert panel is not what a "sheen" is for. The strip below is a
   single finite band, so the parked wash and the second pass are gone.
   If that tiling is ever wanted back, reproduce it with a repeating-linear-gradient
   at width:632% and translateX(−35.443% → 26.582%) — NOT by reverting to
   background-position, which is the repaint this whole change exists to remove.
   The PRIMARY band is reproduced exactly. A 2.4W image in a 1W box moves
   (1 − 2.4)W = −1.4W per 100% of background-position, so 160% put its left edge at
   −2.24W and −120% at +1.68W. On a box that is itself 2.4W wide, transform
   percentages are of that 2.4W: −2.24/2.4 = −93.333%, 1.68/2.4 = 70%.
   The gradient still paints into a 2.4W × 1H box, so the 115deg band is unchanged,
   and the panel's own `overflow: hidden` clips the overhang exactly as the
   pseudo's background box used to.
   Deliberately NO `will-change`: an infinite transform animation is promoted for
   its whole life anyway, and a permanent hint would pin a 2.4×-panel-sized layer
   in memory even when the animation is off (see shared/palitilaw.js:16-17). */
.insert-panel::after {
  content: '';
  position: absolute; top: 0; left: 0; z-index: 2;
  width: 240%; height: 100%;
  background: linear-gradient(115deg, transparent 38%, color-mix(in srgb, var(--accent) 10%, transparent) 50%, transparent 62%);
  animation: sheen 5.2s var(--ease-in-out) infinite;
  pointer-events: none;
}
@keyframes sheen { 0% { transform: translateX(-93.333%); } 55%, 100% { transform: translateX(70%); } }
.insert-glow {
  position: absolute;
  inset: -45% -15%;
  z-index: -1;
  background: radial-gradient(closest-side, var(--accent-wash), transparent 72%);
  animation: breathe 4.2s var(--ease-in-out) infinite;
}
@keyframes breathe {
  0%, 100% { opacity: .5; transform: scale(.9); }
  50% { opacity: 1; transform: scale(1.08); }
}
/* Coin-slot: a real-looking minted disc with the slot cut into it. */
.coin-slot-icon {
  position: relative;
  display: grid; place-items: center;
  width: 92px; height: 92px;
  border-radius: 50%;
  color: var(--accent);
  /* machined minted disc: moving specular glint + top highlight + brand face */
  background:
    linear-gradient(120deg, transparent 40%, color-mix(in srgb,#fff 45%,transparent) 50%, transparent 60%) 140% 0 / 220% 220% no-repeat,
    radial-gradient(70% 70% at 35% 28%, color-mix(in srgb, #fff 70%, transparent), transparent 60%),
    var(--accent-grad);
  box-shadow:
    inset 0 2px 3px color-mix(in srgb,#fff 60%,transparent),
    inset 0 -8px 14px color-mix(in srgb,#000 28%,transparent),
    0 10px 24px -6px color-mix(in srgb,var(--accent) 45%,transparent),
    0 2px 0 color-mix(in srgb,#000 18%,transparent);
  animation: bob 4.6s var(--ease-in-out) infinite, glint 4.6s var(--ease-in-out) infinite;
}
.coin-slot-icon svg { color: var(--on-accent); width: 52px; height: 52px; filter: drop-shadow(0 1px 1px color-mix(in srgb, #000 30%, transparent)); z-index: 1; }
/* the physical slot MOUTH the ₱ coin visibly falls into */
.coin-slot-icon::before {
  content: ''; position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
  width: 30px; height: 6px; border-radius: 3px;
  background: linear-gradient(#00000055, #00000022);
  box-shadow: inset 0 1px 2px #000, 0 1px 0 color-mix(in srgb,#fff 40%,transparent);
  z-index: 0;
}
/* one GPU-cheap shockwave ring on coin impact, fired by the panel's .is-coin-hit */
.coin-slot-icon::after {
  content: ''; position: absolute; inset: 0; margin: auto;
  width: 92px; height: 92px; border-radius: 50%;
  opacity: 0; pointer-events: none; z-index: 2;
}
.insert-panel.is-coin-hit .coin-slot-icon::after { animation: slot-impact .48s var(--ease) forwards; }
@keyframes slot-impact {
  0% { transform: scale(.6); opacity: .55; box-shadow: 0 0 0 0 var(--accent); }
  100% { transform: scale(1.9); opacity: 0; box-shadow: 0 0 0 14px transparent; }
}
@keyframes bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
@keyframes glint { 0%,70% { background-position: 140% 0, 35% 28%, 0 0; } 85%,100% { background-position: -40% 0, 35% 28%, 0 0; } }
.insert-title {
  margin: var(--s-2) 0 0;
  font-size: var(--t-h1);
  font-weight: var(--w-black);
  letter-spacing: var(--track-tight);
  line-height: var(--leading-tight);
  color: var(--ink);
}
.insert-sub { margin: 0; color: var(--ink-3); font-size: var(--fs-md); font-weight: var(--w-semibold); }
/* "Someone else is using the slot" — the only line telling a customer why their
   coin hasn't registered. Bare --warn-ink is 3.92:1 on the insert panel's
   near-white fill on the 4 light presets, under AA for this 16px/600 text.
   Pulling it toward --ink darkens it on the light schemes and LIGHTENS it on the
   dark ones (which already pass at 11:1), so one pair serves all 9 themes. */
.insert-sub[data-kind="busy"] {
  color: var(--warn-ink);   /* fallback — color-mix is Chrome 111+, kiosk is Chromium 87 */
  color: color-mix(in srgb, var(--warn-ink) 80%, var(--ink));
}
.insert-sub[data-kind="mine"] { color: var(--accent-700); }

.insert-panel.is-coin-hit { animation: coinhit .5s var(--ease-spring); }
@keyframes coinhit {
  0% { transform: scale(1); }
  35% { transform: scale(1.03); box-shadow: var(--accent-glow), var(--hairline); }
  100% { transform: scale(1); }
}

/* -------------------- B: QUEUED / "in line" card ------------------------- */
/* Shown only while the coin slot is held by someone else (slot.state==='busy').
   Hidden by default via [hidden]; JS removes [hidden] and sets [data-queued]
   on the landing section to dim the insert panel + rate card behind it. */
.queue-card {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--s-3);
  padding: var(--s-7) var(--s-5) var(--s-6);
  border-radius: var(--r-2xl);
  background:
    radial-gradient(120% 90% at 50% -10%, var(--accent-soft), transparent 64%),
    var(--surface);
  border: 1px solid var(--accent-line);
  box-shadow: var(--shadow-3), var(--accent-glow-sm);
  overflow: hidden;
  isolation: isolate;
  animation: pop var(--dur-slow) var(--ease-spring) both;
}
.queue-card[hidden] { display: none; }

/* While queued, fade + shrink the insert panel and rate card to the background. */
.screen-landing[data-queued="1"] .insert-panel,
.screen-landing[data-queued="1"] .rate-card {
  opacity: .5;
  transform: scale(.985);
  transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}

.queue-eyebrow {
  font-size: var(--t-2xs);
  font-weight: var(--w-bold);
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: var(--accent-700);
}
.queue-pos {
  display: inline-flex;
  align-items: baseline;
  gap: 2px;
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-weight: var(--w-black);
  letter-spacing: var(--track-tight);
  line-height: 1;
  color: var(--ink);
}
.queue-hash { font-size: clamp(28px, 8vw, 40px); color: var(--accent-700); opacity: .85; }
.queue-num { font-size: clamp(54px, 18vw, 82px); }
/* No known position: hide the big "#N" row and let the label carry the message. */
.queue-card[data-no-num="1"] .queue-pos { display: none; }
.queue-card[data-no-num="1"] .queue-label { font-size: var(--fs-lg); color: var(--ink); }
.queue-label {
  font-size: var(--fs-md);
  font-weight: var(--w-semibold);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--ink-3);
}
/* A row of waiting dots that pulse in sequence. */
.queue-dots {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  margin-top: var(--s-1);
}
.queue-dots span {
  width: 9px; height: 9px; border-radius: 50%;
  background: var(--accent-grad);
  box-shadow: var(--accent-glow-sm);
  opacity: .35;
  animation: queuepulse 1.4s var(--ease-in-out) infinite;
}
.queue-dots span:nth-child(2) { animation-delay: .14s; }
.queue-dots span:nth-child(3) { animation-delay: .28s; }
.queue-dots span:nth-child(4) { animation-delay: .42s; }
.queue-dots span:nth-child(5) { animation-delay: .56s; }
@keyframes queuepulse {
  0%, 100% { opacity: .3; transform: scale(.82); }
  45%      { opacity: 1; transform: scale(1.18); }
}
.queue-reassure {
  margin: var(--s-2) 0 0;
  max-width: 30ch;
  color: var(--ink-2);
  font-size: var(--fs-sm);
  font-weight: var(--w-semibold);
  line-height: 1.45;
}

/* Honor reduced-motion: no entrance pop, no dot pulse (show dots steady-on). */
@media (prefers-reduced-motion: reduce) {
  .queue-card { animation: none; }
  .queue-dots span { animation: none; opacity: .85; }
}

/* ----------------------- Mock simulate-coin row -------------------------- */
.sim-row {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding: var(--s-4);
  border-radius: var(--r-xl);
  border: 1px dashed var(--accent-line);
  background:
    repeating-linear-gradient(135deg, transparent 0 10px, color-mix(in srgb, var(--accent) 3%, transparent) 10px 11px),
    var(--accent-softer);
}
/* Same trap as .low-banner below: display:flex beats the UA [hidden] rule, so the
   DEV-only simulate-coin row painted an empty dashed box on real customer screens
   (index.html ships both rows hidden, main.js re-asserts it when features.mock is
   off) — a control-looking thing with no handler, on the money path. */
.sim-row .sim-label {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: var(--t-2xs);
  font-weight: var(--w-bold);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--accent-700);
}
.sim-row .sim-label::before {
  content: 'DEV'; font-size: 9px; line-height: 1;
  padding: 3px 5px; border-radius: 5px;
  background: var(--accent-soft); color: var(--accent-700);
  letter-spacing: var(--track-wide);
}
.sim-btns { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--s-2); }
/* Each simulate chip is a little minted coin. */
.sim-btn {
  position: relative;
  min-height: 56px;
  padding: 0;
  border-radius: var(--r-lg);
  aspect-ratio: 1;
  width: 100%;
  font-family: var(--font-num);
  font-weight: var(--w-black);
  font-size: var(--fs-md);
  color: color-mix(in srgb, var(--accent) 45%, #ffffff);
  border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
  background: color-mix(in srgb, var(--accent) 9%, transparent);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 7%, transparent);
  transition: transform var(--dur-fast) var(--ease-spring), box-shadow var(--dur) var(--ease), border-color var(--dur) var(--ease), background var(--dur) var(--ease);
}
.sim-btn:hover {
  border-color: color-mix(in srgb, var(--accent) 72%, transparent);
  background: color-mix(in srgb, var(--accent) 17%, transparent);
  box-shadow: 0 0 20px color-mix(in srgb, var(--accent) 34%, transparent);
}
.sim-btn:active { transform: scale(.92); }
.sim-row--inline { margin-top: var(--s-1); }

/* ------------------------------ Rate card -------------------------------- */
.rate-card { border-radius: var(--r-xl); padding: var(--s-5) var(--s-5) var(--s-4); }
.rate-card .card-head,
.help-body .help-sub { display: flex; align-items: center; justify-content: space-between; }
.card-head h3 {
  margin: 0; font-size: var(--fs-md); font-weight: var(--w-bold);
  letter-spacing: var(--track-snug);
}
.card-head h3::before {
  content: ''; display: inline-block; width: 7px; height: 7px; margin-right: 8px;
  border-radius: 2px; background: var(--accent-grad); vertical-align: middle;
}
.link-btn {
  position: relative;
  display: inline-flex; align-items: center;
  background: none; border: 0; padding: 4px 2px;
  min-height: 44px;
  color: var(--accent-700); font-weight: var(--w-bold); font-size: var(--fs-sm);
  cursor: pointer; font-family: inherit;
}
/* Widen the horizontal hit area to >=44px without shifting the text baseline. */
.link-btn::after {
  content: ''; position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: 100%; min-width: 44px; height: 100%;
}
.link-btn:hover { text-decoration: underline; text-underline-offset: 3px; }
.link-btn:focus-visible { outline: 3px solid var(--accent-ring); outline-offset: 2px; border-radius: var(--r-xs); }

.rate-table { display: flex; flex-direction: column; gap: 6px; margin-top: var(--s-4); }
.rate-row {
  position: relative;
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border-radius: var(--r-md);
  background: var(--surface-2);
  border: 1px solid transparent;
  transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.rate-row:hover { background: var(--surface-3); }
/* Highlight the best-VALUE row. main.js works out which coin gives the most
   minutes per peso and marks it [data-best="1"] — it used to be :last-child,
   which just crowned whichever row sat last in the config. */
.rate-row[data-best="1"] {
  background: var(--accent-softer);
  border-color: var(--accent-line);
}
/* The badge is real, translated DOM text now (it was a hardcoded English
   content:'BEST'). Absolutely positioned, so it stays out of the row's grid. */
.rate-badge {
  position: absolute; top: -8px; right: 12px;
  font-size: 9px; font-weight: var(--w-bold); letter-spacing: var(--track-wide);
  padding: 2px 7px; border-radius: var(--r-pill);
  background: var(--accent-grad); color: var(--on-accent);
  box-shadow: var(--accent-glow-sm);
}
.rate-coin {
  display: inline-flex; align-items: center; gap: 7px;
  font-family: var(--font-num);
  font-weight: var(--w-bold); font-variant-numeric: tabular-nums; color: var(--ink);
}
.rate-coin::before {
  content: ''; width: 16px; height: 16px; border-radius: 50%;
  background: radial-gradient(70% 70% at 34% 28%, color-mix(in srgb,#fff 75%,transparent), transparent 60%), var(--accent-grad);
  box-shadow: inset 0 -1px 2px color-mix(in srgb,#000 25%,transparent);
  flex: none;
}
.rate-arrow { color: var(--ink-faint); }
.rate-min {
  justify-self: end;
  display: flex; flex-direction: column; align-items: flex-end; gap: 1px;
  font-family: var(--font-num);
  font-weight: var(--w-bold); font-variant-numeric: tabular-nums;
  color: var(--accent-700);
}
/* Free minutes a bonus tier / promo adds on top of the base rate. This is the only
   surface that tells the customer ₱10/₱20 carry bonus minutes — i.e. the line that
   justifies dropping the bigger coin — so it must not be the smallest, faintest
   type on the page. It was 10px at --ok (#0ca678) = 2.77:1 on --surface-2.
   The old `var(--ok, #2f7d5b)` fallback was dead code: --ok is a DECLARED plain
   hex, so substitution always succeeds and the darker #2f7d5b never fired. */
.rate-extra {
  font-family: var(--font-ui);
  font-size: var(--fs-xs); font-weight: var(--w-semibold); letter-spacing: var(--track-wide);
  /* --ok-ink alone is only 4.28:1 here; pulled toward --ink it is 5.47:1 on light
     and BRIGHTER on dark (where --ok-ink mixes toward #fff), so one pair holds
     across all 9 presets. */
  color: var(--ok-ink);           /* fallback — color-mix is Chrome 111+, kiosk is Chromium 87 */
  color: color-mix(in srgb, var(--ok-ink) 82%, var(--ink));
}
/* "A promo is running now" line above the rate table. Hidden unless main.js
   unhides it, so the [hidden] rule must come AFTER the display declaration. */
.rate-promo {
  display: block;
  margin-top: var(--s-3);
  padding: var(--s-2) var(--s-3);
  border-radius: var(--r-md);
  font-size: var(--fs-sm); font-weight: var(--w-semibold);
  background: rgba(20, 224, 160, .10);   /* plain fallback first */
  background: var(--accent-softer);
  border: 1px solid rgba(20, 224, 160, .28);
  border-color: var(--accent-line);
  color: var(--ink-2);
}
.rate-promo[hidden] { display: none; }

.landing-actions { margin-top: var(--s-2); }

/* ---------------------- C: ACTIVE timer + ring -------------------------- */
.screen-active { align-items: center; gap: var(--s-6); }
.timer-wrap {
  position: relative;
  width: min(76vw, 300px);
  height: min(76vw, 300px); /* same value keeps it square where aspect-ratio (Chrome 88+) is unsupported */
  aspect-ratio: 1;
  display: grid;
  place-items: center;
  margin-top: var(--s-5);
}
/* Soft halo behind the ring, tinted by bucket. */
.timer-wrap::before {
  content: '';
  position: absolute; inset: 8%;
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--accent-soft), transparent 72%);
  filter: blur(10px);
  opacity: .9;
  transition: background var(--dur-slow) var(--ease);
  z-index: -1;
}
[data-bucket="warn"] .timer-wrap::before { background: radial-gradient(closest-side, var(--warn-soft), transparent 72%); }
[data-bucket="danger"] .timer-wrap::before { background: radial-gradient(closest-side, var(--danger-soft), transparent 72%); animation: halopulse 1.4s var(--ease-in-out) infinite; }
@keyframes halopulse { 0%,100% { opacity:.6; } 50% { opacity: 1; } }

.timer-ring-svg { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(0); }
.ring-track { stroke: rgba(0,0,0,.08); stroke: color-mix(in srgb, var(--ink) 8%, transparent); }  /* rgba first = Chromium-87 fallback */
:root[data-scheme="dark"] .ring-track { stroke: rgba(255,255,255,.08); stroke: color-mix(in srgb, #fff 8%, transparent); }
.ring-fill {
  stroke: url(#ringGrad);
  transition: stroke-dashoffset 1s linear, filter var(--dur) var(--ease);
  filter: drop-shadow(0 0 10px color-mix(in srgb, var(--accent) 50%, transparent)) drop-shadow(0 0 3px color-mix(in srgb, #fff 45%, transparent));
}
[data-bucket="warn"] .ring-fill { filter: drop-shadow(0 0 10px color-mix(in srgb, var(--warn) 55%, transparent)); }
[data-bucket="danger"] .ring-fill { filter: drop-shadow(0 0 12px color-mix(in srgb, var(--danger) 60%, transparent)); }
.timer-center { text-align: center; z-index: 1; position: relative; }
/* Glass jewel face the readout sits on — the numbers become the centrepiece. */
.timer-center::before {
  content: ''; position: absolute; inset: -16% -20%; z-index: -1; border-radius: 50%;
  background: radial-gradient(closest-side, color-mix(in srgb,var(--surface) 62%,transparent), transparent 74%);
  box-shadow: inset 0 1px 0 color-mix(in srgb,#fff 45%,transparent), 0 10px 28px -12px color-mix(in srgb,var(--accent) 32%,transparent);
  -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
}
.timer-online {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: var(--t-2xs); font-weight: var(--w-bold);
  letter-spacing: var(--track-caps); text-transform: uppercase;
  color: var(--ok-ink);
  margin-bottom: var(--s-2);
}
/* The live dot, plus a ring that expands off it forever.
   The ring used to be an animated box-shadow spread, which repaints every frame
   for the whole life of the session — and its keyframes were built on color-mix,
   which is Chrome 111+, so on the kiosk's Chromium 87 the pulse never ran at all.
   A scaled + faded ring composites, and works on both engines. */
.timer-online { position: relative; }
.timer-online::before {
  content: ''; width: 7px; height: 7px; border-radius: 50%;
  background: var(--ok); flex: none;
}
.timer-online::after {
  content: ''; position: absolute; left: 0; top: 50%;
  width: 7px; height: 7px; margin-top: -3.5px; border-radius: 50%;
  border: 2px solid var(--ok);
  transform-origin: 50% 50%; pointer-events: none;
  animation: livedot 2s var(--ease) infinite;
}
@keyframes livedot {
  0%   { transform: scale(.6); opacity: .55; }
  70%  { transform: scale(2.6); opacity: 0; }
  100% { transform: scale(2.6); opacity: 0; }
}
.timer-readout {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
  font-weight: var(--w-black);
  font-size: clamp(42px, 15vw, 64px);
  letter-spacing: var(--track-tight);
  line-height: 1;
  color: var(--ink);
  /* engraved into the jewel face: crisp top light + soft accent bloom */
  text-shadow: 0 1px 0 color-mix(in srgb,#fff 55%,transparent), 0 0 22px color-mix(in srgb,var(--accent) 22%,transparent);
}
[data-bucket="warn"] .timer-readout { color: var(--warn-ink); }
[data-bucket="danger"] .timer-readout { color: var(--danger-ink); animation: tick-danger 1s steps(1) infinite; }
@keyframes tick-danger { 50% { opacity: .55; } }
.timer-sub {
  margin-top: var(--s-2);
  color: var(--ink-3); font-size: var(--fs-sm); font-weight: var(--w-semibold);
  letter-spacing: var(--track-wide); text-transform: uppercase;
}

.online-hint {
  color: var(--ink-2); font-size: var(--fs-md); font-weight: var(--w-semibold);
  text-align: center;
}

/* Reconnecting chip — visible offline cue shown under the timer while the WS is
   down mid-session. JS removes [hidden] on drop and restores it on reconnect. */
.reconnect-chip {
  display: inline-flex; align-items: center; gap: 7px;
  margin-top: var(--s-3);
  padding: 5px 12px;
  border-radius: var(--r-pill);
  font-size: var(--t-2xs);
  font-weight: var(--w-bold);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  /* 11px/700 on the 16%-warn wash is 3.54:1 with bare --warn-ink on the 4 light
     presets. Mixing toward --ink darkens it on light and lightens it on dark
     (already 8.5:1 there), so the chip clears AA on all 9 with one pair. */
  color: var(--warn-ink);   /* fallback — color-mix is Chrome 111+, kiosk is Chromium 87 */
  color: color-mix(in srgb, var(--warn-ink) 80%, var(--ink));
  background: color-mix(in srgb, var(--warn) 16%, var(--surface));
  border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent);
  box-shadow: 0 4px 12px color-mix(in srgb, var(--warn) 18%, transparent);
  animation: bannerIn var(--dur) var(--ease-spring);
}
.reconnect-chip[hidden] { display: none; }
.reconnect-spinner {
  width: 12px; height: 12px; flex: none;
  border: 2px solid color-mix(in srgb, var(--warn) 35%, transparent);
  border-top-color: var(--warn-ink);
  border-radius: 50%;
  animation: spin .7s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  .reconnect-chip { animation: none; }
  .reconnect-spinner { animation: none; }
}

/* Low-time banner — ONLY visible when JS removes [hidden]. The bug was that
   display:flex overrode the [hidden] UA rule, so it always showed. */
.low-banner {
  display: flex; align-items: center; gap: var(--s-3);
  width: 100%;
  padding: var(--s-3) var(--s-4);
  border-radius: var(--r-md);
  background: color-mix(in srgb, var(--warn) 14%, var(--surface));
  border: 1px solid color-mix(in srgb, var(--warn) 38%, transparent);
  /* "Low time. Insert a coin to keep going." is the most money-relevant string in
     the portal, and bare --warn-ink is 3.59:1 on this wash on the light presets
     (14px/700 is not large text, so the floor is 4.5:1). Same mix-toward-ink
     treatment as .reconnect-chip: darkens on light, lightens on dark. */
  color: var(--warn-ink);   /* fallback — color-mix is Chrome 111+, kiosk is Chromium 87 */
  color: color-mix(in srgb, var(--warn-ink) 80%, var(--ink));
  font-weight: var(--w-bold); font-size: var(--fs-sm);
  box-shadow: 0 6px 16px color-mix(in srgb, var(--warn) 18%, transparent);
  animation: bannerIn var(--dur) var(--ease-spring);
}
.low-banner[hidden] { display: none; }
/* Smart Cashier: solo customer running low + their coin auto-registers to them.
   This is a friendly upsell, not an alarm — shift the amber toward the brand
   accent so it reads as opportunity, and pulse the icon gently. */
.low-banner.is-smart {
  background: color-mix(in srgb, var(--accent) 12%, var(--surface));
  border-color: color-mix(in srgb, var(--accent) 42%, transparent);
  /* Use the theme's PRIMARY text colour (dark on light themes, light on dark) —
     --accent-ink is white-ish text meant to sit ON the accent fill, which failed
     AA on the light-tinted banner across the 4 light themes. */
  color: var(--ink);
  box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 20%, transparent);
}
.low-banner.is-smart .low-ico {
  background: color-mix(in srgb, var(--accent) 22%, transparent);
  color: var(--accent);
  animation: smartPulse 1.8s ease-in-out infinite;
}
@keyframes smartPulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.12); } }
@media (prefers-reduced-motion: reduce) { .low-banner.is-smart .low-ico { animation: none; } }
.low-ico {
  display: grid; place-items: center;
  width: 28px; height: 28px; flex: none;
  border-radius: 50%;
  background: color-mix(in srgb, var(--warn) 22%, transparent);
  font-size: var(--fs-sm);
}
@keyframes bannerIn { from { opacity: 0; transform: translateY(-8px) scale(.98);} to { opacity:1; transform:none; } }

/* Frosted bottom action bar (Active / Paused primary actions). */
.active-actions {
  display: flex; gap: var(--s-3); width: 100%;
  margin-top: auto;
  padding: var(--s-3);
  border-radius: var(--r-pill);
  background: var(--glass-bg-strong);
  border: 1px solid var(--glass-border);
  box-shadow: var(--shadow-2), var(--hairline);
}
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
  .active-actions { -webkit-backdrop-filter: blur(var(--glass-blur)); backdrop-filter: blur(var(--glass-blur)); }
}
.active-actions:has(.btn[hidden]) { background: transparent; border-color: transparent; box-shadow: none; padding: 0; }
/* Chromium-87 twin of the :has() rule above (:has() is Chrome 105+): main.js
   renderControls() toggles .all-hidden under the same any-btn-hidden condition. */
.active-actions.all-hidden { background: transparent; border-color: transparent; box-shadow: none; padding: 0; }
.active-actions .btn { flex: 1; }

/* Stretched buttons must be allowed to WRAP. tokens.css puts white-space:nowrap on
   every .btn — fine for an auto-width pill, fatal for a full-width or flex:1 one:
   the label's min-content width becomes the whole string, so flex-shrink can't
   touch it and the text spills out of both ends of the pill (and off-screen).
   "Insert coin to continue" is 33–38 chars in the Philippine locales
   (topup_continue, redeem_voucher, pause_time), ~320–376px at btn-lg's 19px —
   far more than the ~254px a 390px phone leaves inside the expired card.
   Scoped to the portal so auto-width pills elsewhere keep nowrap. */
.btn-block,
.expired-card .btn,
.paused-card .btn,
.active-actions .btn {
  white-space: normal;
  line-height: 1.15;
  /* .btn uses min-height (not height), so it grows cleanly to a second line. */
  padding-top: var(--s-2);
  padding-bottom: var(--s-2);
  overflow-wrap: anywhere;
}

/* ---------------------------- D: PAUSED --------------------------------- */
.screen-paused, .screen-expired { flex: 1; justify-content: center; }
.paused-card, .expired-card {
  text-align: center; display: flex; flex-direction: column; align-items: center; gap: var(--s-3);
  border-radius: var(--r-2xl);
  padding: var(--s-8) var(--s-6);
}
.paused-card h2, .expired-card h2 {
  margin: 0; font-size: var(--t-h2); font-weight: var(--w-black); letter-spacing: var(--track-tight);
}
.paused-ico {
  display: grid; place-items: center;
  width: 92px; height: 92px; border-radius: 50%;
  color: var(--accent);
  background: var(--accent-soft);
  box-shadow: inset 0 0 0 1px var(--accent-line);
}
.paused-ico svg { width: 48px; height: 48px; }
.paused-amount { margin: 0; font-size: var(--fs-md); color: var(--ink-2); }
.paused-amount strong {
  font-family: var(--font-num); font-size: var(--fs-lg); color: var(--ink);
  font-variant-numeric: tabular-nums; font-weight: var(--w-bold);
}
.paused-card .btn, .expired-card .btn { margin-top: var(--s-3); }

/* ---------------------------- F: EXPIRED -------------------------------- */
.expired-ico {
  width: 132px; height: 132px;
  color: var(--danger);
  filter: drop-shadow(0 8px 20px var(--danger-soft));
}
.expired-ico svg { width: 100%; height: 100%; }
.expired-headline { color: var(--ink); }

/* ----------------------------- Footer ----------------------------------- */
.portal-footer {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s-3);
  padding: var(--s-4) var(--s-5) calc(var(--s-5) + env(safe-area-inset-bottom));
  color: var(--ink-3);
  font-size: var(--fs-xs);
}
/* The opt-in Jukebox (right) and Laro (left) launchers are 52px fixed circles at
   z-index 40 sitting 14–66px off the bottom corners — precisely where the footer's
   Help button and the shop's support line live. Nothing reserved space for them, so
   turning either feature on covered a footer control completely. jukebox.js /
   arcade.js set these body flags when they mount (both build once, never unmount).
   Reserving footer space is the fix: nudging the circles alone just moves the
   collision, and they re-anchor to the card edges at ≥640px. */
body.has-fab-right .portal-footer,
body.has-fab-left .portal-footer {
  padding-bottom: calc(var(--s-5) + 66px + env(safe-area-inset-bottom));
}
/* The mascot needs MORE than the 66px a plain 52px launcher does, because it also
   SPEAKS: the bubble is anchored above the 64px character, so the pair reaches about
   120px up from the viewport bottom while the reservation above clears only 86px.
   The difference landed the bubble on top of the shop's phone number — the mascot
   would say "Hey!" across "Tawag/Text: 0906 ...". Transient, but it clips the one
   piece of text on the page a customer might actually need to read.
   Measured, not guessed: mascot bottom:16px + height:64px = 80px, bubble sits at
   bottom:100% translated -10px and is ~30px tall -> ~120px. 104 + --s-5 clears it. */
body.has-mascot .portal-footer {
  padding-bottom: calc(var(--s-5) + 104px + env(safe-area-inset-bottom));
}
/* Mascot AND arcade: the mascot stacks above the 52px launcher (bottom:78px), so the
   pair plus its bubble reaches ~182px rather than ~120px. Without this the bubble is
   back on the support line, one control higher. */
body.has-mascot.has-arcade .portal-footer {
  padding-bottom: calc(var(--s-5) + 166px + env(safe-area-inset-bottom));
}
.support {
  display: inline-flex; align-items: center; gap: 6px;
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.portal-footer .link-btn { font-size: var(--fs-xs); }

/* ------------------------- Coin floaters -------------------------------- */
.float-layer {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 40;
  display: grid;
  place-items: center;
}
.coin-floater {
  position: absolute;
  top: 44%;
  left: 50%;
  transform: translate(calc(-50% + var(--jx, 0px)), 0);
  font-family: var(--font-num);
  font-weight: var(--w-black);
  font-size: var(--fs-2xl);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  animation: floatUp 1.6s var(--ease) forwards;
}
.coin-floater--coin {
  color: var(--on-accent);
  padding: 6px 16px; border-radius: var(--r-pill);
  background: var(--accent-grad);
  box-shadow: var(--accent-glow);
}
.coin-floater--time {
  color: var(--accent-700);
  font-size: var(--fs-xl);
  animation-delay: .1s;
  text-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 30%, transparent);
}
@keyframes floatUp {
  0% { opacity: 0; transform: translate(calc(-50% + var(--jx,0px)), 16px) scale(.65); }
  18% { opacity: 1; transform: translate(calc(-50% + var(--jx,0px)), 0) scale(1.12); }
  100% { opacity: 0; transform: translate(calc(-50% + var(--jx,0px)), -130px) scale(.95); }
}
/* Suwerte! — the operator-set surprise-bonus floater: larger, gold, longer rise. */
.coin-floater--lucky {
  color: #3a2600;
  font-size: var(--fs-xl);
  font-weight: var(--w-black);
  padding: 8px 18px; border-radius: var(--r-pill);
  background: linear-gradient(135deg, #ffd54a, #ff9e2c);
  box-shadow: 0 12px 34px -8px rgba(255, 158, 44, .75);
  animation: floatUpLucky 3.2s var(--ease) forwards;
}
@keyframes floatUpLucky {
  0% { opacity: 0; transform: translate(calc(-50% + var(--jx,0px)), 18px) scale(.6); }
  12% { opacity: 1; transform: translate(calc(-50% + var(--jx,0px)), -6px) scale(1.25); }
  26% { transform: translate(calc(-50% + var(--jx,0px)), -14px) scale(1.05); }
  100% { opacity: 0; transform: translate(calc(-50% + var(--jx,0px)), -150px) scale(1); }
}
/* Combo! — the sunod-sunod-na-barya chain-bonus floater: fiery, same rise. */
.coin-floater--combo {
  color: #fff;
  font-size: var(--fs-xl);
  font-weight: var(--w-black);
  padding: 8px 18px; border-radius: var(--r-pill);
  background: linear-gradient(135deg, #ff6a3d, #ff2d55);
  box-shadow: 0 12px 34px -8px rgba(255, 45, 85, .7);
  animation: floatUpLucky 3.2s var(--ease) forwards;
}

.confetti-layer {
  position: fixed; inset: 0;
  width: 100vw;
  height: 100vh;  /* fallback — dvh is Chrome 108+, kiosk is Chromium 87 */
  height: 100dvh;
  pointer-events: none;
  z-index: 60;
}

/* ------------------------------ Modals ---------------------------------- */
.modal {
  position: fixed; inset: 0;
  z-index: 80;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s-4);
}
.modal[hidden] { display: none; }
.modal-backdrop {
  position: absolute; inset: 0;
  background: color-mix(in srgb, var(--n-900) 48%, transparent);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  opacity: 0;
  transition: opacity var(--dur) var(--ease);
}
.modal.is-open .modal-backdrop { opacity: 1; }

.modal-panel {
  position: relative;
  width: 100%;
  max-width: var(--maxw);
  max-height: 86vh;   /* fallback — dvh is Chrome 108+, kiosk is Chromium 87 */
  max-height: 86dvh;
  overflow: auto;
  border-radius: var(--r-xl);
  transform: translateY(14px) scale(.98);
  opacity: 0;
  transition: transform var(--dur-slow) var(--ease-spring), opacity var(--dur) var(--ease);
}
.modal.is-open .modal-panel { transform: none; opacity: 1; }

/* Bottom sheet variant (voucher). */
.sheet { align-items: flex-end; padding: 0; }
.sheet-panel {
  position: relative;
  width: 100%;
  max-width: var(--maxw);
  margin: 0 auto;
  background: var(--surface);
  border-radius: var(--r-2xl) var(--r-2xl) 0 0;
  box-shadow: var(--shadow-4), 0 -1px 0 var(--line);
  padding: var(--s-4) var(--s-5) calc(var(--s-7) + env(safe-area-inset-bottom));
  /* The same cap .modal-panel carries, and here it is load-bearing: .sheet is
     align-items:flex-end, so a panel taller than the viewport overflows out of the
     TOP where nothing can reach it. voucher.js focuses the input on open, so on a
     ~500px captive sheet the IME leaves ~250px and the handle, the title and the
     ✕ all leave the screen — while the 318px panel covers the backdrop edge to
     edge, so the [data-close] tap is gone too and Escape means nothing on a phone.
     A paying customer holding a voucher card ends up in a sheet with no exit. */
  max-height: 86vh;   /* fallback — dvh is Chrome 108+, kiosk is Chromium 87 */
  max-height: 86dvh;
  overflow-y: auto;
  overscroll-behavior: contain;
  transform: translateY(100%);
  transition: transform var(--dur-slow) var(--ease);
}
.sheet.is-open .sheet-panel { transform: none; }
.sheet-handle {
  width: 44px; height: 5px; border-radius: var(--r-pill);
  background: var(--line-2);
  margin: 0 auto var(--s-4);
}
.sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--s-4); }
.sheet-head h2 { margin: 0; font-size: var(--fs-lg); font-weight: var(--w-bold); letter-spacing: var(--track-snug); }
.icon-btn {
  position: relative;
  width: 40px; height: 40px; border-radius: 50%;
  border: 1px solid var(--line);
  background: var(--surface-2);
  color: var(--ink-2);
  font-size: 16px; cursor: pointer;
  display: grid; place-items: center;
  transition: transform var(--dur-fast) var(--ease), background var(--dur) var(--ease);
}
/* Invisible 44px hit area so the modal close button meets the touch-target
   minimum on phones (matches the .sound-toggle / .lang-toggle pattern). */
.icon-btn::after {
  content: ''; position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: 44px; height: 44px; border-radius: 50%;
}
.icon-btn:hover { background: var(--surface-3); }
.icon-btn:active { transform: scale(.92); }

/* ----------------- Narrow / older phones (≤360px, incl. 320px) ----------- */
/* Big readouts already use clamp(); here we trim padding + a few fixed sizes so
   nothing clips or forces a sideways scroll on the smallest handsets. */
@media (max-width: 360px) {
  body, .app { overflow-x: hidden; }
  .portal-hero { padding: calc(var(--s-5) + env(safe-area-inset-top)) var(--s-4) var(--s-6); }
  .coin-slot-icon svg { width: 44px; height: 44px; }
  .sim-btns { gap: var(--s-2); }
  .sim-btns > * { padding-left: 4px; padding-right: 4px; font-size: var(--fs-sm); }
  .sheet-panel { padding-left: var(--s-4); padding-right: var(--s-4); }
}

/* ----------------- Phones (≤430px: 360/375/390/414/428) ------------------ */
/* The status pill + lang/sound toggles were eating the hero row and clamping
   "KonekOS" to "Ko…" and clipping the tagline. Below the desktop glass-device
   breakpoint (640px), give the brand block priority: let the toggles keep their
   size, compact the status badge to just its dot, and stop ellipsis-clamping
   the brand name so "KonekOS" + tagline always render in full. */
@media (max-width: 430px) {
  /* Let flex children shrink so a long brand/tagline can't widen the layout. */
  .hero-row, .brand, .brand-text, .timer-center { min-width: 0; }
  /* Brand block wins the row; the compact toggles never squeeze it. */
  .brand { flex: 1 1 auto; }
  .hero-right { flex: 0 0 auto; }
  /* "KonekOS" must never be clamped to "Ko…" — drop nowrap/ellipsis so it wraps
     rather than truncates (it fits on one line once the badge is compacted). The
     tagline keeps its ellipsis so an unusually long one degrades gracefully. */
  .brand-name { white-space: normal; overflow: visible; text-overflow: clip; }
  /* Compact the status badge to just its dot so the brand never truncates ("K...").
     The dot is now the whole control, so give it a little more presence. */
  .status-badge { font-size: 0; gap: 0; padding: 6px; }
  .status-badge::before { margin: 0; width: 8px; height: 8px; }
}

/* ------------- Short viewports (captive sheet, landscape phone) ---------- */
/* A captive-portal sheet is roughly 360×500, and NOTHING on the Paused/Expired
   screens responded to viewport height: a fixed 132px clock, fixed --s-8 card
   padding and the hero above them put "Insert coin to continue" at y=520 and
   "Resume now" at y=538 — the only button on the two screens where the customer
   has JUST lost their connection, entirely below the fold, on a frame that looks
   complete and offers no scroll affordance. Trim the vertical spend so the CTA
   is visible without scrolling. Height-only, so 360×640 and up are untouched. */
@media (max-height: 560px) {
  .portal-hero {
    padding-top: calc(var(--s-4) + env(safe-area-inset-top));
    padding-bottom: var(--s-5);
  }
  .hero-balance { margin-top: var(--s-3); }
  .paused-card, .expired-card { padding: var(--s-5); }
  .expired-ico { width: 84px; height: 84px; }
  .paused-ico { width: 64px; height: 64px; }
  .paused-ico svg { width: 36px; height: 36px; }
}

.field-label { display: block; font-size: var(--fs-sm); font-weight: var(--w-bold); color: var(--ink-2); margin-bottom: var(--s-2); }
.voucher-input {
  width: 100%;
  min-height: 60px;
  font-family: var(--font-num);
  font-size: var(--fs-xl);
  font-weight: var(--w-bold);
  letter-spacing: .14em;
  text-align: center;
  text-transform: uppercase;
  font-variant-numeric: tabular-nums;
  border: 2px solid var(--line-2);
  border-radius: var(--r-md);
  background: var(--surface-2);
  color: var(--ink);
  padding: 0 var(--s-3);
  transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease), background var(--dur) var(--ease);
}
.voucher-input::placeholder { color: var(--ink-placeholder); letter-spacing: .06em; font-weight: var(--w-medium); }
.voucher-input:focus {
  outline: none;
  border-color: var(--accent);
  background: var(--surface);
  box-shadow: 0 0 0 4px var(--accent-ring);
}
[data-vstate="error"] .voucher-input { border-color: var(--danger); box-shadow: 0 0 0 4px var(--danger-soft); }
[data-vstate="success"] .voucher-input { border-color: var(--ok); box-shadow: 0 0 0 4px var(--ok-soft); }

.voucher-msg { min-height: 20px; margin: var(--s-3) 0; font-size: var(--fs-sm); font-weight: var(--w-semibold); color: var(--ink-3); }
.voucher-msg[data-kind="error"] { color: var(--danger-ink); }
.voucher-msg[data-kind="success"] { color: var(--ok-ink); }

/* Submit spinner state. */
.btn-spinner {
  width: 18px; height: 18px;
  border: 2px solid color-mix(in srgb, var(--on-accent) 45%, transparent);
  border-top-color: var(--on-accent);
  border-radius: 50%;
  display: none;
  animation: spin .7s linear infinite;
}
[data-loading] .btn-label { opacity: .4; }
[data-loading] .btn-spinner { display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }

/* Help modal body. */
.modal-panel.card { padding: var(--s-5) var(--s-5) var(--s-6); }
.help-body { display: flex; flex-direction: column; gap: var(--s-4); }
.help-steps {
  margin: 0; padding: 0; list-style: none;
  display: flex; flex-direction: column; gap: var(--s-3); color: var(--ink-2);
  counter-reset: hstep;
}
.help-steps li {
  position: relative;
  padding-left: 42px;
  font-size: var(--fs-sm);
  line-height: 1.45;
  min-height: 30px;
  display: flow-root;
  /* nudge the text block so it optically centres against the 30px number chip */
  padding-top: 4px;
}
.help-steps li::before {
  counter-increment: hstep;
  content: counter(hstep);
  position: absolute; left: 0; top: 50%; transform: translateY(-50%);
  width: 30px; height: 30px; border-radius: 50%;
  display: grid; place-items: center;
  font-family: var(--font-num); font-weight: var(--w-bold); font-size: var(--fs-sm);
  color: var(--accent-700);
  background: var(--accent-soft);
}
.help-steps li strong { color: var(--ink); }
/* keep the number chip vertically centred now that the li is flow-root */
.help-steps li::before { top: 4px; transform: none; }
.help-sub { margin: var(--s-2) 0 0; font-size: var(--fs-md); font-weight: var(--w-bold); }
.help-support { margin-top: var(--s-2); }

body.modal-open { overflow: hidden; }

/* ------------------------------ Toasts ---------------------------------- */
.toaster {
  position: fixed;
  left: 50%;
  bottom: calc(var(--s-6) + env(safe-area-inset-bottom));
  transform: translateX(-50%);
  z-index: 90;
  display: flex;
  flex-direction: column-reverse;
  gap: var(--s-2);
  width: min(92vw, var(--maxw));
  pointer-events: none;
}
.toast {
  pointer-events: auto;
  padding: var(--s-3) var(--s-4);
  border-radius: var(--r-md);
  background: var(--glass-bg-strong);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  color: var(--ink);
  box-shadow: var(--shadow-3), var(--hairline);
  border: 1px solid var(--glass-border);
  font-size: var(--fs-sm);
  font-weight: var(--w-semibold);
  opacity: 0;
  transform: translateY(14px);
  transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease-spring);
}
.toast.is-in { opacity: 1; transform: none; }
.toast.is-out { opacity: 0; transform: translateY(14px); }
.toast--success { border-left: 4px solid var(--ok); }
.toast--error { border-left: 4px solid var(--danger); }
.toast--info { border-left: 4px solid var(--info); }

/* ----------------------- Status-driven app tweaks ----------------------- */
.app[data-status="expired"] .portal-hero { filter: saturate(.78); }

/* THE MINT STRIKE — step 4: quietly raised BEST rate row (the best-VALUE coin,
   marked [data-best="1"] by main.js — not simply the last row in the config). */
.rate-row[data-best="1"] { box-shadow: 0 6px 16px -8px rgba(20, 224, 160, .40); }  /* plain fallback first */
.rate-row[data-best="1"] { box-shadow: 0 6px 16px -8px color-mix(in srgb,var(--accent) 40%,transparent), inset 0 0 0 1px var(--accent-line); }
.rate-coin::before { box-shadow: inset 0 -1px 2px color-mix(in srgb,#000 20%,transparent); }

/* THE MINT STRIKE — step 10: reduced-motion / weak-GPU guard. The coin event
   collapses to a single instant flash; infinite loops are pinned off. */
@media (prefers-reduced-motion: reduce) {
  .coin-slot-icon { animation: none !important; background-position: 140% 0, 35% 28%, 0 0 !important; }
  .insert-panel.is-coin-hit .coin-slot-icon::after { animation: none !important; }
  .balance-pill.is-rolling::after { animation: none !important; opacity: 0 !important; }
  .piso-coin { animation: none !important; opacity: 0 !important; }
}

/* Konek Jukebox — floating customer music player (opt-in). */
.jukebox { position: fixed; right: 14px; bottom: calc(14px + env(safe-area-inset-bottom)); z-index: 40; display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
.jb-toggle { position: relative; width: 52px; height: 52px; border-radius: 50%; border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); background: var(--accent-grad); color: var(--on-accent); box-shadow: var(--accent-glow-sm); cursor: pointer; display: grid; place-items: center; }
.jb-toggle:active { transform: scale(.95); }
.jb-eq { position: absolute; right: 7px; bottom: 7px; display: none; gap: 2px; align-items: flex-end; height: 11px; }
.jukebox.is-playing .jb-eq { display: flex; }
/* Bars are drawn at FULL height and scaled down, rather than animating `height`.
   Animating height relayouts the subtree on every frame, and this runs continuously
   on three bars for as long as music plays — the most expensive loop in the portal
   on the Orange Pi board. scaleY composites instead. transform-origin:bottom keeps
   them growing up off the baseline exactly as before; 3/11 = .273 is the old low. */
.jb-eq i { width: 2px; height: 11px; background: currentColor; border-radius: 1px;
  transform-origin: 50% 100%; animation: jb-eq .9s ease-in-out infinite; }
.jb-eq i:nth-child(2) { animation-delay: .15s; }
.jb-eq i:nth-child(3) { animation-delay: .3s; }
@keyframes jb-eq { 0%,100% { transform: scaleY(.273); } 50% { transform: scaleY(1); } }
.jb-panel { background: var(--glass-bg-strong); border: 1px solid var(--glass-border); border-radius: var(--r-lg); box-shadow: var(--shadow-3); padding: 12px 14px; width: 232px; -webkit-backdrop-filter: blur(var(--glass-blur)); backdrop-filter: blur(var(--glass-blur)); }
.jb-now { font-size: var(--fs-sm); font-weight: var(--w-semibold); color: var(--ink); margin-bottom: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.jb-controls { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
/* 44px, not 40: near-identical round buttons at 40px mean a thumb aiming for play
   catches next. THREE buttons now (prev / play / next) — the arithmetic here used to
   say four, written the same day the repeat toggle was removed and never updated.
   3×44 + 2 gaps = 144px inside the panel's 204px of content box (232px − 28px
   padding), so there is room to spare and nothing reflows. */
.jb-controls button { width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); font-size: 15px; cursor: pointer; display: grid; place-items: center; }
.jb-play { background: var(--accent-grad) !important; color: var(--on-accent) !important; border-color: transparent !important; }
.jb-controls button:active { transform: scale(.92); }
/* Operator "push to devices" toast (bottom-right, above the jukebox button). */
.jb-toast { position: fixed; right: 14px; bottom: calc(76px + env(safe-area-inset-bottom)); z-index: 41; max-width: min(300px, calc(100vw - 28px)); background: var(--glass-bg-strong); border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--glass-border)); border-radius: var(--r-lg); box-shadow: var(--shadow-3); padding: 11px 14px; -webkit-backdrop-filter: blur(var(--glass-blur)); backdrop-filter: blur(var(--glass-blur)); transform: translateY(12px); opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; }
.jb-toast.show { transform: translateY(0); opacity: 1; }
.jb-toast.tappable { pointer-events: auto; cursor: pointer; }
.jb-toast b { display: block; font-size: var(--fs-sm); font-weight: var(--w-bold); color: var(--ink); }
.jb-toast span { display: block; font-size: var(--fs-xs); color: var(--ink-2); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@media (prefers-reduced-motion: reduce) { .jb-eq i { animation: none !important; } }

/* ===================== Suki loyalty + Konek Buddy referral ================= */
/* Built by loyalty.js into [data-loyalty-host] on the landing screen. Empty host
   collapses; the card matches the rate-card's glass + accent language. */
.loyalty-host:empty { display: none; }
/* The other seven opt-in hosts need the same collapse. .screen is a flex column
   with a gap, so a zero-height empty <div> is still a flex item and still earns a
   full gap on BOTH sides — with the features off that was ~80px of unexplained
   blank band on landing and ~72px on active, pushing the action bar and the Help
   link out of thumb reach on a short captive sheet. */
.timecapsule-host:empty,
.store-host:empty,
.aklatan-host:empty,
.prayer-host:empty,
.transfer-host:empty,
.barkada-host:empty,
.print-host:empty { display: none; }
.loyalty-card {
  border-radius: var(--r-xl);
  padding: var(--s-5);
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
}
.loyalty-card[hidden] { display: none; }
.loy-section { display: flex; flex-direction: column; gap: var(--s-3); }
/* Hairline divider between the two programs when both are on. */
.loy-section.buddy:not(:first-child) {
  padding-top: var(--s-5);
  border-top: 1px solid var(--line);
}
.loy-head { display: flex; flex-direction: column; gap: 2px; }
.loy-eyebrow {
  font-size: var(--t-2xs);
  font-weight: var(--w-bold);
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: var(--accent-700);
}
.loy-head h3 {
  margin: 0;
  font-size: var(--fs-md);
  font-weight: var(--w-bold);
  letter-spacing: var(--track-snug);
  color: var(--ink);
}

/* -------------------------------- Suki card ------------------------------- */
.suki-stamps {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}
.suki-stamp {
  display: grid; place-items: center;
  width: 30px; height: 30px; flex: none;
  border-radius: 50%;
  font-size: 15px; line-height: 1;
  color: var(--ink-faint);
  background: var(--surface-2);
  border: 1.5px dashed color-mix(in srgb, var(--accent) 26%, var(--line-2));
  transition: transform var(--dur) var(--ease-spring), background var(--dur) var(--ease), color var(--dur) var(--ease);
}
.suki-stamp.is-on {
  color: var(--on-accent);
  background: var(--accent-grad);
  border: 1.5px solid transparent;
  box-shadow: var(--accent-glow-sm), inset 0 1px 0 color-mix(in srgb, #fff 40%, transparent);
}
.suki-progress {
  margin: 0;
  font-size: var(--fs-sm);
  font-weight: var(--w-semibold);
  color: var(--ink-2);
}
/* Reward celebration: a quick pop across the filled stamps. */
.suki-stamps.is-celebrate .suki-stamp.is-on { animation: suki-pop .6s var(--ease-spring); }
.suki-stamps.is-celebrate .suki-stamp.is-on:nth-child(2) { animation-delay: .05s; }
.suki-stamps.is-celebrate .suki-stamp.is-on:nth-child(3) { animation-delay: .1s; }
.suki-stamps.is-celebrate .suki-stamp.is-on:nth-child(4) { animation-delay: .15s; }
.suki-stamps.is-celebrate .suki-stamp.is-on:nth-child(5) { animation-delay: .2s; }
@keyframes suki-pop { 0%,100% { transform: scale(1); } 45% { transform: scale(1.28) rotate(-6deg); } }

/* ------------------------------ Konek Buddy ------------------------------- */
.buddy-code {
  position: relative;
  display: flex; align-items: center; justify-content: space-between; gap: var(--s-3);
  width: 100%;
  padding: var(--s-3) var(--s-4);
  border-radius: var(--r-md);
  cursor: pointer;
  text-align: left;
  background:
    radial-gradient(120% 90% at 0% -20%, var(--accent-softer), transparent 60%),
    var(--surface-2);
  border: 1.5px dashed var(--accent-line);
  transition: border-color var(--dur) var(--ease), background var(--dur) var(--ease), transform var(--dur-fast) var(--ease-spring);
}
.buddy-code:hover { border-color: var(--accent-ring); background: var(--accent-softer); }
.buddy-code:active { transform: scale(.98); }
.buddy-code:focus-visible { outline: 3px solid var(--accent-ring); outline-offset: 2px; }
.buddy-code.is-copied { border-style: solid; border-color: var(--ok); }
.buddy-code-value {
  font-family: var(--font-num);
  font-weight: var(--w-black);
  font-size: var(--fs-lg);
  letter-spacing: .12em;
  color: var(--accent-700);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.buddy-code-hint {
  flex: none;
  font-size: var(--fs-xs);
  font-weight: var(--w-bold);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--ink-3);
}
.buddy-code.is-copied .buddy-code-hint { color: var(--ok-ink); }
.buddy-share {
  margin: 0;
  font-size: var(--fs-sm);
  font-weight: var(--w-medium);
  line-height: 1.45;
  color: var(--ink-2);
}
.buddy-linked {
  margin: 0;
  font-size: var(--fs-sm);
  font-weight: var(--w-bold);
  color: var(--ok-ink);
}
.buddy-link-label { display: block; font-size: var(--fs-sm); font-weight: var(--w-bold); color: var(--ink-2); margin-bottom: var(--s-2); }
.buddy-link-row { display: flex; gap: var(--s-2); }
.buddy-input {
  flex: 1; min-width: 0;
  min-height: 48px;
  font-family: var(--font-num);
  font-size: var(--fs-md);
  font-weight: var(--w-bold);
  letter-spacing: .1em;
  text-transform: uppercase;
  border: 2px solid var(--line-2);
  border-radius: var(--r-md);
  background: var(--surface-2);
  color: var(--ink);
  padding: 0 var(--s-3);
  transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease), background var(--dur) var(--ease);
}
.buddy-input::placeholder { color: var(--ink-placeholder); letter-spacing: .04em; font-weight: var(--w-medium); text-transform: none; }
.buddy-input:focus {
  outline: none;
  border-color: var(--accent);
  background: var(--surface);
  box-shadow: 0 0 0 4px var(--accent-ring);
}
.buddy-input:disabled { opacity: .6; }
.buddy-link-btn { flex: none; min-height: 48px; padding: 0 var(--s-5); }
.buddy-msg { min-height: 18px; margin: var(--s-2) 0 0; font-size: var(--fs-sm); font-weight: var(--w-semibold); color: var(--ink-3); }
.buddy-msg[data-kind="error"] { color: var(--danger-ink); }
.buddy-msg[data-kind="success"] { color: var(--ok-ink); }

/* Loyalty / referral celebration toast (bottom-centred, above the toaster).
   .tc-toast (timecapsule.js "libreng WiFi" celebration) shares this rule verbatim:
   it appends a .tc-toast to <body> and toggles .show, but had NO styling at all, so
   it rendered as bare text under the footer and — with no opacity:0 base — the
   .show toggle was inert and the message never went away. */
.loy-toast,
.tc-toast {
  position: fixed;
  left: 50%;
  bottom: calc(var(--s-6) + 64px + env(safe-area-inset-bottom));
  transform: translateX(-50%) translateY(14px);
  z-index: 91;
  display: flex; align-items: center; gap: var(--s-3);
  max-width: min(92vw, var(--maxw));
  padding: var(--s-3) var(--s-5);
  border-radius: var(--r-pill);
  background: var(--glass-bg-strong);
  border: 1px solid var(--line-2);  /* fallback — color-mix is Chrome 111+, kiosk is Chromium 87 */
  border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--glass-border));
  box-shadow: var(--shadow-3), var(--accent-glow-sm);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  color: var(--ink);
  font-size: var(--fs-sm);
  font-weight: var(--w-bold);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease-spring);
}
.loy-toast.show,
.tc-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.loy-toast-ico,
.tc-toast-ico { font-size: var(--fs-lg); line-height: 1; }

@media (prefers-reduced-motion: reduce) {
  .suki-stamps.is-celebrate .suki-stamp.is-on { animation: none !important; }
}

/* ============ Opt-in feature cards — shared form + list primitives ========= */
/* transfer.js (.tr-*), barkada.js (.bk-*), print.js (.pr-*), store.js (.store-*)
   and aklatan.js (.ak-*) build their whole DOM from these class names, but none of
   them had a single rule anywhere: the <label>s stayed display:inline and the
   inputs rendered at UA defaults (~28px tall, ~13px text) inside otherwise
   polished cards — under the 44px touch minimum the rest of the portal engineers
   (see .voucher-input / .buddy-input), and small enough that iOS Safari zooms on
   focus and leaves the page scrolled sideways. Modelled on .buddy-input. */
.tr-field, .bk-field, .pr-field {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}
/* barkada.js appends one .bk-code per recipient into this otherwise bare <div>. */
.bk-codes { display: flex; flex-direction: column; gap: var(--s-2); }

.tr-code, .tr-min, .bk-code, .bk-min, .pr-file {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  min-width: 0;
  min-height: 48px;
  padding: 0 var(--s-3);
  font-family: inherit;
  font-size: var(--fs-md);          /* >=16px keeps iOS Safari from zooming on focus */
  font-weight: var(--w-semibold);
  border: 2px solid var(--line-2);
  border-radius: var(--r-md);
  background: var(--surface-2);
  color: var(--ink);
  transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease), background var(--dur) var(--ease);
}
/* A file input's own button chrome doesn't centre in the line box, so give it real
   vertical padding rather than relying on min-height alone. */
.pr-file { padding-top: var(--s-3); padding-bottom: var(--s-3); }
.tr-code::placeholder, .bk-code::placeholder { color: var(--ink-placeholder); font-weight: var(--w-medium); }
.tr-code:focus, .tr-min:focus, .bk-code:focus, .bk-min:focus, .pr-file:focus {
  outline: none;
  border-color: var(--accent);
  background: var(--surface);
  box-shadow: 0 0 0 4px var(--accent-ring);
}

/* Tindahan product list — store.js builds a real <ul>, so the UA markers + indent
   have to go, and each row is name(+note) left / price right instead of the price
   dropping onto its own line. aklatan.js builds a <ul> too. */
.store-list, .ak-list { list-style: none; margin: 0; padding: 0; }
.store-list, .ak-list { display: flex; flex-direction: column; gap: var(--s-2); }
.store-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
}
.store-item-info { display: flex; flex-direction: column; min-width: 0; }
.store-item-price {
  flex: none;
  font-family: var(--font-num);
  font-weight: var(--w-bold);
  font-variant-numeric: tabular-nums;
}

/* ============================== Konek Selfie ============================== */
/* Opt-in, portal-only branded-frame camera. Launcher lives in .active-actions;
   the camera sheet reuses the portal's .modal/.sheet scaffolding. */
.selfie-btn { display: inline-flex; align-items: center; gap: 6px; }
.selfie-modal .selfie-panel { max-width: min(440px, calc(100vw - 24px)); }
.selfie-body { display: flex; flex-direction: column; gap: var(--s-3); padding-top: var(--s-2); }
.selfie-stage {
  position: relative; width: 100%; aspect-ratio: 4 / 5; border-radius: var(--r-lg);
  overflow: hidden; background: var(--ink); display: grid; place-items: center;
}
.selfie-video, .selfie-canvas { width: 100%; height: 100%; object-fit: cover; display: block; }
.selfie-video { transform: scaleX(-1); }   /* mirror the live preview (natural selfie) */
.selfie-msg {
  position: absolute; inset: 0; margin: 0; display: grid; place-items: center;
  padding: var(--s-4); text-align: center; color: var(--paper, #fff);
  background: color-mix(in srgb, var(--ink) 82%, transparent); font-size: var(--fs-sm);
}
.selfie-actions { display: flex; flex-wrap: wrap; gap: var(--s-2); }
.selfie-actions .btn { flex: 1 1 auto; }
.selfie-privacy { text-align: center; font-size: var(--fs-xs); margin: 0; }
@media (prefers-reduced-motion: reduce) {
  .selfie-btn, .selfie-actions .btn { transition: none !important; }
}

/* ===================== Konek Radyo — operator announcement banner ========== */
/* Built by radyo.js on the fly; a dismissible top banner pushed to all portals. */
.radyo-banner {
  position: fixed;
  left: 50%;
  top: calc(10px + env(safe-area-inset-top));
  transform: translate(-50%, -140%);
  z-index: 60;
  display: flex;
  align-items: center;
  gap: 10px;
  max-width: min(520px, calc(100vw - 24px));
  padding: 11px 14px;
  background: var(--glass-bg-strong);
  border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--glass-border));
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-3);
  -webkit-backdrop-filter: blur(var(--glass-blur));
  backdrop-filter: blur(var(--glass-blur));
  opacity: 0;
  pointer-events: none;
  transition: opacity .22s ease, transform .22s ease;
}
.radyo-banner.show { transform: translate(-50%, 0); opacity: 1; pointer-events: auto; }
.radyo-ico { display: inline-flex; align-items: center; line-height: 1; flex: 0 0 auto; color: var(--accent, #14e0a0); }
.radyo-text { font-size: var(--fs-sm); color: var(--ink); font-weight: var(--w-medium); line-height: 1.35; }
.radyo-x {
  position: relative;
  flex: 0 0 auto;
  margin-left: auto;
  border: none;
  background: transparent;
  color: var(--ink-2);
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
  padding: 0 2px;
}
/* The glyph is 15.7×20px — a seventh of the 44px area every other icon control in
   this portal gets. Same invisible extender as .sound-toggle / .lang-toggle /
   .icon-btn / .link-btn: keeps the 20px glyph and the banner's compact height. */
.radyo-x::after {
  content: ''; position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: 44px; height: 44px;
}
.radyo-x:hover { color: var(--ink); }
@media (prefers-reduced-motion: reduce) {
  .radyo-banner { transition: opacity .01s !important; }
}

/* ============================ Konek Laro (offline arcade) ============================
 * Opt-in floating "Laro" launcher + full-screen offline game overlay. Purely
 * client-side entertainment; no coupling to timer/coin/money UI. */
.arcade-launch { position: fixed; left: 14px; bottom: calc(14px + env(safe-area-inset-bottom)); z-index: 40; width: 52px; height: 52px; border-radius: 50%; border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); background: var(--accent-grad); color: var(--on-accent); box-shadow: var(--accent-glow-sm); cursor: pointer; font-size: 22px; display: grid; place-items: center; }
.arcade-launch:active { transform: scale(.95); }
.arcade-overlay { position: fixed; inset: 0; z-index: 60; background: color-mix(in srgb, var(--ink) 72%, transparent); display: grid; place-items: center; padding: 16px; }
.arcade-overlay[hidden] { display: none; }
.arcade-panel { width: min(420px, 100%); max-height: 90vh; overflow: auto; background: var(--surface, #12161f); color: var(--ink, #e7eefc); border-radius: 16px; border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); box-shadow: 0 20px 60px rgba(0,0,0,.5); }
.arcade-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,.08); }
.arcade-title { margin: 0; font-size: 18px; }
.arcade-close { border: 0; background: none; color: inherit; font-size: 18px; cursor: pointer; padding: 4px 8px; }
.arcade-body { padding: 14px 16px 18px; }
.arcade-menu { display: grid; gap: 10px; }
.arcade-game-btn { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left; padding: 12px 14px; border-radius: 12px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: inherit; cursor: pointer; }
.arcade-game-btn:active { transform: scale(.99); }
.arcade-game-name { font-weight: 700; }
.arcade-game-desc { font-size: 13px; }
.arcade-game-hs { font-size: 12px; opacity: .8; margin-top: 2px; }
.arcade-game-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.arcade-back { border: 0; background: none; color: inherit; cursor: pointer; font-size: 14px; padding: 4px 6px; }
.arcade-game-heading { font-weight: 700; }
.arcade-score { margin-left: auto; font-size: 13px; opacity: .9; }
.arcade-stage { display: grid; place-items: center; gap: 12px; }
.arcade-react-pad { width: 100%; min-height: 200px; border-radius: 14px; border: 0; font-size: 20px; font-weight: 700; cursor: pointer; color: #fff; }
.arcade-react-pad.is-wait { background: #33415a; }
.arcade-react-pad.is-arming { background: #b91c1c; }
.arcade-react-pad.is-go { background: #16a34a; }
.arcade-react-pad.is-early { background: #92400e; }
.arcade-react-pad.is-result { background: #1d4ed8; }
.arcade-mem-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; width: 100%; }
.arcade-mem-card { aspect-ratio: 1; border-radius: 10px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.06); font-size: 24px; cursor: pointer; }
.arcade-mem-card.is-flipped { background: rgba(255,255,255,.14); }
.arcade-mem-card.is-matched { background: color-mix(in srgb, var(--accent) 30%, transparent); }
.arcade-snake { width: 100%; max-width: 320px; image-rendering: pixelated; border-radius: 10px; background: rgba(0,0,0,.25); touch-action: none; }
.arcade-hint { font-size: 12px; }
@media (prefers-reduced-motion: reduce) {
  .arcade-launch:active, .arcade-game-btn:active { transform: none; }
}

/* ------------------------------------------------------------------ *
 * aspect-ratio fallbacks — Chromium <=87 (the kiosk's Falkon)
 *
 * `aspect-ratio` is Chrome 88+. Where a box has no other height source it
 * COLLAPSES on the kiosk browser. `.timer-wrap` already pairs it with an
 * explicit height (see above) and needs nothing; these two do not.
 * Squareness degrades to a sensible fixed height rather than a collapsed box.
 * ------------------------------------------------------------------ */
@supports not (aspect-ratio: 1 / 1) {
  /* 4:5 camera stage — without this it collapses to the height of its
     centred placeholder and the preview is unusable. */
  .selfie-stage { height: min(112vw, 420px); }

  /* Memory-game tiles sit in a 4-column grid with no intrinsic height. */
  .arcade-mem-card { height: 68px; }
}

/* ------------------------------------------------------------------ *
 * Signature-moment hosts (ILAW on the balance, BARYA on the coin panel)
 * Both are progressive enhancements: if the component never mounts these
 * wrappers stay empty and collapse, so the layout is unchanged.
 * ------------------------------------------------------------------ */
/* Left margin 0, not auto. This was `margin: 6px auto 0` back when .hero-balance was
   a flex ROW, where the auto side margins were inert. Now that the hero is a COLUMN
   with align-items:flex-start they became live cross-axis margins and CENTRED the
   260px strand — measured left:50px against the balance pill's left:16px on a 360px
   phone, so the decoration sat visibly off-axis from the number it belongs to. */
.ilaw-host { width: 100%; max-width: 260px; margin: 6px 0 0; }
.ilaw-host:empty { display: none; }

.barya-host { margin-top: var(--s-4); }
.barya-host:empty { display: none; }
