/* ============================================================
   ROOY · Shared design tokens & primitives
   ───────────────────────────────────────────────────────────
   Type hierarchy maps to Apple HIG roles, tuned for Simplified
   Chinese (PingFang SC). Each token's role is fixed — DO NOT
   repurpose with inline fontSize overrides. Minimum legible
   size 11pt (HIG floor for body-glyph text).

     T.title    34/40  bold   Large Title (page hero, ≤1/screen)
     T.h1       22/28  semi   Title 2 (top-level section)
     T.h2       20/25  semi   Title 3 (card title, big group)
     T.headline 17/24  semi   Headline (emphasis / nav title)
     T.body     17/24  reg    Body (single-line reading)
     T.para     17/26  reg    Paragraph body (≥2-line CN reading)
     T.subhead  15/22  reg    Subhead (UI default, short labels)
     T.footnote 13/18  reg    Footnote (metadata, captions)
     T.eyebrow  13/18  med    Section / kicker label — no uppercase,
                              no tracking (CN-iOS). Editorial labels
                              with uppercase + tracking allowed only
                              in 08 season-b, styled inline there.
     T.caption1 12/16  reg    Timestamps, smallest UI
     T.caption2 11/14  reg    Floor — map labels / calendar weekday;
                              never use for content the user reads.

   Number scale (upright by default, italic opt-in once per screen):
     N.hero    weight 800 · tabular nums                — sized inline
     N.italic  weight 900 · italic · single anchor      — sized inline
     N.base    weight 700 · plain stat                  — sized inline
     N.reg     default weight · tabular num utility     — sized inline
   ============================================================ */

const R = {
  bg:       'var(--r-bg)',
  bg2:      'var(--r-bg2)',
  /* card — pure-white surface for inset cards / floating embed cards
     / list-group containers, sitting on the near-white paper bg.
     Resolves via CSS var so global palette tweaks still cascade.
     Don't hardcode #FFFFFF — use R.card so future surface changes
     stay in one place. */
  card:     'var(--r-card)',

  /* Ink scale — STRICT role mapping. Pick by what the text MEANS,
     not how dark you want it.
       ink   — PRIMARY CONTENT. Headings, body paragraphs, key numerals,
               list-cell titles, link-state text, button-on-paper text.
       ink2  — SECONDARY BODY. Use for:
                 · long-form CN reading (T.para multi-line) where full
                   ink reads too dense
                 · the "value" half of a labeled-value pair where eyebrow
                   labels in ink3 and the value sits between
                 · soft destructive button text (登出 / 全部断开 / 退出上课)
                 · season-b editorial stat / record row labels
               Note: inline EMPHASIS within ink body (e.g. "已经到了平台期")
               is fontWeight: 600 + R.ink — not ink2. Don't reach for ink2
               to bold-emphasize; reach for weight.
       ink3  — METADATA. Captions (T.caption1/2), footnotes, eyebrows on
               paper, placeholders, secondary sublabels, "次" "天" units.
       ink4  — CHROME. Chevrons, dividers, disabled state, hint glyphs,
               separator characters ("/" "|" between fields). Never use
               for any text the user reads for content. */
  /* Ink scale in OKLCH — perceptually uniform stops so the 4-step
     ramp reads as evenly spaced contrast levels (hex values were
     hand-tuned; oklch makes the rhythm explicit). */
  ink:      'oklch(17% 0.005 250)',           /* near-black #0E1116 */
  ink2:     'oklch(33% 0.005 270)',           /* #3C3C43 */
  ink3:     'oklch(55% 0.01 270)',            /* #76767E */
  ink4:     'oklch(76% 0.005 280)',           /* #B5B5BA */

  hair:     'oklch(17% 0.005 250 / 0.07)',
  hair2:    'oklch(17% 0.005 250 / 0.13)',

  /* iOS tertiarySystemFill — used on search fields, filter chips,
     segmented controls. One canonical token so all "neutral
     interactive grey" surfaces match. */
  systemFill: 'oklch(55% 0.005 270 / 0.12)',

  /* dark-surface label hierarchy (text on ink hero cards) — mirrors
     iOS HIG dark-mode label opacities (1.0 / 0.6 / 0.3 / 0.18). */
  inkD:     'oklch(100% 0 0)',                /* pure white */
  inkD2:    'oklch(100% 0 0 / 0.65)',
  inkD3:    'oklch(100% 0 0 / 0.40)',
  inkD4:    'oklch(100% 0 0 / 0.18)',
  hairD:    'oklch(100% 0 0 / 0.08)',

  data:     'var(--r-data)',
  rooy:     'var(--r-rooy)',
  gold:     'var(--r-gold)',
  alert:    'oklch(60% 0.21 24)',             /* warning red #D93B3B */

  /* Tinted ROOY-color surfaces — use these instead of hardcoding
     rgba() so palette tweaks (moss / cyan / teal / forest / spruce)
     propagate to bubbles, ambient glows, and active-state pills. */
  rooyTint07: 'color-mix(in srgb, var(--r-rooy) 7%, transparent)',
  rooyTint08: 'color-mix(in srgb, var(--r-rooy) 8%, transparent)',
  rooyTint12: 'color-mix(in srgb, var(--r-rooy) 12%, transparent)',
  rooyTint16: 'color-mix(in srgb, var(--r-rooy) 16%, transparent)',
  rooyGlow:   'color-mix(in srgb, var(--r-rooy) 65%, transparent)',
};

const FAM_NUM = '"SF Pro Display", -apple-system, "Helvetica Neue", system-ui, sans-serif';
const FAM_TEXT = '"PingFang SC", -apple-system, "SF Pro Text", "Helvetica Neue", system-ui, sans-serif';

/* T.* 用 HIG 标准 lineHeight（拉丁字符 ratio 1.29-1.41 紧凑）——
   默认假设单行 / 短文本场景（UI label, list cell, button text）。
   T.para 是给"多行中文段落阅读"opt-in 用的：17/26 ≈ 1.53，比 HIG
   宽出 ~25%，对应中文方块字所需呼吸量。short text 用 T.body 保持
   紧凑 cell 密度，长段落 (≥2 行 observation / 介绍语) 才换 T.para。 */
const T = {
  title:        { fontFamily: FAM_TEXT, fontSize: 34, lineHeight: '40px', fontWeight: 700, letterSpacing: '-0.02em' },
  h1:           { fontFamily: FAM_TEXT, fontSize: 22, lineHeight: '28px', fontWeight: 600, letterSpacing: '-0.01em' },
  h2:           { fontFamily: FAM_TEXT, fontSize: 20, lineHeight: '25px', fontWeight: 600, letterSpacing: '-0.01em' },
  headline:     { fontFamily: FAM_TEXT, fontSize: 17, lineHeight: '24px', fontWeight: 600 },
  body:         { fontFamily: FAM_TEXT, fontSize: 17, lineHeight: '24px', fontWeight: 400 },
  para:         { fontFamily: FAM_TEXT, fontSize: 17, lineHeight: '26px', fontWeight: 400 },
  subhead:      { fontFamily: FAM_TEXT, fontSize: 15, lineHeight: '22px', fontWeight: 400 },
  footnote:     { fontFamily: FAM_TEXT, fontSize: 13, lineHeight: '18px', fontWeight: 400 },
  caption1:     { fontFamily: FAM_TEXT, fontSize: 12, lineHeight: '16px', fontWeight: 400 },
  caption2:     { fontFamily: FAM_TEXT, fontSize: 11, lineHeight: '14px', fontWeight: 400 },
  eyebrow:      { fontFamily: FAM_TEXT, fontSize: 13, lineHeight: '18px', fontWeight: 500 },
};

const N = {
  hero:     { fontFamily: FAM_NUM, fontWeight: 800, fontFeatureSettings: '"tnum" 1', letterSpacing: '-0.02em' },
  italic:   { fontFamily: FAM_NUM, fontWeight: 900, fontStyle: 'italic', fontFeatureSettings: '"tnum" 1', letterSpacing: '-0.02em' },
  base:     { fontFamily: FAM_NUM, fontWeight: 700, letterSpacing: '-0.02em' },
  reg:      { fontFamily: FAM_NUM, fontFeatureSettings: '"tnum" 1', fontVariantNumeric: 'tabular-nums' },
};

/* Spacing — 4-pt iOS grid. Use these wherever possible so layout
   intent is legible: gap, padding, margin should snap to a scale
   step. Values between two steps (6/10/14/18/22/26) are still OK
   when a 4-step jump is too coarse, but invent only on purpose. */
const S = {
  s1:  4,   // hairline gap, tight chip-to-chip
  s2:  8,   // default inline gap
  s3:  12,  // tight card padding, list-cell vertical
  s4:  16,  // standard card padding, screen edge padding
  s5:  20,  // section padding inside screens
  s6:  24,  // hero-card padding, between-section breath
  s7:  32,  // section-to-section breath
  s8:  40,  // major page-section breath
  s9:  48,  // page-level rhythm
};

/* Border radius — anchored scale. card/sheet/hero/pill are the only
   meaningful surfaces; pick by SURFACE TYPE, not by aesthetic.
   bubble/bubbleU are asymmetric — the 6px corner is the "tail"
   pointing back at the speaker; never round all four corners or
   the bubble loses its ROOY ↔ user direction. */
const B = {
  sm:     8,                          // small inputs, sub-pills (segmented control items)
  card:   14,                         // PrimaryCTA 主按钮（全宽 ink CTA）；卡片统一走 cardL
  cardL:  16,                         // 内容卡：InsetGroup 白卡 / 浮动内容卡（LiveCard, SessionSummary）
  sheet:  20,                         // modal sheet top corners (LensSheet)
  hero:   24,                         // editorial hero cards (Home hero, history hero)
  sheetL: 28,                         // bottom sheet (ski)
  pill:   999,                        // pills, capsules, status dots
  bubble: '18px 18px 18px 6px',       // ROOY message bubble (tail bottom-left)
  bubbleU:'18px 18px 6px 18px',       // user message bubble  (tail bottom-right)
};

/* Shadow — elevation scale calibrated for iOS-native app on the
   current near-white paper bg (`rgb(250,250,252)`). Each token is a
   self-contained elevation 档位; drop + outline layers are combined
   in one box-shadow value (one elevation = one token), not split.
   Tuned weights match Apple HIG references:
     Wallet / Settings cards · Now Playing · Fitness Today ·
     context menu · UISwitch thumb.
   ANTI-pattern: don't reach for Linear / Stripe / shadcn weights
   (0.18-0.22 alpha, 28-36px blur) — that's web/SaaS dashboard, not
   iOS. If a layer needs more presence, look at color/contrast first. */
const Shadow = {
  /* card — white inset card on near-white paper. Soft 1px drop +
     0.5px hairline outline layered. Apple Wallet receipt recipe.
     Driven by `--r-card-shadow` CSS var so palette tweaks cascade. */
  card:  'var(--r-card-shadow)',
  /* cardSoft — 悬浮白卡的柔和 drop，无 hairline 描边。比 card 更抬起
     一点、比 lift 更收，用于「浮在近白底上的内容白卡」(01e Home 三卡 /
     02 天气卡)。纯 drop 不描边——描边细线读着像 border（用户不要）。 */
  cardSoft: '0 2px 10px oklch(17% 0.005 250 / 0.06)',
  /* lift — floating button (stop button 56pt, action FAB).
     iOS-tight drop; the button surface (ink or white) carries its
     own color contrast so no outline needed. */
  lift:  '0 4px 14px oklch(17% 0.005 250 / 0.12)',
  /* hero — R.ink editorial hero card (Home / Presession / History).
     Subtle "weight" sense — the dark surface already self-defines,
     shadow just confirms it sits above paper. Apple Music Now
     Playing / Fitness Today scale, not dashboard. */
  hero:  '0 6px 20px oklch(17% 0.005 250 / 0.08)',
  /* sheet — bottom sheet inverse (shadow points UP into content
     above the sheet edge). For non-modal peek sheets — modal
     sheets should use a dim overlay behind, not a drop on the sheet. */
  sheet: '0 -6px 20px oklch(17% 0.005 250 / 0.06)',
  /* modal — popover / context menu / dropdown above content.
     iOS context menu scale (not web dropdown). Hairline outline
     layered for edge clarity on light paper. */
  modal: '0 8px 24px oklch(17% 0.005 250 / 0.14), 0 0 0 0.5px oklch(17% 0.005 250 / 0.06)',
  /* alert — full-screen-center modal dialog (alert / AskRooy bubble).
     One step heavier than `modal`: a centered dialog earns more lift
     than an anchored popover, but still iOS-tight — not the 16–44px
     web-dashboard drop the old per-screen hardcoded values had drifted
     into. Hairline outline layered for edge definition on paper. */
  alert: '0 12px 32px oklch(17% 0.005 250 / 0.18), 0 0 0 0.5px oklch(17% 0.005 250 / 0.06)',
  /* thumb — UISwitch / slider thumb "draggable circle" lift used
     by 拨杆开关 (settings toggle 26pt) and SlideToStart 滑块 (60pt).
     Same recipe regardless of thumb size: the size carries the
     scale, the shadow only confirms "this floats a hair above its
     track". Matches Apple UISwitch thumb shadow spec. */
  thumb: '0 2px 6px oklch(17% 0.005 250 / 0.18)',
};

/* Z — z-index scale for layered surfaces. Use these instead of magic
   numbers so stacking stays predictable when an overlay raises another.
     popover    — anchored dropdown / context menu (above content, below sheets)
     sheet      — bottom sheet / action sheet scrim + panel
     alert      — centered modal alert dialog (above sheets)
     fullscreen — media lightbox / full-takeover (top of the stack)        */
const Z = { popover: 20, sheet: 100, alert: 150, fullscreen: 200 };

/* Scrim — the ONE canonical modal dim layer for the whole app.
   ink-tinted 0.42 + saturate 180% + blur 8px. Every overlay (sheet /
   alert / action sheet / lightbox) sits on this so "the modal layer"
   reads as one system, not five ad-hoc rgba recipes. Use via the
   `<ScrimLayer>` wrapper or spread `...Scrim` onto a custom backdrop. */
const Scrim = {
  background: 'rgba(14, 17, 22, 0.42)',
  backdropFilter: 'saturate(180%) blur(8px)',
  WebkitBackdropFilter: 'saturate(180%) blur(8px)',
};

/* Overlay keyframes — injected once globally at script load. Used by
   ScrimLayer (scrim fade), Sheet / ActionSheet (slide up), centered
   alerts (scale-in), Toast (drop-in). One shared timing language so
   every overlay enters the same way. */
if (typeof document !== 'undefined' && !document.getElementById('rooy-overlay-kf')) {
  const s = document.createElement('style');
  s.id = 'rooy-overlay-kf';
  s.textContent = `
@keyframes rooy-scrim-in { from { opacity: 0 } to { opacity: 1 } }
@keyframes rooy-sheet-up { from { transform: translateY(100%) } to { transform: translateY(0) } }
@keyframes rooy-alert-in { from { transform: scale(.92); opacity: 0 } to { transform: scale(1); opacity: 1 } }
@keyframes rooy-toast-in { from { transform: translate(-50%,-14px); opacity: 0 } to { transform: translate(-50%,0); opacity: 1 } }
`;
  document.head.appendChild(s);
}

/* ── iOS chrome ───────────────────────────────────────────── */

/* StatusBar — 两种渲染模式：
   - 默认（design canvas / phone-frame artboard 用）：mock 9:41 +
     signal/wifi/battery chrome，让 phone artboard 看起来像真机截图。
   - hide-mock 模式（prototype/index.html 用，靠 window.
     __ROOY_HIDE_MOCK_STATUSBAR flag 切换）：只渲染一条 env(safe-area-
     inset-top) 高度的透明 spacer。PWA 上 iOS 自带 status bar 会
     overlay 到这条 spacer 上方，刚好；桌面浏览器里 env = 0pt 直接
     塌掉，content 顶到 stage 顶。 */
function StatusBar({ time = '9:41', dark = false }) {
  if (typeof window !== 'undefined' && window.__ROOY_HIDE_MOCK_STATUSBAR) {
    return (
      <div style={{
        height: 'env(safe-area-inset-top, 0px)',
        flexShrink: 0,
      }} />
    );
  }
  const c = dark ? '#fff' : R.ink;
  return (
    <div style={{
      height: 54, padding: '17px 26px 0', display: 'flex',
      alignItems: 'flex-start', justifyContent: 'space-between',
      color: c, fontWeight: 600, fontSize: 17,
      fontFamily: '"SF Pro Text", -apple-system, system-ui',
      flexShrink: 0, position: 'relative', zIndex: 50,
    }}>
      <span style={{ ...N.reg, letterSpacing: '-0.01em' }}>{time}</span>
      <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
        <svg width="18" height="11" viewBox="0 0 18 11">
          {[3, 5, 7, 9].map((h, i) => <rect key={i} x={i * 4.3} y={11 - h} width="3.3" height={h} rx=".8" fill={c}/>)}
        </svg>
        <svg width="16" height="11" viewBox="0 0 16 11">
          <path d="M8 2.5c1.7 0 3.3.6 4.5 1.7M8 5.5c.9 0 1.7.3 2.4.9M8 8.6a.6.6 0 110-1.2.6.6 0 010 1.2zM3.5 4.2C4.8 3 6.3 2.5 8 2.5" stroke={c} strokeWidth="1.2" fill="none" strokeLinecap="round"/>
        </svg>
        <svg width="27" height="12" viewBox="0 0 27 12">
          <rect x=".5" y=".5" width="22" height="11" rx="3.2" stroke={c} fill="none" opacity=".4"/>
          <rect x="2" y="2" width="18.5" height="8" rx="2" fill={c}/>
          <rect x="23.2" y="3.6" width="1.5" height="4.8" rx=".5" fill={c} opacity=".4"/>
        </svg>
      </div>
    </div>
  );
}

function HomeIndicator({ dark = false }) {
  return (
    <div style={{ height: 34, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
      <div style={{ width: 134, height: 5, borderRadius: 3, background: dark ? R.inkD2 : 'rgba(14,17,22,0.32)' }}/>
    </div>
  );
}

/* ── Tab bar — ink-driven active state, icon-only ──────── */

/* TabBar — classic iOS bottom bar, label-less.
   Active state carried by ink black + filled icon (or thicker stroke
   for stroke-only icons). No text labels — three icons earn their
   own legibility. */
function TabBar({ active, onTab }) {
  const items = [
    { id: 'home', icon: HomeIcon },
    { id: 'ski',  icon: SkiIcon },
    { id: 'rooy', icon: RooyIcon },
  ];

  return (
    <div style={{
      position: 'absolute', left: 0, right: 0, bottom: 0,
      /* paddingTop 10, paddingBottom 24, icon 25, button minHeight 42。
         总高 76pt——比原 86 收 10pt（去掉原 paddingTop 14 里多出来的
         dead space），但比上一版 64 厚 12pt（用户反馈砍太狠）。iOS
         native tab bar content ≈ 49pt + 34pt home-indicator inset，
         我们 76 落在中间档. */
      paddingBottom: 24, paddingTop: 10,
      background: 'color-mix(in srgb, var(--r-bg) 92%, transparent)',
      backdropFilter: 'saturate(180%) blur(20px)',
      WebkitBackdropFilter: 'saturate(180%) blur(20px)',
      borderTop: `0.5px solid ${R.hair}`,
      display: 'flex', zIndex: 50,
    }}>
      {items.map(it => {
        const a = active === it.id;
        const Ico = it.icon;
        return (
          <button key={it.id} onClick={() => onTab && onTab(it.id)} style={{
            flex: 1, border: 'none', cursor: 'pointer', background: 'transparent',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            padding: 0, minHeight: 42,
            color: a ? R.ink : R.ink4,
          }}>
            <Ico size={25} active={a}/>
          </button>
        );
      })}
    </div>
  );
}

/* HomeIcon — house silhouette. Inactive: outline. Active: filled. */
function HomeIcon({ size = 24, active }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
      <path
        d="M3.5 11 L12 3.5 L20.5 11 V19 a1.8 1.8 0 0 1 -1.8 1.8 H5.3 a1.8 1.8 0 0 1 -1.8 -1.8 Z"
        fill={active ? 'currentColor' : 'none'}
        stroke="currentColor" strokeWidth={active ? 0 : 1.7}
        strokeLinejoin="round" strokeLinecap="round"/>
    </svg>
  );
}

/* SkiIcon — mountain peak + carving S-curve. The S-curve is what
   makes it read as "skiing" not just "terrain"; never remove it.
   Active fills the peak; the curve is always stroked. */
function SkiIcon({ size = 24, active }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
      {/* Mountain peak — sharper, single dominant peak */}
      <path
        d="M3 20 L11 5 L21 20 Z"
        fill={active ? 'currentColor' : 'none'}
        stroke="currentColor" strokeWidth={active ? 0 : 1.7}
        strokeLinejoin="round" strokeLinecap="round"/>
      {/* Carving S-curve from near apex down to lower-right */}
      <path
        d="M11.5 8 Q 9.5 13 13 15.5 T 17 19.5"
        fill="none"
        stroke={active ? '#fff' : 'currentColor'}
        strokeWidth="1.4"
        strokeLinecap="round" strokeLinejoin="round"/>
    </svg>
  );
}

/* RooyIcon — avatar disc + ROOY-color pip outside top-right.
   Disc follows outline/filled active state like the other tab icons;
   the pip is ALWAYS filled R.rooy — it's the "ROOY is present"
   brand signature, do not toggle it with active state. */
function RooyIcon({ size = 24, active }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
      {/* Disc — outline when inactive, filled when active */}
      <circle cx="10.5" cy="13.5" r="6.8"
        fill={active ? 'currentColor' : 'none'}
        stroke="currentColor" strokeWidth={active ? 0 : 1.7}/>
      {/* ROOY pip — always R.rooy, the brand presence */}
      <circle cx="18" cy="6.5" r="2.6" fill="var(--r-rooy)"/>
    </svg>
  );
}

/* ── Sub-page top bar ─────────────────────────────────────── */

function NavBar({ onBack, title, kicker, right }) {
  return (
    <div style={{
      position: 'relative', zIndex: 1, padding: '4px 12px 10px',
      display: 'flex', alignItems: 'center', justifyContent: 'space-between',
    }}>
      <button onClick={onBack} style={{
        background: 'transparent', border: 'none', cursor: 'pointer', padding: '8px 10px 8px 8px',
        display: 'flex', alignItems: 'center', minWidth: 64, minHeight: 44,
        color: R.ink,
      }}>
        <svg width="11" height="18" viewBox="0 0 11 18">
          <path d="M8.5 1L1.5 9l7 8" stroke="currentColor" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
        </svg>
      </button>
      <div style={{ textAlign: 'center' }}>
        {kicker && <p style={{ ...T.eyebrow, color: R.ink3, marginBottom: 2 }}>{kicker}</p>}
        <p style={{ ...T.headline, color: R.ink }}>{title}</p>
      </div>
      <div style={{ minWidth: 64, display: 'flex', justifyContent: 'flex-end' }}>
        {right}
      </div>
    </div>
  );
}

/* ── Primitives ───────────────────────────────────────────── */

function Dot({ size = 8, color, ring }) {
  return (
    <span style={{
      display: 'inline-block', width: size, height: size, borderRadius: '50%',
      background: color, boxShadow: ring ? `0 0 0 ${size * 0.35}px ${color}22` : 'none',
      flexShrink: 0,
    }}/>
  );
}

function Hair({ color, inset }) {
  return <div style={{
    height: 0.5, background: color || R.hair,
    marginLeft: inset || 0, marginRight: inset || 0,
  }}/>;
}

function TopoBG({ opacity = 0.02, color, dense = false }) {
  const lines = dense ? 14 : 8;
  return (
    <svg width="100%" height="100%" viewBox="0 0 390 844" preserveAspectRatio="xMidYMid slice"
      style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity }}>
      {Array.from({ length: lines }).map((_, i) => {
        const y = 80 + i * (dense ? 58 : 96);
        const amp = 24 + (i % 3) * 8;
        return (
          <path key={i}
            d={`M -30 ${y} Q 70 ${y - amp} 150 ${y} T 280 ${y - amp / 2} T 420 ${y + amp / 3}`}
            fill="none" stroke={color || R.ink} strokeWidth="0.7"/>
        );
      })}
    </svg>
  );
}

/* Big numeric. Default upright. Italic opt-in. */
function StatNumber({ value, unit, size = 64, color = R.ink, italic = false, unitColor, unitSize, baseline = true }) {
  const t = italic ? N.italic : N.hero;
  return (
    <span style={{ display: 'inline-flex', alignItems: baseline ? 'baseline' : 'flex-end', gap: Math.max(3, size * 0.06), color }}>
      <span style={{ ...t, fontSize: size, lineHeight: 0.86 }}>{value}</span>
      {unit && (
        <span style={{
          ...T.eyebrow, fontSize: unitSize || Math.max(11, size * 0.18),
          color: unitColor || R.ink3,
          paddingBottom: size * 0.10, letterSpacing: '0.04em',
        }}>{unit}</span>
      )}
    </span>
  );
}

function Bar({ pct, color = R.ink, height = 3, track }) {
  return (
    <div style={{ height, background: track || R.hair, borderRadius: height, overflow: 'hidden' }}>
      <div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: height,
        transition: 'width .6s cubic-bezier(.2,.7,.3,1)' }}/>
    </div>
  );
}

function RooyLine({ text, em, style = {}, dark = false }) {
  return (
    <div style={{ display: 'flex', gap: 11, alignItems: 'flex-start', ...style }}>
      <div style={{ flexShrink: 0, paddingTop: 8 }}>
        <Dot size={6} color={R.rooy}/>
      </div>
      <p style={{
        ...T.body,
        color: dark ? R.inkD2 : R.ink2,
        flex: 1,
      }}>
        {text}
        {em && <span style={{ color: dark ? '#fff' : R.ink, fontWeight: 600 }}> {em}</span>}
      </p>
    </div>
  );
}

/* Section title — top-level section heading (T.h1 22pt).
   Use when a section is its own information domain (e.g. "能力变化"
   in Home, "高光时刻" in Season).
   - `meta`     inline count / range next to title ("· 本季 4 个", "3 / 8")
   - `subtitle` independent description line below title ("关卡通关")
   - `action`   right-aligned ›All link */
function SectionTitle({ children, meta, subtitle, action, onAction, style = {} }) {
  return (
    <div style={{ padding: '0 20px', ...style }}>
      <div style={{
        display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12,
      }}>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flex: 1, minWidth: 0 }}>
          <h2 style={{ ...T.h1, color: R.ink }}>{children}</h2>
          {meta && <span style={{ ...T.footnote, color: R.ink3 }}>{meta}</span>}
        </div>
        {action && (
          <button onClick={onAction} style={{
            background: 'transparent', border: 'none', cursor: 'pointer',
            ...T.subhead, color: R.ink3, padding: '4px 0',
            display: 'flex', alignItems: 'center', gap: 4,
            flexShrink: 0,
          }}>
            {/* action===true → 只留箭头（无文字）；否则渲染文字 + 箭头 */}
            {action !== true && <span>{action}</span>}
            <span>›</span>
          </button>
        )}
      </div>
      {subtitle && <p style={{ ...T.footnote, color: R.ink3, marginTop: 4 }}>{subtitle}</p>}
    </div>
  );
}

/* Eyebrow section — sub-level grouping within a SectionTitle's domain.
   Used in season-b for "雪况" / "滑行模式" sub-groups. 15pt subhead +
   semibold for weight subordinate-but-readable. No letter-spacing —
   CN text doesn't need tracking, and applying it splits glyphs
   unnaturally. (08's English uppercase labels DO use tracking — but
   those are inline-styled in season-b.jsx, not via this primitive.) */
function EyebrowSection({ children, style = {} }) {
  return (
    <div style={{ padding: '0 22px 12px', ...style }}>
      <span style={{
        ...T.subhead,
        fontWeight: 600,
        color: R.ink2,
      }}>{children}</span>
    </div>
  );
}

/* Inset list group — white grouped card. Use when you have a TRUE
   list of related items. Otherwise pour content directly onto paper. */
function InsetGroup({ children, style = {} }) {
  return (
    <div style={{
      margin: '0 16px',
      background: R.card, borderRadius: B.cardL,
      overflow: 'hidden',
      ...style,
    }}>
      {React.Children.map(children, (child, i) => (
        <React.Fragment key={i}>
          {i > 0 && <Hair inset={16}/>}
          {child}
        </React.Fragment>
      ))}
    </div>
  );
}

function InsetRow({ left, leftSub, right, rightSub, onClick, accent, accessory }) {
  return (
    <div onClick={onClick} style={{
      padding: '14px 16px',
      display: 'flex', alignItems: 'center', justifyContent: 'space-between',
      cursor: onClick ? 'pointer' : 'default', gap: 12,
      minHeight: 52,
    }}>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          {accent && <Dot size={6} color={accent}/>}
          <span style={{ ...T.body, color: R.ink }}>{left}</span>
        </div>
        {leftSub && (
          <p style={{ ...T.footnote, color: R.ink3, marginTop: 3 }}>{leftSub}</p>
        )}
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <div style={{ textAlign: 'right' }}>
          {right}
          {rightSub && (
            <p style={{ ...T.footnote, color: R.ink3, marginTop: 3 }}>{rightSub}</p>
          )}
        </div>
        {accessory}
        {onClick && <Chevron style={{ marginLeft: 2 }}/>}
      </div>
    </div>
  );
}

/* ── Reusable primitives ──────────────────────────── */

/* Chevron — `›` right-pointing disclosure glyph for list-cell /
   settings-row / "tap into detail" pill. SF-style 7×12 path. */
function Chevron({ size = 12, color = R.ink4, strokeWidth = 1.6, style = {} }) {
  const w = size * (7 / 12);
  return (
    <svg width={w} height={size} viewBox="0 0 7 12" style={style}>
      <path d="M1 1l5 5-5 5" stroke={color} strokeWidth={strokeWidth}
        fill="none" strokeLinecap="round" strokeLinejoin="round"/>
    </svg>
  );
}

/* PrimaryCTA — full-width ink action button ("提交 / 下一步 / 保存").
   Don't override background — if it isn't ink, it isn't primary.
   For in-card / in-bubble actions use InlineCTA instead. */
function PrimaryCTA({ children, onClick, disabled, style = {} }) {
  return (
    <button onClick={onClick} disabled={disabled} style={{
      width: '100%', padding: '16px 0',
      background: R.ink, color: '#fff', border: 'none',
      borderRadius: B.card,
      cursor: disabled ? 'default' : 'pointer',
      opacity: disabled ? 0.4 : 1,
      ...T.headline,
      display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 10,
      ...style,
    }}>{children}</button>
  );
}

/* InlineCTA — soft ink-pill action inside a card or bubble. One
   step below PrimaryCTA so it doesn't compete with surrounding copy.
     md (default) — T.subhead 600, for card CTAs (course "试一试 →")
     sm           — T.footnote 500, for chat-bubble inline actions
   Arrow `→` is appended by default; pass arrow={false} to omit. */
function InlineCTA({ children, onClick, size = 'md', arrow = true, style = {} }) {
  const sm = size === 'sm';
  return (
    <button onClick={onClick} style={{
      ...(sm ? T.footnote : T.subhead),
      fontWeight: sm ? 500 : 600,
      color: '#fff', background: R.ink,
      border: 'none', cursor: 'pointer',
      padding: sm ? '8px 16px' : '10px 18px',
      borderRadius: B.pill,
      display: 'inline-flex', alignItems: 'center', gap: sm ? 6 : 8,
      ...style,
    }}>
      {children}
      {arrow && <span>→</span>}
    </button>
  );
}

/* TopoLines — two faint topographic wavy lines for ink hero cards.
   y1/y2 + viewBox parameterized so each hero can tune to its height.
   Stroke is hairD (white 0.08) — readable on ink without becoming
   wallpaper. Per design rule: 2 lines max, no grid, no fake terrain. */
function TopoLines({
  y1 = 70, y2 = 130,
  viewBoxW = 360, viewBoxH = 260,
  preserveAspectRatio = 'xMidYMid slice',
}) {
  const c1y = y1 - 14, t1y = y1 - 6;
  const c2y = y2 - 16, t2y = y2 - 8;
  return (
    <svg width="100%" height="100%" viewBox={`0 0 ${viewBoxW} ${viewBoxH}`}
      preserveAspectRatio={preserveAspectRatio}
      style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
      <path d={`M -30 ${y1} Q 90 ${c1y} 200 ${y1} T 420 ${t1y}`}
        fill="none" stroke={R.hairD} strokeWidth="0.5"/>
      <path d={`M -30 ${y2} Q 90 ${c2y} 200 ${y2} T 420 ${t2y}`}
        fill="none" stroke={R.hairD} strokeWidth="0.5"/>
    </svg>
  );
}

/* ACADEMY_TABS — shared Segmented items for 05 能力 / 06 课程 /
   07 成就. Single source of truth across the three Academy screens.
   'review' tab 对外标签是「能力」（能力雷达），id 仍为 review。 */
const ACADEMY_TABS = [
  { id: 'review', l: '能力' },
  { id: 'course', l: '课程' },
  { id: 'medals', l: '成就' },
];

/* Segmented — iOS systemFill segmented control. `items` is [{id, l}];
   `value` is the active id. Active: white fill + subtle lift + ink 600.
   Inactive: ink2 500 transparent. Reuse this for any future filter /
   range switcher — don't invent another. */
function Segmented({ items, value, onChange, style = {} }) {
  return (
    <div style={{
      display: 'flex', background: R.systemFill,
      borderRadius: 11, padding: 3, ...style,
    }}>
      {items.map(it => {
        const a = value === it.id;
        return (
          <button key={it.id} onClick={() => onChange && onChange(it.id)} style={{
            flex: 1, padding: '8px 0', borderRadius: 8,
            border: 'none', cursor: 'pointer',
            background: a ? '#fff' : 'transparent',
            color: a ? R.ink : R.ink2,
            ...T.footnote, fontWeight: a ? 600 : 500,
            boxShadow: a ? '0 1px 2px rgba(14,17,22,0.10)' : 'none',
            transition: 'all .15s',
          }}>{it.l}</button>
        );
      })}
    </div>
  );
}

/* UserAvatar — ink-disc initial badge for the current user.
   Canonical sizes: 44 (Home top-right → settings) / 56 (settings /
   pairing identity row). letterSize override for other sizes.
   onClick → button (44pt hit area); omit → static div. */
const _USER_AVATAR_LSIZE = { 44: 15, 56: 22 };
function UserAvatar({ size = 44, letter = 'Y', letterSize, onClick, style = {} }) {
  const fs = letterSize ?? _USER_AVATAR_LSIZE[size] ?? Math.round(size * 0.36);
  const Tag = onClick ? 'button' : 'div';
  return (
    <Tag onClick={onClick} style={{
      width: size, height: size, borderRadius: '50%',
      background: R.ink, color: '#fff', border: 'none',
      cursor: onClick ? 'pointer' : 'default',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      flexShrink: 0, padding: 0,
      ...style,
    }}>
      <span style={{
        fontFamily: '"SF Pro Display", system-ui',
        fontSize: fs, fontWeight: 700, lineHeight: 1,
      }}>{letter}</span>
    </Tag>
  );
}

/* DottedGrid — radial-dot bg behind charts / sparklines (review
   radar, ski-b/c track strip, home spotlight). One canonical look:
   0.16 opacity / 18×18 grid / 1.1px dot. Wrapper div; children render
   on top. */
function DottedGrid({ children, style = {}, ...rest }) {
  return (
    <div style={{
      backgroundImage:
        'radial-gradient(circle, rgba(14,17,22,0.16) 1.1px, transparent 1.4px)',
      backgroundSize: '18px 18px',
      backgroundPosition: 'center',
      ...style,
    }} {...rest}>{children}</div>
  );
}

/* StatusBadge — tinted pill for short status labels.
   tones:  rooy    — "正在练 / 进行中 / ROOY 推荐" (rooyTint12 + R.rooy)
           neutral — "下一步 / + boosts"          (systemFill + R.ink2)
   sizes:  sm — T.eyebrow 13/500, padding 2/8  (status verbs)
           md — T.footnote 13/500, padding 3/10 (longer labels) */
function StatusBadge({ children, tone = 'neutral', size = 'sm', style = {} }) {
  const isRooy = tone === 'rooy';
  const isMd = size === 'md';
  return (
    <span style={{
      ...(isMd ? T.footnote : T.eyebrow),
      fontWeight: 500,
      padding: isMd ? '3px 10px' : '2px 8px',
      borderRadius: B.pill,
      background: isRooy ? R.rooyTint12 : R.systemFill,
      color: isRooy ? R.rooy : R.ink2,
      whiteSpace: 'nowrap',
      ...style,
    }}>{children}</span>
  );
}

/* CCond — single column in the 02 / 02a presession weather strip
   (新雪 / 开放雪道 / 风速). Footnote label + N.hero-28 number + unit. */
function CCond({ l, v, u, c, lc, vSize = 28 }) {
  return (
    <div>
      <p style={{ ...T.footnote, color: lc || R.ink3, marginBottom: 6 }}>{l}</p>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 5 }}>
        <p style={{ ...N.hero, fontSize: vSize, color: c || R.ink, lineHeight: 1 }}>
          {v} <span style={{ ...T.caption1, color: R.ink3, fontWeight: 400 }}>{u}</span>
        </p>
      </div>
    </div>
  );
}

/* SensorGlyph — wearable pod silhouette. Pod outline + LED dot +
   status hairline, reading as "挂在滑雪靴上的小硬件" (Whoop strap /
   AirTag 视觉语言), not "wifi signal". ROOY-green 配对 pip 由 call
   site 在 glyph 外渲染。 */
function SensorGlyph({ color = R.ink }) {
  return (
    <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
      {/* Pod body — 垂直圆角矩形, 上下对称 */}
      <rect x="5.5" y="2" width="9" height="16" rx="3.5"
        stroke={color} strokeWidth="1.5" fill="none"/>
      {/* LED indicator dot (top half) */}
      <circle cx="10" cy="8.5" r="1.4" fill={color}/>
      {/* Status line / button (bottom half) */}
      <path d="M8 13.5 L12 13.5" stroke={color} strokeWidth="1.3" strokeLinecap="round"/>
    </svg>
  );
}

/* SunGlyph — disc + 8 rays. Used in presession weather
   header at size 64 (R.ink3). Size param keeps it usable
   at smaller chrome sizes if needed. */
function SunGlyph({ size = 18, color = R.ink }) {
  return (
    <svg width={size} height={size} viewBox="0 0 18 18" fill="none">
      <circle cx="9" cy="9" r="3.2" stroke={color} strokeWidth="1.4"/>
      {[0, 1, 2, 3, 4, 5, 6, 7].map(i => {
        const a = i * Math.PI / 4;
        const x1 = 9 + Math.cos(a) * 5.5, y1 = 9 + Math.sin(a) * 5.5;
        const x2 = 9 + Math.cos(a) * 7.2, y2 = 9 + Math.sin(a) * 7.2;
        return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke={color} strokeWidth="1.4" strokeLinecap="round"/>;
      })}
    </svg>
  );
}

/* ============================================================
   ROOY · Overlays (alerts / sheets / toasts / action sheets)
   ───────────────────────────────────────────────────────────
   One Scrim, one shadow ramp (Shadow.modal → Shadow.alert), one
   z-index scale (Z), one entry-animation language. Compose these
   instead of hand-rolling a backdrop per screen.

   Dismiss contract:
     · center alert   — must tap a button (no scrim dismiss), per iOS HIG
     · sheet / action — scrim tap + drag handle (swipe-down affordance)
     · toast          — no action required, auto-dismiss
   ============================================================ */

/* ScrimLayer — the dim backdrop every modal shares. `onDismiss`
   undefined → scrim tap does nothing (use this for center alerts,
   which require a button). `align` = flex alignItems ('flex-end' for
   bottom sheets, 'center' for alerts). */
function ScrimLayer({ onDismiss, zIndex = Z.sheet, align = 'flex-end', children, style = {} }) {
  return (
    <div onClick={onDismiss} style={{
      position: 'absolute', inset: 0, zIndex,
      ...Scrim,
      display: 'flex', alignItems: align, justifyContent: 'center',
      animation: 'rooy-scrim-in 180ms ease both',
      ...style,
    }}>
      {children}
    </div>
  );
}

/* SheetHandle — the iOS drag grabber. Visual affordance for
   swipe-to-dismiss (functional scrim-tap is the other path). */
function SheetHandle({ style = {} }) {
  return (
    <div style={{ display: 'flex', justifyContent: 'center', paddingTop: 8, ...style }}>
      <div style={{ width: 36, height: 5, borderRadius: 2.5, background: R.hair2 }}/>
    </div>
  );
}

/* Sheet — canonical iOS bottom sheet. ScrimLayer (tap to close) +
   drag-handle panel with B.sheet top corners + Shadow.sheet. NO close
   button: scrim tap + drag handle are the two dismiss paths. Optional
   centered `title` under the handle. Panel slides up on mount. For
   non-standard layouts (tall share sheet, embedded sheet) compose
   ScrimLayer + SheetHandle + the tokens directly instead. */
function Sheet({ onClose, title, children, style = {} }) {
  return (
    <ScrimLayer onDismiss={onClose} zIndex={Z.sheet} align="flex-end">
      <div onClick={(e) => e.stopPropagation()} style={{
        width: '100%',
        background: R.bg,
        borderTopLeftRadius: B.sheet,
        borderTopRightRadius: B.sheet,
        boxShadow: Shadow.sheet,
        paddingBottom: 20,
        animation: 'rooy-sheet-up 320ms cubic-bezier(0.2,0,0,1) both',
        ...style,
      }}>
        <SheetHandle paddingBottom={title ? 6 : 14}/>
        {title && (
          <div style={{ padding: '0 20px 12px', textAlign: 'center' }}>
            <span style={{ ...T.headline, color: R.ink }}>{title}</span>
          </div>
        )}
        {children}
      </div>
    </ScrimLayer>
  );
}

/* Toast — transient top pill for 轻提示 (复制成功 / 已加入课程 / 已设为
   目标). ✓ glyph + one line on a frosted ink pill, auto-dismiss. Sits
   at top center, floats above everything (Z.fullscreen). NOT a modal
   — no scrim, no required action. The克制 alternative to promoting
   every confirmation to an alert. `action` renders a trailing verb
   link (撤销 / 去看看). */
function Toast({ text, action, onAction, style = {} }) {
  return (
    <div style={{
      position: 'absolute', top: 56, left: '50%',
      transform: 'translateX(-50%)',
      zIndex: Z.fullscreen,
      display: 'inline-flex', alignItems: 'center', gap: 8,
      padding: '11px 16px',
      background: 'rgba(14,17,22,0.86)',
      backdropFilter: 'saturate(180%) blur(12px)',
      WebkitBackdropFilter: 'saturate(180%) blur(12px)',
      borderRadius: B.card,
      boxShadow: Shadow.alert,
      animation: 'rooy-toast-in 220ms cubic-bezier(0.2,0,0,1) both',
      ...style,
    }}>
      <svg width="15" height="15" viewBox="0 0 15 15" fill="none" style={{ flexShrink: 0 }}>
        <path d="M3 7.5l3 3 6-6.5" stroke="rgba(255,255,255,0.92)" strokeWidth="1.8"
          fill="none" strokeLinecap="round" strokeLinejoin="round"/>
      </svg>
      <span style={{ ...T.subhead, color: 'rgba(255,255,255,0.92)' }}>{text}</span>
      {action && (
        <button onClick={onAction} style={{
          ...T.subhead, fontWeight: 600, color: R.rooy,
          background: 'transparent', border: 'none', cursor: 'pointer',
          padding: 0, marginLeft: 2, fontFamily: 'inherit',
        }}>{action}</button>
      )}
    </div>
  );
}

/* ActionSheet — iOS bottom action sheet for destructive confirms
   (登出 / 断开传感器 / 删除账号). ScrimLayer + drag-handle panel;
   `actions` is a list of {label, destructive?, onClick}; cancel is
   always last and grouped apart with a gap. Replaces the old "tap a
   red row, no guard" pattern. */
function ActionSheet({ title, actions = [], onCancel, cancelLabel = '取消' }) {
  return (
    <ScrimLayer onDismiss={onCancel} zIndex={Z.sheet} align="flex-end">
      <div onClick={(e) => e.stopPropagation()} style={{
        width: '100%', padding: '0 8px 8px',
        animation: 'rooy-sheet-up 320ms cubic-bezier(0.2,0,0,1) both',
      }}>
        {/* action group */}
        <div style={{
          background: R.card, borderRadius: B.card, overflow: 'hidden',
          boxShadow: Shadow.sheet,
        }}>
          {title && (
            <div style={{
              padding: '14px 16px 12px', textAlign: 'center',
              borderBottom: `0.5px solid ${R.hair}`,
            }}>
              <span style={{ ...T.subhead, color: R.ink3 }}>{title}</span>
            </div>
          )}
          {actions.map((a, i) => (
            <button key={i} onClick={a.onClick} style={{
              width: '100%', padding: '15px 16px',
              background: 'transparent',
              borderTop: i === 0 && !title ? 'none' : `0.5px solid ${R.hair}`,
              border: 'none', cursor: 'pointer', fontFamily: 'inherit',
              ...T.body, fontWeight: 500,
              color: a.destructive ? R.alert : R.ink,
            }}>{a.label}</button>
          ))}
        </div>
        {/* cancel — grouped apart, iOS convention */}
        <div style={{
          background: R.card, borderRadius: B.card, overflow: 'hidden',
          boxShadow: Shadow.sheet, marginTop: 8,
        }}>
          <button onClick={onCancel} style={{
            width: '100%', padding: '15px 16px',
            background: 'transparent', border: 'none', cursor: 'pointer',
            fontFamily: 'inherit',
            ...T.body, fontWeight: 600, color: R.ink,
          }}>{cancelLabel}</button>
        </div>
      </div>
    </ScrimLayer>
  );
}

Object.assign(window, {
  R, T, N, S, B, Shadow, Z, Scrim,
  StatusBar, HomeIndicator, TabBar, NavBar,
  Dot, Hair, TopoBG, StatNumber, Bar, RooyLine,
  SectionTitle, EyebrowSection, InsetGroup, InsetRow,
  HomeIcon, SkiIcon, RooyIcon,
  Chevron, PrimaryCTA, InlineCTA, TopoLines, Segmented, UserAvatar, DottedGrid,
  StatusBadge, CCond, SensorGlyph, SunGlyph,
  ScrimLayer, SheetHandle, Sheet, Toast, ActionSheet,
  ACADEMY_TABS,
});
