/* tokens.css — THE design-token file for the whole site (main site + /p).
   Since 2026-10-09 style.css carries no token copy; every page links this
   file first. A white-label theme overrides tokens here and nowhere else.
   Source of truth: events/jasper-temptation-2026/theme.json (owner ruling
   2026-10-12: JASP.ER is the base theme; PPW's Figma palette was removed).
   Its designTokens are baked into the :root / [data-theme] blocks below, so
   the default project costs no extra request. Edit the JSON and this file
   together. If a future event needs its own separate theme, revisit this:
   see WEBSYS_MD/white-label-assessment.md "Base theme = JASP.ER". */
@import url("https://fonts.googleapis.com/css2?family=Cinzel:wght@600;800&family=Noto+Serif+Thai:wght@600;800&display=swap");

:root {
  /* Backgrounds */
  --bg-primary: #121011;
  --bg-secondary: #1A1617;
  --bg-elevated: #241E1F;
  /* Popup/modal shell background (2026-08-08 fix) — see style.css's
     identical comment (main site) for the full story: .auth-modal__card
     originally had its own literal #0E0F1F, an earlier conversion pass
     mistakenly repointed it to var(--bg-elevated) instead of giving it its
     own token, silently lightening every /p popup's dark-mode background. */
  --modal-bg: #1A1617;
  --navbar-bg: #121011;
  --bottom-bg: #1A1617;
  --dropdown-bg: #1A1617;

  /* Accents */
  --accent-gold: #C0201C;
  /* Teal used as TEXT needs its own light value (added 2026-08-18). The
     --accent-teal light value (#0D8C8B) is tuned to a ~3.2:1 floor, which is
     right for a stripe/border/fill but fails WCAG for small text: measured on
     the real page, /p/notify's "อ่านแล้วทั้งหมด" link was 3.22:1 and the nav
     bell's older one 2.77:1, against 4.5:1 required at 11-12px/600 (not
     "large text"). #0B6360 measures 5.57:1 on the page background and 4.79:1
     on the bell dropdown's surface. Identical to --accent-teal in dark mode —
     this exists ONLY to darken the light value. */
  --accent-teal: #D4A85A;
  --accent-teal-text: #D4A85A;
  /* Gold used as TEXT on its own translucent tint needs its own light value
     (added 2026-08-27) — the exact counterpart of --accent-teal-text above,
     and for the same reason; see style.css for the full rationale. Declared
     in both files like every token here. Identical to --accent-gold in dark
     mode — this exists ONLY to darken the light value. */
  --accent-gold-text: #FF5A52;
  /* Blue accent (added 2026-08-18) — was three hardcoded #3B82F6 literals
     (.tk-btn--blue, .find-assist-banner__link, lightbox confirm--blue) added
     after the 2026-08-08 light-mode audit, so none of them ever got a light
     value: .find-assist-banner__link measured 2.40:1 on its own tinted
     banner in light mode (11.5px/700, needs 4.5:1). Light value #1D4ED8
     measures 5.42:1 there and 5.02:1 on a 10% self-tint. */
  --accent-blue:   #3B82F6;
  /* Text color for buttons filled solid with --accent-gold/--accent-teal —
     see style.css's identical token for the full story: dark mode's bright
     accent works fine with hardcoded near-black text, but light mode's same
     accent is a dark saturated color, and several /p buttons never got a
     light-mode text override, landing dark text on a dark button
     (2026-08-08, user: "ปุ่มต่างๆ ที่สีเข้ม แล้ว text ก็เข้มด้วย?"). */
  --gold-btn-text: #FFFFFF;
  --teal-btn-text: #121011;
  --accent-gold-rgb: 192, 32, 28;
  --accent-teal-rgb: 212, 168, 90;
  --accent-blue-rgb: 59, 130, 246;
  /* Mode-INDEPENDENT channel tokens (white-label Phase 2b, 2026-10-09).
     Each one names a literal that already appears in rgba() across the CSS
     (white ×122, black ×22, violet ×20) so later sweeps can swap the literal
     for the token with zero visual change. Deliberately not redefined in the
     light/dark blocks — they are constants, not theme colours.
     Swept in 2c (164 literals, 13 files). The fixed-dark Temptation Chart surface
     (vote.css) relies on these staying constant, so a project theme must
     NEVER override them — it overrides --accent-* / --bg-* / --text-* only. */
  --white-rgb:        255, 255, 255;
  --black-rgb:        0, 0, 0;
  --accent-violet-rgb: 166, 28, 25;
  /* Brand base colours (white-label 2c step 2). NOT the same as
     --accent-gold/--accent-teal: those darken in light mode, these never do.
     They replace the raw F4A140 / 13CDCB hex literals that were deliberately
     mode-independent (fixed-dark surfaces, glows, gradients). Unlike the
     constants above, a project theme DOES override these — they are the
     brand, just not mode-aware. */
  --brand-gold: #C0201C;
  --brand-gold-rgb: 192, 32, 28;
  --brand-teal: #E0E0E0;
  --brand-teal-rgb: 224, 224, 224;
  /* Round 3 (white-label 2c step 3). Same contract as above: mode-INDEPENDENT.
     --brand-gold-* are brand (theme may override); --on-dark-*, --fixed-bg-*
     and --status-* paint the fixed-dark surfaces (Temptation Chart, lightbox, map
     panels) and are constants — check-fixed-surface.mjs resolves them. */
  --white:           #FFFFFF;
  --brand-gold-hi: #D91A1A;
  --brand-gold-lo: #3D0000;
  --brand-gold-pale: #FF1E1E;
  --brand-gold-ink: #FFFFFF;
  --on-dark-text:    #EEF0FA;
  --on-dark-soft:    #C3C7DC;
  --on-dark-muted:   #9AA0BC;
  --on-dark-dim:     #7C819B;
  --fixed-bg-0:      #030303;
  --fixed-bg-1:      #050505;
  --fixed-bg-2:      #0D0D0D;
  --fixed-bg-3:      #141414;
  --status-like:     #E8435A;
  --status-red:      #F06B6B;
  --status-red-rgb:  240, 107, 107;
  --status-green:    #4FD187;
  --status-green-rgb: 79, 209, 135;
  /* Spacing scale (white-label Phase 2b). Values are the px literals the CSS
     already uses most (8/10/12/6/14/4/16/20/2…), NOT an idealised 4-pt grid —
     adopting the scale must not move a single pixel. */
  --space-1:  2px;
  --space-2:  4px;
  --space-3:  6px;
  --space-4:  8px;
  --space-5:  10px;
  --space-6:  12px;
  --space-7:  14px;
  --space-8:  16px;
  --space-9:  20px;
  /* Theme Contract ALIASES (UIUX_REDESIGN_FRAMEWORK.md §3.4, owner ruling
     2026-10-09: canonical names are the ones below/above that the site already
     uses; the Contract's names are aliases for code that prefers them).
     Aliases resolve per element, so they follow light/dark automatically.
     A theme overrides the CANONICAL name only — overriding an alias would not
     reach the thousands of existing call sites (schema rejects alias names).
     Contract names with no equivalent yet (--text-3, --surface, --danger,
     --fs-*, --elev-*, --dur-*) are deliberately NOT aliased. */
  --bg-1: var(--bg-primary);
  --bg-2: var(--bg-secondary);
  --bg-3: var(--bg-elevated);
  --text-1: var(--text-primary);
  --text-2: var(--text-secondary);
  --accent: var(--accent-gold);
  --accent-text: var(--accent-gold-text);
  --accent-rgb: var(--accent-gold-rgb);
  --sp-1: var(--space-2);   /* 4px  */
  --sp-2: var(--space-4);   /* 8px  */
  --sp-3: var(--space-6);   /* 12px */
  --sp-4: var(--space-8);   /* 16px */
  --sp-5: var(--space-9);   /* 20px */
  --sp-6: var(--space-10);  /* 24px */
  --sp-7: 32px;
  --sp-8: 48px;
  /* Keyboard focus ring (white-label Phase 6, 2026-10-09). Follows the accent,
     so it switches with light/dark and with a project theme; a theme may set
     its own. Must stay ≥ 3:1 against the page (WCAG 1.4.11 / 2.4.7). */
  --focus-ring: #FF5A52;
  --nav-ic-guide: #FF1E1E;
  --nav-ic-my: #FF7466;
  --nav-ic-home: #B8140E;
  --nav-ic-ttm: #FFA08C;
  --nav-ic-md: #C8973A;
  --nav-ic-fanbene: #F2DA94;
  --nav-ic-find: #8FB0CC;
  --nav-ic-gohome: #4F86BA;
  --nav-ic-directory-map: #C6D8E8;
  --nav-ic-homesafe: #6CC3C9;
  --nav-ic-vote: #E8798F;
  --nav-ic-giveaway: #F5A060;
  --space-10: 24px;
  --like-red:      #E8435A;
  --like-red-rgb:  232, 67, 90;
  --success:       #02C458;
  /* Lighter pastel danger/success — separate from --like-red/--success
     (2026-08-08 fix). See style.css's identical comment for the full story:
     a badge/status-text color family nobody ever tokenized, found via a
     literal-hex sweep, measured 1.5-2.6:1 in light mode. */
  --danger-text:   #F06B6B;
  --success-text:  #4FD187;

  /* Text */
  --text-primary: #F2ECEA;
  --text-secondary: #B9AFAD;

  /* Lines / surfaces */
  --border: rgba(242, 236, 234, 0.10);

  /* Raw RGB channels (no alpha) for translucent "raised surface" overlays —
     mirrors style.css's --surface-rgb (main site) so /p matches. See that
     file's comment for the full rationale. */
  --surface-rgb: 255, 255, 255;

  /* Elevation shadows (2026-08-08 ask, mirrors style.css's same fix) — every
     box-shadow was written as literal rgba(var(--black-rgb),X); invisible against the
     near-black dark background so alpha (0.3-0.5) was never tuned, but a
     hard dark smudge on the light background. --shadow-mult scales alpha
     down in light mode instead of just retinting. */
  --shadow-rgb: 61, 0, 0;
  --shadow-mult: 1;
  /* Decorative starfield color (2026-08-08 fix) — see style.css's identical
     comment (main site) for the full story. */
  --star-rgb: 255, 255, 255;
  /* Text-shadow "glow behind text" intensity (2026-08-08 fix) — see
     style.css's identical comment (main site) for the full story. */
  --glow-mult: 1;

  /* Typography (Thai + Latin share Noto Sans Thai per main site) */
  --font-thai:  'Noto Sans Thai', sans-serif;
  --font-latin: 'Noto Sans Thai', sans-serif;
  /* Display/headline face (2026-10-12). Defaults to the body face so the
     default theme renders unchanged; a theme overrides it in theme.json. */
  --font-display: 'Cinzel', 'Noto Serif Thai', serif;

  /* Layout */
  --navbar-h: 56px;
  --bottom-h: 64px;

  /* iOS safe-area insets (binding, 2026-08-22) — every page now ships
     `viewport-fit=cover` in its viewport meta, so the layout viewport
     extends UNDER the status bar / Dynamic Island / home indicator and
     these env() values stop resolving to 0 (they did until this date, which
     is why p/gohome.css's two `env(safe-area-inset-bottom)` calls had never
     once taken effect). Always go through these tokens rather than calling
     env() inline: the fallback is centralised here, and on every non-iOS
     device / desktop browser they resolve to 0px so nothing shifts.
     --navbar-total-h is the one to subtract from 100dvh — .ppw-nav is
     `position: sticky; top: 0`, so it now occupies navbar-h PLUS the top
     inset, and a plain `calc(100dvh - var(--navbar-h))` overflows the
     viewport by exactly the inset amount. */
  --safe-top:    env(safe-area-inset-top, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  --safe-left:   env(safe-area-inset-left, 0px);
  --safe-right:  env(safe-area-inset-right, 0px);
  --navbar-total-h: calc(var(--navbar-h) + var(--safe-top));

  /* Border-radius scale (binding, 2026-08-19 — see CLAUDE.md). Mirrors
     style.css's identical scale (main site). Snap any new radius value to
     the nearest step below rather than adding a literal px value inline. */
  --radius-xs: 0px;
  --radius-sm: 0px;
  --radius-md: 2px;
  --radius-lg: 4px;
  --radius-xl: 4px;
  --radius-2xl: 4px;
  --radius-3xl: 4px;
  --radius-4xl: 4px;
  /* One extra step beyond the mirrored 8-step scale — 22px is the actual
     shared radius of the auth-gate/empty-state card component reused
     verbatim across p/auth.css, p/gohome.css, p/homesafe.css, p/my.css
     (see CLAUDE.md's "Login-gate pattern" reference implementation). Kept
     as its own token instead of snapping to --radius-4xl to avoid any
     visual change while still removing the literal px value. */
  --radius-5xl: 4px;
  --radius-pill: 4px;
  --radius-full: 50%;

}

/* In-app browser bottom inset (2026-08-27). NOT --safe-bottom: that token is
   also used as PADDING in a dozen places (#bottom-bar's own height, modal
   scrims, page bottoms), so widening it to a real number would inflate all of
   them. This one is only ever ADDED to a floating element's `bottom` offset.
   (#bottom-bar and .ppw-back-to-top omit this token on purpose since
   2026-09-23 — they keep --safe-bottom only.)
   0 everywhere except inside an in-app browser, where the app draws its own
   controls over the page without shrinking the viewport and without
   reporting anything through env(safe-area-inset-bottom) — measured 233px of
   coverage on Android 16 / TwitterAndroid 12.19.1 at 411x852, with
   innerHeight, visualViewport and all four viewport units agreeing on 852.
   The app collapses those controls (and grows its sheet) once the page is
   scrolled, which is what `is-inapp-scrolled` tracks — set from the same
   pre-paint block that sets `is-inapp`. **The intent is that a floating
   control HUGS the top edge of that bar and follows it down as it collapses,
   taking the place the reload button vacated — not that it parks somewhere
   safe and stays there** (owner's ruling, 2026-08-27). Everything reads this
   one token, so they all move together.

   Values re-measured off the probe page's own ruler rather than estimated
   (2026-08-27, Android 16 / TwitterAndroid 12.19.1, 411x852): the full bar
   covers 194-222px, the collapsed bar covers 94-132px, and — the piece
   missed the first time — `env(safe-area-inset-bottom)` reports **15px**
   there, which every one of these offsets already adds. So a control sitting
   at `20px + safe + inapp` lands at 240px / 150px, roughly 18px above the
   bar in both states. Earlier values of 233/150 put it 46px and 33px clear,
   which read as floating rather than hugging. Anything that hides itself on scroll
   never sees the smaller value; the main site's #bottom-bar, which does not,
   is the reason the second value exists at all. */
:root { --inapp-bottom: 0px; }
:root.is-inapp { --inapp-bottom: 214px; }
:root.is-inapp.is-inapp-scrolled { --inapp-bottom: 117px; }
/* iOS is a different app build with a different, much smaller control bar —
   measured 31px of coverage against Android's 222px (2026-08-27, iPhone /
   iOS 26.6, 390x575). 30px lands a floating control ~18px above that bar —
   the same clearance the Android values give — because iOS reports
   safe-area-inset-bottom as 0 where Android reports 15px, so the control's
   own 20px offset is all that is added underneath. Two classes, so it ties with the
   `is-inapp-scrolled` rule above on specificity and wins on order — which is
   intended: iOS never gets that class, since its bar does not collapse. */
/* iOS needs NOTHING here, and that is the whole point (corrected 2026-08-27,
   second pass). On iOS the app's viewport ENDS above its own controls —
   screen.height 844 vs innerHeight 575, a 269px difference that is status bar
   plus address bar plus the tweet strip — so nothing of ours is ever covered,
   and `dvh` already reports the truth (575, against vh/svh/lvh's 631). Android
   is the opposite: innerHeight 852 against screen.height 892 is only the
   status bar, so its viewport runs to the physical bottom and the app's bar
   floats ON TOP of the page, invisible to every viewport unit — all four
   report 852. The 31px measured on an iPhone screenshot was the height of a
   bar sitting OUTSIDE the viewport; reserving for it just pushed every
   control 31px too high. Zero here is a real value, not a missing one. */
:root.is-inapp.is-inapp-ios { --inapp-bottom: 0px; }
/* And iOS reports a TOP inset that is not there. In the X app's sheet the
   WebView already starts below the notch, but env(safe-area-inset-top) still
   reports the whole screen's 44px — so `.ppw-nav` (height --navbar-h + this,
   plus a matching padding-top) rendered 100px tall instead of 56px, leaving a
   44px band above it. Confirmed by the probe page's top ruler reading 0 with
   nothing covering it. Android reports 0 here, so this line is a no-op there.
   NOTE: the same value read 0 on an earlier measurement of the same device —
   it changes with how far the sheet is dragged, so it cannot be trusted at
   all inside this browser, in either direction. */
:root.is-inapp { --safe-top: 0px; }
/* Reserved page area vs. lifted control offset — two different questions, two
   different tokens (2026-08-27). --inapp-bottom above answers "how far up must
   a FLOATING CONTROL sit to clear the bar", and it deliberately changes when
   the bar collapses. --inapp-reserve answers "how much of the page can never
   be used", and it must NOT change on scroll: driving a layout size from the
   collapsing value would resize the map every time the page is scrolled.
   It therefore takes the larger, bar-open figure and holds it. */
:root { --inapp-reserve: 0px; }
:root.is-inapp { --inapp-reserve: 229px; }
:root.is-inapp.is-inapp-ios { --inapp-reserve: 0px; }

/* Tell the browser this page IS dark, and follow the site's own toggle
   (2026-08-27). Without this declaration a browser has no way to know the
   page ships a dark theme of its own — Android WebView (which is what the X
   app's in-app browser is) then treats the page as light-only and runs its
   own algorithmic darkening on top of our CSS, recomputing every colour by
   heuristic. Reported as "colours look off in the X app on Android in dark
   mode". Must track data-theme, NOT prefers-color-scheme: the site's dark
   default is deliberate and independent of the OS setting (2026-08-06 ask),
   so a flat `color-scheme: dark` would leave light mode declaring the wrong
   scheme and its native controls rendering dark. Side benefit: scrollbars,
   checkboxes and date pickers pick the right built-in palette on their own —
   this does NOT replace the per-class `option { background }` rules, which
   are still required (see CLAUDE.md, native <select> theming). */
:root { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }

/* Light mode (space theme, no pure white) — mirrors style.css's override
   block (main site) so /p matches. Dark is always the default, regardless
   of the visitor's OS color-scheme setting (2026-08-06 ask) — no
   @media (prefers-color-scheme) branch here on purpose. Light only ever
   applies when theme-toggle.js sets data-theme explicitly. */
:root[data-theme="light"] {
  /* Deliberately deeper than a first-pass "barely off-white" (2026-08-06
     ask — mirrors style.css's same fix on the main site). */
  --bg-primary: #EFECEC;
  --bg-secondary: #F7F5F5;
  --bg-elevated: #FFFFFF;
  --modal-bg: #FFFFFF;
  --navbar-bg: #F7F5F5;
  --bottom-bg: #F7F5F5;
  --dropdown-bg: #F7F5F5;
  --text-primary: #0A0A0A;
  --text-secondary: #3D3D3D;
  --border: rgba(166, 28, 25, 0.35);
  --surface-rgb: 10, 10, 10;
  --shadow-rgb: 61, 0, 0;
  /* 0.4 -> 0.28 (2026-08-08 ask: "แอบเข้มไปหน่อย") — faded further, same ink
     color, no hue change. */
  --shadow-mult: 0.28;
  --star-rgb: 244, 161, 64;
  --glow-mult: 0.4;

  /* Accent + tier colors — tuned to a ~3.2:1 contrast floor against the
     light backgrounds above, preserving each color's own hue/saturation
     (not maxed out), same as style.css's light block (main site); see
     that file's comment for the full rationale and revision history
     (2026-08-06, three passes). */
  --accent-gold: #A61C19;
  --accent-gold-text: #A61C19;
  --accent-teal: #595959;
  --accent-teal-text: #4A4A4A;
  --accent-blue: #1D4ED8;
  --gold-btn-text: #FFFFFF;
  --teal-btn-text: #FFFFFF;
  --accent-gold-rgb: 166, 28, 25;
  --accent-teal-rgb: 89, 89, 89;
  --accent-blue-rgb: 29, 78, 216;
  --like-red:    #E73C54;
  --like-red-rgb: 231, 60, 84;
  --success:     #019241;
  --danger-text: #D81616;
  --success-text:#207C48;
  /* Boosted ~1.8x (2026-08-08 fix) — the base --space-gradient's alpha
     values were tuned for dark mode, where even a faint tint of the vivid
     dark accent colors reads clearly against the near-black page. In light
     mode --accent-teal-rgb/--accent-gold-rgb are the muted/darker variants,
     so the same alpha produced an almost invisible nebula (confirmed via
     screenshot — no visible atmosphere on /p/md). Alpha only, not color —
     same tokens, same technique. */
  --space-gradient: radial-gradient(circle at 50% 0%, rgba(166, 28, 25, 0.12) 0%, rgba(239, 236, 236, 0) 70%);
  /* JASP.ER token with no earlier declaration in this block */
  --focus-ring: #A61C19;
  --nav-ic-guide: #A61C19;
  --nav-ic-my: #D2382E;
  --nav-ic-home: #7A110E;
  --nav-ic-ttm: #C2452F;
  --nav-ic-md: #8C6A1F;
  --nav-ic-fanbene: #6B4F0E;
  --nav-ic-find: #3E6485;
  --nav-ic-gohome: #1F4A73;
  --nav-ic-directory-map: #5A7F9E;
  --nav-ic-homesafe: #1F6E78;
  --nav-ic-vote: #A3354F;
  --nav-ic-giveaway: #A64A1F;
}
:root[data-theme="dark"] {
  --bg-primary: #121011;
  --bg-secondary: #1A1617;
  --bg-elevated: #241E1F;
  --modal-bg: #1A1617;
  --navbar-bg: #121011;
  --bottom-bg: #1A1617;
  --dropdown-bg: #1A1617;
  --text-primary: #F2ECEA;
  --text-secondary: #B9AFAD;
  --border: rgba(242, 236, 234, 0.10);
  --surface-rgb: 255, 255, 255;
  --shadow-rgb: 61, 0, 0;
  --shadow-mult: 1;
  --star-rgb: 255, 255, 255;
  --glow-mult: 1;
  --accent-gold: #C0201C;
  --accent-gold-text: #FF5A52;
  --accent-teal: #D4A85A;
  --accent-teal-text: #D4A85A;
  --accent-blue: #3B82F6;
  --gold-btn-text: #FFFFFF;
  --teal-btn-text: #121011;
  --accent-gold-rgb: 192, 32, 28;
  --accent-teal-rgb: 212, 168, 90;
  --accent-blue-rgb: 59, 130, 246;
  --like-red:    #E8435A;
  --like-red-rgb: 232, 67, 90;
  --success:     #02C458;
  --danger-text: #F06B6B;
  --success-text:#4FD187;
  --space-gradient: radial-gradient(circle at 50% 0%, rgba(232, 121, 143, 0.14) 0%, rgba(18, 16, 17, 0) 70%);
  /* JASP.ER token with no earlier declaration in this block */
  --focus-ring: #FF5A52;
  --nav-ic-guide: #FF1E1E;
  --nav-ic-my: #FF7466;
  --nav-ic-home: #B8140E;
  --nav-ic-ttm: #FFA08C;
  --nav-ic-md: #C8973A;
  --nav-ic-fanbene: #F2DA94;
  --nav-ic-find: #8FB0CC;
  --nav-ic-gohome: #4F86BA;
  --nav-ic-directory-map: #C6D8E8;
  --nav-ic-homesafe: #6CC3C9;
  --nav-ic-vote: #E8798F;
  --nav-ic-giveaway: #F5A060;
}

:root {
  /* Spring-physics easing (Feel Lab reference spec: stiffness 170, damping
     16, mass 1) — a damped-harmonic-oscillator curve sampled at 25 points
     over 450ms and encoded as a CSS `linear()` easing function, instead of
     a hand-picked "back out" bezier. Gives modal/sheet entrances a real
     settle-with-overshoot feel. Falls back to the browser default (~ease)
     in browsers without `linear()` support (Chrome 113+/Safari 17.4+/
     Firefox 112+) since an unparsed value is simply ignored. */
  --ease-spring: linear(0, 0.027, 0.0969, 0.1952, 0.3093, 0.4293, 0.5476, 0.6584, 0.7581, 0.8443, 0.9162, 0.9738, 1.0178, 1.0495, 1.0704, 1.0823, 1.0869, 1.0858, 1.0806, 1.0726, 1.0629, 1.0526, 1.0422, 1.0323, 1);

  /* Shared space-themed gradient (2026-07-08 restore) — every /p page's own
     `body` rule layers this under --bg-primary: `background: var(--space-gradient), var(--bg-primary);`.
     A solid-only fallback had briefly replaced this everywhere while fixing an
     unrelated white-flash-during-load bug; that fix only needed a non-white
     *color*, not the loss of the gradient art. Paired with starfield.js's
     twinkling stars below, now applied site-wide (2026-07-10 — was
     myJASPER-only). */
  /* 2026-07-27 (Feel Lab ref) — extended from 2 corner points to 4 (a real
     mesh gradient) by adding a faint teal bottom-left + gold top-right pair
     at lower opacity than the original two. Same 2 accent colors only, no
     new palette additions. */
  --space-gradient: radial-gradient(circle at 50% 0%, rgba(232, 121, 143, 0.14) 0%, rgba(18, 16, 17, 0) 70%);
}

/* Grain overlay (2026-07-27, Feel Lab ref) — SVG feTurbulence noise at very
   low opacity to soften the space-gradient's color bands, not a decorative
   texture (kept low enough to be imperceptible at a glance). Covers every
   /p page since tokens.css is the one stylesheet all of them load. */
body::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  opacity: 0.03;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* Body-level font base (2026-07-10 fix) — every OTHER page's own body rule
   (my.css) sets this, but p/index.html (login/signup) never loads my.css;
   its own content still rendered in Thai fine because auth.css scopes
   font-family to `.auth-page`, not `body`. That gap only became visible once
   the Concert Guide popup could be opened from every page's nav — its modal
   is appended directly to `document.body` (outside `.auth-page`), so on
   this one page it fell through to the browser's default font instead of
   Noto Sans Thai. tokens.css is the only stylesheet every /p page loads
   (including this one), so the base belongs here, not in my.css. */
body { font-family: var(--font-thai); }

/* ── Shared loading spinner (2026-07-07) ─────────────────────────────────
   Every /p page loads this file, so one definition covers the whole system
   (Admin Console included). Use `.ppw-loading` (spinner + text row) for a
   page/section's initial data-fetch state, or bare `.ppw-spinner` inline
   (e.g. on a submit button) while an action is in flight. */
/* ─── Keyboard focus ring (2026-10-09) ────────────────────────────────────
   Before this, nav buttons, price chips and most controls showed only the
   browser default (1px #EEE) — invisible on light mode. :where() keeps the
   specificity at zero, so every component's own :focus-visible / outline rule
   still wins; :focus-visible means mouse clicks show nothing new. */
:where(a, button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])):focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

@keyframes ppw-spin { to { transform: rotate(360deg); } }
.ppw-spinner {
  display: inline-block;
  width: 16px; height: 16px;
  border: 2px solid rgba(var(--surface-rgb), 0.2);
  border-top-color: var(--accent-gold);
  border-radius: var(--radius-full);
  animation: ppw-spin 0.7s linear infinite;
  flex-shrink: 0;
}
.ppw-loading {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  color: var(--text-secondary);
  font-size: 15px;
  padding: 40px 0;
}
/* Separate class for the text only (2026-07-10) — was a bare text node, so
   the opacity fade below couldn't target it without also dimming the
   spinner icon beside it. */
.ppw-loading-text { opacity: 0.8; }

/* Stuck-loading recovery state (2026-07-13, see boot-timeout.js) — swapped
   in only after a page's initial spinner has already auto-reloaded once
   and is still stuck, so this stacks vertically instead of the normal
   spinner+text row to make room for the retry button. */
.ppw-loading--stuck { flex-direction: column; gap: 14px; }
.ppw-retry-btn {
  background: var(--accent-gold);
  /* Was var(--bg-primary) — same fix as style.css's identical rule
     (2026-08-08): worked by coincidence in dark mode, failed in light
     mode (3.19:1). */
  color: var(--gold-btn-text);
  border: none;
  border-radius: var(--radius-md);
  padding: 10px 24px;
  font-family: var(--font-thai);
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
@media (hover: hover) { .ppw-retry-btn:hover { opacity: 0.9; } }
/* Keeps dark mode's vivid gold + dark text in light mode too (2026-08-08 ask)
   — solid fill + in-box text has self-contained contrast, independent of
   the page background, so light mode's muted gold isn't needed here. */
:root[data-theme="light"] .ppw-retry-btn { background: var(--brand-gold); color: var(--brand-gold-ink); }

/* ── Space-vibe starfield (mountStarfield(), starfield.js) — decorative
   only, z-index:0/pointer-events:none, never affects layout, colors, or
   content. Moved here from my.css (2026-07-10) — now mounted on every /p
   page (login, myJASPER, Merch Vault, Lucky Chain), not just myJASPER, so it needs
   a home every page loads; tokens.css is the only stylesheet linked
   everywhere. find.js instead mounts it scoped inside .find-map — see the
   .is-scoped override in find.css, which wins on specificity over the
   unscoped position:fixed rule below regardless of stylesheet order. */
#p-starfield, #p-shooting-stars { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }

.p-star {
  position: absolute;
  border-radius: var(--radius-full);
  background: rgb(var(--star-rgb));
  /* Base opacity matches the animation's own low point — without this, a
     star sits at the browser's default opacity:1 (solid white dot, looking
     like a dead pixel) for its whole animation-delay before p-twinkle
     actually starts (2026-07-08 fix). */
  opacity: var(--opacity-lo, 0.2);
  animation: p-twinkle var(--twinkle-dur, 3s) var(--twinkle-delay, 0s) infinite ease-in-out;
}
@keyframes p-twinkle {
  0%, 100% { opacity: var(--opacity-lo, 0.2); transform: scale(1); }
  50%      { opacity: var(--opacity-hi, 0.9); transform: scale(1.15); }
}

.p-shooting-star {
  position: absolute;
  width: 100px; height: 1px;
  /* Peak alpha 0.85 -> 0.68 (matches main.js — 2026-08-06 "ลดความเข้มลง 20%"). */
  background: linear-gradient(90deg, rgba(var(--star-rgb),0) 0%, rgba(var(--star-rgb),0.68) 60%, rgba(var(--star-rgb),0) 100%);
  opacity: 0;
  animation: p-shoot var(--dur, 5s) var(--delay, 0s) infinite ease-in;
}
@keyframes p-shoot {
  0%   { opacity: 0; transform: translate(0, 0) rotate(var(--ang, -30deg)); }
  3%   { opacity: 0.8; }
  25%  { opacity: 0; transform: translate(var(--dx, 300px), var(--dy, 150px)) rotate(var(--ang, -30deg)); }
  100% { opacity: 0; transform: translate(var(--dx, 300px), var(--dy, 150px)) rotate(var(--ang, -30deg)); }
}

/* Real flag <img> (flag-icon.js) — replaces Unicode flag emoji everywhere,
   since Windows' Segoe UI Emoji has no flag glyphs (2026-07-09 fix). Moved
   here from auth.css (2026-07-10) — flag-icon.js is used on pages that don't
   load auth.css (e.g. admin-country.js), so the one rule needs a home every
   page actually loads; tokens.css is the only stylesheet linked everywhere. */
.flag-icon { border-radius: var(--radius-xs); object-fit: cover; vertical-align: middle; flex-shrink: 0; }
.nt-detail__redemption-flag { margin-left: 2px; }

/* Fluent Emoji <img> (emojiIcon()/emojiEl(), emoji-icons.js at the repo root)
   — replaces literal emoji characters everywhere in /p, same reasoning and
   same fix location as .flag-icon just above: it must render at text size
   (1em), not the source SVG's native ~32-48px, and this is the one
   stylesheet every /p page actually loads (2026-07-15 fix — this rule
   already existed in the main site's style.css, but /p never loads that file). */
/* transform: translateZ(0) (2026-07-27 ask) — promotes each icon to its own
   GPU compositing layer, independent of any ancestor's `backdrop-filter`
   (navbar, dropdowns, glass buttons — all over /p). Works around an iOS
   WebKit bug where content painted inside a `backdrop-filter: blur()`
   container (worse combined with `position: sticky`, exactly `.ppw-nav`'s
   case) gets rasterized at reduced fidelity along with the blur itself —
   macOS Safari is unaffected, matching every report so far. A null
   transform, so it has zero visual/layout effect on its own. */
.emoji-icon { width: 1em; height: 1em; vertical-align: -0.125em; display: inline-block; transform: translateZ(0); opacity: 0; filter: blur(4px); transition: opacity 0.2s ease, filter 0.2s ease; }
.emoji-icon.is-loaded { opacity: 1; filter: blur(0); }

/* Shared search-box clear ("×") button (search-clear.js, 2026-07-26) — every
   /p search input across the public pages and Admin Console uses this same
   button, so one definition here (the one stylesheet every /p page loads)
   keeps them visually identical. */
.search-clear-btn {
  position: absolute;
  right: 8px;
  top: 50%;
  transform: translateY(-50%);
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  border-radius: var(--radius-full);
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
}
.search-clear-btn[hidden] { display: none; }
.search-clear-btn svg { width: 12px; height: 12px; display: block; }
@media (hover: hover) { .search-clear-btn:hover { color: var(--text-primary); background: rgba(var(--surface-rgb),0.08); } }

/* ── Shared pagination (2026-07-07) — admin-shell.js's list pages AND the
   public Marketplace grid (market.js) both build this via pagination.js,
   so one definition here keeps them visually + behaviorally identical. */
.ppw-pagination {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 12px 4px 0;
  font-size: 13px;
  color: var(--text-secondary);
  flex-wrap: wrap;
}
.ppw-pagination__controls { display: flex; align-items: center; gap: 8px; }
.ppw-pagination__page { font-size: 13px; color: var(--text-secondary); min-width: 72px; text-align: center; }
.ppw-pagination__btn {
  width: 30px; height: 30px;
  display: flex; align-items: center; justify-content: center;
  background: rgba(var(--surface-rgb),0.04);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  color: var(--text-primary);
  font-size: 15px;
  cursor: pointer;
}
.ppw-pagination__btn:disabled { opacity: 0.35; cursor: default; }
.ppw-pagination__select {
  background: rgba(var(--surface-rgb),0.04);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 6px 8px;
  color: var(--text-primary);
  font-family: inherit;
  font-size: 13px;
}
.ppw-pagination__select option { background: var(--bg-elevated); color: var(--text-primary); }

/* ── Floating "back to top" button (2026-07-07, restyled same day —
   subtle/translucent by request, not an attention-grabbing accent color) ── */
.ppw-back-to-top {
  position: fixed;
  right: calc(16px + var(--safe-right));
  bottom: calc(20px + var(--safe-bottom)); /* no --inapp-bottom (2026-09-23) */
  width: 34px;
  height: 34px;
  border-radius: var(--radius-full);
  display: flex; align-items: center; justify-content: center; /* centers the SVG glyph (2026-07-15 ask — was the plain "↑" character) */
  background: rgba(var(--surface-rgb), 0.12);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
  color: rgba(var(--surface-rgb), 0.75);
  border: 1px solid rgba(var(--surface-rgb), 0.15);
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
  box-shadow: 0 4px 14px rgba(var(--shadow-rgb), calc(0.3 * var(--shadow-mult)));
  opacity: 0;
  pointer-events: none;
  /* translateZ(0) appended (2026-07-27) — same iOS backdrop-filter
     compositing fix as .emoji-icon above; this button's icon is a raw
     inline SVG (ARROW_UP_SVG, scroll-top.js), not an .emoji-icon, so it
     isn't covered by that rule. Must stay combined with the existing
     translateY (not a separate `transform` declaration) since this is a
     single `transform` property — a later duplicate would silently
     override it, dropping the fix; the .is-visible override below keeps
     the same combined form for the same reason. */
  transform: translateY(8px) translateZ(0);
  transition: opacity 0.2s ease, transform 0.2s ease, background 0.15s ease;
  z-index: 300;
}
.ppw-back-to-top.is-visible { opacity: 1; pointer-events: auto; transform: translateY(0) translateZ(0); }
@media (hover: hover) { .ppw-back-to-top:hover { background: rgba(var(--surface-rgb), 0.2); } }

/* ── Toast notifications (toast.js) ──────────────────────────────────────
   Duplicated into style.css for the main site — same convention as this
   file's scrollbar rule going the other direction (TROUBLESHOOTING_BIBLE
   §6.11). Replaces alert() sitewide (2026-07-25 ask) with a non-blocking,
   auto-dismissing notice. z-index 9999 — above nav.css's 1000/1001 and
   guide-modal.css's 1200 (the highest prior z-index anywhere in /p), so a
   toast is never hidden behind a modal. Every /p page loads this file. */
.ppw-toast-host {
  position: fixed;
  left: 50%;
  bottom: calc(24px + var(--safe-bottom) + var(--inapp-bottom));
  transform: translateX(-50%);
  z-index: 9999;
  display: flex;
  flex-direction: column-reverse;
  gap: 8px;
  align-items: center;
  pointer-events: none;
  max-width: calc(100vw - 32px);
}
.ppw-toast {
  pointer-events: auto;
  cursor: pointer;
  padding: 16px 22px;
  border-radius: var(--radius-xl);
  /* A light frosted-glass tint (2026-07-26 fix) — the previous dark navy
     fill (rgba(28,30,53,...)) was too close to the page's own near-black
     background/space-gradient, so the toast barely stood out despite the
     blur. Lighter than the page reads as a distinct floating card instead.
     Fixed white glass (not rgba(var(--surface-rgb),X), unlike every other
     "raised surface" tint sitewide) — reverted 2026-08-13 back to this
     original subtle-glow-glass look after a same-day attempt at an opaque
     white-card-with-dark-text variant read as flatter/lost the frosted
     quality against the site's dark space theme. Fixed background (not a
     theme-reactive token) so this glass look stays put across the light/dark
     toggle instead of swapping. Text color BELOW stays var(--text-primary)
     though (not also fixed white) — fixed white text on this same white
     glass went unreadable in light mode (2026-08-13 follow-up fix); the
     glass tint is subtle enough that dark text in light mode still reads
     fine against it. Kept in sync with the main site's duplicate copy in
     style.css. */
  background: rgba(var(--white-rgb), 0.10);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid rgba(var(--white-rgb), 0.18);
  border-left: 5px solid var(--text-secondary);
  color: var(--text-primary);
  font-family: var(--font-thai);
  font-size: 15px;
  font-weight: 600;
  line-height: 1.5;
  box-shadow: 0 14px 36px rgba(var(--shadow-rgb), calc(0.45 * var(--shadow-mult)));
  opacity: 0;
  transform: translateY(12px);
  transition: opacity 0.2s ease, transform 0.2s ease;
  max-width: 420px;
}
.ppw-toast.is-in { opacity: 1; transform: translateY(0); }
/* Type glow (2026-07-26 ask: "more prominent") — a soft colored glow on top
   of the base drop shadow, tinted per type, on every toast sitewide. */
.ppw-toast--error { border-left-color: var(--like-red); box-shadow: 0 14px 36px rgba(var(--shadow-rgb), calc(0.45 * var(--shadow-mult))), 0 0 28px rgba(var(--like-red-rgb), 0.28); }
.ppw-toast--success { border-left-color: var(--success-text); box-shadow: 0 14px 36px rgba(var(--shadow-rgb), calc(0.45 * var(--shadow-mult))), 0 0 28px rgba(var(--status-green-rgb), 0.28); }
.ppw-toast--info { border-left-color: var(--accent-teal); box-shadow: 0 14px 36px rgba(var(--shadow-rgb), calc(0.45 * var(--shadow-mult))), 0 0 28px rgba(var(--accent-teal-rgb), 0.28); }

/* ── Shared scrollbar (2026-07-07, restyled same day) — neutral/translucent
   so it recedes into each page's own theme instead of announcing itself in
   accent-gold everywhere. Every /p page loads this file. */
* { scrollbar-width: thin; scrollbar-color: rgba(var(--surface-rgb),0.18) transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
  background: rgba(var(--surface-rgb),0.18); border-radius: var(--radius-pill); border: 2px solid transparent; background-clip: padding-box;
}
@media (hover: hover) { *::-webkit-scrollbar-thumb:hover { background: rgba(var(--surface-rgb),0.32); background-clip: padding-box; } }

/* ── Read-only lock banner (2026-08-27) — the one shared explanation shown
   where a page's write controls sit while `phase_config.site_readonly` is on.
   Built by p/readonly.js, which every /p page (never /p/admin-*) reaches
   through its own boot. Lives here rather than in a per-page stylesheet
   because it appears on eight different pages that share no other CSS file.

   The gold accent is the WASH and the border; the text takes
   --accent-gold-text, which is the token tuned for ink on that wash
   (CLAUDE.md — an identity colour is not a text colour). Both tokens are
   theme-aware, so nothing here needs a repaint hook. */
.ppw-readonly-banner {
  /* Full-width bar, never a column (2026-08-28 owner ask). `/p/my`'s
     .my-content is a 2-column CSS grid above 768px, so the banner appended
     into it became the LEFT COLUMN and shoved the dashboard sideways. These
     two lines make it span every track wherever it is dropped, and are inert
     in a flex or block parent. `width: 100%` covers the flex case, where a
     stretch default would otherwise be the only thing sizing it. */
  grid-column: 1 / -1;
  width: 100%;
  display: flex; align-items: center; gap: 8px;
  padding: 10px 14px;
  border: 1px solid rgba(var(--accent-gold-rgb), 0.35);
  background: rgba(var(--accent-gold-rgb), 0.10);
  border-radius: var(--radius-lg);
  color: var(--accent-gold-text);
  font-size: 13.5px; font-weight: 600; line-height: 1.5;
}
.ppw-readonly-banner__icon { flex-shrink: 0; display: flex; }
/* Only the lead sentence carries weight; the explanation after it stays thin
   so the bar reads as one line of information rather than a wall of bold. */
.ppw-readonly-banner__text { flex: 1; font-weight: 400; }
.ppw-readonly-banner__lead { font-weight: 700; }

/* Fixed-dark surfaces for a red-fog theme (events/jasper-temptation-2026, 2026-10-12).
   These constants stay PPW's navy everywhere else; theme.json may not override them
   (schema), so the theme's neutral-black family is set here, scoped to the motif. */
:root[data-motif-pattern="red-fog"][data-project] {
  --fixed-bg-0: #050505;
  --fixed-bg-1: #080808;
  --fixed-bg-2: #111111;
  --fixed-bg-3: #161616;
  --on-dark-text:  #F2F2F2;
  --on-dark-soft:  #D0D0D0;
  --on-dark-muted: #A6A6A6;
  --on-dark-dim:   #939393;   /* 4.8:1 on the #1A1A1A light-mode cards */
}
