// ─────────────────────────────────────────────────────────────
// LifeWheel Coach — shared UI primitives
// Loaded after react/babel + tokens.js + data.js
// Exports to window at the end so other Babel scripts can use them.
// ─────────────────────────────────────────────────────────────

const { useState, useEffect, useMemo, useRef, useCallback } = React;

// ── theme context (mode + accent) ──────────────────────────────
const LWThemeCtx = React.createContext(null);

function LWThemeProvider({ mode = 'dark', accent = 'emerald', density = 'comfy', lang: langProp = null, children }) {
  // recolor accent in the active palette
  const base = LWC.t(mode);
  const accentHex = base.themes[accent] || base.accent;
  // when accent is non-emerald, override accent + accentMuted
  const palette = useMemo(() => {
    const p = { ...base };
    p.accent = accentHex;
    if (mode === 'dark') {
      p.accentHover = mixHex(accentHex, '#FFFFFF', 0.18);
      p.accentMuted = hexToRgba(accentHex, 0.15);
      // Re-tint the entire surface stack toward the accent (very dark, ~6-12% mix)
      p.bg        = mixHex('#0A0E10', accentHex, 0.06);
      p.bgSubtle  = mixHex('#0F1316', accentHex, 0.07);
      p.card      = mixHex('#141A1E', accentHex, 0.10);
      p.cardHover = mixHex('#192126', accentHex, 0.12);
      p.elevated  = mixHex('#1E272D', accentHex, 0.14);
      // Secondary text gets a subtle accent wash for cohesion
      p.textSecondary = mixHex('#8FA0A8', accentHex, 0.18);
      p.textTertiary  = mixHex('#5E6F77', accentHex, 0.18);
    } else {
      p.accentHover = mixHex(accentHex, '#000000', 0.10);
      p.accentMuted = hexToRgba(accentHex, 0.12);
      // Light surfaces wash toward the accent (~5-8% mix off near-white)
      p.bg        = mixHex('#EEF2F0', accentHex, 0.06);
      p.bgSubtle  = mixHex('#F1F5F3', accentHex, 0.05);
      p.card      = mixHex('#F6F9F7', accentHex, 0.04);
      p.cardHover = mixHex('#EEF2F0', accentHex, 0.06);
      p.elevated  = mixHex('#FAFCFB', accentHex, 0.03);
      p.textSecondary = mixHex('#3E5260', accentHex, 0.20);
      p.textTertiary  = mixHex('#6E8290', accentHex, 0.18);
    }
    p.borderSubtle  = hexToRgba(accentHex, mode === 'dark' ? 0.24 : 0.12);
    p.borderDefault = hexToRgba(accentHex, mode === 'dark' ? 0.34 : 0.20);
    p.borderStrong  = hexToRgba(accentHex, mode === 'dark' ? 0.48 : 0.35);
    return p;
  }, [mode, accent, accentHex, base]);

  const type = LWC.type(mode);

  // Live language tracking: subscribe so components reading useLW() re-render
  // when the user toggles language at runtime.
  const [lang, setLangState] = useState(() => (window.LWLang ? window.LWLang.lang() : 'en'));
  useEffect(() => {
    if (langProp && window.LWLang) window.LWLang.setLang(langProp);
  }, [langProp]);
  useEffect(() => {
    if (!window.LWLang) return;
    return window.LWLang.onLangChanged(setLangState);
  }, []);
  const t = (key, vars) => (window.LWLang ? window.LWLang.t(key, vars) : key);

  const value = { mode, accent, accentHex, density, lang, t, c: palette, type, fonts: LWC.fonts };

  return (
    <LWThemeCtx.Provider value={value}>
      <PageBackdrop>{children}</PageBackdrop>
    </LWThemeCtx.Provider>
  );
}

// Defensive: if a component renders outside <LWThemeProvider> (e.g. during a
// concurrent-mode speculative render or React error recovery), or if the
// provider's value is partially constructed (rare but possible during state
// transitions), return safe stubs so destructuring `{ c, fonts, t, lang }`
// always yields defined sub-objects.
function _safeTheme() {
  return {
    mode: 'dark', accent: 'emerald', accentHex: '#3EC08D', density: 'comfy',
    lang: (window.LWLang && window.LWLang.lang()) || 'en',
    t: (key, vars) => (window.LWLang ? window.LWLang.t(key, vars) : key),
    c: (window.LWC && window.LWC.dark) || {},
    type: (window.LWC && window.LWC.type && window.LWC.type('dark')) || {},
    fonts: (window.LWC && window.LWC.fonts) || {},
  };
}
function useLW() {
  const ctx = React.useContext(LWThemeCtx);
  const safe = _safeTheme();
  const base = ctx ? {
    ...safe, ...ctx,
    c:     ctx.c     || safe.c,
    fonts: ctx.fonts || safe.fonts,
    t:     ctx.t     || safe.t,
    lang:  ctx.lang  || safe.lang,
    type:  ctx.type  || safe.type,
  } : safe;

  // Brutalism global override — if any page set window.__brutMode (or
  // localStorage has the brutmode flag), every consumer of useLW() gets
  // brutalist c+fonts. The page-level override sets this *during render*
  // of the top component; we ALSO honor localStorage for the early
  // PageBackdrop pass that happens before child renders.
  let brutMode = window.__brutMode;
  if (!brutMode) {
    try {
      const s = localStorage.getItem('lw_coach_brutmode');
      if (s === 'dark' || s === 'light') brutMode = s;
    } catch {}
  }
  // Localized canonical sphere names (per-locale V4.2 mapping). Components
  // that render sphere labels read this and ignore the iOS-stored area.title
  // so the coach always sees their own language regardless of client locale.
  const sphereNames = (window.LWLang && window.LWLang.sphereNames)
    ? window.LWLang.sphereNames(base.lang)
    : ['Health','Career','Money','Love','Joy','Growth','People','Contribution'];
  if (brutMode && window.LWBrutal) {
    const brutC = window.__brutC || window.LWBrutal.makeC(base.c, brutMode);
    const brutFonts = window.__brutFonts || { ...base.fonts, ...window.LWBrutal.fonts };
    return { ...base, c: brutC, fonts: brutFonts, brutMode, sphereNames };
  }
  return { ...base, sphereNames };
}

// ── viewport hook ──
function useViewport() {
  const [w, setW] = useState(typeof window !== 'undefined' ? window.innerWidth : 1280);
  useEffect(() => {
    const on = () => setW(window.innerWidth);
    window.addEventListener('resize', on);
    return () => window.removeEventListener('resize', on);
  }, []);
  return { w, isMobile: w < 720, isTablet: w >= 720 && w < 1024, isDesktop: w >= 1024 };
}

// ── color utils ──
function hexToRgba(hex, a) {
  const h = hex.replace('#','');
  const v = h.length === 3 ? h.split('').map(x => x + x).join('') : h;
  const r = parseInt(v.slice(0,2),16), g = parseInt(v.slice(2,4),16), b = parseInt(v.slice(4,6),16);
  return `rgba(${r},${g},${b},${a})`;
}
function mixHex(a, b, t) {
  const pa = hexToRgb(a), pb = hexToRgb(b);
  const m = (x,y) => Math.round(x + (y-x)*t);
  const to = n => n.toString(16).padStart(2,'0');
  return '#' + to(m(pa.r,pb.r)) + to(m(pa.g,pb.g)) + to(m(pa.b,pb.b));
}
function hexToRgb(hex) {
  const h = hex.replace('#','');
  const v = h.length === 3 ? h.split('').map(x=>x+x).join('') : h;
  return { r:parseInt(v.slice(0,2),16), g:parseInt(v.slice(2,4),16), b:parseInt(v.slice(4,6),16) };
}

// ── Page backdrop with sage atmospheric gradient ──
function PageBackdrop({ children }) {
  const lw = useLW();
  if (!lw) return children;
  const { c: cBase, mode } = lw;
  // If brutalism is active, use the brut tokens for the backdrop and skip
  // the radial-glow gradients (BrutalPageShell paints its own atmosphere).
  // Read from localStorage (synchronous, persistent) rather than the
  // window.__brutMode global since that's set inside child components and
  // PageBackdrop renders BEFORE its children on first paint.
  const brutModeLs = (() => {
    try { const s = localStorage.getItem('lw_coach_brutmode'); return (s === 'dark' || s === 'light') ? s : null; }
    catch { return null; }
  })();
  const isBrut = !!brutModeLs && !!window.LWBrutal;
  const c = isBrut ? window.LWBrutal.makeC(cBase, brutModeLs) : cBase;
  const bgGradient = isBrut
    ? c.bg
    : (mode === 'dark'
        ? `radial-gradient(1200px 680px at 18% -10%, ${hexToRgba(c.accent, 0.10)} 0%, transparent 60%),
           radial-gradient(900px 600px at 100% 110%, ${hexToRgba(c.accent, 0.06)} 0%, transparent 60%),
           linear-gradient(180deg, #080E0B 0%, ${c.bg} 22%, ${c.bg} 78%, #080E0B 100%)`
        : `radial-gradient(1200px 680px at 18% -10%, ${hexToRgba(c.accent, 0.10)} 0%, transparent 60%),
           radial-gradient(900px 600px at 100% 110%, ${hexToRgba(c.accent, 0.06)} 0%, transparent 60%),
           linear-gradient(180deg, #F4F8F5 0%, ${c.bg} 30%, ${c.bg} 100%)`);

  return (
    <div style={{
      minHeight: '100vh',
      background: bgGradient,
      backgroundAttachment: isBrut ? 'scroll' : 'fixed',
      color: c.textPrimary,
      fontFamily: isBrut ? '"IBM Plex Mono", monospace' : LWC.fonts.body,
      WebkitFontSmoothing: 'antialiased',
    }}>
      {/* faint film grain — skip in brutalism (Atmosphere component handles it) */}
      {!isBrut && (
        <div aria-hidden style={{
          position: 'fixed', inset: 0, pointerEvents: 'none', zIndex: 0,
          opacity: mode === 'dark' ? 0.5 : 0.25,
          backgroundImage:
            `radial-gradient(1px 1px at 20% 30%, ${hexToRgba(c.accent, 0.06)}, transparent 50%),
             radial-gradient(1px 1px at 70% 60%, ${hexToRgba(c.accent, 0.05)}, transparent 50%),
             radial-gradient(1px 1px at 40% 80%, ${hexToRgba(c.accent, 0.04)}, transparent 50%)`,
          backgroundSize: '240px 240px, 320px 320px, 200px 200px',
          mixBlendMode: mode === 'dark' ? 'screen' : 'multiply',
        }} />
      )}
      <div style={{ position: 'relative', zIndex: 1 }}>{children}</div>
    </div>
  );
}

// ── Brand mark ──
// LifeWheel mark — 8 colored petals around a center hole, same shape used on
// the iOS app icon and Claim.html. Uses the live sphere palette so it picks
// up theme-mode + accent changes without a separate asset swap.
function BrandMark({ size = 24 }) {
  const { c } = useLW();
  const sphereColors = c.spheres ? Object.values(c.spheres) : [c.accent];
  const palette = sphereColors.length === 8 ? sphereColors
                : Array.from({ length: 8 }, (_, i) => sphereColors[i % sphereColors.length]);
  const r = size / 2;
  return (
    <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden style={{ display: 'block' }}>
      {palette.map((col, i) => {
        const a0 = (i / 8) * Math.PI * 2 - Math.PI / 2;
        const a1 = ((i + 1) / 8) * Math.PI * 2 - Math.PI / 2;
        const x0 = r + Math.cos(a0) * r, y0 = r + Math.sin(a0) * r;
        const x1 = r + Math.cos(a1) * r, y1 = r + Math.sin(a1) * r;
        return <path key={i} d={`M${r} ${r} L${x0} ${y0} A${r} ${r} 0 0 1 ${x1} ${y1} Z`} fill={col} opacity={0.88} />;
      })}
      <circle cx={r} cy={r} r={r * 0.32} fill={c.bg} />
    </svg>
  );
}

// ── Card ──
function Card({ children, style, padding = 18, hoverable = false, onClick, ...rest }) {
  const { c: cBase } = useLW();
  // If brutalism is active, swap to brutalist tokens + hard borders + press shadow
  const isBrut = !!(window.__brutMode && window.__brutC);
  const c = isBrut ? window.__brutC : cBase;
  const [hover, setHover] = useState(false);
  const brutStyle = isBrut ? {
    border: `1.5px solid ${c.ink}`,
    borderRadius: 2,
    boxShadow: hoverable && hover ? `4px 4px 0 0 ${c.ink}` : `2px 2px 0 0 ${c.ink}`,
    transform: hoverable && hover ? 'translate(-1px, -1px)' : 'none',
    transition: 'transform 160ms cubic-bezier(.2,.7,.2,1), box-shadow 160ms ease, background 160ms ease',
  } : {
    border: `1px solid ${c.borderSubtle}`,
    borderRadius: 14,
    transition: 'background 0.15s ease, border-color 0.15s ease',
  };
  return (
    <div
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      onClick={onClick}
      style={{
        background: hover && hoverable ? c.cardHover : c.card,
        padding,
        cursor: onClick ? 'pointer' : 'default',
        ...brutStyle,
        ...style,
      }}
      {...rest}
    >{children}</div>
  );
}

// ── Button ──
function Button({ children, variant = 'primary', size = 'md', icon, style, onClick, type = 'button', disabled, ...rest }) {
  const { c: cBase, type: tp, fonts: fontsBase } = useLW();
  const isBrut = !!(window.__brutMode && window.__brutC);
  const c = isBrut ? window.__brutC : cBase;
  const fonts = isBrut ? (window.__brutFonts || fontsBase) : fontsBase;
  const [hover, setHover] = useState(false);
  const heights = { sm: 32, md: 40, lg: 48 };
  const px = { sm: '0 14px', md: '0 18px', lg: '0 24px' };

  const brutVariants = {
    primary: {
      background: hover ? c.accentHover : c.accent,
      color: c.textOnAccent,
      border: `1.5px solid ${c.ink}`,
    },
    ghost: {
      background: hover ? c.ink : c.bg,
      color: hover ? c.bg : c.ink,
      border: `1.5px solid ${c.ink}`,
    },
    quiet: {
      background: 'transparent',
      color: hover ? c.ink : c.textSecondary,
      border: `1.5px solid ${hover ? c.ink : 'transparent'}`,
    },
    accent: {
      background: hover ? c.accent : c.accentMuted,
      color: hover ? c.textOnAccent : c.accentInk,
      border: `1.5px solid ${c.ink}`,
    },
  };
  const variants = isBrut ? brutVariants : {
    primary: {
      background: hover ? c.accentHover : c.accent,
      color: c.textOnAccent,
      border: '1px solid transparent',
    },
    ghost: {
      background: hover ? c.cardHover : 'transparent',
      color: c.textPrimary,
      border: `1px solid ${c.borderDefault}`,
    },
    quiet: {
      background: 'transparent',
      color: hover ? c.textPrimary : c.textSecondary,
      border: '1px solid transparent',
    },
    accent: {
      background: hover ? hexToRgba(c.accent, 0.20) : c.accentMuted,
      color: c.accent,
      border: `1px solid ${hexToRgba(c.accent, 0.35)}`,
    },
  };

  const brutShadow = isBrut && variant !== 'quiet'
    ? (hover ? `4px 4px 0 0 ${c.ink}` : `2px 2px 0 0 ${c.ink}`)
    : 'none';

  return (
    <button
      type={type}
      disabled={disabled}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      onClick={onClick}
      style={{
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
        height: heights[size], padding: px[size],
        borderRadius: isBrut ? 2 : 10,
        fontFamily: isBrut ? fonts.mono : fonts.app,
        fontWeight: 700, fontSize: size === 'sm' ? (isBrut ? 11 : 13) : (isBrut ? 12 : 15),
        letterSpacing: isBrut ? '0.10em' : '0.2px',
        textTransform: isBrut ? 'uppercase' : 'none',
        cursor: disabled ? 'not-allowed' : 'pointer',
        opacity: disabled ? 0.5 : 1,
        whiteSpace: 'nowrap',
        boxShadow: brutShadow,
        transform: isBrut && hover && variant !== 'quiet' ? 'translate(-1px, -1px)' : 'none',
        transition: isBrut
          ? 'transform 120ms cubic-bezier(.2,.7,.2,1), box-shadow 120ms ease, background 160ms ease, color 160ms ease'
          : 'background 0.15s ease, color 0.15s ease',
        ...variants[variant],
        ...style,
      }}
      {...rest}
    >
      {icon && <span style={{ display:'inline-flex', alignItems: 'center' }}>{icon}</span>}
      {children}
    </button>
  );
}

// ── Pill / chip ──
function Chip({ children, color, tone = 'subtle', icon, style }) {
  const { c: cBase, fonts: fontsBase } = useLW();
  const isBrut = !!(window.__brutMode && window.__brutC);
  const c = isBrut ? window.__brutC : cBase;
  const fonts = isBrut ? (window.__brutFonts || fontsBase) : fontsBase;
  const tones = {
    subtle: { bg: isBrut ? c.bg : c.cardHover, fg: c.textSecondary, border: isBrut ? c.ink : c.borderSubtle },
    accent: { bg: isBrut ? c.bg : c.accentMuted, fg: isBrut ? c.accentInk : c.accent, border: isBrut ? c.ink : hexToRgba(c.accent, 0.35) },
    flame:  { bg: isBrut ? c.bg : c.flameBg, fg: c.flame, border: isBrut ? c.ink : c.flameBorder },
    danger: { bg: isBrut ? c.bg : hexToRgba(c.error, 0.10), fg: c.error, border: isBrut ? c.ink : hexToRgba(c.error, 0.35) },
  };
  const t = tones[tone] || tones.subtle;
  const fg = color || t.fg;
  return (
    <span style={{
      display: 'inline-flex', alignItems: 'center', gap: 6,
      padding: isBrut ? '3px 10px' : '3px 10px',
      borderRadius: isBrut ? 2 : 999,
      background: t.bg, color: fg,
      border: isBrut ? `1.5px solid ${t.border}` : `1px solid ${t.border}`,
      fontFamily: isBrut ? fonts.mono : fonts.body,
      fontSize: isBrut ? 10 : 12,
      fontWeight: isBrut ? 700 : 600,
      letterSpacing: isBrut ? '0.10em' : '0.02em',
      textTransform: isBrut ? 'uppercase' : 'none',
      whiteSpace: 'nowrap',
      ...style,
    }}>
      {icon && <span style={{ display:'inline-flex' }}>{icon}</span>}
      {children}
    </span>
  );
}

// ── Avatar ──
function Avatar({ name, initials, hue = 140, size = 36, status, photoUrl }) {
  const { c, fonts } = useLW();
  const bg = `linear-gradient(135deg, hsl(${hue}, 45%, 30%) 0%, hsl(${(hue+30)%360}, 35%, 18%) 100%)`;
  const lightBg = `linear-gradient(135deg, hsl(${hue}, 25%, 75%) 0%, hsl(${(hue+30)%360}, 25%, 60%) 100%)`;
  return (
    <div style={{
      position: 'relative',
      width: size, height: size, borderRadius: '50%',
      background: photoUrl ? `center/cover no-repeat url('${photoUrl}')` : (c.bg === '#0C1A12' ? bg : lightBg),
      display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
      color: c.bg === '#0C1A12' ? 'rgba(255,255,255,0.85)' : 'rgba(14,33,24,0.85)',
      fontFamily: fonts.app, fontWeight: 700, fontSize: size * 0.36,
      letterSpacing: '0.01em',
      boxShadow: `inset 0 0 0 1px ${c.borderSubtle}`,
      flexShrink: 0,
      overflow: 'hidden',
    }}>
      {!photoUrl && (initials || (name ? name.split(' ').map(n => n[0]).slice(0,2).join('') : '?'))}
      {status && (
        <span style={{
          position: 'absolute', bottom: -1, right: -1,
          width: size * 0.32, height: size * 0.32, borderRadius: '50%',
          background: status === 'attn' ? c.flame : status === 'live' ? c.accent : c.textTertiary,
          border: `2px solid ${c.bg}`,
        }} />
      )}
    </div>
  );
}

// ── Sphere icons (SF-style line glyphs, replace emoji) ──
const SPHERE_ICON_PATHS = {
  // health — heart with pulse line
  health: 'M12 20.5s-7-4.5-9-9.5C1.4 6.5 4.5 3.5 7.5 3.5c1.7 0 3.3.9 4.5 2.4 1.2-1.5 2.8-2.4 4.5-2.4 3 0 6.1 3 5.5 7.5-2 5-9 9.5-9 9.5z',
  // career — briefcase
  career: 'M4 8h16v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8zM9 8V6a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2M4 13h16',
  // money — dollar in circle
  money:  'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 7v10M15 9.5c0-1.4-1.3-2-3-2s-3 .5-3 1.8 1.5 1.7 3 2 3 .8 3 2.2-1.3 2-3 2-3-.6-3-2',
  // love — two interlocking circles
  love:   'M9 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zM15 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8z',
  // joy — sparkle / star burst
  joy:    'M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M5.6 18.4l2.8-2.8M15.6 8.4l2.8-2.8M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z',
  // growth — plant / arrow up
  growth: 'M12 21V10M12 10c0-3 2-5 5-5-1 4-3 5-5 5zM12 10c0-3-2-5-5-5 1 4 3 5 5 5z',
  // people — two figures
  people: 'M9 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM2 20c0-3.3 3.1-6 7-6s7 2.7 7 6M17 11a3 3 0 1 0 0-6M22 20c0-3-2.5-5.5-6-6',
  // contribution — gift box
  contribution: 'M3 8h18v4H3zM5 12v9h14v-9M12 8v13M8 8c0-2 1-3.5 2.5-3.5S12 5.5 12 8c0-2.5 1-3.5 2.5-3.5S17 6 17 8',
};
function SphereIcon({ sphere, size = 18, color = 'currentColor', strokeWidth = 1.6, iconId = null }) {
  // When iOS provides a per-area iconIdentifier, prefer the SVG mapped from that
  // id over the canonical sphere-key fallback. Both render to the same 24×24 box.
  const d = (iconId != null ? LW_ICON_PATHS_BY_ID[String(iconId)] : null) || SPHERE_ICON_PATHS[sphere];
  if (!d) return null;
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ display: 'block' }} aria-hidden="true">
      <path d={d} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

// iOS color palette → web hex. Mirrors `ColorHelper.colors` (LifeWheel/New/Helpers/ColorHelper.swift).
// Each entry has a dark-mode and a light-mode hex; we pick by current `mode`.
// Legacy color id remap matches `ColorHelper.legacyColorMap` for ids that were
// retired but live in old user data.
// Mirror of ColorHelper.legacyColorMap (LifeWheel/New/Helpers/ColorHelper.swift).
// Older user data references retired color ids that were collapsed into
// canonical equivalents. Without this remap the wheel falls back to the
// default sphere palette and misrenders (e.g. legacy id 15 → 6 green; without
// the map, "Finance" with color=15 picks up the sphere-default yellow).
const LW_COLOR_LEGACY_MAP = {
  // Red dupes → 11
  22: 11, 24: 11, 39: 11, 47: 11, 50: 11, 51: 11,
  // Orange dupes
  9: 1, 7: 26, 48: 26,
  // Green dupes
  5: 4, 15: 6, 27: 6, 38: 6, 40: 29, 10: 28,
  // Blue dupes
  17: 3, 30: 14, 31: 14, 32: 12,
  // Purple dupes
  33: 8, 34: 8, 35: 8, 37: 19, 41: 19, 45: 19,
  // Neutral dupes
  42: 20, 43: 23,
};
const LW_COLOR_PALETTE = {
  11: ['#F55050', '#D14A4A'],  0:  ['#F55091', '#D14A7A'],  49: ['#F55065', '#CC4661'],
  26: ['#F5962D', '#C87A1A'],  1:  ['#F5A72D', '#C88820'],  16: ['#F5BB2D', '#C89A1A'], 25: ['#F5CD2D', '#C4A520'],
  6:  ['#B0D52D', '#849C20'],  46: ['#46D078', '#239A55'],  4:  ['#4BD78C', '#1A7A50'],
  28: ['#46D0B0', '#1A7A6E'],  29: ['#46D0D0', '#1A7878'],
  3:  ['#50AEF5', '#3E8CC9'],  12: ['#5091F8', '#3E6CC9'],  44: ['#6080E0', '#4F5BB8'], 14: ['#46C4D5', '#1A8090'],
  2:  ['#6050F5', '#5544C9'],  8:  ['#9150F8', '#7344D4'],  13: ['#5050F5', '#4040C9'], 19: ['#F550F5', '#C944C9'], 23: ['#F550AE', '#C944A4'],
  20: ['#C9C9C9', '#8E8E8E'],  21: ['#5D9AE8', '#3E80C9'],  18: ['#E8A85D', '#C8893A'], 36: ['#E8D03A', '#C8A920'],
};
function lwColorByIosId(id, mode) {
  if (id == null) return null;
  const n = typeof id === 'number' ? id : parseInt(String(id), 10);
  if (isNaN(n)) return null;
  const canonical = LW_COLOR_LEGACY_MAP[n] != null ? LW_COLOR_LEGACY_MAP[n] : n;
  const pair = LW_COLOR_PALETTE[canonical];
  if (!pair) return null;
  return mode === 'dark' ? pair[0] : pair[1];
}

// Subset of LifeWheel/New/Helpers/IconHelper.swift mapping iOS iconIdentifier
// to a 24×24 SVG path. Covers the icons most commonly seeded by templates +
// the canonical sphere defaults (5 heart, 1 briefcase, 6 dollar, 7 heart-circle,
// 3 face-smiling, 4 cube, 2 family, 27 child). For unknown ids the wheel falls
// back to the canonical sphere icon.
const LW_ICON_PATHS_BY_ID = {
  // 5: heart.fill
  '5': 'M12 21s-7-4.5-7-10.2A4.8 4.8 0 0 1 12 5.4 4.8 4.8 0 0 1 19 10.8C19 16.5 12 21 12 21z',
  // 1: briefcase.fill
  '1': 'M3 9h18v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2',
  // 6: dollarsign.circle.fill
  '6': 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 6v12M9.5 9a2.5 2.5 0 0 1 2.5-2.5h.5a2.5 2.5 0 0 1 0 5H11a2.5 2.5 0 0 0 0 5h.5a2.5 2.5 0 0 0 2.5-2.5',
  // 7: heart.circle.fill (same heart at center)
  '7': 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 17s-4-2.7-4-6a2.7 2.7 0 0 1 4-2.4 2.7 2.7 0 0 1 4 2.4c0 3.3-4 6-4 6z',
  // 8: house.fill
  '8': 'M3 11l9-8 9 8M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5',
  // 9: lightbulb.fill
  '9': 'M9 18h6M10 21h4M9 14a4 4 0 1 1 6 0c-1 .8-1.5 1.5-1.5 3H10.5C10.5 15.5 10 14.8 9 14z',
  // 10: gift.fill
  '10': 'M3 12h18v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1zM2 8h20v4H2zM12 8v13M12 8c-2 0-3.5-1-3.5-2.5S10 3 12 5c2-2 3.5-1 3.5.5S14 8 12 8z',
  // 11: bubble.left.and.bubble.right.fill
  '11': 'M3 7a3 3 0 0 1 3-3h7a3 3 0 0 1 3 3v3a3 3 0 0 1-3 3H8l-3 3v-3a3 3 0 0 1-2-3zM10 11h7a3 3 0 0 1 3 3v3a3 3 0 0 1-3 3l-3-3h-2',
  // 13: sparkles
  '13': 'M12 3v6M12 15v6M3 12h6M15 12h6M5 5l4 4M15 15l4 4M19 5l-4 4M9 15l-4 4',
  // 14: graduationcap.fill
  '14': 'M2 9l10-5 10 5-10 5zM6 11v4c0 2 3 3 6 3s6-1 6-3v-4M21 11v4',
  // 22: person.2.fill
  '22': 'M9 13a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM2 21v-1a4 4 0 0 1 4-4h6a4 4 0 0 1 4 4v1M16 8a3 3 0 1 0 0-6M22 21v-1a3 3 0 0 0-2-3',
  // 27: figure.child
  '27': 'M12 6a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM10 8h4l2 7h-2l-1 6h-2l-1-6H8z',
  // 80: leaf.fill
  '80': 'M5 19c0-9 6-14 14-14 0 9-5 14-14 14zM5 19l8-8',
  // 45: target
  '45': 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 6a6 6 0 1 0 0 12 6 6 0 0 0 0-12zM12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4z',
  // 51: paintpalette.fill — fallback star
  '51': 'M12 2l3 7 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z',
};

// ── Sphere wheel chart (stacked-segment radial — matches LifeWheel iOS app) ──
// Each sphere = a wedge divided into 10 concentric rings. Filled rings (1..score)
// use vibrant sphere color, unfilled rings (score+1..10) use dark dimmed sphere.
// Thin separators between rings; transparent gap between wedges.
function WheelChart({ size = 200, scores, prevScores = null, showLabels = true, showAxes = true, animated = false, labels: customLabels = null, colorIds: customColorIds = null, iconIds: customIconIds = null }) {
  const { c, fonts, mode, sphereNames } = useLW();
  const isBrut = !!(window.__brutMode && window.__brutC);
  // Color resolution priority:
  //   1. Per-area iOS color IDs (real client data) → mapped via LW_COLOR_PALETTE
  //   2. Canonical sphere-key palette (demo/legacy clients)
  const fallbackColors = LWDATA.SPHERE_KEYS.map(k => c.spheres[k]);
  const colors = customColorIds && customColorIds.length === LWDATA.SPHERE_KEYS.length
    ? customColorIds.map((id, i) => lwColorByIosId(id, mode) || fallbackColors[i])
    : fallbackColors;
  // Default labels: canonical sphere names in the COACH'S locale. Coach UI
  // always speaks the coach's language even when the iOS user wrote area.title
  // in a different locale. `customLabels` is still respected for callers that
  // explicitly pass demo/preview labels.
  const baseLabels = (sphereNames && sphereNames.length === 8) ? sphereNames : LWDATA.SPHERE_NAMES;
  const labels = (customLabels && customLabels.length === LWDATA.SPHERE_NAMES.length)
    ? customLabels.map((t, i) => t || baseLabels[i])
    : baseLabels;
  const emojis = LWDATA.SPHERE_EMOJI;
  const cx = size/2, cy = size/2;
  const maxR   = size * (showLabels ? 0.32 : 0.44);
  const iconR  = maxR + size * 0.07;
  const labelR = maxR + size * 0.135;
  const N = LWDATA.SPHERE_KEYS.length;
  const RINGS = 10;
  const valid = scores && scores.every(v => typeof v === 'number');

  // -π/2 start (top), clockwise. Each wedge centered on its angle.
  const wedgeAngle = (Math.PI * 2) / N;
  const wedgeGap = wedgeAngle * 0.04; // tiny dark gap between wedges
  const angleAt = (i) => -Math.PI / 2 + wedgeAngle * i;

  // Build an annular sector (ring slice) path for wedge i, ring r (0-indexed).
  const ringSlice = (i, r) => {
    const a0 = angleAt(i) - wedgeAngle / 2 + wedgeGap / 2;
    const a1 = angleAt(i) + wedgeAngle / 2 - wedgeGap / 2;
    const r0 = (r / RINGS) * maxR;
    const r1 = ((r + 1) / RINGS) * maxR;
    const p = (r, a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)];
    const [x0a, y0a] = p(r0, a0);
    const [x0b, y0b] = p(r0, a1);
    const [x1a, y1a] = p(r1, a0);
    const [x1b, y1b] = p(r1, a1);
    return `M ${x0a.toFixed(2)} ${y0a.toFixed(2)} ` +
           `A ${r0.toFixed(2)} ${r0.toFixed(2)} 0 0 1 ${x0b.toFixed(2)} ${y0b.toFixed(2)} ` +
           `L ${x1b.toFixed(2)} ${y1b.toFixed(2)} ` +
           `A ${r1.toFixed(2)} ${r1.toFixed(2)} 0 0 0 ${x1a.toFixed(2)} ${y1a.toFixed(2)} Z`;
  };

  const polar = (r, a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)];

  const fontScale = size / 480;
  const labelSize  = Math.max(8.5, 10 * fontScale * 1.6);
  const valueSize  = Math.max(8,   9  * fontScale * 1.6);
  const iconSize   = Math.max(13,  18 * fontScale * 1.5);
  const sepStroke  = mode === 'dark' ? hexToRgba('#000000', 0.55) : hexToRgba('#FFFFFF', 0.6);
  // Dim color for unfilled rings — ~18% sat, dark
  const dimColor = (hex) => mode === 'dark' ? hexToRgba(hex, 0.16) : hexToRgba(hex, 0.10);
  const prevDot = (i) => {
    if (!prevScores || prevScores[i] == null) return null;
    const v = prevScores[i];
    const r = (v / RINGS) * maxR;
    const [x, y] = polar(r, angleAt(i));
    return { x, y, r };
  };

  const cellAvg = valid ? (scores.reduce((s,v)=>s+v,0) / N) : null;

  // Brutalist wheel: one solid filled wedge per sphere from center to score height,
  // hard ink outline, paper showing through above for the unfilled portion. Same
  // treatment as the signup-landing hero wheel — clean, printed, not muddy.
  const inkColor = isBrut ? c.ink : null;
  const brutalistWedge = (i) => {
    const score = valid ? Math.max(0, Math.min(RINGS, scores[i])) : 0;
    const color = colors[i];
    const a0 = angleAt(i) - wedgeAngle / 2 + wedgeGap / 2;
    const a1 = angleAt(i) + wedgeAngle / 2 - wedgeGap / 2;
    const rFilled = (score / RINGS) * maxR;
    const p = (r, a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)];
    const [fx0, fy0] = [cx, cy];
    const [fx1, fy1] = p(rFilled, a0);
    const [fx2, fy2] = p(rFilled, a1);
    const [ex1, ey1] = p(maxR, a0);
    const [ex2, ey2] = p(maxR, a1);
    return (
      <g key={`wedge-${i}`}>
        {/* Solid filled wedge to score */}
        {score > 0 && (
          <path
            d={`M ${fx0.toFixed(2)} ${fy0.toFixed(2)} L ${fx1.toFixed(2)} ${fy1.toFixed(2)} A ${rFilled.toFixed(2)} ${rFilled.toFixed(2)} 0 0 1 ${fx2.toFixed(2)} ${fy2.toFixed(2)} Z`}
            fill={color}
            stroke={inkColor}
            strokeWidth={Math.max(0.6, size / 200)}
            strokeLinejoin="miter"
          />
        )}
        {/* Hollow wedge from score to outer ring — paper showing through */}
        <path
          d={`M ${fx1.toFixed(2)} ${fy1.toFixed(2)} L ${ex1.toFixed(2)} ${ey1.toFixed(2)} A ${maxR.toFixed(2)} ${maxR.toFixed(2)} 0 0 1 ${ex2.toFixed(2)} ${ey2.toFixed(2)} L ${fx2.toFixed(2)} ${fy2.toFixed(2)} A ${rFilled.toFixed(2)} ${rFilled.toFixed(2)} 0 0 0 ${fx1.toFixed(2)} ${fy1.toFixed(2)} Z`}
          fill="none"
          stroke={inkColor}
          strokeWidth={Math.max(0.6, size / 200)}
        />
      </g>
    );
  };

  return (
    <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ display: 'block', overflow: 'visible' }}>
      {/* Outer ink ring (brutalist only) */}
      {isBrut && (
        <circle cx={cx} cy={cy} r={maxR} fill="none" stroke={inkColor} strokeWidth={Math.max(0.8, size / 180)} />
      )}
      {/* Per-wedge rendering: brutalist clean OR legacy 10-ring stacked */}
      {Array.from({length: N}, (_, i) => {
        if (isBrut) return brutalistWedge(i);
        const score = valid ? Math.max(0, Math.min(RINGS, scores[i])) : 0;
        const color = colors[i];
        return (
          <g key={`wedge-${i}`}>
            {Array.from({length: RINGS}, (_, r) => {
              const filled = r < score;
              return (
                <path
                  key={r}
                  d={ringSlice(i, r)}
                  fill={filled ? color : dimColor(color)}
                  stroke={sepStroke}
                  strokeWidth="0.8"
                  style={animated ? { transition: 'fill 300ms ease' } : null}
                />
              );
            })}
          </g>
        );
      })}

      {/* prev-wheel ghost dots */}
      {prevScores && Array.from({length: N}, (_, i) => {
        const d = prevDot(i);
        if (!d) return null;
        return (
          <circle key={`prev-${i}`} cx={d.x} cy={d.y} r={Math.max(2, 2.5 * fontScale * 1.2)}
            fill="none" stroke={c.textSecondary} strokeWidth="1.2" strokeDasharray="2 2" opacity="0.7" />
        );
      })}

      {/* center disc with avg score */}
      <circle cx={cx} cy={cy} r={maxR * (isBrut ? 0.20 : 0.14)}
        fill={isBrut ? c.bg : (mode === 'dark' ? '#15121f' : '#fff')}
        stroke={isBrut ? c.ink : hexToRgba(c.textTertiary, 0.3)}
        strokeWidth={isBrut ? Math.max(0.8, size / 180) : 1} />
      {cellAvg != null && (
        <text x={cx} y={cy} textAnchor="middle" dominantBaseline="central"
          style={{
            fontFamily: isBrut ? '"IBM Plex Mono", monospace' : fonts.display,
            fontSize: maxR * (isBrut ? 0.18 : 0.13),
            fontWeight: 700,
            fill: c.textPrimary,
            letterSpacing: '-0.02em',
          }}>
          {cellAvg.toFixed(1)}
        </text>
      )}

      {/* sphere icons just outside grid */}
      {showLabels && Array.from({length: N}, (_, i) => {
        const [x, y] = polar(iconR, angleAt(i));
        const s = iconSize;
        const iconId = customIconIds && customIconIds[i] != null ? customIconIds[i] : null;
        return (
          <g key={`ico-${i}`} transform={`translate(${x - s/2}, ${y - s/2})`}>
            <SphereIcon sphere={LWDATA.SPHERE_KEYS[i]} size={s} color={colors[i]} strokeWidth={1.7} iconId={iconId} />
          </g>
        );
      })}

      {/* sphere labels with value/10 — inline so the value never collides with the icon below */}
      {showLabels && Array.from({length: N}, (_, i) => {
        const [x, y] = polar(labelR, angleAt(i));
        const anchor = x < cx - 4 ? 'end' : x > cx + 4 ? 'start' : 'middle';
        const valueText = valid
          ? (Number.isInteger(scores[i]) ? String(scores[i]) : Number(scores[i]).toFixed(1))
          : '';
        // Brutalist labels: ink color, mono caps, tight letter-spacing — no
        // sphere-color italic body. Sphere color stays in the wedge fill.
        const labelStyle = isBrut ? {
          fontFamily: '"IBM Plex Mono", monospace',
          fontSize: Math.max(8, labelSize * 0.85),
          fontWeight: 700,
          fill: c.ink || c.textPrimary,
          letterSpacing: '0.06em',
          textTransform: 'uppercase',
        } : {
          fontFamily: fonts.body, fontSize: labelSize, fontWeight: 600, fill: colors[i],
        };
        const valueStyle = isBrut
          ? { fill: c.ink || c.textPrimary, opacity: 0.55 }
          : { fill: colors[i], opacity: 0.85 };
        const labelText = isBrut ? String(labels[i] || '').toUpperCase() : labels[i];
        return (
          <text key={`lab-${i}`} x={x} y={y} textAnchor={anchor}
            dominantBaseline="middle"
            style={labelStyle}>
            <tspan>{labelText}</tspan>
            {valid && (
              <tspan fontSize={valueSize} fontWeight="700" dx="0.45em" style={valueStyle}>
                {valueText}
              </tspan>
            )}
          </text>
        );
      })}

      {!valid && (
        <text x={cx} y={cy + 4} textAnchor="middle"
          style={{ fontFamily: fonts.display, fontStyle: 'italic', fontSize: size * 0.09, fill: c.textTertiary }}>
          no wheel yet
        </text>
      )}
    </svg>
  );
}

// ── Sparkline (mood week) ──
function Sparkline({ values, width = 120, height = 32, stroke, area = true, dot = true }) {
  const { c } = useLW();
  const color = stroke || c.accent;
  const filled = values.map(v => v == null ? null : v);
  const min = 0, max = 10;
  const xs = (i) => (width - 4) * (i / (filled.length - 1)) + 2;
  const ys = (v) => height - 4 - ((v - min) / (max - min)) * (height - 8);
  const pts = filled.map((v, i) => v == null ? null : [xs(i), ys(v)]).filter(Boolean);
  if (pts.length < 2) return <svg width={width} height={height}></svg>;
  const path = pts.map((p, i) => (i ? 'L' : 'M') + p[0] + ' ' + p[1]).join(' ');
  const areaPath = path + ` L${pts[pts.length-1][0]} ${height} L${pts[0][0]} ${height} Z`;
  return (
    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ display:'block' }}>
      {area && <path d={areaPath} fill={hexToRgba(color, 0.10)} />}
      <path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
      {dot && pts.length > 0 && (
        <circle cx={pts[pts.length-1][0]} cy={pts[pts.length-1][1]} r="2.5" fill={color} stroke={c.bg} strokeWidth="1" />
      )}
    </svg>
  );
}

// ── 30-day mood line w/ axes ──
function MoodTimeline({ values, width = 600, height = 140 }) {
  const { c, fonts, t } = useLW();
  const isBrut = !!(window.__brutMode && window.__brutC);
  const W = width, H = height;
  // Vertical gradient: blue at low values (sad) → cyan-green mid → warm amber
  // at high values (joyful). Aligned to the y-axis so the line reads its own
  // mood as you scan it. Stable id per render so the gradient survives re-mount.
  const gradientId = React.useMemo(() => `mood-grad-${Math.random().toString(36).slice(2, 9)}`, []);
  const gradStops = [
    { offset: '0%',   color: '#5D8CE8' },  // low → calm blue (sphere palette: people)
    { offset: '50%',  color: '#46D0B0' },  // mid → balanced teal-green
    { offset: '100%', color: '#E8C73A' },  // high → warm amber (sphere palette: money/joy)
  ];
  const padL = 28, padR = 12, padT = 10, padB = 22;
  const innerW = W - padL - padR, innerH = H - padT - padB;
  const xs = (i) => padL + (innerW * i) / (values.length - 1);
  const ys = (v) => padT + innerH - (v / 10) * innerH;

  // Brutalism path: column chart on cream paper, hard ink outline + filled
  // wedges. No gradients, no soft strokes, no dots — just a printed bar
  // chart. Each day-slot is a vertical column from baseline to value.
  if (isBrut) {
    const ink = c.ink || c.textPrimary;
    const accent = c.accent || ink;
    const colW = values.length > 1 ? innerW / values.length : innerW;
    const barW = Math.max(2, colW * 0.6);
    const baselineY = padT + innerH;
    const gridLines = [3, 5, 7];
    return (
      <svg width="100%" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block' }}>
        {/* Hard ink frame */}
        <rect x={padL} y={padT} width={innerW} height={innerH}
              fill="none" stroke={ink} strokeWidth="1.5" />
        {/* Faint y-grid as solid 0.5px ink hatches */}
        {gridLines.map(g => (
          <g key={g}>
            <line x1={padL} x2={W - padR} y1={ys(g)} y2={ys(g)}
                  stroke={ink} strokeOpacity="0.20" strokeWidth="0.5" />
            <text x={padL - 6} y={ys(g) + 3} textAnchor="end"
                  style={{ fontFamily: '"IBM Plex Mono", monospace', fontSize: 10, fontWeight: 700, fill: ink, opacity: 0.7 }}>{g}</text>
          </g>
        ))}
        {/* Bars */}
        {values.map((v, i) => {
          if (v == null) return null;
          const cx = padL + colW * i + colW / 2;
          const x0 = cx - barW / 2;
          const y0 = ys(v);
          const h = baselineY - y0;
          return (
            <rect key={i} x={x0} y={y0} width={barW} height={Math.max(1, h)}
                  fill={accent} stroke={ink} strokeWidth="1" />
          );
        })}
        {/* Axis labels — IBM Plex Mono caps */}
        {values.length > 0 && (
          [
            { slot: 'start', i: 0,                                label: t('mood.axis_days_ago', { n: values.length }) },
            { slot: 'mid',   i: Math.floor(values.length/2),       label: t('mood.axis_midpoint') },
            { slot: 'end',   i: values.length - 1,                 label: t('mood.axis_today') },
          ].map(({ slot, i, label }) => (
            <text key={slot} x={xs(i)} y={H - 6} textAnchor={slot === 'start' ? 'start' : slot === 'end' ? 'end' : 'middle'}
                  style={{ fontFamily: '"IBM Plex Mono", monospace', fontSize: 10, fill: ink, opacity: 0.75, letterSpacing: '0.10em', textTransform: 'uppercase', fontWeight: 700 }}>
              {label}
            </text>
          ))
        )}
      </svg>
    );
  }

  // Single connected path through all valid points — null days bridge to the
  // next reading rather than breaking the line. Honest about coverage via dots
  // (only days with actual readings get a dot).
  const validPoints = [];
  values.forEach((v, i) => {
    if (v == null) return;
    validPoints.push([xs(i), ys(v)]);
  });
  const path = validPoints.length
    ? validPoints.map((p, idx) => `${idx === 0 ? 'M' : 'L'}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ')
    : '';

  // simple horizontal grid at 3, 5, 7
  const gridLines = [3, 5, 7];

  return (
    <svg width="100%" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block' }}>
      <defs>
        {/* Stroke gradient: top-to-bottom maps high mood → low mood */}
        <linearGradient id={`${gradientId}-stroke`} x1="0%" y1="0%" x2="0%" y2="100%">
          <stop offset="0%"   stopColor="#E8C73A" />
          <stop offset="50%"  stopColor="#46D0B0" />
          <stop offset="100%" stopColor="#5D8CE8" />
        </linearGradient>
        {/* Area fill gradient: same hues at lower opacity */}
        <linearGradient id={`${gradientId}-fill`} x1="0%" y1="0%" x2="0%" y2="100%">
          <stop offset="0%"   stopColor="#E8C73A" stopOpacity="0.20" />
          <stop offset="60%"  stopColor="#46D0B0" stopOpacity="0.12" />
          <stop offset="100%" stopColor="#5D8CE8" stopOpacity="0.04" />
        </linearGradient>
      </defs>
      {gridLines.map(g => (
        <g key={g}>
          <line x1={padL} x2={W - padR} y1={ys(g)} y2={ys(g)}
                stroke={c.borderSubtle} strokeWidth="1" strokeDasharray="2 4" />
          <text x={padL - 6} y={ys(g) + 3} textAnchor="end"
                style={{ fontFamily: fonts.body, fontSize: 10, fill: c.textTertiary }}>{g}</text>
        </g>
      ))}
      {/* Fill polygon under the connected line — single shape across all
          valid readings so the area matches the line's continuity. */}
      {validPoints.length >= 2 && (
        <path
          d={`M${validPoints[0][0].toFixed(1)} ${ys(0).toFixed(1)} ` +
             validPoints.map(p => `L${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ') +
             ` L${validPoints[validPoints.length - 1][0].toFixed(1)} ${ys(0).toFixed(1)} Z`}
          fill={`url(#${gradientId}-fill)`}
        />
      )}
      <path d={path} fill="none" stroke={`url(#${gradientId}-stroke)`} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
      {/* dots on actual readings — color picks up the gradient stop nearest the value */}
      {values.map((v, i) => {
        if (v == null) return null;
        const dotColor = v >= 7 ? '#E8C73A' : v >= 4 ? '#46D0B0' : '#5D8CE8';
        return <circle key={i} cx={xs(i)} cy={ys(v)} r={1.8} fill={dotColor} opacity="0.85" />;
      })}
      {/* x-axis labels — always 3 stable slots so empty-data state doesn't dupe key=0 */}
      {values.length > 0 && (
        [
          { slot: 'start', i: 0,                                label: t('mood.axis_days_ago', { n: values.length }) },
          { slot: 'mid',   i: Math.floor(values.length/2),       label: t('mood.axis_midpoint') },
          { slot: 'end',   i: values.length - 1,                 label: t('mood.axis_today') },
        ].map(({ slot, i, label }) => (
          <text key={slot} x={xs(i)} y={H - 6} textAnchor={slot === 'start' ? 'start' : slot === 'end' ? 'end' : 'middle'}
                style={{ fontFamily: fonts.body, fontSize: 10, fill: c.textTertiary, letterSpacing: '0.08em', textTransform: 'uppercase', fontWeight: 600 }}>
            {label}
          </text>
        ))
      )}
    </svg>
  );
}

// ── Sphere delta bar (redesigned: per-row, icon + name + before→after + bar) ──
function SphereDeltas({ scores, prev, height = 84, labels: customLabels = null, colorIds: customColorIds = null, iconIds: customIconIds = null }) {
  const { c, fonts, mode, sphereNames } = useLW();
  const isBrut = !!(window.__brutMode && window.__brutC);
  if (!scores || !prev) return null;
  // Coach-locale canonical labels override iOS area.title — see WheelChart.
  const baseLabels = (sphereNames && sphereNames.length === 8) ? sphereNames : LWDATA.SPHERE_NAMES;
  const labels = (customLabels && customLabels.length === LWDATA.SPHERE_NAMES.length)
    ? customLabels.map((t, i) => t || baseLabels[i])
    : baseLabels;
  const fallbackColors = LWDATA.SPHERE_KEYS.map(k => c.spheres[k]);
  const colors = customColorIds && customColorIds.length === LWDATA.SPHERE_KEYS.length
    ? customColorIds.map((id, i) => lwColorByIosId(id, mode) || fallbackColors[i])
    : fallbackColors;
  const items = LWDATA.SPHERE_KEYS.map((k, i) => ({
    key: k, name: labels[i],
    color: colors[i],
    iconId: customIconIds ? customIconIds[i] : null,
    score: scores[i],
    prev: prev[i],
    delta: scores[i] - prev[i],
  }));
  // Sort: biggest movers first (drops top, then gains, then steady)
  items.sort((a, b) => {
    const aRank = a.delta < 0 ? -2 : a.delta > 0 ? -1 : 0;
    const bRank = b.delta < 0 ? -2 : b.delta > 0 ? -1 : 0;
    if (aRank !== bRank) return aRank - bRank;
    return Math.abs(b.delta) - Math.abs(a.delta);
  });
  const maxAbs = Math.max(1, ...items.map(it => Math.abs(it.delta)));
  const anyMovement = items.some(it => it.delta !== 0);
  // No-movement state: replace the noisy "7→7 · 7→7 · ..." dump with a clean
  // current-state ranking (highest → lowest sphere). Lets the coach scan the
  // shape of the wheel without the visual noise of 8 identical rows.
  if (!anyMovement) {
    const ranked = items.slice().sort((a, b) => Number(b.score) - Number(a.score));
    return (
      <div style={{ display: 'flex', flexDirection: 'column' }}>
        {ranked.map((it, idx) => {
          const isLast = idx === ranked.length - 1;
          const v = Number(it.score);
          const w = Math.max(0, Math.min(1, v / 10)) * 100;
          return (
            <div key={it.key} style={{
              display: 'grid', gridTemplateColumns: '20px 1fr minmax(120px, 1.2fr) 36px',
              alignItems: 'center', gap: 12, padding: '8px 0',
              borderBottom: isLast ? 'none' : `1px solid ${c.borderSubtle}`,
            }}>
              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
                <SphereIcon sphere={it.key} size={16} color={it.color} iconId={it.iconId} />
              </div>
              <div style={{
                fontFamily: isBrut ? '"IBM Plex Mono", monospace' : fonts.body,
                fontSize: isBrut ? 11 : 13,
                fontWeight: isBrut ? 700 : 500,
                letterSpacing: isBrut ? '0.06em' : 'normal',
                textTransform: isBrut ? 'uppercase' : 'none',
                color: c.textPrimary,
                minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
              }}>
                {it.name}
              </div>
              <div style={{
                height: isBrut ? 8 : 6,
                borderRadius: isBrut ? 0 : 3,
                background: isBrut ? c.bg : c.borderSubtle,
                border: isBrut ? `1.5px solid ${c.ink || c.textPrimary}` : 'none',
                overflow: 'hidden',
              }}>
                <div style={{
                  width: `${w}%`, height: '100%',
                  background: isBrut ? (c.ink || c.textPrimary) : it.color,
                  borderRadius: isBrut ? 0 : 3,
                }} />
              </div>
              <div style={{
                fontFamily: isBrut ? '"IBM Plex Mono", monospace' : 'inherit',
                fontSize: 13, fontWeight: 700,
                color: isBrut ? (c.ink || c.textPrimary) : c.textSecondary,
                textAlign: 'right', fontVariantNumeric: 'tabular-nums',
                letterSpacing: isBrut ? '0.02em' : 'normal',
              }}>
                {Number.isInteger(v) ? v : v.toFixed(1)}
              </div>
            </div>
          );
        })}
      </div>
    );
  }
  return (
    <div style={{ display: 'flex', flexDirection: 'column' }}>
      {items.map((it, idx) => {
        const isLast = idx === items.length - 1;
        const w = (Math.abs(it.delta) / maxAbs) * 100;
        const tone = it.delta > 0 ? c.accent : it.delta < 0 ? c.flame : c.textTertiary;
        return (
          <div key={it.key} style={{
            display: 'grid', gridTemplateColumns: '20px 1fr 56px minmax(120px, 1.2fr) 44px',
            alignItems: 'center', gap: 12,
            padding: '8px 0',
            borderBottom: isLast ? 'none' : `1px solid ${c.borderSubtle}`,
          }}>
            {/* icon */}
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
              <SphereIcon sphere={it.key} size={16} color={it.color} iconId={it.iconId} />
            </div>
            {/* name */}
            <div style={{ fontFamily: fonts.body, fontSize: 13, fontWeight: 500, color: c.textPrimary, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
              {it.name}
            </div>
            {/* before → after */}
            <div style={{ fontFamily: fonts.app, fontSize: 12, color: c.textTertiary, textAlign: 'right', whiteSpace: 'nowrap' }}>
              <span>{Number.isInteger(it.prev) ? it.prev : Number(it.prev).toFixed(1)}</span>
              <span style={{ margin: '0 4px', opacity: 0.6 }}>→</span>
              <span style={{ color: c.textSecondary, fontWeight: 700 }}>{Number.isInteger(it.score) ? it.score : Number(it.score).toFixed(1)}</span>
            </div>
            {/* center-anchored bar */}
            <div style={{ position: 'relative', height: 6 }}>
              <div style={{ position: 'absolute', inset: 0, display: 'flex' }}>
                {/* left half (negative) */}
                <div style={{ flex: 1, position: 'relative', display: 'flex', justifyContent: 'flex-end' }}>
                  {it.delta < 0 && (
                    <div style={{ width: `${w}%`, height: '100%', background: it.color, opacity: 0.85, borderRadius: '3px 0 0 3px' }} />
                  )}
                </div>
                <div style={{ width: 1, background: c.borderSubtle }} />
                {/* right half (positive) */}
                <div style={{ flex: 1, position: 'relative' }}>
                  {it.delta > 0 && (
                    <div style={{ width: `${w}%`, height: '100%', background: it.color, borderRadius: '0 3px 3px 0' }} />
                  )}
                </div>
              </div>
            </div>
            {/* delta number */}
            <div style={{
              fontFamily: fonts.app, fontSize: 14, fontWeight: 700, color: tone, textAlign: 'right',
              fontVariantNumeric: 'tabular-nums',
            }}>
              {it.delta > 0 ? '+' : ''}{it.delta || '·'}
            </div>
          </div>
        );
      })}
    </div>
  );
}

// ── Habit completion heatmap (4 weeks × 7 days) ──
function HabitHeatmap({ data, weeks = 4 }) {
  const { c, fonts } = useLW();
  // data: array of length weeks*7 with values 0..1 (completion ratio) or null
  const arr = data && data.length ? data : Array.from({length: weeks*7}, () => Math.random());
  const cell = 14, gap = 4;
  const W = 7 * cell + 6 * gap, H = weeks * cell + (weeks-1) * gap;
  const days = ['M','T','W','T','F','S','S'];
  return (
    <div>
      <svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: 'block', maxWidth: 200 }}>
        {arr.map((v, i) => {
          const w = Math.floor(i / 7), d = i % 7;
          const x = d * (cell + gap), y = w * (cell + gap);
          const v01 = v == null ? null : Math.max(0, Math.min(1, v));
          const fill = v01 == null
            ? c.borderSubtle
            : hexToRgba(c.accent, 0.12 + v01 * 0.78);
          return <rect key={i} x={x} y={y} width={cell} height={cell} rx="3" fill={fill} />;
        })}
      </svg>
      <div style={{ display: 'flex', gap: 4, marginTop: 4, fontFamily: fonts.body, fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: c.textTertiary, maxWidth: 200 }}>
        {days.map((d, i) => <div key={i} style={{ width: cell, textAlign: 'center' }}>{d}</div>)}
      </div>
    </div>
  );
}

// ── Top nav ──
function TopNav({ active = 'dashboard', coach: coachOverride }) {
  const { c, fonts, mode, t } = useLW();
  const { isMobile } = useViewport();
  const [menuOpen, setMenuOpen] = useState(false);
  const [authedCoach, setAuthedCoach] = useState(null);
  useEffect(() => {
    if (!window.LWAuth) return;
    return window.LWAuth.onAuthChanged(setAuthedCoach);
  }, []);
  // Build a coach object from the authed user's profile, falling back to demo data.
  const coach = coachOverride || (authedCoach && authedCoach.profile
    ? {
        name: authedCoach.profile.displayName || authedCoach.email,
        initials: authedCoach.profile.initials || (window.LWAuth ? window.LWAuth.initialsOf(authedCoach.profile.displayName) : '·'),
        title: authedCoach.profile.title || '',
        email: authedCoach.email,
        photoUrl: authedCoach.profile.avatarUrl || null,
      }
    : (authedCoach
        ? { name: authedCoach.email, initials: '·', title: '', email: authedCoach.email, photoUrl: null }
        : (window.LWDATA && LWDATA.COACH))) || { name: '·', initials: '·', email: '' };
  const items = [
    { id: 'dashboard', label: t('nav.today'),    href: '#/dashboard' },
    { id: 'calendar',  label: t('nav.calendar'), href: 'Calendar.html' },
    { id: 'settings',  label: t('nav.settings'), href: 'Settings.html' },
  ];
  return (
    <header style={{
      position: 'sticky', top: 0, zIndex: 20,
      background: hexToRgba(mode === 'dark' ? '#080E0B' : '#F4F8F5', 0.7),
      backdropFilter: 'saturate(140%) blur(14px)',
      WebkitBackdropFilter: 'saturate(140%) blur(14px)',
      borderBottom: `1px solid ${c.borderSubtle}`,
      paddingTop: 'env(safe-area-inset-top)',
    }}>
      <div style={{
        maxWidth: 1280, margin: '0 auto',
        padding: isMobile ? '12px 16px' : '14px 28px',
        display: 'flex', alignItems: 'center', gap: isMobile ? 12 : 28,
      }}>
        <a href="Dashboard.html" style={{ display: 'inline-flex', alignItems: 'center', gap: 9, textDecoration: 'none', color: c.textPrimary }}>
          <BrandMark size={22} />
          <span style={{ fontFamily: fonts.display, fontWeight: 600, fontSize: isMobile ? 17 : 19, letterSpacing: '-0.01em' }}>LifeWheel</span>
          {!isMobile && <span style={{ fontFamily: fonts.body, fontSize: 10, fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: c.textTertiary, marginTop: 4 }}>{t('nav.coach').toUpperCase ? t('nav.coach') : 'coach'}</span>}
        </a>
        {!isMobile && (
          <nav style={{ display: 'flex', alignItems: 'center', gap: 4, marginLeft: 8 }}>
            {items.map(it => (
              <a key={it.id} href={it.href} style={{
                padding: '8px 14px', borderRadius: 9,
                fontFamily: fonts.body, fontSize: 14, fontWeight: 500,
                color: active === it.id ? c.textPrimary : c.textSecondary,
                background: active === it.id ? c.accentMuted : 'transparent',
                textDecoration: 'none',
                border: active === it.id ? `1px solid ${hexToRgba(c.accent, 0.35)}` : '1px solid transparent',
                letterSpacing: '0.005em',
              }}>{it.label}</a>
            ))}
          </nav>
        )}
        <div style={{ flex: 1 }} />
        {!isMobile && (
          <a href="Invite.html" style={{ textDecoration: 'none' }}>
            <Button variant="primary" size="sm" icon={<span style={{ fontSize: 14 }}>+</span>}>{t('nav.invite_client')}</Button>
          </a>
        )}
        {isMobile ? (
          <button
            aria-label="Open menu"
            onClick={() => setMenuOpen(v => !v)}
            style={{
              width: 40, height: 40, borderRadius: 10,
              background: menuOpen ? c.accentMuted : 'transparent',
              border: `1px solid ${c.borderDefault}`,
              color: c.textPrimary, cursor: 'pointer',
              display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
            }}>
            <svg width="18" height="14" viewBox="0 0 18 14" fill="none">
              <path d="M1 1h16M1 7h16M1 13h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/>
            </svg>
          </button>
        ) : (
          <CoachAvatarMenu coach={coach} c={c} fonts={fonts} />
        )}
      </div>
      {isMobile && menuOpen && (
        <div style={{
          padding: '4px 16px 14px', borderTop: `1px solid ${c.borderSubtle}`,
          display: 'flex', flexDirection: 'column', gap: 2,
        }}>
          {items.map(it => (
            <a key={it.id} href={it.href} style={{
              padding: '12px 14px', borderRadius: 10,
              fontFamily: fonts.body, fontSize: 15, fontWeight: 500,
              color: active === it.id ? c.textPrimary : c.textSecondary,
              background: active === it.id ? c.accentMuted : 'transparent',
              border: active === it.id ? `1px solid ${hexToRgba(c.accent, 0.35)}` : '1px solid transparent',
              textDecoration: 'none',
            }}>{it.label}</a>
          ))}
          <a href="Invite.html" style={{ textDecoration: 'none', marginTop: 8 }}>
            <Button variant="primary" size="md" style={{ width: '100%' }} icon={<span style={{ fontSize: 14 }}>+</span>}>{t('nav.invite_client')}</Button>
          </a>
          {window.LWAuth && (
            <button onClick={() => window.LWAuth.signOut().then(() => (window.LWRouter ? window.LWRouter.go('Login.html') : (window.location.href = 'Login.html')))} style={{
              marginTop: 6, padding: '12px 14px', borderRadius: 10,
              background: 'transparent', border: `1px solid ${c.borderDefault}`,
              color: c.textSecondary, cursor: 'pointer',
              fontFamily: fonts.body, fontSize: 14, fontWeight: 500, textAlign: 'left',
            }}>Sign out — {coach.email || coach.name}</button>
          )}
        </div>
      )}
    </header>
  );
}

// Avatar with a small popover: Settings · Sign out (desktop).
function CoachAvatarMenu({ coach, c, fonts }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (!ref.current || !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);

  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button onClick={() => setOpen(v => !v)}
        aria-label="Account menu"
        style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'inline-flex' }}>
        <Avatar initials={coach.initials} hue={170} size={34} photoUrl={coach.photoUrl} />
      </button>
      {open && (
        <div style={{
          position: 'absolute', top: 'calc(100% + 8px)', right: 0, minWidth: 200,
          background: c.elevated, border: `1px solid ${c.borderDefault}`,
          borderRadius: 12, boxShadow: '0 12px 36px rgba(0,0,0,0.30)',
          padding: 6, zIndex: 30,
        }}>
          <div style={{
            padding: '8px 12px 10px', borderBottom: `1px solid ${c.borderSubtle}`, marginBottom: 4,
            fontFamily: fonts.body, fontSize: 12, color: c.textTertiary, lineHeight: 1.3,
          }}>
            <div style={{ color: c.textPrimary, fontWeight: 600, marginBottom: 1 }}>{coach.name || '—'}</div>
            <div>{coach.email}</div>
          </div>
          <a href="Settings.html" style={{
            display: 'block', padding: '8px 12px', borderRadius: 8,
            fontFamily: fonts.body, fontSize: 14, color: c.textPrimary, textDecoration: 'none',
          }}>{window.LWLang ? window.LWLang.t('nav.settings_link') : 'Settings'}</a>
          {window.LWAuth && (
            <button onClick={() => window.LWAuth.signOut().then(() => (window.LWRouter ? window.LWRouter.go('Login.html') : (window.location.href = 'Login.html')))} style={{
              display: 'block', width: '100%', padding: '8px 12px', borderRadius: 8,
              background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left',
              fontFamily: fonts.body, fontSize: 14, color: c.textSecondary,
            }}>{window.LWLang ? window.LWLang.t('nav.sign_out') : 'Sign out'}</button>
          )}
        </div>
      )}
    </div>
  );
}

// ── PageShell wrapper ──
function PageShell({ active, children, maxWidth = 1280 }) {
  const { isMobile } = useViewport();
  // If brutalism is active for this session (set by any in-app page that
  // wraps with the brutal theme override), defer to BrutalPageShell so all
  // chrome (top nav, mode toggle) renders in brutalist style.
  if (window.LWBrutal && window.__brutMode && window.__brutC) {
    return React.createElement(window.LWBrutal.BrutalPageShell, {
      active, maxWidth,
      c: window.__brutC,
      fonts: window.__brutFonts,
      brutMode: window.__brutMode,
      setBrutMode: window.__brutSetMode,
    }, children);
  }
  return (
    <>
      <TopNav active={active} />
      <main style={{
        maxWidth, margin: '0 auto',
        padding: isMobile ? '20px 16px calc(96px + env(safe-area-inset-bottom))' : '32px 28px 96px',
      }}>
        {children}
      </main>
    </>
  );
}

// ── Section heading ──
function SectionHead({ eyebrow, title, accent, right, style }) {
  const { c: cBase, fonts: fontsBase } = useLW();
  const isBrut = !!(window.__brutMode && window.__brutC);
  const c = isBrut ? window.__brutC : cBase;
  const fonts = isBrut ? (window.__brutFonts || fontsBase) : fontsBase;
  return (
    <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 16, marginBottom: 18, ...style }}>
      <div>
        {eyebrow && <div style={{
          fontFamily: isBrut ? fonts.mono : fonts.body,
          fontSize: 11, fontWeight: isBrut ? 600 : 700, letterSpacing: isBrut ? '0.18em' : '0.16em',
          textTransform: 'uppercase',
          color: c.textTertiary, marginBottom: 8,
        }}>{isBrut ? `—— ${eyebrow}` : eyebrow}</div>}
        <h2 style={{
          margin: 0,
          fontFamily: isBrut ? fonts.mono : fonts.display,
          fontWeight: isBrut ? 700 : 600,
          fontSize: isBrut ? 22 : 26,
          lineHeight: isBrut ? 1.1 : 1.15,
          letterSpacing: '-0.01em',
          color: c.textPrimary,
          textTransform: isBrut ? 'uppercase' : 'none',
        }}>
          {title}
          {accent && (isBrut
            ? <span style={{ color: c.accentInk, textDecoration: 'underline', textDecorationColor: c.accentInk, textDecorationThickness: '2px', textUnderlineOffset: '6px', textDecorationSkipInk: 'none' }}> {accent}</span>
            : <em style={{ fontStyle: 'italic', color: c.accent, fontWeight: 600 }}> {accent}</em>)}
        </h2>
      </div>
      {right}
    </div>
  );
}

// ── Hairline divider ──
function Hairline({ style }) {
  const { c } = useLW();
  return <div style={{ height: 1, background: c.borderSubtle, ...style }} />;
}

// ── tweaks bridge: read URL/localStorage/window for theme + mode ──
function readTweaks() {
  if (typeof window === 'undefined') return {};
  try {
    const saved = JSON.parse(localStorage.getItem('lwc-tweaks') || '{}');
    return saved;
  } catch { return {}; }
}
function writeTweaks(t) {
  try { localStorage.setItem('lwc-tweaks', JSON.stringify(t)); } catch {}
}

// ── Helpers exposed ──
Object.assign(window, {
  React, useState, useEffect, useMemo, useRef, useCallback,
  LWThemeProvider, useLW, useViewport,
  hexToRgba, mixHex,
  BrandMark, Card, Button, Chip, Avatar,
  WheelChart, Sparkline, MoodTimeline, SphereDeltas, HabitHeatmap,
  TopNav, PageShell, SectionHead, Hairline,
  readTweaks, writeTweaks,
});
