// ui.jsx — Shared UI primitives for Monthly Plan
// All components are aware of `tokens` (warm cream paper, ink, sage).

// ── Icons (hand-drawn, single-stroke) ──────────────────────────────────────
const Ico = {
  plus:    (s=20,c='currentColor') => <svg width={s} height={s} viewBox="0 0 20 20" fill="none"><path d="M10 4v12M4 10h12" stroke={c} strokeWidth="1.6" strokeLinecap="round"/></svg>,
  back:    (s=20,c='currentColor') => <svg width={s} height={s} viewBox="0 0 20 20" fill="none"><path d="M12 4l-6 6 6 6" stroke={c} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  close:   (s=20,c='currentColor') => <svg width={s} height={s} viewBox="0 0 20 20" fill="none"><path d="M5 5l10 10M15 5L5 15" stroke={c} strokeWidth="1.6" strokeLinecap="round"/></svg>,
  check:   (s=20,c='currentColor') => <svg width={s} height={s} viewBox="0 0 20 20" fill="none"><path d="M4 10.5l4 4 8-9" stroke={c} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  chev:    (s=20,c='currentColor') => <svg width={s} height={s} viewBox="0 0 20 20" fill="none"><path d="M8 4l6 6-6 6" stroke={c} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  trash:   (s=20,c='currentColor') => <svg width={s} height={s} viewBox="0 0 20 20" fill="none"><path d="M4 6h12M8 6V4h4v2M6 6l1 10h6l1-10" stroke={c} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  edit:    (s=20,c='currentColor') => <svg width={s} height={s} viewBox="0 0 20 20" fill="none"><path d="M3 17l4-1L17 6l-3-3L4 13l-1 4z" stroke={c} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  pause:   (s=20,c='currentColor') => <svg width={s} height={s} viewBox="0 0 20 20" fill="none"><rect x="6" y="4" width="2.5" height="12" rx="0.6" fill={c}/><rect x="11.5" y="4" width="2.5" height="12" rx="0.6" fill={c}/></svg>,
  play:    (s=20,c='currentColor') => <svg width={s} height={s} viewBox="0 0 20 20" fill="none"><path d="M6 4l11 6-11 6V4z" fill={c}/></svg>,
  // tab icons (24px)
  home:    (s=22,c='currentColor',f=false) => <svg width={s} height={s} viewBox="0 0 22 22" fill="none"><path d="M3 10l8-7 8 7v8a1 1 0 01-1 1h-4v-6h-6v6H4a1 1 0 01-1-1v-8z" stroke={c} strokeWidth="1.6" fill={f?c:'none'} strokeLinejoin="round" fillOpacity={f?.12:0}/></svg>,
  subs:    (s=22,c='currentColor',f=false) => <svg width={s} height={s} viewBox="0 0 22 22" fill="none"><rect x="3" y="5" width="16" height="13" rx="2" stroke={c} strokeWidth="1.6" fill={f?c:'none'} fillOpacity={f?.12:0}/><path d="M3 9h16M7 3v4M15 3v4" stroke={c} strokeWidth="1.6" strokeLinecap="round"/></svg>,
  cal:     (s=22,c='currentColor',f=false) => <svg width={s} height={s} viewBox="0 0 22 22" fill="none"><rect x="3" y="5" width="16" height="13" rx="2" stroke={c} strokeWidth="1.6" fill={f?c:'none'} fillOpacity={f?.12:0}/><path d="M7 3v4M15 3v4M3 9h16M7 13h2M11 13h2M15 13h0" stroke={c} strokeWidth="1.6" strokeLinecap="round"/></svg>,
  stats:   (s=22,c='currentColor',f=false) => <svg width={s} height={s} viewBox="0 0 22 22" fill="none"><path d="M4 18V12M9 18V8M14 18v-9M19 18V5" stroke={c} strokeWidth="1.8" strokeLinecap="round"/></svg>,
  cog:     (s=22,c='currentColor',f=false) => <svg width={s} height={s} viewBox="0 0 22 22" fill="none"><circle cx="11" cy="11" r="3" stroke={c} strokeWidth="1.6" fill={f?c:'none'} fillOpacity={f?.12:0}/><path d="M11 2v3M11 17v3M2 11h3M17 11h3M4.5 4.5l2 2M15.5 15.5l2 2M4.5 17.5l2-2M15.5 6.5l2-2" stroke={c} strokeWidth="1.6" strokeLinecap="round"/></svg>,
  history: (s=22,c='currentColor',f=false) => <svg width={s} height={s} viewBox="0 0 22 22" fill="none"><path d="M3 11a8 8 0 108-8" stroke={c} strokeWidth="1.6" strokeLinecap="round"/><path d="M3 4v5h5M11 7v4l3 2" stroke={c} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  coffee:  (s=22,c='currentColor',f=false) => <svg width={s} height={s} viewBox="0 0 22 22" fill="none"><path d="M4 9h12v5a4 4 0 01-4 4H8a4 4 0 01-4-4V9z" stroke={c} strokeWidth="1.6" fill={f?c:'none'} fillOpacity={f?.12:1} strokeLinejoin="round"/><path d="M16 11h2a2 2 0 010 4h-2" stroke={c} strokeWidth="1.6" strokeLinecap="round"/><path d="M7.5 5.5c.5-.7.5-1.3 0-2M10.5 5.5c.5-.7.5-1.3 0-2M13.5 5.5c.5-.7.5-1.3 0-2" stroke={c} strokeWidth="1.4" strokeLinecap="round"/></svg>,
  more:    (s=22,c='currentColor') => <svg width={s} height={s} viewBox="0 0 22 22" fill="none"><circle cx="5" cy="11" r="1.6" fill={c}/><circle cx="11" cy="11" r="1.6" fill={c}/><circle cx="17" cy="11" r="1.6" fill={c}/></svg>,
  sun:     (s=22,c='currentColor') => <svg width={s} height={s} viewBox="0 0 22 22" fill="none"><circle cx="11" cy="11" r="3.6" stroke={c} strokeWidth="1.6"/><path d="M11 2.5v2.2M11 17.3v2.2M2.5 11h2.2M17.3 11h2.2M5 5l1.6 1.6M15.4 15.4L17 17M5 17l1.6-1.6M15.4 6.6L17 5" stroke={c} strokeWidth="1.6" strokeLinecap="round"/></svg>,
  moon:    (s=22,c='currentColor') => <svg width={s} height={s} viewBox="0 0 22 22" fill="none"><path d="M17.5 13.2A7 7 0 018.8 4.5a.6.6 0 00-.8-.7 8 8 0 1010.2 10.2.6.6 0 00-.7-.8z" stroke={c} strokeWidth="1.6" strokeLinejoin="round" fill={c} fillOpacity="0.08"/></svg>,
};

// ── Card (cream paper raised) ──────────────────────────────────────────────
function Card({ children, style = {}, onClick, padding = 18, pressable = false, delay = 0 }) {
  const interactive = !!onClick || pressable;
  return (
    <div
      onClick={onClick}
      className={`rise-in${interactive ? ' pressable' : ''}`}
      style={{
        background: tokens.card,
        borderRadius: tokens.r.xl,
        padding,
        boxShadow: '0 1px 0 rgba(255,255,255,0.6) inset, 0 1px 2px rgba(33,28,22,0.04), 0 8px 22px rgba(33,28,22,0.04)',
        animationDelay: delay + 'ms',
        cursor: interactive ? 'pointer' : 'default',
        ...style,
      }}>{children}</div>
  );
}

// ── Pill button (sage / ghost) ─────────────────────────────────────────────
function PillButton({ children, onClick, kind = 'primary', size = 'md', style = {}, disabled = false }) {
  const heights = { sm: 32, md: 44, lg: 52 };
  const fontSizes = { sm: 13, md: 15, lg: 16 };
  const palette = {
    primary: { bg: tokens.ink, fg: tokens.paper },
    sage:    { bg: tokens.sage, fg: '#FAF7F1' },
    ghost:   { bg: 'transparent', fg: tokens.ink, border: `1px solid ${tokens.divider}` },
    soft:    { bg: tokens.inset, fg: tokens.ink },
    danger:  { bg: 'transparent', fg: tokens.bad, border: `1px solid ${tokens.bad}33` },
  }[kind];
  return (
    <button onClick={onClick} disabled={disabled} className="pressable" style={{
      height: heights[size], padding: '0 18px', borderRadius: tokens.r.pill,
      background: palette.bg, color: palette.fg, border: palette.border || 'none',
      fontFamily: tokens.sans, fontSize: fontSizes[size], fontWeight: 500, letterSpacing: -0.1,
      cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
      opacity: disabled ? 0.4 : 1,
      ...style,
    }}>{children}</button>
  );
}

// ── Avatar (initials in muted square; no logos) ────────────────────────────
function SubAvatar({ name, size = 36, paused = false }) {
  // hash name to pick a tonal background within the cream palette
  let h = 0;
  for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
  const tones = [
    '#E8E1D0', '#DCD3C0', '#E5DCD2', '#D8D4C5', '#E2DBCC',
    '#D5CFBE', '#E0D9C9', '#CFC9B7',
  ];
  const bg = paused ? tokens.inset : tones[h % tones.length];
  const initial = (name || '?').trim().charAt(0).toUpperCase();
  return (
    <div style={{
      width: size, height: size, borderRadius: 10, background: bg,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      color: paused ? tokens.ink3 : tokens.ink, fontFamily: tokens.serif,
      fontSize: size * 0.5, fontWeight: 400, lineHeight: 1, flexShrink: 0,
      filter: paused ? 'saturate(0.4)' : 'none',
    }}>{initial}</div>
  );
}

// ── Bottom sheet (modal) ───────────────────────────────────────────────────
function Sheet({ open, onClose, title, children, height = 'auto' }) {
  const [mounted, setMounted] = React.useState(open);
  const [visible, setVisible] = React.useState(false);
  // Drag-to-dismiss
  const [drag, setDrag] = React.useState(0); // current drag offset in px
  const [dragging, setDragging] = React.useState(false);
  const dragStart = React.useRef(null);
  const sheetRef = React.useRef(null);

  // Reusable pointer handlers so we can attach them to grabber AND header.
  const dragHandlers = {
    onPointerDown: (e) => {
      // Don't start drag from interactive children (close button, inputs).
      if (e.target.closest('button, input, textarea, select, [data-no-drag]')) return;
      try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {}
      dragStart.current = { y: e.clientY, t: Date.now() };
      setDragging(true);
    },
    onPointerMove: (e) => {
      if (!dragStart.current) return;
      const dy = e.clientY - dragStart.current.y;
      setDrag(dy > 0 ? dy : dy * 0.2);
    },
    onPointerUp: (e) => {
      if (!dragStart.current) { setDragging(false); return; }
      const dy = e.clientY - dragStart.current.y;
      const dt = Date.now() - dragStart.current.t;
      const velocity = dy / Math.max(dt, 1);
      dragStart.current = null;
      setDragging(false);
      if (dy > 100 || velocity > 0.45) {
        setDrag(0);
        onClose && onClose();
      } else {
        setDrag(0);
      }
    },
    onPointerCancel: () => {
      dragStart.current = null;
      setDragging(false);
      setDrag(0);
    },
    style: { touchAction: 'none', userSelect: 'none' },
  };
  // Track keyboard inset on iOS via visualViewport. When the keyboard is up,
  // visualViewport.height shrinks; we offset the sheet bottom by that delta
  // so it floats just above the keyboard instead of being hidden.
  const [kbInset, setKbInset] = React.useState(0);
  React.useEffect(() => {
    if (!open) { setKbInset(0); return; }
    const vv = window.visualViewport;
    if (!vv) return;
    const update = () => {
      const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
      setKbInset(inset);
    };
    update();
    vv.addEventListener('resize', update);
    vv.addEventListener('scroll', update);
    return () => {
      vv.removeEventListener('resize', update);
      vv.removeEventListener('scroll', update);
    };
  }, [open]);
  // Prevent iOS body scroll behind the sheet (keeps the page from lurching
  // up when an input is focused). iOS Safari, even with `position: fixed`
  // on body, will still try to scroll the document to put a focused input
  // above the keyboard. We snap it back immediately on every scroll event
  // and right after focus, when iOS does its automatic scrollIntoView.
  React.useEffect(() => {
    if (!open) return;
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    const snap = () => {
      if (window.scrollY !== 0 || window.scrollX !== 0) window.scrollTo(0, 0);
      if (document.documentElement.scrollTop !== 0) document.documentElement.scrollTop = 0;
      if (document.body.scrollTop !== 0) document.body.scrollTop = 0;
    };
    const onFocusIn = () => {
      // iOS scrolls a few times after focus; snap on next frames too.
      snap();
      requestAnimationFrame(snap);
      setTimeout(snap, 50);
      setTimeout(snap, 150);
      setTimeout(snap, 300);
    };
    window.addEventListener('scroll', snap, { passive: true });
    document.addEventListener('scroll', snap, { passive: true });
    document.addEventListener('focusin', onFocusIn);
    return () => {
      document.body.style.overflow = prevOverflow;
      window.removeEventListener('scroll', snap);
      document.removeEventListener('scroll', snap);
      document.removeEventListener('focusin', onFocusIn);
    };
  }, [open]);
  React.useEffect(() => {
    if (open) {
      setDrag(0);
      setMounted(true);
      requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
    } else {
      setVisible(false);
      const t = setTimeout(() => setMounted(false), 360);
      return () => clearTimeout(t);
    }
  }, [open]);
  if (!mounted) return null;
  return (
    <div style={{
      position: 'absolute', inset: 0, zIndex: 200,
      pointerEvents: open ? 'auto' : 'none',
    }}>
      {/* backdrop */}
      <div onClick={onClose} style={{
        position: 'absolute', inset: 0,
        background: 'rgba(28,24,18,0.28)',
        backdropFilter: visible ? 'blur(3px)' : 'blur(0px)',
        WebkitBackdropFilter: visible ? 'blur(3px)' : 'blur(0px)',
        opacity: visible ? 1 : 0,
        transition: 'opacity 280ms cubic-bezier(0.22,1,0.36,1), backdrop-filter 280ms cubic-bezier(0.22,1,0.36,1)',
      }}/>
      {/* sheet */}
      <div style={{
        position: 'absolute', left: 0, right: 0, bottom: kbInset,
        background: tokens.paper,
        borderTopLeftRadius: 24, borderTopRightRadius: 24,
        padding: '14px 0 0',
        transform: visible
          ? `translateY(${drag}px) scale(${dragging ? 1 - drag/4000 : 1})`
          : 'translateY(100%) scale(0.98)',
        transition: dragging
          ? 'none'
          : 'transform 460ms cubic-bezier(0.32, 0.72, 0, 1), bottom 280ms cubic-bezier(0.32, 0.72, 0, 1)',
        boxShadow: '0 -10px 30px rgba(33,28,22,0.18)',
        maxHeight: kbInset > 0 ? `calc(100% - ${kbInset}px - 24px)` : '92%',
        display: 'flex', flexDirection: 'column',
        willChange: 'transform, bottom',
      }}>
        {/* drag handle area — grabber + title both draggable */}
        <div {...dragHandlers} style={{
          ...dragHandlers.style,
          cursor: 'grab',
        }}>
          <div style={{
            padding: '8px 0 10px', display: 'flex', justifyContent: 'center',
          }}>
            <div style={{
              width: 38, height: 5, borderRadius: 3,
              background: dragging ? 'rgba(33,28,22,0.42)' : 'rgba(33,28,22,0.18)',
              transition: 'background 200ms, width 200ms',
              width: dragging ? 48 : 38,
            }}/>
          </div>
          {title && (
            <div style={{
              display: 'flex', alignItems: 'center', justifyContent: 'space-between',
              padding: '6px 20px 14px',
            }}>
              <span style={{ fontFamily: tokens.sans, fontSize: 17, fontWeight: 600, color: tokens.ink }}>
                {title}
              </span>
              <button data-no-drag onClick={onClose} style={{
                background: tokens.inset, border: 'none', borderRadius: tokens.r.pill,
                width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
                color: tokens.ink2, cursor: 'pointer',
              }}>{Ico.close(16, tokens.ink2)}</button>
            </div>
          )}
        </div>
        <div style={{ flex: 1, overflowY: 'auto' }}>{children}</div>
      </div>
    </div>
  );
}

// ── Form field ─────────────────────────────────────────────────────────────
function Field({ label, children, hint }) {
  return (
    <div style={{ marginBottom: 14 }}>
      <div style={{
        fontFamily: tokens.sans, fontSize: 12, fontWeight: 500, letterSpacing: 0.4,
        textTransform: 'uppercase', color: tokens.ink3, marginBottom: 6, paddingLeft: 4,
      }}>{label}</div>
      {children}
      {hint && (
        <div style={{ fontFamily: tokens.sans, fontSize: 12, color: tokens.ink3, marginTop: 6, paddingLeft: 4 }}>
          {hint}
        </div>
      )}
    </div>
  );
}

function TextInput({ value, onChange, placeholder, type = 'text', suffix, autoFocus, inputMode }) {
  return (
    <div style={{
      display: 'flex', alignItems: 'center',
      background: tokens.card, borderRadius: 14,
      border: `1px solid ${tokens.divider}`,
      padding: '0 14px', height: 50,
    }}>
      <input
        type={type} value={value} onChange={e => onChange(e.target.value)}
        placeholder={placeholder} autoFocus={autoFocus} inputMode={inputMode}
        style={{
          flex: 1, border: 'none', outline: 'none', background: 'transparent',
          fontFamily: tokens.sans, fontSize: 17, color: tokens.ink, letterSpacing: -0.2,
        }}
      />
      {suffix && (
        <span style={{ fontFamily: tokens.sans, fontSize: 15, color: tokens.ink3, marginLeft: 8 }}>
          {suffix}
        </span>
      )}
    </div>
  );
}

// ── Animated count-up number ──────────────────────────────────────────────
function CountNumber({ value, format = (v) => v.toFixed(0), duration = 600, style }) {
  const [display, setDisplay] = React.useState(value);
  const fromRef = React.useRef(value);
  const startRef = React.useRef(null);
  React.useEffect(() => {
    const from = fromRef.current;
    const to = value;
    if (from === to) return;
    let raf;
    const ease = (t) => 1 - Math.pow(1 - t, 3);
    const step = (ts) => {
      if (startRef.current == null) startRef.current = ts;
      const t = Math.min(1, (ts - startRef.current) / duration);
      setDisplay(from + (to - from) * ease(t));
      if (t < 1) raf = requestAnimationFrame(step);
      else { fromRef.current = to; startRef.current = null; }
    };
    raf = requestAnimationFrame(step);
    return () => { cancelAnimationFrame(raf); fromRef.current = to; startRef.current = null; };
  }, [value, duration]);
  return <span style={style}>{format(display)}</span>;
}

// ── Floating action button ─────────────────────────────────────────────────
function FAB({ onClick, children }) {
  const fg = tokens.card;
  const content = children || Ico.plus(22, fg);
  const [iconKey, setIconKey] = React.useState(0);
  const handleClick = () => {
    setIconKey(k => k + 1);
    onClick && onClick();
  };
  return (
    <button key="fab" onClick={handleClick} className="fab-in pressable" style={{
      position: 'absolute', right: 22, bottom: 'calc(58px + env(safe-area-inset-bottom))',
      width: 56, height: 56, borderRadius: tokens.r.pill,
      background: tokens.ink, color: fg, border: 'none',
      boxShadow: '0 8px 22px rgba(33,28,22,0.28), 0 2px 4px rgba(33,28,22,0.18)',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      cursor: 'pointer', zIndex: 20,
    }}>
      <span key={iconKey} className="icon-swap" style={{ display: 'inline-flex' }}>{content}</span>
    </button>
  );
}

// ── Tab bar ────────────────────────────────────────────────────────────────
function TabBar({ active, onChange }) {
  const tabs = [
    { id: 'home',   label: 'Home',     icon: Ico.home },
    { id: 'coffee', label: 'Coffee',   icon: Ico.coffee },
    { id: 'cal',    label: 'Calendar', icon: Ico.cal },
    { id: 'stats',  label: 'Stats',    icon: Ico.stats },
    { id: 'more',   label: 'Settings', icon: Ico.cog },
  ];
  const isDark = tokens.paper === '#0E0D0B';
  const containerRef = React.useRef(null);
  const buttonRefs = React.useRef({});
  const [indicator, setIndicator] = React.useState({ left: 0, width: 0, ready: false });

  React.useLayoutEffect(() => {
    const el = buttonRefs.current[active];
    const wrap = containerRef.current;
    if (el && wrap) {
      const er = el.getBoundingClientRect();
      const wr = wrap.getBoundingClientRect();
      const w = 18;
      setIndicator({
        left: er.left - wr.left + er.width / 2 - w / 2,
        width: w,
        ready: true,
      });
    }
  }, [active]);

  return (
    <div ref={containerRef} style={{
      position: 'absolute', left: 0, right: 0, bottom: 0,
      paddingBottom: 'env(safe-area-inset-bottom)',
      paddingTop: 4,
      background: isDark ? 'rgba(14, 13, 11, 0.85)' : 'rgba(244, 240, 232, 0.85)',
      backdropFilter: 'blur(20px) saturate(180%)',
      WebkitBackdropFilter: 'blur(20px) saturate(180%)',
      borderTop: `0.5px solid ${tokens.divider}`,
      display: 'flex', justifyContent: 'space-around', alignItems: 'flex-start',
      zIndex: 10,
    }}>
      {indicator.ready && (
        <div style={{
          position: 'absolute',
          bottom: 'env(safe-area-inset-bottom)', height: 3, borderRadius: 2,
          background: tokens.ink,
          left: indicator.left,
          width: indicator.width,
          transition: 'left 460ms cubic-bezier(0.34, 1.20, 0.64, 1), width 460ms cubic-bezier(0.34, 1.20, 0.64, 1), background-color 320ms cubic-bezier(0.22,1,0.36,1)',
          opacity: 0.85,
        }}/>
      )}
      {tabs.map(t => {
        const isActive = active === t.id;
        const c = isActive ? tokens.ink : tokens.ink3;
        return (
          <button key={t.id} ref={el => { buttonRefs.current[t.id] = el; }}
            onClick={() => onChange(t.id)} className="pressable" style={{
            background: 'none', border: 'none', cursor: 'pointer',
            display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
            padding: '2px 8px',
          }}>
            <span style={{
              display: 'inline-flex',
              transition: 'transform 320ms var(--ease)',
              transform: isActive ? 'translateY(-1px) scale(1.06)' : 'translateY(0) scale(1)',
            }}>{t.icon(22, c, isActive)}</span>
            <span style={{
              fontFamily: tokens.sans, fontSize: 10, fontWeight: isActive ? 600 : 500,
              color: c, letterSpacing: 0.1,
            }}>{t.label}</span>
          </button>
        );
      })}
    </div>
  );
}

// ── Burden dot ─────────────────────────────────────────────────────────────
function BurdenDot({ pct, thresholds, size = 8, pulse = false }) {
  const c = burdenColor(pct, thresholds);
  return (
    <span style={{
      position: 'relative',
      display: 'inline-flex', width: size, height: size,
    }}>
      {pulse && (
        <span style={{
          position: 'absolute', inset: 0, borderRadius: '50%',
          background: c, opacity: 0.55,
          animation: 'burden-pulse 2400ms var(--ease) infinite',
        }}/>
      )}
      <span style={{
        position: 'relative',
        display: 'inline-block', width: size, height: size, borderRadius: '50%',
        background: c, boxShadow: `0 0 0 3px ${c}1A`,
      }}/>
    </span>
  );
}

// ── Header (large title, scroll-aware optional) ────────────────────────────
function ScreenHeader({ eyebrow, title, right, action }) {
  return (
    <div style={{ padding: '10px 22px 14px', display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 12 }}>
      <div style={{ flex: 1, minWidth: 0 }}>
        {eyebrow && (
          <div style={{
            fontFamily: tokens.sans, fontSize: 12, fontWeight: 500, letterSpacing: 0.6,
            textTransform: 'uppercase', color: tokens.ink3, marginBottom: 4,
          }}>{eyebrow}</div>
        )}
        <div style={{
          fontFamily: tokens.serif, fontSize: 34, fontWeight: 400,
          color: tokens.ink, letterSpacing: -0.5, lineHeight: 1.0,
          whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
        }}>{title}</div>
      </div>
      {right && <div style={{ paddingBottom: 4, flexShrink: 0 }}>{right}</div>}
      {action}
    </div>
  );
}

// ── Tiny icon button (top right of screens) ────────────────────────────────
function IconButton({ children, onClick, style }) {
  return (
    <button onClick={onClick} className="pressable" style={{
      width: 40, height: 40, borderRadius: tokens.r.pill,
      background: tokens.card, border: `1px solid ${tokens.divider}`,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      color: tokens.ink, cursor: 'pointer',
      ...style,
    }}>{children}</button>
  );
}

// ── Animated horizontal progress bar ───────────────────────────────────────
function ProgressBar({ value, max = 100, color, track, height = 6, delay = 0 }) {
  const pct = Math.max(0, Math.min(1, value / max));
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => {
    const t = setTimeout(() => setMounted(true), 50 + delay);
    return () => clearTimeout(t);
  }, []);
  return (
    <div style={{
      width: '100%', height, borderRadius: height,
      background: track || tokens.inset, overflow: 'hidden',
    }}>
      <div style={{
        height: '100%', width: (mounted ? pct : 0) * 100 + '%',
        background: color || tokens.ink,
        borderRadius: height,
        transition: 'width 760ms cubic-bezier(0.22,1,0.36,1), background-color 320ms cubic-bezier(0.22,1,0.36,1)',
      }}/>
    </div>
  );
}

// ── Swipe-to-delete row ────────────────────────────────────────────────────
// Wrap any row that should reveal a red Delete behind it on swipe-left.
// Children render inside .swipe-content; the action button reveals when
// dragged left more than ~16px, locks open at -92px, deletes on full swipe.
function SwipeRow({ onDelete, children, actionLabel = 'Delete' }) {
  const [x, setX] = React.useState(0);
  const [dragging, setDragging] = React.useState(false);
  const start = React.useRef(null);
  const open = x <= -60;

  const reset = () => { setX(0); setDragging(false); start.current = null; };

  return (
    <div className="swipe-row"
      onPointerDown={(e) => {
        if (e.target.closest('button, input, [data-no-swipe]')) return;
        start.current = { x: e.clientX, y: e.clientY, baseX: x, locked: null };
        setDragging(true);
      }}
      onPointerMove={(e) => {
        if (!start.current) return;
        const dx = e.clientX - start.current.x;
        const dy = e.clientY - start.current.y;
        // Lock direction on first significant movement
        if (start.current.locked == null) {
          if (Math.abs(dx) > 6 || Math.abs(dy) > 6) {
            start.current.locked = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';
          } else return;
        }
        if (start.current.locked !== 'x') return;
        try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {}
        let next = start.current.baseX + dx;
        // Resistance past full open
        if (next < -92) next = -92 + (next + 92) * 0.25;
        if (next > 0) next = next * 0.25;
        setX(next);
      }}
      onPointerUp={(e) => {
        if (!start.current || start.current.locked !== 'x') { reset(); return; }
        const dx = e.clientX - start.current.x;
        const final = start.current.baseX + dx;
        // Full swipe → delete
        if (final < -180) {
          setX(-400);
          setTimeout(() => onDelete && onDelete(), 220);
          start.current = null; setDragging(false);
          return;
        }
        // Snap open or closed
        setX(final < -46 ? -92 : 0);
        start.current = null; setDragging(false);
      }}
      onPointerCancel={reset}
    >
      <div className="swipe-action" onClick={() => onDelete && onDelete()} aria-hidden={!open}>
        {actionLabel}
      </div>
      <div className="swipe-content" style={{
        transform: `translateX(${x}px)`,
        transition: dragging ? 'none' : 'transform 320ms var(--ease)',
      }}>
        {children}
      </div>
    </div>
  );
}

// ── iOS-style toggle ──────────────────────────────────────────────────────
function IOSToggle({ value, onChange }) {
  return (
    <button
      role="switch" aria-checked={value}
      className={`ios-toggle${value ? ' on' : ''}`}
      onClick={() => onChange(!value)}
      style={{ border: 'none', padding: 0 }}
    >
      <span className="thumb"/>
    </button>
  );
}

// ── Pull-to-refresh wrapper ───────────────────────────────────────────────
// Wrap a scrollable region. When user pulls past threshold from the top,
// a small spinner appears; releasing fires onRefresh().
function PullToRefresh({ onRefresh, children, scrollRef, enabled = true }) {
  const [pull, setPull] = React.useState(0);
  const [refreshing, setRefreshing] = React.useState(false);
  const start = React.useRef(null);
  const TRIGGER = 64;

  const internalRef = React.useRef(null);
  const ref = scrollRef || internalRef;

  const handlers = enabled ? {
    onTouchStart: (e) => {
      const el = ref.current;
      if (!el || el.scrollTop > 0 || refreshing) return;
      start.current = { y: e.touches[0].clientY };
    },
    onTouchMove: (e) => {
      if (!start.current) return;
      const dy = e.touches[0].clientY - start.current.y;
      if (dy <= 0) { setPull(0); return; }
      // Resistance
      setPull(Math.min(120, dy * 0.5));
    },
    onTouchEnd: () => {
      if (!start.current) return;
      start.current = null;
      if (pull >= TRIGGER) {
        setRefreshing(true);
        setPull(TRIGGER);
        Promise.resolve(onRefresh && onRefresh()).finally(() => {
          setTimeout(() => { setRefreshing(false); setPull(0); }, 300);
        });
      } else {
        setPull(0);
      }
    },
  } : {};

  const ringPct = Math.min(1, pull / TRIGGER);
  const ringColor = tokens.ink2;

  return (
    <div ref={ref} {...handlers} style={{
      height: '100%', overflowY: 'auto', WebkitOverflowScrolling: 'touch',
      position: 'relative',
    }}>
      <div style={{
        position: 'absolute', top: 0, left: 0, right: 0,
        height: pull, display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
        paddingBottom: 8, pointerEvents: 'none',
        transition: start.current ? 'none' : 'height 320ms var(--ease)',
        zIndex: 1,
      }}>
        <div style={{
          width: 22, height: 22, borderRadius: '50%',
          border: `2px solid ${ringColor}33`,
          borderTopColor: ringColor,
          opacity: ringPct,
          transform: refreshing ? '' : `rotate(${ringPct * 270}deg) scale(${0.7 + ringPct * 0.3})`,
        }} className={refreshing ? 'spin' : ''}/>
      </div>
      <div style={{
        transform: `translateY(${pull}px)`,
        transition: start.current ? 'none' : 'transform 320ms var(--ease)',
        minHeight: '100%',
      }}>
        {children}
      </div>
    </div>
  );
}

Object.assign(window, {
  Ico, Card, PillButton, SubAvatar, Sheet, Field, TextInput, CountNumber,
  FAB, TabBar, BurdenDot, ScreenHeader, IconButton, ProgressBar,
  SwipeRow, IOSToggle, PullToRefresh,
});
