/*
 * V3 "Ivory & Navy" - core stylesheet
 * Tokens, base rules, header, footer, shared .v3-card component.
 * Loaded on every V3 page. Ported from the approved home-v3.html /
 * product-detail-v3.html mockups (commit a51b654).
 * See public/css/v3/home.css and product.css for page-specific rules.
 */

i, .fa, .fas, .far, .fal, .fab, .fad, .fat,
[class^="fa-"], [class*=" fa-"] {
    font-family: "Font Awesome 6 Free", "Font Awesome 6 Pro", inherit !important;
}

/* ============================================================
   TOKENS - "Ivory & Navy"
   Ivory/sand/navy/gold pulled from the real brand, not invented:
   the logo mark (logo-circle.svg) is navy #2c2d5e; production's own
   01-tokens.css carries #272360 and a gold in the same family. Role
   split is the whole point of this pass - v2 used one indigo for
   both buttons AND prices, so neither read as meaning anything.
   Here: navy = structure (nav, CTAs, footer). Gold = money (live
   prices only). Never swapped.
   Light is the page's home; dark is a full second pass, not an
   afterthought - toggle in the header, respects system preference
   on first visit (see script at the bottom).
   ============================================================ */
:root {
    --page: #FBF8F2;
    --panel: #F2EDE3;
    --plate: #FFFFFF;
    --ink: #1B1F2E;
    --ink-soft: #5A5F70;
    /* .v3-select chevron, drawn in --ink-soft (url() can't read a var). */
    --select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%235A5F70' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
    --accent: #2C2D5E;
    --accent-text: #FBF8F2;
    --gold: #8A6D14;
    --line: #E3DCCC;
    --up: #2E6B4F;
    --down: #9A3B2E;
    --shadow: rgba(27, 31, 46, .14);
    --focus: var(--accent);
    /* Fixed, theme-independent - the header price strip. --accent flips
       luminance between modes (dark navy in light mode, brightened
       indigo in dark mode, so it can double as both link-text AND
       button-fill), which broke the strip when it was wired to
       var(--accent): gold/up/down all measured under 3:1 against it in
       BOTH modes (checked with a WCAG sweep after a contrast question -
       #8A6D14 on #2C2D5E was 2.60:1, not the "3:1+" originally assumed
       here). A strip that never changes background needs its own
       colors verified against that one fixed background, not two. */
    --strip-bg: #2C2D5E;
    --strip-text: #FBF8F2;
    --strip-gold: #D6AC29;
    --strip-up: #5FCB8A;
    --strip-down: #F08A7E;
}
html.dark {
    --page: #15161A;
    --panel: #1D1E23;
    --plate: #24252B;
    --ink: #EFEBE1;
    --ink-soft: #A9A69E;
    /* .v3-select chevron, drawn in --ink-soft (url() can't read a var). */
    --select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%23A9A69E' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
    /* Lifted from the light mode's #2C2D5E: the raw logo navy only
       reached ~2.5:1 against this dark page, which fails as a focus
       ring / link color. This value clears 3.2:1 against the page
       (WCAG 1.4.11 non-text minimum) and 5:1 for --accent-text sat on
       top of it as a button fill - verified via a WCAG contrast sweep,
       see BSM_REWRITE_SPEC-adjacent design notes for the numbers. */
    --accent: #5A5CC0;
    --accent-text: #F4F1EA;
    --gold: #C9A227;
    --line: #2E2F36;
    --up: #5FA783;
    --down: #D9705F;
    --shadow: rgba(0, 0, 0, .45);
    --focus: #8385D6;
}

* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
/* CSS Scroll Anchoring (on by default) silently adjusts window.scrollY
   whenever off-screen layout above the viewport changes size - exactly
   what .v3-announce-bar's scroll-hide toggle does to the sticky header
   above the fold. That produced a real scrollY jump (confirmed: hiding
   an ~85px-tall bar at scrollY 150 silently dropped it to ~74, no user
   input involved). public/js/v3/core.js's announce-bar hide/show logic
   applies its own deterministic compensation instead (see that file's
   own comment on why) - disabling anchoring here stops the browser from
   *also* nudging scrollY on its own unpredictable schedule, which would
   otherwise fight that compensation. */
html { overflow-anchor: none; }

/* Themed scrollbar - Firefox via scrollbar-color, everyone else via
   ::-webkit-scrollbar. Tokens only, so it flips with dark mode for free
   like everything else on the page. Dark tokens are overridden on
   html.dark (the anti-FOUC inline script in layouts/v3.php sets that class
   before first paint; core.js keeps it in sync on toggle) - custom
   properties inherit downward, so body (declared here) picks them up fine
   without needing its own .dark variant. Browsers propagate a scrolling
   body's scrollbar styling to the viewport scrollbar, so this still themes
   the actual page scrollbar, not just body's own box. This is the page's
   own scrollbar, not one of the horizontal scroll-snap rails
   (.v3-scroll-rail etc), which already hide their scrollbar entirely. */
body {
    scrollbar-width: thin;
    scrollbar-color: var(--line) transparent;
}
body::-webkit-scrollbar { width: 10px; height: 10px; }
body::-webkit-scrollbar-track { background: transparent; }
body::-webkit-scrollbar-thumb { background-color: var(--line); border-radius: 999px; border: 2px solid var(--page); background-clip: padding-box; }
body::-webkit-scrollbar-thumb:hover { background-color: var(--ink-soft); }
body {
    margin: 0;
    background: var(--page);
    color: var(--ink);
    font-family: Manrope, sans-serif;
    -webkit-font-smoothing: antialiased;
    transition: background-color .25s ease, color .25s ease;
}
img { max-width: 100%; display: block; }
a { color: inherit; }
h1, h2, h3, .v3-serif { font-family: Newsreader, serif; font-weight: 400; margin: 0; }
em, i.v3-em { font-style: italic; }
/* italic descender clearance - a tight line-height on display serif clips
   the tail of y/g/j/p/q in italic; this reserves room without touching
   the line-height everywhere else. */
h1 em, h2 em, h3 em { line-height: 1.12; padding-bottom: 4px; display: inline-block; }
.v3-inner { max-width: 1200px; margin: 0 auto; padding: 0 24px; }
.v3-tnum { font-variant-numeric: tabular-nums; }
/* Cents match the whole-number color (not dimmed) at a smaller size -
   same treatment as the legacy site's .decjjj/.deciii price split
   (BSM/src/bsm/css/css.css), swapped in per explicit request over this
   file's previous opacity-based approach. */
/* Cents render at half the integer size, raised so their top sits level with
   the top of the integer digits (not on the baseline, which looked
   bottom-aligned; not `top`, which floated them above the digits). */
.v3-cents { font-size: .65em; vertical-align: .32em; }
/* Visually hidden but still announced/focusable - standard clip pattern. */
.v3-vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }

@media (prefers-reduced-motion: reduce) {
    * { animation-duration: .001ms !important; animation-delay: 0s !important; transition-duration: .001ms !important; scroll-behavior: auto !important; }
}

a:focus-visible, button:focus-visible {
    outline: 2px solid var(--focus);
    outline-offset: 3px;
    border-radius: 2px;
}

/* ============================================================
   HEADER
   ============================================================ */
/* Sticky on the whole header, not just the nav row - nav-row alonesat
   inside a containing block (this header) barely taller than itself,
   so it had only a few px of "room" before running out of container
   and un-sticking again. Parking the sticky here instead gives it the
   whole page to stay pinned against. */
.v3-header { font-family: Manrope, sans-serif; position: sticky; top: 0; z-index: 60; }
.v3-header * { box-sizing: border-box; }

/* Row 1 - price strip. Navy, not ivory: the one place a bright band
   sits above the fold, so the "live price" fact reads as the site's
   pulse, not just another nav row. */
.v3-price-strip { background: var(--strip-bg); }
.v3-price-strip-row {
    display: flex; align-items: center; gap: 20px; height: 34px;
    font-size: 12.5px; color: var(--strip-text);
    overflow-x: auto; white-space: nowrap; scrollbar-width: none;
}
.v3-price-strip-row::-webkit-scrollbar { display: none; }
.v3-price-loc { letter-spacing: .01em; }
.v3-price-sep { width: 1px; height: 14px; background: rgba(255,255,255,.25); flex: none; }
.v3-price-item { color: var(--strip-text); text-decoration: none; display: inline-flex; align-items: center; gap: 5px; }
.v3-price-item b { color: var(--strip-gold); font-weight: 700; }
/* Gold sits on a fixed navy strip here, not on ivory/sand/page - a
   different contrast pair than the "gold on panel" case, and one that
   needed its own tuned value: #D6AC29 clears 5.95:1 against #2C2D5E,
   where the page's normal --gold only reached ~2.6:1. */
.v3-price-item:hover { text-decoration: underline; }
.v3-dir { font-size: 9px; }

/* Row 2 - brand row */
.v3-brand-row { background: var(--page); border-bottom: 1px solid var(--line); }
.v3-brand-inner { display: flex; align-items: center; gap: 20px; padding: 22px 0; position: relative; }
.v3-burger { display: none; background: none; border: 0; color: var(--ink); font-size: 20px; padding: 4px; cursor: pointer; }
.v3-logo-lockup {
    display: flex; flex-direction: column; align-items: center; text-decoration: none;
    position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
}
/* height:auto + max-height (not a fixed height) - a fixed height combined
   with the global `img { max-width: 100% }` rule is what caused the skew
   bug: once a tight mobile brand-actions row forced this image narrower
   than its natural width, max-width clamped the width but a FIXED height
   doesn't get recomputed to match (per the CSS auto-sizing algorithm, that
   only happens when height is 'auto') - width visibly compresses while
   height stays put. With height:auto instead, the browser shrinks both
   dimensions together to fit whichever of max-height/max-width is hit
   first, so the logo can get smaller on very narrow screens but never
   distorts. */
.v3-logo-img { height: auto; max-height: 30px; width: auto; max-width: 100%; display: block; }
/* Desktop only, mirroring the 1023px threshold the header's own mobile
   block below switches on. .v3-logo-lockup is position:absolute here
   (centered via top/left 50% + translate), so it's out of normal flow -
   growing the logo doesn't grow .v3-brand-inner's own height (still set
   by .v3-brand-actions' flex content + its 22px/22px padding), so the
   header's overall height is untouched by this. */
@media (min-width: 1024px) {
    .v3-logo-img { max-height: 48px; }
}
.v3-brand-actions { display: flex; align-items: center; gap: 14px; margin-left: auto; }
/* Dark / light switch -- pill toggle ported from the legacy storefront's
   .switch (BSM/src/bsm/css/icon.css): a circle slides inside a track, showing
   a sun in light mode and a moon in dark mode, with a small accent bar that
   slides behind it. Same 46x24 / 18px circle geometry as the original. */
.v3-theme-toggle {
    --sw-w: 46px; --sw-h: 24px; --sw-circle: 18px;
    --sw-offset: calc((var(--sw-h) - var(--sw-circle)) / 2);
    --sw-ease: all .2s cubic-bezier(.27, .2, .25, 1.51);
    display: inline-flex; align-items: center; cursor: pointer;
}
.v3-theme-toggle input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.v3-theme-slider {
    box-sizing: border-box; width: var(--sw-w); height: var(--sw-h);
    background: var(--panel); border: 1px solid var(--line); border-radius: 999px;
    display: flex; align-items: center; position: relative; transition: var(--sw-ease);
}
.v3-theme-effect {
    position: absolute; width: calc(var(--sw-circle) / 2); height: calc(var(--sw-circle) / 4 - 1px);
    left: calc(var(--sw-offset) + var(--sw-circle) / 4);
    background: var(--up); border-radius: 1px; transition: var(--sw-ease);
}
.v3-theme-circle {
    position: absolute; left: var(--sw-offset); z-index: 1;
    width: var(--sw-circle); height: var(--sw-circle); border-radius: 50%;
    background: #fff; box-shadow: 1px 1px 2px rgba(146, 146, 146, .45);
    display: flex; align-items: center; justify-content: center; transition: var(--sw-ease);
}
.v3-theme-circle i {
    position: absolute; font-size: 10px; color: var(--accent); transition: var(--sw-ease);
}
.v3-theme-moon { transform: scale(0); }
.v3-theme-toggle input:checked + .v3-theme-slider { background: rgb(63, 62, 62); border-color: rgb(63, 62, 62); }
.v3-theme-toggle input:checked + .v3-theme-slider .v3-theme-circle {
    left: calc(100% - var(--sw-circle) - var(--sw-offset));
    box-shadow: -1px 1px 2px rgba(163, 163, 163, .45);
}
.v3-theme-toggle input:checked + .v3-theme-slider .v3-theme-effect {
    left: calc(100% - var(--sw-circle) / 2 - var(--sw-circle) / 4 - var(--sw-offset));
}
.v3-theme-toggle input:checked + .v3-theme-slider .v3-theme-sun { transform: scale(0); }
.v3-theme-toggle input:checked + .v3-theme-slider .v3-theme-moon { transform: scale(1); }
.v3-theme-toggle:hover .v3-theme-slider,
.v3-theme-toggle:has(input:focus-visible) .v3-theme-slider { border-color: var(--accent); }
.v3-icon-link { color: var(--ink); font-size: 17px; text-decoration: none; display: inline-flex; position: relative; }
.v3-icon-link:hover { color: var(--accent); }
/* WhatsApp's official brand green (#25D366), not the header's navy ink -
   this icon should read as WhatsApp at a glance, same as the fa-whatsapp
   glyph anywhere else on the site. */
.v3-whatsapp-link { color: #25D366; }
.v3-whatsapp-link:hover { color: #1DA851; }
.v3-cart-icon-wrap { position: relative; display: inline-flex; }
.v3-cart-count {
    /* red, not --accent: --accent (#2C2D5E) is almost the same navy as the
       cart icon's --ink (#1B1F2E), so the badge and its digit disappeared into
       the icon. A red notification badge reads clearly against both the navy
       icon and the cream header, in both themes. */
    position: absolute; top: -10px; right: -7px; background: var(--down); color: #fff;
    font-size: 9px; font-weight: 700; line-height: 1; border-radius: 8px;
    min-width: 15px; height: 15px; padding: 0 3px; display: flex; align-items: center; justify-content: center;
}
.v3-vr { width: 1px; height: 20px; background: var(--line); }
.v3-text-link { color: var(--ink); font-size: 13px; text-decoration: none; display: inline-flex; align-items: center; font-weight: 500; }
.v3-text-link:hover { color: var(--accent); }
.v3-login-icon { display: none; font-size: 17px; }

/* Member account menu (logged-in state) - a native <details>/<summary>
   disclosure, not a bootstrap dropdown (V3 loads no bootstrap). */
.v3-member-menu { position: relative; }
.v3-member-menu summary { list-style: none; cursor: pointer; }
.v3-member-menu summary::-webkit-details-marker { display: none; }
.v3-member-menu-list {
    position: absolute; top: calc(100% + 10px); right: 0; z-index: 70;
    background: var(--plate); border: 1px solid var(--line); border-radius: 10px;
    box-shadow: 0 12px 28px var(--shadow); min-width: 200px; padding: 8px; margin: 0; list-style: none;
}
.v3-member-menu-list li a {
    display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: 6px;
    color: var(--ink); font-size: 13px; text-decoration: none;
}
.v3-member-menu-list li a:hover { background: var(--panel); color: var(--accent); }
.v3-member-menu-list li a i { width: 14px; text-align: center; color: var(--ink-soft); }
/* Logout is the one destructive/exit action in this menu - --down is the
   same red token .v3-btn-danger and the cart-count badge already use for
   "this ends something", so it's consistent with the rest of the site's
   color language rather than a one-off red. */
.v3-member-menu-list li a i.v3-member-menu-icon-logout { color: var(--down); }
.v3-member-menu-divider { height: 1px; background: var(--line); margin: 6px 2px; }
/* Hidden by default (desktop already shows the name via .v3-login-text
   next to the trigger) - shown only at the <=640px breakpoint below,
   alongside .v3-login-text's own hide. */
.v3-member-menu-welcome { display: none; padding: 6px 10px 2px; font-size: 13px; font-weight: 700; color: var(--accent); }
/* Hidden by default too, same as .v3-member-menu-welcome above (and for the
   same reason) - without this it's a stray divider line with nothing above
   it once the welcome text it separates is hidden on desktop. */
.v3-member-menu-welcome-divider { display: none; }
/* Filled Login button - desktop only (min-width mirrors the 640px mobile
   breakpoint below), so on phones Login stays the plain icon-only
   .v3-text-link it always was. Sign Up is the plain text link beside it. */
@media (min-width: 641px) {
.v3-btn-login {
    font-size: 13px; font-weight: 700; color: var(--accent-text); background: var(--accent);
    padding: 8px 16px; border-radius: 8px; text-decoration: none; white-space: nowrap; flex-shrink: 0;
    transition: filter .2s ease, transform .15s ease;
}
/* filter, not background: var(--ink) - --ink flips from dark-navy
   (light mode) to light-cream (dark mode), so a hover state built on
   "swap fill to --ink" went cream-on-cream in dark mode, hiding the
   label entirely. Darkening the EXISTING fill by a fixed ratio keeps
   accent-text's contrast intact in both themes since it never has to
   guess what color the token resolves to on the other side. */
/* color re-set: .v3-text-link:hover would otherwise turn the label --accent on an --accent fill. */
.v3-btn-login:hover { filter: brightness(.82); color: var(--accent-text); }
.v3-btn-login:active { transform: translateY(1px); }
}

/* Row 3 - announcement bar. Per-announcement colors come from the admin
   (kept as inline --ann-cb/--ann-cf custom properties, same mechanism
   production and v2 both use) - only the typeface changed here. Each
   announcement also carries a --ann-cb-dark/--ann-cf-dark pair
   (components/v3/header.php, falls back to the light pair when the
   admin never configured a dark variant), swapped in below - same
   light/dark-per-announcement contract the legacy announcement bar has
   (AnnouncementService::announcementHtml()'s dark_color_bg/
   dark_color_font), V3 just switches on html.dark instead of
   body.light-mode. */
.v3-announce { background: var(--ann-cb); color: var(--ann-cf); }
html.dark .v3-announce { background: var(--ann-cb-dark); color: var(--ann-cf-dark); }
.v3-announce-inner { display: flex; align-items: center; gap: 8px; padding: 8px 16px; }
/* span for a plain announcement, a for one with a URL (header.php) - both
   need the same centering, or the linked variant falls back to default
   inline sizing/left alignment since it doesn't match a span-only rule. */
.v3-announce-inner span, .v3-announce-inner a { flex: 1; min-width: 0; text-align: center; font-size: 14px; letter-spacing: .01em; text-decoration: none; }
.v3-announce-close { flex: 0 0 auto; background: none; border: 0; color: inherit; opacity: .7; cursor: pointer; font-size: 18px; line-height: 1; padding: 4px 8px; }
.v3-announce-close:hover { opacity: 1; }
.v3-announce.v3-announce-hidden { display: none; }
.v3-announce-bar.v3-scroll-hide { display: none; }

/* Row 4 - primary nav. Single line, capped height (rule: no agency-style
   nav bars eating the viewport). */
.v3-nav-row { background: var(--page); border-bottom: 1px solid var(--line); }
.v3-nav-list { list-style: none; margin: 0; padding: 0; display: flex; justify-content: center; gap: 30px; }
.v3-nav-list > li { position: relative; }
.v3-nav-list li a {
    display: inline-block; padding: 14px 0; color: var(--ink); text-decoration: none;
    font-size: 14px; font-weight: 500; letter-spacing: .01em; border-bottom: 2px solid transparent;
}
.v3-nav-list li a:hover { color: var(--accent); border-bottom-color: var(--accent); }
.v3-nav-sub {
    display: none; position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
    background: var(--plate); border: 1px solid var(--line); border-top: 2px solid var(--accent);
    list-style: none; margin: 0; padding: 6px 0; min-width: 220px; z-index: 61;
    box-shadow: 0 12px 28px var(--shadow);
}
.v3-has-sub:hover .v3-nav-sub, .v3-has-sub:focus-within .v3-nav-sub { display: block; }
.v3-nav-sub a {
    display: block !important; padding: 9px 18px !important; border-bottom: 0 !important;
    color: var(--ink) !important; font-size: 13px !important; font-weight: 400 !important; white-space: nowrap;
}
.v3-nav-sub a:hover { color: var(--accent) !important; background: var(--panel); }

@media (max-width: 1023px) {
    /* Mobile-only, matching the legacy site's #container_header (box-shadow
       zeroed out again above its own 900px breakpoint - BSM/src/bsm/css/
       css.css:1863/4794). Nav-row collapses into the burger menu here, so
       .v3-header's visible bottom edge is the brand row - the shadow reads
       as the header's own edge either way since it's on the sticky wrapper,
       not a specific row. */
    .v3-header { box-shadow: 0 6px 16px rgba(0, 0, 0, .2); }
    .v3-burger { display: inline-flex; }
    .v3-logo-lockup { position: static; transform: none; }
    .v3-nav-row { display: none; position: static; }
    /* overscroll-behavior:contain - core.js sets overflow-y:auto + a capped
       max-height inline when opened. Without this, scrolling past the menu's
       own end chains into the page behind it (it's inline in normal flow,
       not a fixed overlay), and reversing direction then scrolls the page
       instead of the menu until the page itself is back at the top. This
       traps scroll at the menu's own boundaries instead. */
    .v3-nav-row.v3-nav-row-open { display: block; overscroll-behavior: contain; }
    .v3-nav-list { flex-direction: column; gap: 0; align-items: stretch; }
    .v3-nav-list li a { padding: 12px 4px; border-bottom: 1px solid var(--line); }
    .v3-nav-sub { display: block; position: static; transform: none; border: 0; background: transparent; padding: 0; min-width: 0; box-shadow: none; }
    .v3-nav-sub a { padding: 10px 4px 10px 20px !important; border-bottom: 1px solid var(--line) !important; }
    .v3-header .v3-inner { padding-right: 36px; }
}
@media (max-width: 640px) {
    /* left/right only - shorthand "0 16px" used to also zero out whatever
       vertical padding a more specific rule (e.g. .v3-brand-inner) had set,
       since this selector (.v3-header .v3-inner, 2 classes) outranks it
       (.v3-brand-inner, 1 class) regardless of source order. Every element
       this reaches shares both classes (.v3-inner.v3-brand-inner etc.), so
       that's not a hedge against a hypothetical - it was silently flattening
       the brand row's vertical padding to 0 on real phones (<640px) the
       whole time these paddings existed. */
    .v3-header .v3-inner { padding-left: 16px; padding-right: 16px; }
    .v3-brand-actions { gap: 0; }
    .v3-signup-link { display: none; }
    .v3-login-text { display: none; }
    .v3-login-icon { display: inline; }
    .v3-member-menu-welcome { display: block; }
    .v3-member-menu-welcome-divider { display: block; }
    .v3-theme-toggle, .v3-icon-link, .v3-text-link { padding: 10px; }
    .v3-brand-inner { gap: 10px; }
    /* Ratio stays visible on mobile inside the horizontal-scroll ticker --
       matches the approved cart-v3.html mockup (commit a51b654), which
       fixed this same rule in its own embedded <style> during the cart design
       pass; that fix never got ported to this shared stylesheet, so home/
       listing/PDP have been dropping Ratio on mobile ever since. */
    .v3-price-loc, .v3-price-sep { display: none; }
}
/* Fold/S8+-class widths (Galaxy Z Fold 5 folded = 344px, Galaxy S8+ = 360px) -
   the 640px breakpoint's 10px-per-side icon padding (20px between adjacent
   icons) reads as too loosely spaced once the row is this narrow; tightened
   just for these widths rather than lowering it for every phone above this
   size too.
   gap:4px (not 0) - padding alone shrank each tap target to ~27-31px CSS
   px, and with the row's gap:0 (set at 640px above) they sat flush against
   each other with zero space between tap areas - clears WCAG 2.5.8's 24px
   floor individually but a mis-tap on one edge would land on its neighbor.
   4px doesn't fully undo the tightening but gives adjacent icons a real
   boundary again.
   Theme switch scaled ~0.83x (46/24/18 -> 38/20/15) in the same block -
   every other piece of it (.v3-theme-slider/-effect/-circle, checked-state
   offsets) is sized off these three custom properties via calc(), so
   overriding them here is enough to shrink the whole control in
   proportion; only the icon glyph inside the circle needs its own explicit
   size to match. */
@media (max-width: 360px) {
    .v3-brand-actions { gap: 4px; }
    .v3-theme-toggle, .v3-icon-link, .v3-text-link { padding: 6px; }
    .v3-theme-toggle { --sw-w: 38px; --sw-h: 20px; --sw-circle: 15px; }
    .v3-theme-circle i { font-size: 8px; }
}


/* ============================================================
   SCROLL-REVEAL (motion, dial 4) - IntersectionObserver only, no
   scroll listeners. Sequencing device for a long catalog page: each
   section rises in as it's approached, once, then stays put.
   ============================================================ */
.v3-reveal { opacity: 0; transform: translateY(14px); transition: opacity .6s cubic-bezier(.16,1,.3,1), transform .6s cubic-bezier(.16,1,.3,1); }
.v3-reveal.v3-in { opacity: 1; transform: translateY(0); }

/* ============================================================
   MAIN
   ============================================================ */
main { display: block; }


/* --- shared product card component -------------------------- */
.v3-card { display: flex; flex-direction: column; height: 100%; }
/* Real markup (unlike the mockup) never wraps the whole card - including
   the CTA button - in one <a>; see components/v3/product_card.php's
   comment. These two wrappers only carry the image/name links, so
   display:contents drops their own box without hiding anything. */
.v3-card-plate-link, .v3-card-name-link { display: contents; text-decoration: none; color: inherit; }
.v3-card-plate {
    position: relative; border-radius: 14px; background: linear-gradient(180deg, var(--plate) 0%, var(--panel) 100%);
    padding: 20px; overflow: visible;
    /* Legacy's .product img:hover (css.css:2394-2398) swaps in a colored
       border + a stronger drop shadow on hover - ported here as an inset
       box-shadow "ring" (not a real `border`: a transparent border on a
       border-radius + gradient-background box like this one antialiases
       into a faint seam at the edge - visible as a stray light line even
       at 0 hover, worst along a flat edge like the bottom) plus a real
       drop-shadow layer, alongside the existing ::after ambient-shadow-bar/
       image-scale hover effects below, rather than replacing them. */
    box-shadow: inset 0 0 0 2px transparent;
    transition: box-shadow .2s ease;
}
.v3-card-plate img { width: 100%; height: auto; aspect-ratio: 3/4; object-fit: contain; display: block; }
.v3-card-plate::after {
    content: ""; position: absolute; left: 12%; right: 12%; bottom: -10px; height: 18px;
    background: radial-gradient(ellipse at center, var(--shadow) 0%, rgba(0,0,0,0) 72%);
    filter: blur(3px); z-index: -1; transition: opacity .25s ease, transform .25s ease;
}
.v3-card:hover .v3-card-plate::after { transform: scaleX(1.06); opacity: .9; }
.v3-card:hover .v3-card-plate img { transform: scale(1.02); }
.v3-card:hover .v3-card-plate { box-shadow: inset 0 0 0 2px var(--accent), 0 10px 24px var(--shadow); }
.v3-card-plate img { transition: transform .25s ease; }
.v3-card-label { margin-top: 14px; flex: 1 0 auto; display: flex; flex-direction: column; }
.v3-card-name {
    font-family: Newsreader, serif; font-size: 15.5px; line-height: 1.35; color: var(--ink);
    margin-bottom: 6px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
/* align-items:flex-start, not baseline - same fix as .v3-pdp-price-row
   (product.css): baseline pinned the Live badge to the price text's
   baseline, reading as stuck to the bottom instead of level with the top
   of the digits. */
.v3-card-price-row { display: flex; align-items: flex-start; gap: 8px; flex-wrap: wrap; }
.v3-card-price { font-family: Manrope, sans-serif; font-weight: 800; font-variant-numeric: lining-nums tabular-nums; font-size: 18px; line-height: 1; color: var(--gold); }
.v3-card-price .v3-strike { font-size: 12.5px; color: var(--ink-soft); text-decoration: line-through; margin-left: 4px; }
.v3-card-tag {
    font-size: 9.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
    padding: 2px 7px; border-radius: 999px;
}
.v3-card-tag-live { color: #FBF8F2; background: var(--down); }
/* Desktop shows the full "Live Pricing" label; mobile falls back to "Live" to
   avoid crowding the price row. Only cart step-1 (cart.js) and the PDP price
   (product/show.php) opt in via this modifier - the listing card's badge
   (product_card.php) stays "Live" at every width, unchanged. */
.v3-card-tag-live-expand .v3-tag-short { display: none; }
@media (max-width: 640px) {
    .v3-card-tag-live-expand .v3-tag-full { display: none; }
    .v3-card-tag-live-expand .v3-tag-short { display: inline; }
}
/* .v3-card-tag-fixed removed: fixed-price products no longer show a badge. */
.v3-card-status { font-size: 12px; color: var(--ink-soft); margin-top: 6px; min-height: 1.4em; }
.v3-card-status.is-closed { color: var(--down); }
.v3-card-status.is-out { color: var(--ink-soft); }
.v3-card-cta {
    /* margin-top:auto, not a fixed value - .v3-card-label is the flex
       column parent and gets stretched to the row's tallest card, so
       this is what actually pins every CTA to the same baseline
       regardless of how many lines a given product's name/status run
       to. The gap lands above the button, not below it. */
    /* font-family + line-height pinned explicitly, not left to inherit -
       four different states render as three different elements here
       (<button> for buy/notify/closed, <a> for contact), and a <button>
       does NOT inherit the page font by default (falls back to the
       browser's UI default, Arial, while the <a> correctly inherited
       Manrope). Different fonts at "the same" font-size have different
       normal line-height metrics, so the four states were rendering at
       four different heights (35/37/37/40px) despite identical padding.
       An explicit height removes any remaining content-dependent
       variance (icon present or not) instead of trusting metrics at all. */
    margin-top: auto; width: 100%; height: 38px; border: 0; border-radius: 8px; padding: 0 10px;
    font-family: Manrope, sans-serif; font-size: 12.5px; font-weight: 700; line-height: 1;
    letter-spacing: .01em; cursor: pointer; text-align: center;
    text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
    transition: background-color .2s ease, color .2s ease, border-color .2s ease, filter .2s ease;
}
.v3-card-cta.is-buy { background: var(--accent); color: var(--accent-text); }
/* filter, not background: var(--ink) - same cream-on-cream failure in
   dark mode as the other two CTA fills above. */
.v3-card-cta.is-buy:hover { filter: brightness(.82); }
.v3-card-cta.is-buy.is-added { background: var(--up); color: #F4F1EA; }
.v3-card-cta.is-notify, .v3-card-cta.is-contact { background: transparent; border: 1px solid var(--line); color: var(--ink); }
.v3-card-cta.is-notify:hover, .v3-card-cta.is-contact:hover { border-color: var(--accent); color: var(--accent); }
/* cursor stays the base .v3-card-cta pointer, not not-allowed - this
   button IS clickable, it opens the office-hours notice modal
   (components/v3/market_closed_modal.php via core.js), it's just not a
   purchase action. */
.v3-card-cta.is-closed { background: transparent; border: 1px solid var(--line); color: var(--ink-soft); }

/* ============================================================
   BUTTON PRIMITIVES - .v3-btn family. Was duplicated verbatim in
   home.css and listing.css; promoted here so cart (and any future page)
   shares one copy. Ported from the approved cart-v3.html mockup (commit a51b654).
   ============================================================ */
.v3-btn {
    display: inline-flex; align-items: center; gap: 8px; padding: 14px 24px; border-radius: 8px;
    font-family: Manrope, sans-serif; font-size: 14px; font-weight: 700; letter-spacing: .01em; text-decoration: none; cursor: pointer;
    border: 1px solid transparent; transition: background-color .2s ease, color .2s ease, border-color .2s ease, transform .15s ease, filter .2s ease;
    white-space: nowrap;
}
.v3-btn:active { transform: translateY(1px); }
.v3-btn-primary { background: var(--accent); color: var(--accent-text); }
.v3-btn-primary:hover { filter: brightness(.82); }
.v3-btn-ghost { border-color: var(--ink); color: var(--ink); background: transparent; }
.v3-btn-ghost:hover { background: rgba(27,31,46,.06); }
html.dark .v3-btn-ghost:hover { background: rgba(239,235,225,.08); }
.v3-btn-danger { background: var(--down); color: #F4F1EA; }
.v3-btn-danger:hover { filter: brightness(.88); }
.v3-btn[disabled] { opacity: .5; cursor: not-allowed; }
.v3-btn-block { width: 100%; justify-content: center; }
.v3-btn-sm { padding: 9px 16px; font-size: 13px; }
.v3-btn-xs { padding: 5px 12px; font-size: 12px; }

/* ============================================================
   MODAL - .v3-pdp-modal-* family, promoted here (order.css's superset:
   .is-wide, max-height/overflow-y for long content) so a page with no
   page-level CSS bundle of its own (home.css) can still use one, e.g. the
   pending-order guard modal (components/v3/pending_order_modal.php). The
   four page-level copies (product.css, cart.css, order.css, listing.css)
   are left in place -- they load after this and win with identical
   values, so this is purely additive.
   ============================================================ */
.v3-pdp-modal-backdrop {
    position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 200;
    display: flex; align-items: center; justify-content: center; padding: 20px;
}
.v3-pdp-modal-backdrop.is-hidden { display: none; }
.v3-pdp-modal {
    background: var(--plate); border: 1px solid var(--line); border-top: 2px solid var(--accent);
    border-radius: 14px; padding: 26px; max-width: 460px; width: 100%; position: relative;
    box-shadow: 0 24px 48px var(--shadow); max-height: 86vh; overflow-y: auto;
}
.v3-pdp-modal.is-wide { max-width: 620px; }
/* flex + align-items:center - a bare inline-flex tick icon (below) aligns to
   the text baseline by default, which sits it visibly higher than the title
   since the icon is taller than the text's x-height. Matches product.css's
   original fix, now standardized across every .v3-pdp-modal-* consumer. */
.v3-pdp-modal h3 { display: flex; align-items: center; gap: 8px; font-size: 21px; color: var(--accent); margin: 0 0 14px; }
.v3-pdp-modal p { color: var(--ink); font-size: 14.5px; line-height: 1.65; margin: 0 0 16px; }
.v3-pdp-modal-actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.v3-pdp-modal-close {
    /* z-index above .v3-pdp-modal-intro's 5px - stays clickable through the
       tick-only intro stage instead of getting covered by that overlay. */
    position: absolute; top: 10px; right: 10px; z-index: 6; background: none; border: 0; color: var(--ink-soft);
    font-size: 20px; line-height: 1; cursor: pointer; padding: 6px;
}
.v3-pdp-modal-close:hover { color: var(--accent); }

/* Draw-in checkmark for "Item Added to Cart" modals (product.css's original
   comment/derivation) - promoted alongside the rest of the .v3-pdp-modal-*
   family above so components/v3/added_cart_modal.php's generic instance
   (any V3 page, via core.js) gets the same animation as PDP's own copy
   even on pages with no page-level CSS bundle. product.css's copy is left
   in place, purely additive per this block's own comment. */
.v3-pdp-modal-tick { display: inline-flex; flex: none; position: relative; }
.v3-pdp-modal-tick svg { width: 24px; height: 24px; overflow: visible; }
.v3-pdp-modal-tick-circle {
    stroke: var(--up); stroke-width: 3;
    stroke-dasharray: 151; stroke-dashoffset: 151;
    animation: v3-pdp-tick-draw .75s ease-out forwards;
}
.v3-pdp-modal-tick-check {
    stroke: var(--up); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round;
    stroke-dasharray: 33; stroke-dashoffset: 33;
    animation: v3-pdp-tick-draw .25s .4s ease-out forwards;
}
@keyframes v3-pdp-tick-draw { to { stroke-dashoffset: 0; } }
/* Expanding ring "success ping" - fires once the check mark has basically
   finished drawing (~.55s in). A stroke draw only moves within the icon's
   own 24px footprint, which is easy to miss if you're not already looking
   right at it; a ring that visibly grows past the icon's bounds and fades
   catches peripheral vision the stroke draw alone can't. inset:0 on the
   circle's own box (24x24) is the ring's starting size/position - it
   scales outward from there, centered, via transform. */
.v3-pdp-modal-tick::after {
    content: ""; position: absolute; inset: 0; border-radius: 50%;
    border: 2px solid var(--up); opacity: 0;
    animation: v3-pdp-tick-pulse .8s ease-out .55s both;
}
@keyframes v3-pdp-tick-pulse {
    0%   { transform: scale(1); opacity: .7; }
    100% { transform: scale(2.6); opacity: 0; }
}

/* Two-stage "Item Added to Cart" reveal - .v3-pdp-modal-intro (tick only,
   enlarged) covers the modal as a solid plate first; .v3-pdp-modal-content
   (heading/message/buttons) sits underneath at opacity:0 the whole time, so
   the box's real height is set by the content's normal layout from the very
   first frame - no jump once intro clears. Intro holds until the tick draw
   + ring pulse above have had time to read as their own "success" beat
   (ring finishes at .55s+.8s=1.35s) before fading out; content crossfades
   in slightly before intro is gone, not after, so there's no bare gap
   between the two. Same restart-on-redisplay mechanism as the rest of this
   family - no JS timing needed. */
.v3-pdp-modal-intro {
    position: absolute; inset: 0; z-index: 5; border-radius: inherit;
    display: flex; align-items: center; justify-content: center;
    background: var(--plate); pointer-events: none;
    animation: v3-pdp-modal-intro-out 1.9s ease forwards;
}
.v3-pdp-modal-intro .v3-pdp-modal-tick svg { width: 72px; height: 72px; }
@keyframes v3-pdp-modal-intro-out {
    0%, 78% { opacity: 1; visibility: visible; }
    100%    { opacity: 0; visibility: hidden; }
}
.v3-pdp-modal-content {
    opacity: 0; pointer-events: none;
    animation: v3-pdp-modal-content-in .5s ease 1.55s forwards;
}
@keyframes v3-pdp-modal-content-in { to { opacity: 1; pointer-events: auto; } }

/* Form-field styling for .v3-pdp-modal-* consumers with an actual <form>
   inside (currently just notify_me_modal.php's email/phone fields) -
   promoted from product.css alongside the rest of the family above, same
   reason: notify_me_modal.php is now rendered once, site-wide, by
   layouts/v3.php (not just PDP), so a page with no page-level CSS bundle
   of its own still needs these styled instead of falling back to bare
   unstyled <label>/<input>. product.css's copy is left in place, purely
   additive per this block's own comment. */
.v3-pdp-modal label { display: block; font-size: 12px; color: var(--ink-soft); margin-bottom: 6px; }
.v3-pdp-modal input[type="email"], .v3-pdp-modal input[type="text"] {
    width: 100%; padding: 10px 12px; background: var(--panel); border: 1px solid var(--line);
    color: var(--ink); border-radius: 8px; margin-bottom: 10px; font-family: Manrope, sans-serif; font-size: 14px;
}
.v3-pdp-modal .v3-form-error { display: block; color: var(--down); font-size: 12.5px; margin: -4px 0 12px; min-height: 1em; }
.v3-pdp-modal .v3-form-result { font-size: 13.5px; margin: 0 0 16px; }

/* Market Closed modal (components/v3/market_closed_modal.php): trading hours as two
   label/value rows in a panel instead of one dense sentence. Selectors carry
   .v3-pdp-modal to beat the `.v3-pdp-modal p` rule above. */
.v3-pdp-modal.v3-mc h3 i { color: var(--gold); font-size: 18px; }
.v3-pdp-modal.v3-mc .v3-mc-lead { margin: 0 0 12px; }
.v3-mc-hours { background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--gold); border-radius: 12px; padding: 4px 18px; margin: 0 0 16px; }
.v3-mc-row { display: flex; justify-content: space-between; align-items: baseline; gap: 14px; padding: 11px 0; font-size: 14.5px; }
.v3-mc-row + .v3-mc-row { border-top: 1px solid var(--line); }
.v3-mc-row span { color: var(--ink-soft); font-size: 13.5px; }
.v3-mc-row strong { color: var(--ink); text-align: right; }
.v3-pdp-modal.v3-mc .v3-mc-note { margin: 0 0 20px; font-size: 13px; color: var(--ink-soft); }

@media (max-width: 640px) {
    .v3-btn-block-mobile { width: 100%; justify-content: center; }
}

/* ============================================================
   FOOTER
   ============================================================ */
.v3-footer-press { background: var(--page); padding: 30px 0; text-align: center; }
.v3-footer-eyebrow { font-size: 11.5px; color: var(--ink-soft); margin: 0 0 16px; }
.v3-footer-logos { display: flex; justify-content: center; align-items: center; gap: 40px; flex-wrap: wrap; }
.v3-footer-logos img { height: 42px; width: auto; filter: grayscale(1); opacity: .55; transition: filter .2s ease, opacity .2s ease; }
.v3-footer-logos img:hover { filter: grayscale(0); opacity: 1; }
/* These press-logo PNGs are dark ink on transparent - fine on the
   light-mode --page, invisible against the dark-mode --page. invert(1)
   only touches RGB (alpha stays intact on the transparent background),
   flipping dark marks to light ones. Hover skips the true-color reveal
   in dark mode - inverting an already-inverted color back to "true"
   would just be wrong-looking, so hover only raises opacity here. */
html.dark .v3-footer-logos img { filter: grayscale(1) invert(1); opacity: .6; }
html.dark .v3-footer-logos img:hover { filter: grayscale(1) invert(1); opacity: 1; }

.v3-footer-badges { background: var(--panel); padding: 28px 0; position: relative; }
.v3-footer-badges::before, .v3-footer-badges::after {
    content: ""; position: absolute; left: 50%; transform: translateX(-50%); width: 64px; height: 1px; background: var(--gold); opacity: .55;
}
.v3-footer-badges::before { top: 0; }
.v3-footer-badges::after { bottom: 0; }
.v3-footer-badges-row { display: flex; justify-content: center; align-items: center; gap: 32px; flex-wrap: wrap; }
.v3-footer-badges-row img { height: 100px; width: auto; object-fit: contain; }

.v3-footer-navy { background: var(--accent); color: var(--accent-text); }
.v3-footer-links { padding: 48px 0; }
.v3-footer-links-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 24px; }
/* h3 at full opacity, links dimmer - was inverted (h3 .68 vs links .88),
   so the bold small-caps heading actually looked FAINTER than the plain
   items under it, undercutting the one thing meant to set it apart. */
.v3-footer-col h3 { font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--accent-text); opacity: 1; margin: 0 0 16px; }
.v3-footer-col ul { list-style: none; margin: 0; padding: 0; }
.v3-footer-col li { margin-bottom: 9px; }
.v3-footer-col a { color: var(--accent-text); opacity: .92; text-decoration: none; font-size: 13px; }
.v3-footer-col a:hover { opacity: 1; text-decoration: underline; }

.v3-footer-copyright { border-top: 1px solid rgba(251,248,242,.16); padding: 22px 0; text-align: center; font-size: 12.5px; line-height: 1.7; opacity: .82; }
.v3-footer-copyright a { color: var(--accent-text); text-decoration: none; font-weight: 600; opacity: 1; }
.v3-footer-copyright a:hover { text-decoration: underline; }

@media (max-width: 900px) {
    .v3-footer-links-grid { grid-template-columns: repeat(2, 1fr); gap: 28px 20px; }
    .v3-footer-badges-row { gap: 20px; }
    .v3-footer-badges-row img { height: 72px; }
    .v3-footer-logos { gap: 24px; }
}
@media (max-width: 480px) {
    .v3-footer-links-grid { grid-template-columns: 1fr; }
    /* Each logo's own aspect ratio sets how much width a given height costs
       (The_Edge/Personal_Money run ~3.5-3.7x wider than tall, Berita_Harian
       ~1.1x) - at this height+gap, the row needs ~315px, fitting one line
       down to ~363px viewports (327px available inside .v3-inner's padding
       on a 375px phone, the narrowest common target); below that (foldable
       cover screens) it may still wrap, which is an acceptable fallback. */
    .v3-footer-logos { gap: 10px; }
    .v3-footer-logos img { height: 27px; }
}

/* ============================================================
   BANNER PLACEMENTS (banner_rail.php) - used on both home and
   product detail pages, so this lives in core, not home.css.
   Crossfade slider: all slides stack absolutely and cross-fade via
   .is-active, so the frame needs its own height - uploads aren't all
   one ratio (site's own images run ~2.2:1, admin's upload hint is
   2.67:1), so aspect-ratio here is a fallback only. core.js measures
   the first slide's real image once loaded and overrides it inline
   for an exact, uncropped fit; without JS every banner still renders
   at a sane ratio via this fallback, just possibly lightly cropped by
   object-fit: cover. Dots stay hidden until JS marks the slot
   .is-ready, so a no-JS/CSS-only visitor never sees inert controls.
   ============================================================ */
.v3-banner-slot { margin: 48px 0 0; }
/* aspect-ratio is re-set per active slide by core.js, so it changes mid-
   rotation whenever two banners differ in ratio - transition it over the
   same 900ms as the slide crossfade so the frame resizes with the fade
   instead of snapping. */
.v3-banner-frame { position: relative; aspect-ratio: 12 / 5; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; transition: aspect-ratio 900ms ease; }
.v3-banner-frame picture { display: block; width: 100%; height: 100%; }
.v3-banner-frame img { width: 100%; height: 100%; display: block; object-fit: cover; }
.v3-banner-slide { position: absolute; inset: 0; opacity: 0; transition: opacity 900ms ease; }
.v3-banner-slide.is-active { opacity: 1; z-index: 1; }
.v3-banner-dots { display: none; justify-content: center; align-items: center; gap: 8px; margin-top: 16px; }
.v3-banner-slot.is-ready .v3-banner-dots { display: flex; }
.v3-banner-dot { width: 24px; height: 24px; padding: 0; border: none; background: none; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.v3-banner-dot::before { content: ''; width: 9px; height: 9px; border-radius: 50%; background: var(--line); transition: background 200ms ease, box-shadow 200ms ease; }
.v3-banner-dot:hover::before { background: var(--ink-soft); }
.v3-banner-dot.is-active::before { background: transparent; box-shadow: inset 0 0 0 2px var(--accent); }

@media (max-width: 640px) {
    /* Banner is the one full-bleed component in the V3 card language:
       edge-to-edge, square corners, no frame - the mobile art is meant
       to fill the viewport, not sit inside a card. Cancels .v3-inner's
       24px side padding (core.css .v3-inner). */
    .v3-banner-slot { margin-inline: -24px; }
    .v3-banner-frame { border: 0; border-radius: 0; }
}

/* ============================================================
   SECTION / SCROLL-RAIL primitives - used on both home and product
   detail pages (product/show.php's Product Description and
   Recommended Products sections), so these live in core, not home.css.
   Page-specific overrides (e.g. home's desktop 4-column New Arrivals
   lock) stay in home.css.
   ============================================================ */
.v3-section { padding: 72px 0; }
.v3-section.on-panel { background: var(--panel); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.v3-section-header { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 16px; margin-bottom: 32px; }
.v3-section-header h2 { font-size: 27px; color: var(--ink); }
.v3-section-header a { font-size: 13px; font-weight: 600; color: var(--accent); text-decoration: none; white-space: nowrap; }
.v3-section-header a:hover { color: var(--ink); }

.v3-scroll-rail-wrap { position: relative; }
.v3-scroll-rail {
    display: grid; grid-auto-flow: column; grid-auto-columns: minmax(220px, 1fr);
    gap: 24px; overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth;
    padding-bottom: 4px; scrollbar-width: none;
}
.v3-scroll-rail::-webkit-scrollbar { display: none; }
.v3-scroll-rail > .v3-card { scroll-snap-align: start; }

/* Swipe position under a scroll rail - the rail's own scrollbar is hidden, so this is the
   only cue that more cards sit off-screen. Thumb width = visible share, position = scroll
   share (core.js); hidden when nothing overflows, e.g. the home page's static desktop grid. */
.v3-rail-progress { height: 3px; margin-top: 14px; border-radius: 999px; background: var(--line); overflow: hidden; }
.v3-rail-progress[hidden] { display: none; }
.v3-rail-progress span { display: block; height: 100%; border-radius: 999px; background: var(--accent); will-change: transform; }
@media (max-width: 900px) {
    .v3-section-header { flex-wrap: wrap; }
}

/* ============================================================
   V3 cart form/table primitives - shared with future login/signup/
   member pages. Ported verbatim from the approved cart-v3.html mockup (commit a51b654).
   ============================================================ */

/* --- form fields - label above input, error below, never
   placeholder-as-label --- */
.v3-field { margin-bottom: 18px; display: flex; flex-direction: column; gap: 6px; }
.v3-field label { font-size: 12.5px; font-weight: 600; color: var(--ink-soft); letter-spacing: .01em; }
.v3-required { color: var(--down); margin-left: 2px; }
.v3-input, .v3-select, .v3-textarea {
    font-family: Manrope, sans-serif; font-size: 14px; color: var(--ink);
    background: var(--plate); border: 1px solid var(--line); border-radius: 8px;
    padding: 10px 12px; width: 100%;
}
/* Own chevron instead of the native arrow, which sits flush against the
   right border and ignores padding - 14px inset, text clears it at 38px. */
.v3-select {
    appearance: none; -webkit-appearance: none;
    background-image: var(--select-arrow); background-repeat: no-repeat;
    background-position: right 14px center; background-size: 12px 8px;
    padding-right: 38px;
}
.v3-input[readonly], .v3-input[disabled], .v3-select[disabled] { background: var(--panel); color: var(--ink-soft); }
.v3-textarea { resize: vertical; min-height: 88px; font-family: Manrope, sans-serif; }
/* Collapses to ~0 (just the .v3-field flex gap) when empty, expands to fit
   the message only for fields that actually fail validation -- reserving
   this height unconditionally on every field doesn't scale to a form this
   long (cart step 2 has ~19 fields): it'd add a fixed amount of permanent
   dead space under every single one, paid by every visitor, to prevent a
   shift that only a failed submit ever triggers. One reflow on submit (all
   failing fields expand together in a single repaint) is the standard
   pattern for long forms (Stripe/Shopify checkout, Google Forms) -- reserved
   space stays the right call for short forms (login, 2-3 fields) where the
   cost is negligible, just not here. */
.v3-field-error { font-size: 12px; color: var(--down); }
.v3-field-error:empty { min-height: 0; }
.v3-field-hint { font-size: 12px; color: var(--ink-soft); }
.v3-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 640px) {
    /* Stacked to 1 column, the row's own 16px grid-gap adds on top of each
       .v3-field's own 18px margin-bottom (grid gap doesn't collapse with
       margins), so paired fields (Address 1/2, Postcode/City, State/Phone)
       ended up ~34px apart -- nearly double the ~18px gap between any two
       ordinary consecutive fields. Zeroed here since the fields' own margins
       already provide the spacing once there's no side-by-side column to gap. */
    .v3-field-row { grid-template-columns: 1fr; gap: 0; }
}

/* checkbox / radio - native control, accent-color token, label reads first */
.v3-check { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; font-size: 13.5px; color: var(--ink); line-height: 1.5; }
.v3-check input { width: 18px; height: 18px; margin-top: 2px; accent-color: var(--accent); flex: none; }

/* pill radio group - bill-to / ship-to type */
.v3-pill-group { display: flex; flex-wrap: wrap; gap: 10px; }
.v3-pill-radio { position: relative; }
.v3-pill-radio input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.v3-pill-radio span {
    display: inline-flex; align-items: center; padding: 9px 18px; border: 1px solid var(--line); border-radius: 999px;
    font-size: 13px; font-weight: 600; color: var(--ink); cursor: pointer; transition: border-color .2s ease, color .2s ease, background-color .2s ease;
}
.v3-pill-radio input:checked + span { border-color: var(--accent); background: var(--accent); color: var(--accent-text); }
.v3-pill-radio input:focus-visible + span { outline: 2px solid var(--focus); outline-offset: 2px; }

/* segmented control - currency toggle */
.v3-segmented { display: flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; margin-bottom: 18px; }
.v3-segmented label { flex: 1; text-align: center; padding: 9px 6px; font-size: 12.5px; font-weight: 700; color: var(--ink-soft); cursor: pointer; position: relative; }
.v3-segmented label:not(:last-child) { border-right: 1px solid var(--line); }
.v3-segmented input { position: absolute; opacity: 0; }
.v3-segmented label:has(input:checked) { background: var(--accent); color: var(--accent-text); }
.v3-segmented label:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: -2px; }

/* table - hairline data table, right-aligned numeric column */
.v3-table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 14px; }
.v3-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.v3-table th, .v3-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--line); }
.v3-table th { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); font-weight: 700; background: var(--panel); }
.v3-table td.v3-num, .v3-table th.v3-num { text-align: right; }
/* Money cells (.v3-tnum) never break mid-amount ("RM" stranded above the
   digits); the name column wraps to give up the width instead. Wrap container
   scrolls if an amount is genuinely too wide. */
.v3-table td.v3-tnum { white-space: nowrap; }
.v3-table tr:last-child td { border-bottom: 0; }
/* totals get their own tinted band (bookends the item rows the same way the
   thead does) so "sub-total / shipping / total" reads as one distinct block
   instead of three more rows that look just like the item rows above them. */
.v3-table tfoot { background: var(--panel); }
.v3-table tfoot td { font-weight: 700; }
.v3-table tfoot tr.is-grand td { font-size: 18px; color: var(--ink); border-top: 1px solid var(--line); }
.v3-table tfoot tr.is-grand td.v3-num { color: var(--gold); font-family: Manrope, sans-serif; font-weight: 800; }

/* table, mobile: the Item/Price/Qty/Total recap reads as a receipt line
   instead of a label:value form dump or a horizontal-scroll table - name on
   its own line, "price x qty" flush left under it, row total flush right on
   the same line. 2-col key-value tables (bank details, completion panel)
   already fit at any width so this modifier is opt-in per table. */
@media (max-width: 640px) {
    .v3-table--stack thead { display: none; }
    .v3-table--stack, .v3-table--stack tbody, .v3-table--stack tfoot { display: block; width: 100%; }
    .v3-table--stack tbody tr {
        display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 6px; row-gap: 4px;
        padding: 13px 16px; border-bottom: 1px solid var(--line);
    }
    .v3-table--stack tbody td { display: block; border-bottom: 0; padding: 0; text-align: left; }
    .v3-table--stack tbody td:first-child { flex: 0 0 100%; font-weight: 700; }
    /* Price and Qty (2nd/3rd col) both read too faint on a small screen at
       the shared ink-soft/regular-weight treatment they used to have -
       matched to the same weight/color as the row total below. */
    .v3-table--stack tbody td:nth-child(2),
    .v3-table--stack tbody td:nth-child(3) { font-size: 13.5px; font-weight: 600; color: var(--ink); }
    .v3-table--stack tbody td:nth-child(3)::before { content: "\00d7"; margin-right: 5px; color: var(--ink); }
    .v3-table--stack tbody td:nth-child(4) { margin-left: auto; font-weight: 800; }
    /* display:flex on tr, not td - putting flex on td here would split
       .v3-cents (a nested span) into its own flex item and misalign the
       totals; keep this on tr exactly as reviewed. */
    .v3-table--stack tfoot tr { display: flex; justify-content: space-between; align-items: baseline; padding: 3px 16px; }
    .v3-table--stack tfoot td { display: block; padding: 0; border-bottom: 0; text-align: left; }
    /* Symmetric padding (not the row's default 3px bottom) so the label/value
       sit vertically centred in the band whether or not breakdown rows precede
       this one. */
    .v3-table--stack tfoot tr.is-grand { padding-top: 13px; padding-bottom: 13px; margin-top: 5px; border-top: 1px solid var(--line); align-items: center; }
    .v3-table--stack tfoot tr.is-grand td { border-top: 0; }
    /* No breakdown rows (shipping/charges/storage all absent) -> the grand-total
       row is the first tfoot row; drop the separator margin + its own top border
       (the last item row's border-bottom already divides it) so it doesn't read
       as an empty row / doubled line between the item list and Total. */
    .v3-table--stack tfoot tr.is-grand:first-child { margin-top: 0; border-top: 0; }
    /* Same case, but the breakdown rows are hidden at runtime via inline
       display:none (JS) rather than absent from the DOM, so :first-child can't
       match — cart.js flags the tfoot with .is-grand-only instead. */
    .v3-table--stack tfoot.is-grand-only tr.is-grand { margin-top: 0; border-top: 0; }
}

/* alert band - replaces #masterMsgId / .alert-warning */
.v3-alert { display: flex; gap: 12px; align-items: flex-start; padding: 16px 18px; border-radius: 14px; margin-bottom: 20px; }
.v3-alert i { font-size: 16px; margin-top: 2px; flex: none; }
.v3-alert p { margin: 0; font-size: 14px; line-height: 1.6; }
.v3-alert-info { background: var(--panel); border: 1px solid var(--accent); color: var(--ink); }
.v3-alert-info i { color: var(--accent); }
.v3-alert-warn { background: var(--panel); border: 1px solid var(--down); color: var(--ink); }
.v3-alert-warn i { color: var(--down); }
.v3-alert-warn a { color: var(--accent); font-weight: 700; }
.v3-alert-success { background: var(--panel); border: 1px solid var(--up); color: var(--ink); }
.v3-alert-success i { color: var(--up); }

/* status - a dot, not a pill. Four families by what the customer must do, not
   one colour per status code: 'owing' reuses --gold, extending the site's
   "gold = money" rule from prices to balances still due; 'settled'/--up and
   'void'/--down mirror their meaning everywhere else in the system; 'moving'
   (in progress, no action needed) gets --accent, the same token used for
   structural UI. A bordered pill was the first pass here, but several labels
   run long ("Transferred to BSP", the LNPL-suffixed "... Active (12 days)")
   and a fixed-shape chip either clips or blows up its row. A small dot +
   plain-flowing text carries the same colour signal without owning a shape -
   the label wraps like any other text. currentColor drives both the dot and
   the text so it needs no light/dark/panel variants of its own - see
   order/v3/status.php + order/v3/view.php for the family assignment
   (OrderHistoryService::statusFamily()). */
.v3-status { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 700; letter-spacing: .01em; line-height: 1.3; }
.v3-status::before { content: ''; width: .5em; height: .5em; border-radius: 50%; flex: none; background: currentColor; }
.v3-status.is-owing   { color: var(--gold); }
.v3-status.is-moving  { color: var(--accent); }
.v3-status.is-settled { color: var(--up); }
.v3-status.is-void    { color: var(--down); }

/* ============================================================
   AUTH - the assay-card treatment.

   Minted bullion arrives sealed in an assay card: a certificate backing
   carrying the refiner, weight, fineness and an assayer's mark, whose
   visual language is hairline rules, corner registration marks,
   wide-tracked micro-labels and tabular figures. The auth card borrows
   that language, which is why it reads as luxury without anything being
   decorated - it is the certification vocabulary of the actual product.

   Deliberately NOT the conventional auth-page move (split-screen + stock
   bullion photography): that leans on imagery instead of the brand's own
   material world, and every fintech login already looks like it.

   Guardrail against costume: hairlines only. No faux-paper texture, no
   guilloche pattern (cut - it was the move too many), and no fabricated
   serial number, which would be a lie on a page whose whole job is trust.
   The one live element is the real spot price (.v3-auth-spot), fed from
   $_chrome['spot'] - the same source the header ticker reads.
   ============================================================ */
/* No background vignette here on purpose. A radial "pool of light" was tried
   and cut: .v3-auth-shell is bounded by .v3-inner (max-width 1200px), so any
   gradient that still carries colour at its outer stop draws a hard-edged
   rectangle against the page around it. Full-bleeding it onto .v3-section
   just moves the seam to the section's top/bottom edge. The card's own
   layered shadow, the warm spot strip and the gold framing already do the
   work a vignette would have; this is the accessory removed before leaving
   the house. */
/* Auth pages are a single standalone card, not stacked marketing sections, so
   the wrapping .v3-section's own 72px rhythm would double up with this shell's
   padding - zero it out here and let the shell alone set the page's vertical
   rhythm. */
.v3-section:has(.v3-auth-shell) { padding: 0; }
.v3-auth-shell { padding: 48px 0 72px; display: flex; justify-content: center; }
.v3-auth-card {
    position: relative;
    width: 100%; max-width: 460px; background: var(--plate); border: 1px solid var(--line);
    border-radius: 14px; padding: 30px 34px 34px;
    /* Layered, not the previous flat 0 1px 3px - a soft pool underneath is
       what makes the card feel like an object resting on the page. */
    box-shadow: 0 1px 2px var(--shadow), 0 18px 40px -12px var(--shadow);
}
/* Corner registration marks - the print-alignment ticks on a real assay
   card. Drawn as 8 gradients on one pseudo-element (4 corners x 2 arms), so
   no extra markup. Chosen INSTEAD of a full inner border: ticks alone read
   as certificate corners without boxing the card in a second time. */
.v3-auth-card::after {
    content: ""; position: absolute; inset: 12px; pointer-events: none; border-radius: 8px;
    /* --- see the :has() rule below for the bottom offset --- */
    --mark: color-mix(in srgb, var(--gold) 52%, transparent);
    background:
        linear-gradient(var(--mark), var(--mark)) 0 0 / 11px 1px no-repeat,
        linear-gradient(var(--mark), var(--mark)) 0 0 / 1px 11px no-repeat,
        linear-gradient(var(--mark), var(--mark)) 100% 0 / 11px 1px no-repeat,
        linear-gradient(var(--mark), var(--mark)) 100% 0 / 1px 11px no-repeat,
        linear-gradient(var(--mark), var(--mark)) 0 100% / 11px 1px no-repeat,
        linear-gradient(var(--mark), var(--mark)) 0 100% / 1px 11px no-repeat,
        linear-gradient(var(--mark), var(--mark)) 100% 100% / 11px 1px no-repeat,
        linear-gradient(var(--mark), var(--mark)) 100% 100% / 1px 11px no-repeat;
}
/* On a real assay card the registration marks bound the printed certificate
   area, not the whole blister - so when the live spot strip is present the
   bottom pair lifts to sit above it (44px strip + 12px inset) instead of
   colliding with it. :has() is already an established technique here
   (.v3-payment-option:has(input:checked), .v3-segmented label:has(...)). */
.v3-auth-card:has(.v3-auth-spot)::after { bottom: 56px; }
.v3-auth-card--wide { max-width: 640px; }

/* Assay micro-label strip. Not a new typeface - Manrope at 10px, wide
   tracking, uppercase; the same treatment .v3-auth-group-title already uses
   on signup, so the three signup group headers unify with this for free. */
.v3-auth-stamp {
    display: flex; align-items: center; justify-content: space-between; gap: 12px;
    font-size: 10px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase;
    color: var(--ink-soft); margin: 0 0 24px; padding-bottom: 12px;
    border-bottom: 1px solid var(--line);
}
.v3-auth-stamp span { color: var(--gold); white-space: nowrap; }

/* Live spot strip - the honest counterpart to an assay card's serial number.
   Gold on the figures only, per the locked V3 colour rule (gold = metal and
   money, never structure). Renders nothing when spot data is unavailable. */
.v3-auth-spot {
    display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px 22px;
    margin: 26px -34px -34px; padding: 14px 34px; border-top: 1px solid var(--line);
    background: var(--panel); border-radius: 0 0 13px 13px;
    font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-soft);
}
.v3-auth-spot b {
    color: var(--gold); font-weight: 800; letter-spacing: 0; text-transform: none;
    font-variant-numeric: tabular-nums; margin-left: 5px;
}
.v3-auth-title { font-size: 30px; text-align: center; margin-bottom: 8px; line-height: 1.2; }
.v3-auth-sub { font-size: 14px; color: var(--ink-soft); text-align: center; margin: 0 0 30px; }
.v3-auth-links { text-align: center; font-size: 13px; margin-top: 20px; color: var(--ink-soft); }
.v3-auth-links a { color: var(--accent); font-weight: 600; text-decoration: none; }
.v3-auth-links a:hover { text-decoration: underline; }
.v3-auth-links .v3-auth-sep { color: var(--line); margin: 0 8px; }
.v3-auth-group-title {
    font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-soft);
    margin: 32px 0 16px; padding-top: 24px; border-top: 1px solid var(--line);
}
.v3-auth-group-title:first-child { margin-top: 0; padding-top: 0; border-top: 0; }

/* birthday - 3 short inputs in one row, own field group (not .v3-field-row,
   that's a 2-up grid; this is 3 fixed-width boxes) */
.v3-bday-row { display: flex; align-items: center; gap: 8px; }
.v3-bday-row input { text-align: center; padding: 10px 4px; }
.v3-bday-row .v3-bday-dd, .v3-bday-row .v3-bday-mm { width: 100%; max-width: 64px; }
.v3-bday-row .v3-bday-yyyy { width: 100%; max-width: 84px; }
.v3-bday-row span { color: var(--ink-soft); flex: none; }

/* OTP step */
.v3-otp-contact { text-align: center; font-size: 13px; color: var(--ink-soft); margin: -10px 0 22px; }
.v3-otp-input { text-align: center; font-size: 24px; letter-spacing: .35em; font-family: Manrope, sans-serif; font-weight: 700; padding-left: 18px; }
.v3-otp-resend-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.v3-otp-resend-row .v3-select { max-width: 190px; }
.v3-otp-countdown { font-size: 12.5px; color: var(--ink-soft); white-space: nowrap; }

/* secure splash - a transition screen, not a marketing moment: restyled to
   V3 tokens, animation mechanism (progress ring, JS redirect) kept as-is */
.v3-secure-shell { min-height: 46vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 56px 24px; text-align: center; gap: 22px; }
.v3-secure-ring { width: 84px; height: 84px; border-radius: 999px; border: 3px solid var(--line); border-top-color: var(--accent); animation: v3-spin 1s linear infinite; }
@keyframes v3-spin { to { transform: rotate(360deg); } }
.v3-secure-msg { font-size: 19px; }
.v3-secure-msg p { font-size: 14px; color: var(--ink-soft); margin: 8px 0 0; font-family: Manrope, sans-serif; }

/* Login splash (auth/v3/secure.php only - checkout/v3/payment_redirect.php
   keeps the plain .v3-secure-ring spinner above, untouched) - faithful
   port of legacy proj_bsm's secure.php: the same hand-drawn "S" mark
   (its own SVG outline, not img/logo-s.svg) fills bottom-to-top like
   rising water via an SVG mask, with a live "NN%" readout on top, both
   tied to the same redirect timer. --ink swaps in for legacy's
   --fill-color custom property (black in light mode / white in dark
   mode there - --ink already does exactly that here, no new var
   needed). The water-fill duration is per-request ($redirect_timer),
   so that one rule stays inline in the view, same as legacy did it;
   everything duration-independent lives here. */
.v3-progress-container { position: relative; width: 150px; height: 225px; display: flex; align-items: center; justify-content: center; }
.v3-secure-svg { overflow: visible; }
.v3-secure-outline { stroke: var(--line); stroke-width: 2; }
.v3-secure-water { fill: var(--ink); }
.v3-secure-percent {
    position: absolute; font-family: Manrope, sans-serif; font-weight: 800;
    font-size: 22px; color: var(--ink); font-variant-numeric: tabular-nums;
    text-shadow: -1px -1px 0 var(--page), 1px -1px 0 var(--page), -1px 1px 0 var(--page), 1px 1px 0 var(--page);
}

@media (max-width: 480px) {
    .v3-auth-card { padding: 24px 20px 28px; }
    /* Negative margins must track the card's own padding, or the strip
       stops sitting flush to the card edges. */
    .v3-auth-spot { margin: 22px -20px -28px; padding: 13px 20px; gap: 4px 16px; }
    .v3-auth-title { font-size: 26px; }
}

/* ============================================================
   BACK TO TOP - fixed bottom-right, hidden until scrolled past
   100px (core.js), circular progress ring tracks scroll position.
   Parity port of legacy proj_bsm's #backToTopBtn (css/css.css), same
   interaction, restyled to V3 tokens instead of the legacy per-theme
   --back-to-top-background variable.
   ============================================================ */
.v3-back-to-top {
    position: fixed; right: 20px; bottom: -60px; z-index: 90;
    width: 46px; height: 46px; border-radius: 999px; border: 0; padding: 0;
    background: var(--plate); box-shadow: 0 8px 20px var(--shadow);
    display: flex; align-items: center; justify-content: center; cursor: pointer;
    opacity: 0; transition: bottom .3s ease, opacity .3s ease, filter .2s ease;
}
.v3-back-to-top:hover { filter: brightness(.94); }
.v3-back-to-top.is-shown { bottom: 20px; opacity: 1; }
.v3-back-to-top i { font-size: 15px; color: var(--accent); z-index: 1; }
.v3-back-to-top svg { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); }
/* No permanent "track" ring - at 0% scroll the arc's own dashoffset hides
   it completely (see below), so a resting button is a plain circle (plate
   + shadow only). A track was tried and dropped: var(--line) read as a
   pale, slightly-misaligned second ring around the button's true edge -
   an unwanted "double ring" look, not a deliberate progress-track affordance. */
.v3-back-to-top-progress {
    fill: none; stroke: var(--accent); stroke-width: 2.5px; stroke-linecap: round;
    stroke-dasharray: 125.6; stroke-dashoffset: calc(125.6 - (125.6 * var(--v3-scroll-progress, 0) / 100));
    transition: stroke-dashoffset .1s linear;
}
@media (max-width: 640px) {
    .v3-back-to-top { right: 14px; width: 42px; height: 42px; }
    .v3-back-to-top.is-shown { bottom: 14px; }
}
