/* Wolfie's Loyalty — global custom styles.
 * Tailwind handles 99% of styling; this stylesheet covers the bits Tailwind
 * cannot express ergonomically: glassmorphism, the 3D card perspective,
 * the ambient background blobs, and the custom scrollbar.
 */

:root {
    /* Native form controls (checkbox, radio, select, type=date picker,
       scrollbars) follow the OS light palette unless this is declared —
       which is why unchecked boxes rendered as white squares on the dark
       glass cards. accent-color only paints the *checked* state. */
    color-scheme: dark;
    --brand-primary: #7DCCC1;
    --brand-secondary: #4FA896;
    --brand-font-body: "Geologica Brand", system-ui, sans-serif;
    --brand-font-heading: "Geologica Brand", "Geologica", system-ui, sans-serif;
    /* Pre-derived tints so children don't need color-mix calls everywhere. */
    --brand-soft-bg: color-mix(in srgb, var(--brand-primary) 14%, transparent);
    --brand-soft-border: color-mix(in srgb, var(--brand-primary) 32%, transparent);
    --brand-strong-bg: color-mix(in srgb, var(--brand-primary) 28%, transparent);
    /* The ONLY brand tone allowed as text on the dark UI. --brand-primary
       itself (#F90403) measures 4.3:1 on body #0f172a and 3.5:1 on the
       lighter cards — under AA for body text. This mix resolves to
       rgb(248,196,197): 11.6:1 on #0f172a, 9.5:1 on #1e293b — AA and AAA. */
    --brand-readable: color-mix(in srgb, var(--brand-primary) 22%, #f8fafc 78%);
    --brand-glow: color-mix(in srgb, var(--brand-primary) 45%, transparent);
}

body {
    background-color: #0f172a;
    color: #f8fafc;
    font-family: var(--brand-font-body, "Geologica Brand", system-ui, sans-serif);
}

@font-face {
    font-family: "Geologica Brand";
    src: url("../brands/fonts/Geologica-Variable.2926daa6f6c9.ttf") format("truetype");
    font-weight: 100 900;
    font-style: normal;
    font-display: swap;
}

/* A quantity.

   These numbers were set in Space Mono — a third face that is neither the
   brand's display nor its body, and one whose 600 and 800 the page asked for
   while only 400 and 700 ship, so the browser was synthesising the difference.

   Numbers stay in the body face; what this class does is the part that was
   always doing real work: columns of digits that line up.

   Codes are deliberately NOT in this class. A member code or a coupon read out
   at the till wants fixed width and an unambiguous zero, which is what the
   monospace was actually for. */
.brand-figures {
    font-synthesis-weight: none;
    font-variant-numeric: tabular-nums;
}

/* Headings take the *text* cut, not the display one.

   --brand-font-heading is the display face the client drew for the card and
   the splash, and at heading sizes it is smaller than the paragraph under it:
   its capitals sit at 0.68 em against the text cut's 0.884, so an <h2> at 18px
   measured 12.2px of ink against the 14px body's 12.4px — a ratio of 0.98,
   measured on /legal/terms/ rather than judged by eye. Every section title on
   both legal pages read as small as the text it introduced.

   The text cut is the same drawings, so nothing about the brand is lost; what
   changes is that a heading is now bigger than what it heads (1.28x at <h2>,
   1.71x at <h1>). The display cut keeps .card-type and the splash below, which
   are the sizes it was drawn for. */
h1, h2, h3, .brand-heading {
    font-family: var(--brand-font-body);
}

::selection {
    background-color: var(--brand-primary);
    color: white;
}

/* ------------------------------------------------------------------ */
/* Brand-aware helpers — used in place of Tailwind static brand-XXX   */
/* utilities so the whole UI re-tints when the active brand changes.  */
/* ------------------------------------------------------------------ */

.text-brand        { color: var(--brand-readable); }
.text-brand-deep   { color: var(--brand-primary); }
.bg-brand-soft     { background-color: var(--brand-soft-bg); }
.bg-brand-strong   { background-color: var(--brand-strong-bg); }
.border-brand-soft { border-color: var(--brand-soft-border); }
.ring-brand        { box-shadow: 0 0 0 1px var(--brand-soft-border); }

.btn-brand {
    background: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary));
    color: white;
    box-shadow: 0 12px 28px -12px var(--brand-glow);
}

.btn-brand:hover {
    filter: brightness(1.08);
}

.btn-brand-ghost {
    background-color: var(--brand-soft-bg);
    color: var(--brand-readable);
    border: 1px solid var(--brand-soft-border);
}

.btn-brand-ghost:hover {
    background-color: var(--brand-strong-bg);
}

/* The old ring was 2px of raw --brand-primary against a #1e293b field:
   1.54:1, below the 3:1 that WCAG 2.2 SC 1.4.11 requires for a focus
   indicator. --brand-readable keeps the brand tint and measures >7:1. */
.input-brand:focus {
    outline: 2px solid var(--brand-readable);
    outline-offset: 0;
    border-color: var(--brand-readable);
    box-shadow: 0 0 0 1px var(--brand-readable);
}

/* Smooth re-tint of brand-aware surfaces between switches. The actual
 * brand-color CSS variables are toggled instantly by the server-rendered
 * <style> block, but any element that pulls from them gets a soft fade. */
.btn-brand,
.btn-brand-ghost,
.text-brand,
.text-brand-deep,
.bg-brand-soft,
.bg-brand-strong,
.border-brand-soft,
.blob,
.loyalty-card-bg {
    transition:
        background-color 600ms ease,
        color 600ms ease,
        border-color 600ms ease,
        box-shadow 600ms ease,
        background 600ms ease;
}

/* ------------------------------------------------------------------ */
/* Glassmorphism                                                       */
/* ------------------------------------------------------------------ */

.glass {
    background: rgba(30, 41, 59, 0.70);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    border: 1px solid rgba(255, 255, 255, 0.08);
}

/* The sticky mobile header is the one .glass surface with page content
   scrolling *under* it; at 0.70 opacity bright chips and reward photos stayed
   legible straight through the bar. Scoped to the header only — .glass is
   shared by ~106 cards/toasts that must stay translucent. */
header.glass {
    background: rgba(15, 23, 42, 0.94);
    backdrop-filter: blur(20px) saturate(140%);
    -webkit-backdrop-filter: blur(20px) saturate(140%);
}

.glass-card {
    background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.01) 100%);
    backdrop-filter: blur(10px);
    border: 1px solid rgba(255, 255, 255, 0.10);
}

/* Store portal — KPI card labels & section headings (white, not muted grey) */
.card-stat-label {
    font-size: 0.75rem;
    line-height: 1rem;
    font-weight: 500;
    letter-spacing: 0.05em;
    color: #ffffff;
}

.card-stat-label-sm {
    font-size: 0.625rem;
    line-height: 0.875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    color: #ffffff;
}

.section-kicker {
    font-size: 0.875rem;
    line-height: 1.25rem;
    font-weight: 600;
    letter-spacing: 0.1em;
    color: #ffffff;
    margin-bottom: 0.75rem;
}

/* ------------------------------------------------------------------ */
/* 3D card                                                             */
/* ------------------------------------------------------------------ */

.perspective-1000 {
    perspective: 1000px;
    touch-action: none;
}

.preserve-3d {
    transform-style: preserve-3d;
}

.translate-z-10 {
    transform: translateZ(50px);
}

.card-corner-btn {
    transform: translateZ(60px);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    pointer-events: auto;
    cursor: pointer;
    touch-action: manipulation;
    -webkit-tap-highlight-color: transparent;
    transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}

.card-corner-btn:active {
    transform: translateZ(60px) scale(0.94);
}

.card-corner-btn:focus-visible {
    outline: 2px solid rgba(255, 255, 255, 0.85);
    outline-offset: 2px;
}

.translate-z-bg {
    transform: translateZ(-20px);
}

.card-flip {
    transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1);
    transform-style: preserve-3d;
}

.card-flip.is-flipped {
    transform: rotateY(180deg);
}

/* Backface culling alone does not hold these two faces. Both contain children
   carrying their own 3D transforms (.translate-z-10, .card-corner-btn), which
   WebKit renders as separate 3D objects and does not cull along with their
   parent — so once the card flipped, the front face's logo, points, tier pill
   and corner button all painted through, mirrored, on top of the QR. Hiding
   the away-facing side outright is what actually keeps it off screen.

   The 350ms delay swaps the pair at the midpoint of the 0.7s flip, when the
   card is edge-on and neither face is legible, so the swap is invisible. It
   applies in both directions, which is why it sits on the shared rule rather
   than on either state. */
.card-face-front,
.card-face-back {
    backface-visibility: hidden;
    -webkit-backface-visibility: hidden;
    transition: visibility 0s linear 350ms;
}

.card-face-back {
    transform: rotateY(180deg);
    visibility: hidden;
}

.card-flip.is-flipped .card-face-back {
    visibility: visible;
}

.card-flip.is-flipped .card-face-front {
    visibility: hidden;
}

/* ------------------------------------------------------------------ */
/* Brand-themed loyalty card                                           */
/* ------------------------------------------------------------------ */

/* The card's own type: the brand's heading face, so it re-tints with
   everything else. tabular-nums keeps the balance's digits from shifting
   width as it changes. */
.card-type {
    font-family: var(--brand-font-heading, var(--brand-font-body));
    font-synthesis-weight: none;
    font-variant-numeric: tabular-nums;
}

/* The QR side carries a masthead and a name band, but only in scan mode —
   the full-screen frame a customer holds up at the till, where the cashier
   sees nothing but this face and a name to check against is real help.
   Hidden with display rather than opacity: opacity:0 still reserves its
   layout box, and the small flipped card at 342x216 has no room to spare. */
#card-container.is-scan-mode .scan-band {
    display: flex;
}

.loyalty-card-bg {
    background:
        radial-gradient(circle at 20% 20%, color-mix(in srgb, var(--brand-primary) 92%, white 8%) 0%, transparent 55%),
        radial-gradient(circle at 80% 75%, color-mix(in srgb, var(--brand-secondary) 80%, black 20%) 0%, transparent 60%),
        linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-secondary) 100%);
    box-shadow: 0 25px 50px -12px color-mix(in srgb, var(--brand-primary) 45%, transparent);
}

.loyalty-card-logo img {
    filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.45));
}

.loyalty-card-bg[data-brand="wolfies"] {
    background:
        radial-gradient(circle at 22% 18%, rgba(255, 77, 58, 0.55) 0%, transparent 50%),
        radial-gradient(circle at 78% 82%, rgba(249, 4, 3, 0.65) 0%, transparent 55%),
        linear-gradient(135deg, #f90403 0%, #8c0201 48%, #140000 100%);
    border: 1px solid rgba(255, 77, 58, 0.35);
    box-shadow:
        0 25px 50px -12px rgba(249, 4, 3, 0.45),
        inset 0 1px 0 rgba(255, 255, 255, 0.08);
}

.loyalty-card-bg[data-brand="wolfies"] .loyalty-card-logo img {
    filter: drop-shadow(0 2px 12px rgba(0, 0, 0, 0.55)) brightness(1.05);
}

/* ------------------------------------------------------------------ */
/* Auth pages — dual-brand ambient backdrop                            */
/* ------------------------------------------------------------------ */

.auth-scene {
    position: relative;
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1.5rem;
    overflow: hidden;
    isolation: isolate;
    min-height: 100%;
    /* Down to the bottom of the screen, so the body's navy never shows as a
       band under the scene on a phone. */
    min-height: 100dvh;
    width: 100%;
}

/* Auth pages: side gradients only — no bottom band, full viewport height. */
body:has(.auth-scene) .blob {
    display: none;
}

body:has(.auth-scene) main,
body:has(.auth-scene) .page-fade {
    min-height: 100%;
}

.auth-scene::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    background-color: #050505;
    background-image:
        radial-gradient(ellipse 88% 120% at 0% 50%, var(--auth-glow, rgba(249, 4, 3, 0.32)) 0%, transparent 58%),
        radial-gradient(ellipse 60% 90% at 100% 45%, rgba(255, 77, 58, 0.14) 0%, transparent 55%);
}

.auth-scene::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    opacity: 0.42;
    background:
        radial-gradient(ellipse 48% 92% at 0% 50%, var(--auth-primary, #F90403) 0%, transparent 54%);
    filter: blur(88px);
    pointer-events: none;
}

.auth-scene > * {
    position: relative;
    z-index: 1;
}

.auth-scene[data-auth-brand="wolfies"]::before {
    background-color: #050505;
    background-image:
        radial-gradient(ellipse 92% 120% at 0% 50%, var(--auth-glow, rgba(249, 4, 3, 0.32)) 0%, transparent 62%),
        radial-gradient(ellipse 55% 85% at 100% 50%, rgba(255, 77, 58, 0.12) 0%, transparent 58%);
}

.auth-card {
    border: 1px solid rgba(255, 255, 255, 0.12);
    box-shadow:
        0 24px 48px -16px rgba(0, 0, 0, 0.55),
        0 0 0 1px rgba(255, 255, 255, 0.04) inset;
}

.auth-brand-strip {
    padding: 0.35rem 0.5rem;
}

.auth-brand-pill {
    display: flex;
    align-items: center;
    justify-content: center;
    /* The mark is a round badge cropped tight to its disc, so the pill
       supplies all of its breathing room — evenly, so the pill is a circle. */
    padding: 0.6rem;
    border-radius: 9999px;
    border: 1px solid rgba(255, 255, 255, 0.1);
    background: rgba(255, 255, 255, 0.04);
}

.auth-brand-pill--wolfies {
    box-shadow: 0 0 24px -6px rgba(249, 4, 3, 0.35);
    border-color: rgba(255, 77, 58, 0.28);
}

.store-role-badge {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.2rem 0.55rem;
    border-radius: 9999px;
    font-size: 0.625rem;
    font-weight: 600;
    letter-spacing: 0.06em;
}

.store-role-badge--manager {
    color: #fde68a;
    background: rgba(245, 158, 11, 0.14);
    border: 1px solid rgba(251, 191, 36, 0.35);
}

.store-role-badge--cashier {
    color: #cbd5e1;
    background: rgba(148, 163, 184, 0.12);
    border: 1px solid rgba(148, 163, 184, 0.25);
}

/* Tier-coloured chip used in the levels page. */
/* ------------------------------------------------------------------ */
/* Ambient animated blobs                                              */
/* ------------------------------------------------------------------ */

.blob {
    position: absolute;
    filter: blur(80px);
    z-index: 0;
    opacity: 0.4;
    animation: blob-drift 18s infinite alternate ease-in-out;
}

.blob--primary   { background-color: var(--brand-primary); }
.blob--secondary { background-color: var(--brand-secondary); }

/* Drift leftwards only: the blobs already sit flush against the right edge
   (right-0 / right-10), so any positive X in this keyframe pushes the
   scrollable area ~120px past the viewport. Today body{overflow:hidden}
   hides that; the negative X removes the overflow at the source. */
@keyframes blob-drift {
    0%   { transform: translate(0, 0)         scale(1); }
    50%  { transform: translate(-40px, 50px)  scale(1.15); }
    100% { transform: translate(-90px, 90px)  scale(0.85); }
}

/* ------------------------------------------------------------------ */
/* Scrollbar                                                           */
/* ------------------------------------------------------------------ */

::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #0f172a; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }

/* ------------------------------------------------------------------ */
/* Form polish                                                         */
/* ------------------------------------------------------------------ */

input[type="checkbox"],
input[type="radio"] {
    accent-color: var(--brand-primary);
    /* Browser default is 13x13px, which is both a poor touch target and
       visually lost next to 44px controls. min-* rather than width/height so
       a template that deliberately asks for something larger still wins. */
    min-width: 1.15rem;
    min-height: 1.15rem;
    flex-shrink: 0;
}

/* A label wrapping a checkbox/radio is the real tap target — give it the
   vertical room the row never had (measured 20px tall on the consent rows). */
label:has(> input[type="checkbox"]),
label:has(> input[type="radio"]) {
    min-height: 2.75rem;
    cursor: pointer;
}

/* Native <select> and the type=date/time text box otherwise keep the OS
   chrome (white field, blue system focus) which clashes with every other
   rounded dark input on the same form. color-scheme:dark on :root handles
   the picker popup; these handle the closed control. */
select,
input[type="date"],
input[type="time"],
input[type="datetime-local"],
input[type="month"] {
    color-scheme: dark;
    background-color: rgba(15, 23, 42, 0.65);
    color: #f8fafc;
}

/* WebKit renders the date field's own calendar glyph nearly black on the dark
   field; invert it back to the field's text colour. */
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
    filter: invert(1) opacity(0.7);
    cursor: pointer;
}

/* iOS Safari force-zooms the viewport whenever a focused field is under 16px,
   and the customer viewport (templates/base.html) deliberately does not lock
   the scale. Every form input in the app renders at 14px (text-sm), so this
   floors them on phones. The attribute selector outranks Tailwind's .text-sm. */
@media (max-width: 767px) {
    input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
    select:not([hidden]),
    textarea:not([hidden]) {
        font-size: 16px;
    }
}

.errorlist {
    color: #f87171;
    font-size: 0.8rem;
    margin-top: 0.25rem;
}

.messages-toast {
    animation: fade-in-up 0.4s ease-out;
}

@keyframes fade-in-up {
    from { opacity: 0; transform: translateY(8px); }
    to   { opacity: 1; transform: translateY(0); }
}

/* ------------------------------------------------------------------ */
/* Scan-to-pay mode — the 3D card lifts out of its grid slot, scales  */
/* up, flips to QR, a red laser-style beam slides over the QR and a   */
/* faux 1D barcode shimmers below. Driven by static/js/card.js.        */
/* ------------------------------------------------------------------ */

#scan-backdrop {
    position: fixed;
    inset: 0;
    z-index: 90;
    opacity: 0;
    pointer-events: none;
    background:
        radial-gradient(circle at 50% 45%,
            color-mix(in srgb, var(--brand-primary) 35%, transparent) 0%,
            rgba(0, 0, 0, 0.85) 65%);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    transition: opacity 380ms ease;
}

#scan-backdrop.is-active {
    opacity: 1;
    pointer-events: auto;
}

.scan-backdrop-hint {
    position: absolute;
    left: 50%;
    bottom: 6%;
    transform: translateX(-50%);
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.55rem 1rem;
    border-radius: 999px;
    background: rgba(255, 255, 255, 0.08);
    border: 1px solid rgba(255, 255, 255, 0.12);
    color: rgba(255, 255, 255, 0.78);
    font-size: 0.75rem;
    letter-spacing: 0.08em;
    opacity: 0;
    transition: opacity 280ms ease 320ms;
}

#scan-backdrop.is-active .scan-backdrop-hint { opacity: 1; }

/* Hide scan-only chrome by default — visible only while .is-scan-mode is on. */
.scan-headline,
.scan-beam,
.scan-barcode {
    opacity: 0;
    pointer-events: none;
    transition: opacity 280ms ease;
}

#card-container.is-scan-mode .scan-headline,
#card-container.is-scan-mode .scan-beam,
#card-container.is-scan-mode .scan-barcode {
    opacity: 1;
}

.scan-headline {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    transition-delay: 140ms;
}

.scan-headline-dot {
    width: 8px; height: 8px;
    border-radius: 999px;
    background: #f87171;
    box-shadow: 0 0 12px rgba(248, 113, 113, 0.8);
    animation: scan-pulse 1.1s ease-in-out infinite;
}

@keyframes scan-pulse {
    0%, 100% { transform: scale(1);   opacity: 1;   }
    50%      { transform: scale(1.4); opacity: 0.5; }
}

/* Laser scan beam — slides up and down over the QR while scanning. */
.scan-beam {
    position: absolute;
    left: -6%;
    right: -6%;
    top: 0;
    height: 3px;
    z-index: 20;
    background: linear-gradient(90deg,
        rgba(239, 68, 68, 0) 0%,
        rgba(239, 68, 68, 0.95) 50%,
        rgba(239, 68, 68, 0) 100%);
    box-shadow: 0 0 18px 4px rgba(239, 68, 68, 0.55);
    transform: translateY(0);
}

#card-container.is-scan-mode .scan-beam {
    animation: scan-beam 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite alternate;
}

@keyframes scan-beam {
    0%   { transform: translateY(0);     opacity: 0.4; }
    8%   { opacity: 1; }
    92%  { opacity: 1; }
    100% { transform: translateY(11rem); opacity: 0.4; }
}

/* In scan mode the card is portrait, so we can grow the QR + barcode for
 * easier cashier scanning. */
#card-container.is-scan-mode .scan-qr {
    width:  11rem;
    height: 11rem;
}

/* Faux 1D barcode rendered with repeating-linear-gradients. JS overrides
 * the gradient stops per member_code so each card has its own pattern. */
.scan-barcode {
    width: 11rem;
    height: 28px;
    background:
        repeating-linear-gradient(
            90deg,
            #0f172a 0 2px,
            transparent 2px 4px,
            #0f172a 4px 5px,
            transparent 5px 8px,
            #0f172a 8px 11px,
            transparent 11px 13px);
    border-radius: 4px;
    transition-delay: 200ms;
}

/* The loyalty card itself: when scan-mode is on, the container is pulled
 * into a fixed-position centred frame by JS (via inline left/top/width).
 * The CSS here just adds the polish: deeper shadow, glow, and disabling
 * the natural mouse-tilt while scanning. */
#card-container.is-scan-mode {
    cursor: default;
    filter: drop-shadow(0 30px 60px var(--brand-glow));
}

#card-container.is-scan-mode #loyalty-card {
    transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Reusable transition for the page underlay so each navigation feels smooth */
.page-fade {
    animation: page-fade-in 0.35s ease-out;
}

@keyframes page-fade-in {
    from { opacity: 0; transform: translateY(6px); }
    to   { opacity: 1; transform: translateY(0); }
}

/* ------------------------------------------------------------------ */
/* Magical brand-switch overlay                                        */
/* ------------------------------------------------------------------ */

#brand-switch-overlay {
    position: fixed;
    inset: 0;
    z-index: 100;
    pointer-events: none;
    opacity: 0;
    background:
        radial-gradient(circle at var(--switch-x, 50%) var(--switch-y, 50%),
            var(--switch-primary, #4F46E5) 0%,
            var(--switch-secondary, #7E22CE) 55%,
            #0f172a 100%);
    clip-path: circle(0% at var(--switch-x, 50%) var(--switch-y, 50%));
    transition: clip-path 700ms cubic-bezier(0.83, 0, 0.17, 1), opacity 220ms ease;
}

#brand-switch-overlay.is-expanding {
    opacity: 1;
    pointer-events: auto;
    clip-path: circle(160% at var(--switch-x, 50%) var(--switch-y, 50%));
}

#brand-switch-overlay.is-collapsing {
    opacity: 1;
    pointer-events: none;
    /* The overlay is rendered fully expanded at first paint when arriving
     * from a brand switch, then this class fades it back to invisible. */
    clip-path: circle(0% at var(--switch-x, 50%) var(--switch-y, 50%));
}

.brand-switch-card {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%) scale(0.6);
    text-align: center;
    color: white;
    opacity: 0;
    transition: opacity 320ms ease 180ms, transform 480ms cubic-bezier(0.34, 1.56, 0.64, 1) 180ms;
    pointer-events: none;
}

#brand-switch-overlay.is-expanding .brand-switch-card {
    opacity: 1;
    transform: translate(-50%, -50%) scale(1);
}

.brand-switch-emoji {
    font-size: 5rem;
    line-height: 1;
    margin-bottom: 0.75rem;
    filter: drop-shadow(0 0 24px rgba(255, 255, 255, 0.45));
    animation: brand-switch-pulse 1.2s ease-in-out infinite alternate;
}

.brand-switch-name {
    font-size: 1.5rem;
    font-weight: 700;
    letter-spacing: 0.05em;
}

.brand-switch-tagline {
    margin-top: 0.5rem;
    font-size: 0.9rem;
    color: rgba(255, 255, 255, 0.8);
    max-width: 28ch;
}

@keyframes brand-switch-pulse {
    from { transform: scale(1);    filter: drop-shadow(0 0 18px rgba(255,255,255,0.35)); }
    to   { transform: scale(1.08); filter: drop-shadow(0 0 32px rgba(255,255,255,0.7));  }
}

/* Inline pre-paint state — set by the inline <script> in <head>
 * when arriving from a brand switch. Renders the overlay fully covered
 * so there is no flash of the new brand colors before the iris collapses. */
#brand-switch-overlay.is-arriving {
    opacity: 1;
    clip-path: circle(160% at var(--switch-x, 50%) var(--switch-y, 50%));
}

/* ---------------------------------------------------------------------------
 * Co-branding: optional partner banner + "powered by" credit (settings.COBRAND).
 * ------------------------------------------------------------------------- */
.cobrand-banner {
    width: 100%;
}
.cobrand-banner-media,
.cobrand-banner-placeholder {
    display: block;
    position: relative;
    width: 100%;
    aspect-ratio: 4 / 1;
    border-radius: 1rem;
    overflow: hidden;
}
.cobrand-banner-img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}
/* Tagline rendered by us as an overlay — decoupled from whatever banner
   image file the partner sends, so it always shows regardless of format. */
.cobrand-banner-tagline {
    position: absolute;
    left: 1rem;
    bottom: 0.9rem;
    padding: 0.3rem 0.75rem;
    border-radius: 999px;
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    color: #fff;
    background: rgba(15, 23, 42, 0.55);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    pointer-events: none;
}
.cobrand-banner-tagline-placeholder {
    font-size: 0.75rem;
    font-style: italic;
    color: color-mix(in srgb, var(--brand-primary) 75%, white);
}
.cobrand-banner-placeholder {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.35rem;
    text-align: center;
    border: 2px dashed color-mix(in srgb, var(--brand-primary) 45%, rgba(255, 255, 255, 0.18));
    background:
        radial-gradient(120% 140% at 50% 0%,
            color-mix(in srgb, var(--brand-primary) 16%, transparent),
            transparent 70%),
        rgba(255, 255, 255, 0.02);
}
.cobrand-banner-icon {
    font-size: 1.5rem;
    color: color-mix(in srgb, var(--brand-primary) 70%, white);
    margin-bottom: 0.15rem;
}
.cobrand-banner-title {
    font-size: 0.8rem;
    font-weight: 600;
    letter-spacing: 0.08em;
    color: rgba(255, 255, 255, 0.75);
}
.cobrand-banner-dims {
    font-family: "Space Mono", monospace;
    font-size: 0.7rem;
    color: rgba(255, 255, 255, 0.45);
}

/* "powered by …" credit baked under the QR on the loyalty-card back face. */
.cobrand-card-credit {
    font-size: 8px;
    line-height: 1;
    letter-spacing: 0.16em;
    font-weight: 500;
    color: rgba(15, 23, 42, 0.42);
    padding-top: 1px;
}

/* "Powered by" + partner logo — co-brand watermark on the loyalty-card
   front. The QR/scan back face is intentionally credit-free (checkout screen
   = pure utility, per Nestlé feedback 2026-08). */
.cobrand-card-credit-inline {
    position: absolute;
    right: 1.5rem;
    bottom: 4.3rem;
    z-index: 10;
    display: flex;
    align-items: center;
    gap: 0.35rem;
    pointer-events: none;
}
.cobrand-card-credit-text {
    font-size: 8px;
    letter-spacing: 0.14em;
    font-weight: 500;
    color: rgba(255, 255, 255, 0.72);
    white-space: nowrap;
}
.cobrand-card-logo {
    height: 20px;
    width: auto;
    opacity: 0.82;
}

/* ---------------------------------------------------------------------------
 * Carousels (news & rewards) — scroll-snap track + paging buttons.
 * ------------------------------------------------------------------------- */
.carousel-track {
    display: flex;
    gap: 1rem;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
    padding-bottom: 0.25rem;
}
.carousel-track::-webkit-scrollbar { display: none; }
.carousel-track > * {
    scroll-snap-align: start;
    flex-shrink: 0;
}
.carousel-btn {
    width: 2.25rem;
    height: 2.25rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: 9999px;
    background: rgba(255, 255, 255, 0.06);
    border: 1px solid rgba(255, 255, 255, 0.12);
    color: rgba(255, 255, 255, 0.8);
    transition: background 0.2s, opacity 0.2s;
}
.carousel-btn:hover:not(:disabled) {
    background: color-mix(in srgb, var(--brand-primary) 30%, transparent);
}
.carousel-btn:disabled { opacity: 0.35; cursor: default; }

/* News & campaigns cards */
.news-card {
    flex: 0 0 clamp(240px, 80vw, 300px);
    scroll-snap-align: start;
    display: flex;
    flex-direction: column;
    border-radius: 1rem;
    overflow: hidden;
    background: rgba(255, 255, 255, 0.04);
    border: 1px solid rgba(255, 255, 255, 0.08);
    text-decoration: none;
    color: inherit;
    transition: border-color 0.2s, transform 0.2s;
}
a.news-card:hover {
    border-color: color-mix(in srgb, var(--brand-primary) 55%, transparent);
    transform: translateY(-2px);
}
.news-card-media {
    aspect-ratio: 16 / 9;
    position: relative;
    overflow: hidden;
    display: flex;
    align-items: center;
    justify-content: center;
    background: linear-gradient(135deg,
        color-mix(in srgb, var(--brand-primary) 70%, transparent),
        color-mix(in srgb, var(--brand-secondary) 70%, transparent));
    color: rgba(255, 255, 255, 0.85);
    font-size: 2rem;
}
.news-card-media img { width: 100%; height: 100%; object-fit: cover; }
.news-card-body {
    padding: 1rem 1.1rem 1.2rem;
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
}
.news-card-body h3 { font-weight: 600; font-size: 1rem; color: #fff; }
.news-card-body p { font-size: 0.8rem; color: rgba(255, 255, 255, 0.55); line-height: 1.4; }
.news-card-cta {
    margin-top: 0.35rem;
    font-size: 0.8rem;
    font-weight: 600;
    color: var(--brand-readable);
}

/* ---------------------------------------------------------------------------
 * Mobile bottom tab bar — native-app style primary navigation (md:hidden).
 * ------------------------------------------------------------------------- */
.mobile-bottom-nav {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 40;
    display: flex;
    align-items: stretch;
    justify-content: space-around;
    background: rgba(15, 23, 42, 0.92);
    backdrop-filter: blur(18px) saturate(140%);
    -webkit-backdrop-filter: blur(18px) saturate(140%);
    box-shadow: 0 -10px 30px -16px rgba(0, 0, 0, 0.6);
    padding-bottom: env(safe-area-inset-bottom, 0px);
}

.mobile-bottom-nav a {
    position: relative;
    flex: 1 1 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 3px;
    padding: 0.5rem 0 0.45rem;
    font-size: 10px;
    font-weight: 500;
    color: rgba(148, 163, 184, 0.85);
    transition: color 0.18s ease, transform 0.12s ease;
    -webkit-tap-highlight-color: transparent;
}

.mobile-bottom-nav a i {
    font-size: 17px;
    line-height: 1;
}

.mobile-bottom-nav a:active {
    transform: scale(0.9);
}

/* --brand-readable, not --brand-primary: raw brand red on the bar sits under
   AA for text, so the *selected* tab would be the hardest one to read. The
   readable mix clears AAA on the same bar. */
.mobile-bottom-nav a.is-active {
    color: var(--brand-readable);
}

.mobile-bottom-nav a.is-active i {
    filter: drop-shadow(0 0 9px color-mix(in srgb, var(--brand-readable) 55%, transparent));
}

/* Active-tab indicator pill along the top edge of the bar. */
.mobile-bottom-nav a.is-active::before {
    content: "";
    position: absolute;
    top: 0;
    width: 26px;
    height: 3px;
    border-radius: 0 0 3px 3px;
    /* Non-text contrast (WCAG 1.4.11) needs 3:1; the raw brand blue gave 1.9:1. */
    background: var(--brand-readable);
    box-shadow: 0 0 10px color-mix(in srgb, var(--brand-readable) 60%, transparent);
}

/* Soft fade above the bar so scrolling content dissolves instead of hard-cutting
 * against the bar's top edge. */
.mobile-bottom-nav::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    bottom: 100%;
    height: 56px;
    pointer-events: none;
    background: linear-gradient(to top,
        rgba(15, 23, 42, 0.92) 0%,
        rgba(15, 23, 42, 0.62) 32%,
        rgba(15, 23, 42, 0) 100%);
}

/* Scroll clearance so the last content always clears the fixed bottom bar. */
@media (max-width: 767px) {
    main {
        padding-bottom: calc(3.75rem + env(safe-area-inset-bottom, 0px));
    }
}

/* ---------------------------------------------------------------------------
 * Animated announcements banner — admin-managed, rotates at the top (mobile).
 * ------------------------------------------------------------------------- */
.announce-banner {
    position: sticky;
    top: 0;
    z-index: 30;
    flex-shrink: 0; /* it's a flex child of <main>; its items are absolute → don't collapse */
    /* Extend the banner bg up into the notch (standalone PWA) so the colour
     * continues seamlessly instead of a bright break. */
    padding-top: env(safe-area-inset-top, 0px);
    background:
        linear-gradient(90deg,
            color-mix(in srgb, var(--brand-primary) 24%, #0f172a),
            color-mix(in srgb, var(--brand-secondary) 20%, #0f172a));
    border-bottom: 1px solid color-mix(in srgb, var(--brand-primary) 32%, transparent);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
}

.announce-track {
    position: relative;
    height: 46px;
    overflow: hidden;
}

.announce-item {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0 1rem;
    font-size: 13px;
    font-weight: 500;
    color: #f8fafc;
    text-decoration: none;
    opacity: 0;
    transform: translateY(7px);
    transition: opacity 0.5s ease, transform 0.5s ease;
    pointer-events: none;
}

.announce-item.is-active {
    opacity: 1;
    transform: translateY(0);
    pointer-events: auto;
}

.announce-emoji {
    font-size: 15px;
    line-height: 1;
    flex-shrink: 0;
    filter: drop-shadow(0 0 6px color-mix(in srgb, var(--brand-primary) 45%, transparent));
}

.announce-text {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    letter-spacing: 0.005em;
}

.announce-arrow {
    font-size: 10px;
    opacity: 0.75;
    flex-shrink: 0;
}

@media (prefers-reduced-motion: reduce) {
    .announce-item { transition: opacity 0.2s ease; transform: none; }
}

/* ---------------------------------------------------------------------------
 * Intro splash — branded once-per-session loading screen (tiled brand
 * wordmark + animated logo, then scales/fades to reveal the app).
 * ------------------------------------------------------------------------- */
:root[data-splash-done="1"] .app-splash { display: none !important; }

.app-splash {
    position: fixed;
    inset: 0;
    z-index: 200;
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    background: radial-gradient(circle at 50% 38%,
        color-mix(in srgb, var(--brand-primary) 22%, #050a14) 0%,
        color-mix(in srgb, var(--brand-primary) 9%, #04070e) 45%,
        #05090c 100%);
}
.app-splash.is-hidden { display: none; }
.app-splash.is-dismissing { animation: splash-out 0.78s cubic-bezier(0.4, 0, 0.2, 1) forwards; }
@keyframes splash-out { to { opacity: 0; visibility: hidden; } }

/* Tiled brand wordmark — now actually legible (was 17% → invisible). A radial
   mask keeps it faint behind the logo and lets it read towards the edges. */
.app-splash-pattern {
    position: absolute;
    inset: 0;                       /* viewport-box so the mask %s land on-screen */
    font-family: var(--brand-font-heading, "Geologica Brand", system-ui, sans-serif);
    font-weight: 800;
    font-size: clamp(3rem, 12vw, 6rem);
    line-height: 1.05;
    letter-spacing: -0.02em;
    /* Lightened brand accent so it reads against the dark brand backdrop —
       accent-on-accent is near-zero contrast. */
    color: color-mix(in srgb, var(--brand-primary) 42%, #ffffff);
    transform: rotate(-16deg) scale(1.4);   /* scale covers the corners the rotation exposes */
    user-select: none;
    pointer-events: none;
    -webkit-mask-image: radial-gradient(circle at 50% 42%, transparent 0%, transparent 17%, #000 54%);
            mask-image: radial-gradient(circle at 50% 42%, transparent 0%, transparent 17%, #000 54%);
    /* settles fast — must finish well before the 1.25s dismiss (was 1.5s → never full) */
    animation: splash-pattern-in 0.6s ease-out forwards;
}
@keyframes splash-pattern-in {
    from { opacity: 0;    transform: rotate(-16deg) scale(1.52); }
    to   { opacity: 0.14; transform: rotate(-16deg) scale(1.4); }
}
.app-splash.is-dismissing .app-splash-pattern {
    animation: splash-pattern-out 0.78s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes splash-pattern-out {
    to { opacity: 0; transform: rotate(-16deg) scale(1.9); }
}

/* Soft brand halo behind the logo — fills the void, adds depth. */
.app-splash-glow {
    position: absolute;
    top: 42%; left: 50%;
    width: 560px; height: 560px;
    transform: translate(-50%, -50%);
    background: radial-gradient(circle,
        color-mix(in srgb, var(--brand-primary) 60%, transparent) 0%,
        transparent 66%);
    filter: blur(30px);
    z-index: 1;
    pointer-events: none;
    animation: splash-glow-pulse 3.2s ease-in-out infinite;
}
@keyframes splash-glow-pulse {
    0%, 100% { opacity: 0.42; transform: translate(-50%, -50%) scale(0.94); }
    50%      { opacity: 0.62; transform: translate(-50%, -50%) scale(1.04); }
}

.app-splash-center {
    position: relative;
    z-index: 2;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1.35rem;
    animation: splash-center-in 0.7s ease-out both;
}
@keyframes splash-center-in {
    from { opacity: 0; transform: translateY(10px) scale(0.96); }
    to   { opacity: 1; transform: translateY(0) scale(1); }
}
.app-splash-logo {
    width: 118px;
    height: auto;
    filter: drop-shadow(0 16px 32px rgba(0, 0, 0, 0.55));
    animation: splash-logo-bob 1.7s ease-in-out infinite;
}
@keyframes splash-logo-bob {
    0%, 100% { transform: translateY(0); }
    50%      { transform: translateY(-9px); }
}
/* No wordmark rule here any more: the logo image carries the name, and a
   second copy in live type sat directly beneath it. The tagline keeps its
   own slot — it says something the logo does not. */
.app-splash-tag {
    font-family: var(--brand-font-heading, "Geologica Brand", system-ui, sans-serif);
    font-weight: 700;
    font-size: 0.72rem;
    letter-spacing: 0.62em;
    text-indent: 0.62em;
    color: var(--brand-readable);
    opacity: 0.92;
}
/* Determinate loading bar — reads as "loading" far better than 3 dots. */
.app-splash-bar {
    position: relative;
    width: 150px;
    height: 4px;
    border-radius: 999px;
    background: color-mix(in srgb, var(--brand-primary) 16%, transparent);
    overflow: hidden;
    margin-top: 0.35rem;
}
.app-splash-bar span {
    position: absolute;
    inset: 0;
    border-radius: 999px;
    background: linear-gradient(90deg, var(--brand-primary), var(--brand-secondary));
    transform-origin: left center;
    transform: scaleX(0);
    animation: splash-bar-fill 1.25s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes splash-bar-fill {
    0%   { transform: scaleX(0); }
    70%  { transform: scaleX(0.82); }
    100% { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
    .app-splash-logo, .app-splash-glow, .app-splash-pattern,
    .app-splash-center, .app-splash-bar span { animation-duration: 0.01ms; animation-iteration-count: 1; }
    .app-splash-bar span { transform: scaleX(1); }
}

/* Native file inputs (e.g. the menu-item photo field) otherwise render as a
   stark OS-default button that clashes with every other dark, rounded input
   on the same form. */
input[type="file"]::file-selector-button {
    margin-right: 0.75rem;
    padding: 0.375rem 0.75rem;
    border-radius: 0.5rem;
    border: 1px solid rgba(255, 255, 255, 0.10);
    background: rgba(255, 255, 255, 0.08);
    color: #f8fafc;
    font-size: 0.75rem;
    cursor: pointer;
}
input[type="file"]::file-selector-button:hover {
    background: rgba(255, 255, 255, 0.14);
}

/* Password show/hide toggle (wired by static/js/password-toggle.js).
   The button sits inside the field, so the input needs room on the right for
   it; 2.75rem keeps the 44px touch target from ever overlapping the text. */
.password-field {
    position: relative;
    display: block;
}

.password-field > input {
    padding-right: 2.75rem;
}

.password-toggle {
    position: absolute;
    top: 0;
    right: 0;
    height: 100%;
    width: 2.75rem;
    display: flex;
    align-items: center;
    justify-content: center;
    background: none;
    border: 0;
    cursor: pointer;
    color: rgba(148, 163, 184, 0.85);
    border-radius: 0 0.75rem 0.75rem 0;
}

.password-toggle:hover {
    color: #f8fafc;
}

.password-toggle:focus-visible {
    outline: 2px solid var(--brand-secondary);
    outline-offset: -2px;
}

/* ---------------------------------------------------------------------------
 * Reduced motion — the rest of it.
 *
 * The splash and the announcement ticker were already handled where they are
 * defined; everything else was not, and two of those run forever: the drifting
 * background blobs on every authenticated page, and the scanner's laser beam.
 * Continuous movement is the specific thing that provokes nausea and dizziness
 * in vestibular disorders, so an unattended infinite animation is the worst
 * kind to leave uncovered.
 *
 * The scanner's cues are stopped rather than deleted: the dot keeps its red
 * glow and the beam stays parked over the QR, so the cashier can still see the
 * reader is live — the information survives, only the movement stops. Entrance
 * animations are collapsed to nothing, which lands the element exactly where it
 * was going.
 * ------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
    .blob,
    .scan-headline-dot,
    #card-container.is-scan-mode .scan-beam,
    .brand-switch-emoji,
    .page-fade,
    .messages-toast,
    .announce-item {
        animation: none !important;
    }

    /* The beam is positioned by the animation alone, so with it removed the
       element has to be placed by hand or it sits at the very top edge. */
    #card-container.is-scan-mode .scan-beam {
        transform: translateY(5.5rem);
        opacity: 0.9;
    }

    /* Tailwind ships these as plain utilities with no reduced-motion variant,
       so the shimmer on the tier progress bar kept looping. They are purely
       decorative — nothing is communicated that the static element does not
       already say. */
    .animate-pulse, .animate-bounce, .animate-ping {
        animation: none !important;
    }

    /* A spinner is the exception: it is not decoration, it is the only signal
       that the app is still working. Stopping it dead reads as a hang, so it is
       slowed to a crawl instead of removed. */
    .animate-spin {
        animation-duration: 3s !important;
    }
}

/* ------------------------------------------------------------------ */
/* Provider sign-in — the buttons above the auth form                  */
/* ------------------------------------------------------------------ */

/* Ghost-styled rather than filled, so neither provider outranks the form
   below them: the email-and-password route is still the main one, and a
   solid white Google button would read as the primary action on a page
   whose primary action is the member's own account. */
.social-auth {
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
    margin-bottom: 1.25rem;
}

.social-auth-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.65rem;
    /* 48px tall: these are the first controls on the page on a phone and the
       rest of the auth card is already sized to the same target. */
    min-height: 48px;
    padding: 0.75rem 1rem;
    border-radius: 0.75rem;
    border: 1px solid rgba(255, 255, 255, 0.14);
    background: rgba(255, 255, 255, 0.05);
    color: #f8fafc;
    font-size: 0.8125rem;
    font-weight: 600;
    /* The labels are already uppercase Greek written out by greek_upper, so no
       letter-spacing trick and above all no text-transform: capitals in Greek
       carry no tonos and CSS uppercasing puts it back. */
    letter-spacing: 0.08em;
    text-decoration: none;
    transition: background-color 0.18s ease, border-color 0.18s ease;
}

.social-auth-btn:hover,
.social-auth-btn:focus-visible {
    background: rgba(255, 255, 255, 0.09);
    border-color: rgba(255, 255, 255, 0.24);
}

.social-auth-mark {
    width: 1.15rem;
    height: 1.15rem;
    flex-shrink: 0;
}

/* Apple's mark is a single filled path and inherits the label colour; Google's
   carries its own four. Nothing else in the button is tinted per provider. */
.social-auth-btn[data-provider="apple"] .social-auth-mark {
    color: #f8fafc;
}

.social-auth-divider {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    margin-top: 0.5rem;
    color: #64748b;
    font-size: 0.6875rem;
    letter-spacing: 0.12em;
}

.social-auth-divider::before,
.social-auth-divider::after {
    content: "";
    flex: 1;
    height: 1px;
    background: rgba(255, 255, 255, 0.08);
}

/* The identity the provider just confirmed, shown on the consent page. It is
   read-only on purpose — everything the member can still decide sits below it
   in real form controls. */
.social-identity {
    border: 1px solid rgba(255, 255, 255, 0.08);
    background: rgba(255, 255, 255, 0.04);
    border-radius: 0.75rem;
    padding: 0.85rem 1rem;
}

/* Disabled until the terms box is ticked. The attribute is set by script, so
   this rule only ever paints a state the member can undo in one click. */
.social-auth [disabled],
button[data-consent-submit][disabled] {
    opacity: 0.45;
    cursor: not-allowed;
}

.social-linked-dot {
    width: 0.5rem;
    height: 0.5rem;
    border-radius: 9999px;
    display: inline-block;
    background: #94a3b8;
}

.social-linked-dot[data-provider="google"] {
    background: #4285F4;
    box-shadow: 0 0 8px rgba(66, 133, 244, 0.5);
}
