/* ============================================================
   primitives.jsx — shared building blocks
   ============================================================ */

const { useState, useEffect, useRef, useMemo, useCallback } = React;
const D = window.STATUS_DATA;

/* ============================================================
   Phase 1 — admin Edit Mode (cross-component coordination)
   ============================================================
   No module system here, so we use a window-level flag plus a
   custom event so any page/component can opt-in via useEditMode().

   - window.__editMode__: boolean
   - 'edit-mode-change' CustomEvent fires whenever toggled
   - useEditMode() returns the current bool and re-renders on change
   - setEditMode(bool) flips the flag globally + persists to localStorage
*/
function setEditMode(on) {
  const v = !!on;
  window.__editMode__ = v;
  try { localStorage.setItem('admin_edit_mode', v ? '1' : '0'); } catch (_) {}
  window.dispatchEvent(new CustomEvent('edit-mode-change', { detail: v }));
}
// Restore from localStorage on first load.
try {
  if (window.__editMode__ === undefined) {
    window.__editMode__ = localStorage.getItem('admin_edit_mode') === '1';
  }
} catch (_) { window.__editMode__ = false; }

function useEditMode() {
  const [on, setOn] = useState(() => !!window.__editMode__);
  useEffect(() => {
    const handler = (e) => setOn(!!e.detail);
    window.addEventListener('edit-mode-change', handler);
    return () => window.removeEventListener('edit-mode-change', handler);
  }, []);
  return on;
}

/* canEditUser(meRole, meTeam, targetTeam)
   Mirrors backend _can_edit_user: admin → any, manager → same team. */
function canEditUser(meRole, meTeam, targetTeam) {
  const r = (meRole || '').toLowerCase();
  if (r === 'admin') return true;
  if (r !== 'manager') return false;
  if (!meTeam) return false;
  return (meTeam || '') === (targetTeam || '');
}

/* showUndoToast({ token, label, onUndone })
   Pops a 90-second toast with an Undo button. Calls POST /api/admin/undo/{token}.
   On success, fires `onUndone` so the caller can refresh state.
   Multiple toasts stack vertically.
*/
function _ensureToastContainer() {
  let c = document.getElementById('admin-undo-toasts');
  if (c) return c;
  c = document.createElement('div');
  c.id = 'admin-undo-toasts';
  c.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:10000;display:flex;flex-direction:column;gap:8px;pointer-events:none;';
  document.body.appendChild(c);
  return c;
}
function showUndoToast({ token, label, onUndone }) {
  if (!token) return;
  const root = _ensureToastContainer();
  const el = document.createElement('div');
  el.style.cssText = 'pointer-events:auto;background:var(--bg-elev-2,#222);color:var(--text,#fff);padding:10px 14px;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.3);display:flex;align-items:center;gap:12px;font-size:13px;min-width:280px;border:1px solid var(--line,#333);';
  el.innerHTML = `<span style="flex:1">${label || (window.appT||(s=>s))('Change saved.')}</span>`;
  const btn = document.createElement('button');
  btn.textContent = (window.appT||(s=>s))('Undo');
  btn.style.cssText = 'background:var(--brand,#4af);color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;font-weight:600;';
  btn.onclick = async () => {
    btn.disabled = true; btn.textContent = '…';
    try {
      const r = await fetch(`/api/admin/undo/${encodeURIComponent(token)}`, {
        method: 'POST', credentials: 'same-origin',
      });
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      btn.textContent = '✓';
      if (typeof onUndone === 'function') onUndone();
      setTimeout(() => el.remove(), 800);
    } catch (e) {
      btn.textContent = (window.appT||(s=>s))('Failed');
      btn.style.background = 'var(--err,#e44)';
    }
  };
  el.appendChild(btn);
  root.appendChild(el);
  // Auto-dismiss after 90 s.
  setTimeout(() => { try { el.remove(); } catch (_) {} }, 90000);
}

/* <EditableCell value onSave kind formatter readOnly />
   Inline-editable cell that swaps in an <input> on click when edit mode is on.
   - kind: 'time' | 'datetime' | 'text' | 'select'
   - options (when kind='select'): array of {value, label}
   - formatter: (value) => string for display
   - onSave(newValue) → Promise; should resolve when persisted
   - readOnly: hard override (e.g. when caller can't edit this user)
   When edit mode is OFF or readOnly is true, renders the formatted value as plain text. */
function EditableCell({ value, onSave, kind = 'text', options, formatter, readOnly, placeholder }) {
  const editMode = useEditMode();
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState('');
  const [busy, setBusy] = useState(false);

  const display = formatter ? formatter(value) : (value ?? '—');

  if (!editMode || readOnly) {
    return <span>{display}</span>;
  }

  const start = () => {
    setDraft(value == null ? '' : String(value));
    setEditing(true);
  };
  const cancel = () => { setEditing(false); setDraft(''); };
  const commit = async () => {
    if (busy) return;
    setBusy(true);
    try {
      await onSave(draft);
      setEditing(false);
    } catch (e) {
      // Leave the input open so the user can retry.
    } finally {
      setBusy(false);
    }
  };
  const onKey = (e) => {
    if (e.key === 'Enter') commit();
    else if (e.key === 'Escape') cancel();
  };

  if (editing) {
    if (kind === 'select') {
      return (
        <select className="inp" value={draft} onChange={e => setDraft(e.target.value)}
                onBlur={commit} autoFocus>
          {(options || []).map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
        </select>
      );
    }
    return (
      <input className="inp" autoFocus
             type={kind === 'time' ? 'time' : kind === 'datetime' ? 'datetime-local' : 'text'}
             value={draft}
             placeholder={placeholder}
             onChange={e => setDraft(e.target.value)}
             onBlur={commit}
             onKeyDown={onKey}
             disabled={busy} />
    );
  }
  return (
    <span onClick={start}
          title={(window.appT||(s=>s))("Click to edit")}
          style={{ cursor:'pointer', borderBottom:'1px dashed var(--text-3,#888)', paddingBottom:1 }}>
      {display}
    </span>
  );
}

/* useTheme — returns 'dark' or 'light' by watching documentElement[data-theme].
   Re-renders callers when theme toggles (sun/moon button). */
function useTheme() {
  const [theme, setTheme] = useState(() =>
    document.documentElement.dataset.theme || 'dark'
  );
  useEffect(() => {
    const el = document.documentElement;
    const observer = new MutationObserver(() => {
      setTheme(el.dataset.theme || 'dark');
    });
    observer.observe(el, { attributes: true, attributeFilter: ['data-theme'] });
    return () => observer.disconnect();
  }, []);
  return theme;
}

/* resolveAvatarUrl — pick the right variant URL for a person object.
   Falls back: variant → other variant → legacy avatar_url → null (initials).
   person shape: { avatar_dark_url, avatar_light_url, avatar_preference, avatar_url } */
function resolveAvatarUrl(person) {
  if (!person) return null;
  const dark  = person.avatar_dark_url  || null;
  const light = person.avatar_light_url || null;
  const legacy = person.avatar_url || null;
  // Priority 2: Synapse-sourced avatar (fallback when no custom upload)
  const synapse = person.synapse_avatar_url || null;
  const pref = person.avatar_preference || 'auto';
  let variant;
  if (pref === 'dark')  variant = 'dark';
  else if (pref === 'light') variant = 'light';
  else {
    // auto — check current theme
    variant = (document.documentElement.dataset.theme || 'dark') === 'dark' ? 'dark' : 'light';
  }
  // P0 #1: prefer the real Synapse photo over the locally-generated data:
  // URL initials; data: URLs are fallback avatars produced when no real
  // image was uploaded, so they lose to the chat.k9.ms profile photo.
  const isInitialsDataUrl = (s) => typeof s === 'string' && s.startsWith('data:image/');
  const realLegacy = isInitialsDataUrl(legacy) ? null : legacy;
  if (variant === 'dark')  return dark  || light || synapse || realLegacy || legacy || null;
  if (variant === 'light') return light || dark  || synapse || realLegacy || legacy || null;
  return synapse || realLegacy || legacy || null;
}

/* Avatar */
function Avatar({ person, size = 28, status, showPip = true }) {
  if (!person) return null;
  // Re-render when theme changes (affects 'auto' preference avatars)
  const theme = useTheme();
  const [c1, c2] = person.avatar || ['#3b66f5', '#1d3286'];
  const inits = person.initials || (person.display_name || '?').slice(0, 1);
  const pipColor = status === 'present' ? 'var(--ok)' : status === 'late' ? 'var(--warn)' : status === 'absent' ? 'var(--err)' : 'var(--text-3)';
  const avatarUrl = resolveAvatarUrl(person);
  if (avatarUrl) {
    return (
      <div className="ava" style={{ width: size, height: size, background: 'transparent', fontSize: size * 0.4 }}>
        <img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', borderRadius: 'inherit', objectFit: 'cover', display: 'block' }} />
        {showPip && status && <span className="stat-pip" style={{ background: pipColor }} />}
      </div>
    );
  }
  return (
    <div className="ava" style={{
      width: size, height: size,
      background: `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)`,
      fontSize: size * 0.4,
    }}>
      {inits}
      {showPip && status && <span className="stat-pip" style={{ background: pipColor }} />}
    </div>
  );
}

/* Person cell (avatar + name + K9 ID) */
function PersonCell({ person, status, sub, onClick }) {
  // #35 fix: default behavior is to open the user's profile drawer.
  // Every call site rendering a PersonCell now opens the user when
  // clicked — Today's roster, Top 10, late list, on-call rotation,
  // etc. Call sites that need a custom action still pass onClick.
  const _handleClick = onClick || ((ev) => {
    try { ev.stopPropagation(); } catch (_) {}
    try {
      window.dispatchEvent(new CustomEvent('request-open-user', {
        detail: {
          user_id: person?.id ?? person?.user_id ?? null,
          matrix_id: person?.matrix_id || null,
          display_name: person?.display_name || null,
        },
      }));
    } catch (_) {}
  });
  return (
    <div className="person" onClick={_handleClick} style={{ cursor: 'pointer' }}
         title={onClick ? undefined : `Open ${person?.display_name || person?.matrix_id || 'profile'}`}>
      <Avatar person={person} status={status} size={28} />
      <div className="meta">
        <div className="n">{person.display_name}</div>
        <div className="h">{sub || person.matrix_id}</div>
      </div>
    </div>
  );
}

/* Badge */
function Badge({ kind = 'mute', dot = false, children, style }) {
  return (
    <span className={`badge badge-${kind}`} style={style}>
      {dot && <span className="badge-dot" />}
      {children}
    </span>
  );
}

function StatusBadge({ status }) {
  if (status === 'present')    return <Badge kind="ok"   dot>{(window.appT||(s=>s))('present')}</Badge>;
  if (status === 'late')       return <Badge kind="warn" dot>{(window.appT||(s=>s))('late')}</Badge>;
  if (status === 'absent')     return <Badge kind="err"  dot>{(window.appT||(s=>s))('absent')}</Badge>;
  // 'signed-off' = day complete (user signed in earlier and signed off).
  // Distinct from 'present' (which now means "still at the desk").
  if (status === 'signed-off') return <Badge kind="mute" dot>{(window.appT||(s=>s))('signed off')}</Badge>;
  if (status === 'late-off')   return <Badge kind="mute" dot>{(window.appT||(s=>s))('late · signed off')}</Badge>;
  if (status === 'pto')     return <Badge kind="info" dot>{(window.appT||(s=>s))('pto')}</Badge>;
  if (status === 'pending') return <Badge kind="warn" dot>{(window.appT||(s=>s))('pending')}</Badge>;
  if (status === 'approved')return <Badge kind="ok" dot>{(window.appT||(s=>s))('approved')}</Badge>;
  if (status === 'denied')  return <Badge kind="err" dot>{(window.appT||(s=>s))('denied')}</Badge>;
  if (status === 'active')  return <Badge kind="ok" dot>{(window.appT||(s=>s))('active')}</Badge>;
  if (status === 'inactive')return <Badge kind="mute" dot>{(window.appT||(s=>s))('inactive')}</Badge>;
  return <Badge kind="mute">{status}</Badge>;
}

/* Section header
   #5: optional `tip` shows a small (i) glyph next to the title that
   reveals an on-hover tooltip explaining what the section measures.
   Call sites that omit `tip` keep the existing layout. */
function SectionHeader({ title, sub, right, tip }) {
  return (
    <div className="sh">
      <div>
        <div className="sh-title">
          {title}
          {tip && (
            <span
              className="hint-glyph"
              data-tip={tip}
              tabIndex={0}
              aria-label={typeof title === 'string' ? ((window.appLang&&window.appLang()==='ar') ? `مساعدة حول ${title}` : `Help for ${title}`) : (window.appT||(s=>s))('Help')}
              style={{
                marginLeft: 6, fontSize: 11, opacity: 0.7,
                cursor: 'help', display: 'inline-block', position: 'relative',
                borderRadius: '50%', width: 15, height: 15, lineHeight: '15px',
                textAlign: 'center', border: '1px solid currentColor',
                verticalAlign: 'middle', fontWeight: 600,
              }}
            >
              ?
            </span>
          )}
        </div>
        {sub && <div className="sh-sub">{sub}</div>}
      </div>
      {right && <div className="right">{right}</div>}
    </div>
  );
}

/* Page header */
function PageHeader({ title, meta, right }) {
  return (
    <div className="ph">
      <div>
        <h2>{title}</h2>
        {meta && <div className="ph-meta" style={{ marginTop: 6 }}>{meta}</div>}
      </div>
      {right && <div className="right">{right}</div>}
    </div>
  );
}

/* KPI card */
function KPI({ label, value, denom, trend, color, accent, sparkData, icon }) {
  const fillColor = color || 'var(--brand)';
  const max = sparkData ? Math.max(...sparkData, 1) : 1;
  const pct = denom ? Math.min(100, (Number(value) / Number(denom)) * 100) : null;
  return (
    <div className="kpi">
      <div className="kpi-head">
        <div className="kpi-label">{label}</div>
        {icon && <span className="kpi-icon">{icon}</span>}
      </div>
      <div className="kpi-value">
        <span style={{ color: accent || 'inherit' }}>{value}</span>
        {denom != null && <span className="denom">/ {denom}</span>}
      </div>
      {trend && (
        <div className="kpi-trend">
          <span className={`delta ${trend.dir}`}>{trend.dir === 'up' ? '↑' : trend.dir === 'down' ? '↓' : '·'} {trend.value}</span>
          <span className="muted">{trend.label}</span>
        </div>
      )}
      {sparkData && (
        <div className="spark" aria-hidden="true">
          {sparkData.map((v, i) => (
            <div key={i} className="spark-bar" style={{ height: `${(v / max) * 100}%`, background: fillColor, opacity: i === sparkData.length - 1 ? 1 : 0.55 }} />
          ))}
        </div>
      )}
      {pct != null && (
        <>
          <div className="kpi-bar"><div className="kpi-bar-fill" style={{ width: `${pct}%`, background: fillColor }} /></div>
        </>
      )}
    </div>
  );
}

/* Empty state */
function Empty({ icon = '·', title, subtitle }) {
  return (
    <div className="empty">
      <div className="ic">{icon}</div>
      <div className="t">{title}</div>
      {subtitle && <div className="s">{subtitle}</div>}
    </div>
  );
}

/* Skeleton block */
function Skel({ w='100%', h=14, br='var(--r-xs)' }) {
  return <div className="sk" style={{ width: w, height: h, borderRadius: br }} />;
}

/* Mascot loader — same robot as the app's pulse-mascot.svg (rounded head,
   antenna, ear pods, ECG visor, teardrop body), reused as the animated
   loading indicator across the dashboard and the boot splash (index.html
   renders a plain-HTML twin of this same markup/animation). No background
   shape of its own — always transparent, floats on the page. Colors come
   from the theme's --brand tokens so it matches in light/dark automatically;
   inline fallbacks keep it visible even if those tokens aren't set yet. */
const MASCOT_CSS = `
@keyframes mloader-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
@keyframes mloader-antenna { 0%, 100% { opacity: .55; transform: scale(1); } 50% { opacity: 1; transform: scale(1.35); } }
@keyframes mloader-ecg { 0% { stroke-dashoffset: 64; opacity: .35; } 45% { stroke-dashoffset: 0; opacity: 1; } 100% { stroke-dashoffset: -64; opacity: .35; } }
@keyframes mloader-fade { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
.mloader-bob { animation: mloader-bob 2.4s ease-in-out infinite; transform-origin: 50% 50%; }
.mloader-antenna-dot { animation: mloader-antenna 1.6s ease-in-out infinite; transform-origin: 50px 7px; }
.mloader-ecg-line { stroke-dasharray: 64; animation: mloader-ecg 1.8s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
  .mloader-bob, .mloader-antenna-dot { animation: mloader-fade 1.8s ease-in-out infinite !important; transform: none !important; }
  .mloader-ecg-line { animation: mloader-fade 1.8s ease-in-out infinite !important; stroke-dashoffset: 0 !important; }
}
`;

function Mascot({ size = 64 }) {
  return (
    <svg viewBox="0 0 100 100" width={size} height={size} style={{ display: 'block', overflow: 'visible' }} aria-hidden="true" focusable="false">
      <g className="mloader-bob">
        <line x1="50" y1="10" x2="50" y2="20" stroke="var(--brand, #7c3aed)" strokeWidth="3" strokeLinecap="round" />
        <circle className="mloader-antenna-dot" cx="50" cy="7" r="4" fill="var(--brand, #7c3aed)" />
        <rect x="10" y="34" width="9" height="20" rx="4.5" fill="var(--bg-elev-2, rgba(124,58,237,.14))" stroke="var(--brand, #7c3aed)" strokeWidth="1.4" />
        <rect x="81" y="34" width="9" height="20" rx="4.5" fill="var(--bg-elev-2, rgba(124,58,237,.14))" stroke="var(--brand, #7c3aed)" strokeWidth="1.4" />
        <rect x="22" y="22" width="56" height="46" rx="16" fill="var(--brand, #7c3aed)" opacity="0.92" />
        <rect x="31" y="34" width="38" height="24" rx="8" fill="color-mix(in oklab, var(--brand, #7c3aed) 35%, black 55%)" />
        <path className="mloader-ecg-line" d="M35 46 H41 L44 39 L49 53 L53 43 L56 46 H65" fill="none" stroke="color-mix(in oklab, var(--brand, #7c3aed) 25%, white 75%)" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
        <path d="M35 76 Q35 98 50 98 Q65 98 65 76 Z" fill="var(--brand-2, #6d28d9)" opacity="0.9" />
      </g>
    </svg>
  );
}

/* Reusable section loader — shown while data is pending from the DB. Shows
   the animated, transparent mascot as the primary indicator, with a compact
   skeleton-bar option (via `rows`) for extra context underneath. No card,
   no border, no box-shadow — it floats directly on the page background.
   Convention: at the top of a data section do `if (loading) return <Loading .../>;`. */
function Loading({ label = 'Loading…', rows = 3, pad = 16 }) {
  return (
    <div style={{ display: 'grid', justifyItems: 'center', gap: 10, padding: pad, background: 'transparent' }} aria-busy="true" aria-live="polite">
      <style>{MASCOT_CSS}</style>
      <Mascot size={64} />
      {rows > 0 && (
        <div style={{ display: 'grid', gap: 6, width: '100%', maxWidth: 220 }}>
          {Array.from({ length: Math.max(1, rows) }).map((_, i) => (
            <div key={i} className="sk" style={{ height: 10, borderRadius: 'var(--r-xs)', opacity: Math.max(0.35, 0.8 - i * 0.15) }} />
          ))}
        </div>
      )}
      <div className="muted" style={{ fontSize: 12 }}>{label}</div>
    </div>
  );
}

/* Table-body loading state — spans the given number of columns. Same
   transparent, animated mascot as `Loading`, sized down and laid out inline
   to fit a table row. */
function LoadingRow({ colSpan = 1, label = 'Loading…' }) {
  return (
    <tr aria-busy="true">
      <td colSpan={colSpan} style={{ padding: 12, background: 'transparent' }}>
        <style>{MASCOT_CSS}</style>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <Mascot size={28} />
          <div className="muted" style={{ fontSize: 12 }}>{label}</div>
        </div>
      </td>
    </tr>
  );
}

/* Segmented control */
function Segmented({ value, onChange, options }) {
  return (
    <div className="seg" role="tablist">
      {options.map(o => (
        <button key={o.value} className={value === o.value ? 'on' : ''} onClick={() => onChange(o.value)}>
          {o.label}
        </button>
      ))}
    </div>
  );
}

/* Sortable table */
function useSortable(rows, init) {
  const [sort, setSort] = useState(init);
  const sorted = useMemo(() => {
    if (!sort?.key) return rows;
    const dir = sort.dir === 'asc' ? 1 : -1;
    return [...rows].sort((a, b) => {
      const av = a[sort.key]; const bv = b[sort.key];
      if (av == null && bv == null) return 0;
      if (av == null) return 1;
      if (bv == null) return -1;
      if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
      return String(av).localeCompare(String(bv)) * dir;
    });
  }, [rows, sort]);
  const click = (key) => setSort(s => s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' });
  return { sorted, sort, click };
}

function SortHead({ sort, sortKey, click, label, align = 'left', width }) {
  const on = sort?.key === sortKey;
  return (
    <th className={`sortable ${on ? 'sorted' : ''}`} onClick={() => click(sortKey)} style={{ textAlign: align, width }}>
      {label}
      <span className="sort-ic">{on ? (sort.dir === 'asc' ? '↑' : '↓') : '↕'}</span>
    </th>
  );
}

/* Toast */
const ToastCtx = React.createContext({ push: () => {} });
function ToastProvider({ children }) {
  const [items, setItems] = useState([]);
  const push = useCallback((t) => {
    const id = Math.random().toString(36).slice(2);
    setItems(s => [...s, { ...t, id }]);
    setTimeout(() => setItems(s => s.filter(x => x.id !== id)), t.duration || 3500);
  }, []);
  return (
    <ToastCtx.Provider value={{ push }}>
      {children}
      <div className="toast-wrap">
        {items.map(t => (
          <div key={t.id} className={`toast ${t.kind || ''}`}>
            <span style={{ fontSize: 16, lineHeight: 1, marginTop: 1 }}>{t.icon || '✓'}</span>
            <div>
              {t.title && <div style={{ fontWeight: 600, marginBottom: 2, color: 'var(--text)' }}>{t.title}</div>}
              <div style={{ color: 'var(--text-2)' }}>{t.msg}</div>
            </div>
          </div>
        ))}
      </div>
    </ToastCtx.Provider>
  );
}
const useToast = () => React.useContext(ToastCtx);

/* Time bar — TODAY's sign-in → sign-off window on a 06:00-20:00 clock axis.
   NOT a week-hours meter: employee cards render it directly under a "Week
   hours: 32.0h" row, and with no caption the empty track read as that number
   being broken. It is captioned here so the two can't be confused. */
function TimeBar({ inAt, outAt, late }) {
  const DAY_START = 360;          // 06:00
  const DAY_MINUTES = 14 * 60;    // → 20:00
  const pct = (h) => {
    if (!h) return null;
    const [hh, mm] = h.split(':').map(Number);
    const m = hh * 60 + mm;
    return Math.max(0, Math.min(100, ((m - DAY_START) / DAY_MINUTES) * 100));
  };
  const a = pct(inAt);
  const b = pct(outAt);
  const nowMin = (() => { const d = new Date(); return d.getHours() * 60 + d.getMinutes(); })();
  const nowPct = Math.max(0, Math.min(100, ((nowMin - DAY_START) / DAY_MINUTES) * 100));
  // Outside 06:00-20:00 the marker used to clamp to 0% and sit at the far left
  // pretending to be "now" — the stray sliver on an otherwise empty bar at,
  // say, 02:57. Off the axis means don't draw it.
  const nowOnAxis = nowMin >= DAY_START && nowMin <= DAY_START + DAY_MINUTES;
  const _t = (window.appT || (s => s));

  return (
    <div>
      <div style={{ display:'flex', justifyContent:'space-between', fontSize:10,
                    color:'var(--text-3)', marginBottom:2 }}>
        <span>{_t("Today's window")}</span>
        {a == null && <span>{_t('no sign-in yet')}</span>}
      </div>
      <div className="timebar">
        {a != null && (
          <div
            className={`timebar-fill ${late ? 'timebar-late' : ''}`}
            style={{ left: `${a}%`, width: `${Math.max(0.5, (b ?? (nowOnAxis ? nowPct : 100)) - a)}%` }}
          />
        )}
        {nowOnAxis && <div className="timebar-marker" style={{ left: `${nowPct}%` }} />}
      </div>
      <div className="timebar-axis">
        <span>06</span><span>09</span><span>12</span><span>15</span><span>18</span><span>20</span>
      </div>
    </div>
  );
}

/* Week hours bar — cumulative hours this week vs the weekly target. A plain
   0-100% fill (not a clock axis like TimeBar). Renders nothing without a
   target — an untargeted meter has no scale to read. Pace coloring is a
   simple hours/target threshold rather than a day-of-week elapsed-fraction,
   per the "keep it simple" call — good enough to flag "clearly behind". */
function WeekHoursBar({ hours, target }) {
  if (!target) return null;
  const h = hours || 0;
  const pct = Math.max(0, Math.min(100, (h / target) * 100));
  const behind = h < target * 0.6;
  const _t = (window.appT || (s => s));

  return (
    <div>
      <div style={{ display:'flex', justifyContent:'space-between', fontSize:10,
                    color:'var(--text-3)', marginBottom:2 }}>
        <span>{_t('This week')}</span>
        <span>{h.toFixed(1)}h / {target.toFixed(1)}h {_t('this week')}</span>
      </div>
      <div className="timebar">
        <div
          className={`timebar-fill ${behind ? 'timebar-late' : ''}`}
          style={{ left: '0%', width: `${Math.max(0.5, pct)}%` }}
        />
      </div>
    </div>
  );
}

/* Mini bar histogram */
function MiniBars({ data, w = 220, h = 60, accent = 'var(--brand)' }) {
  const max = Math.max(...data.map(d => d.n || d.value || 0), 1);
  const bw = (w - 6) / data.length;
  return (
    <svg width={w} height={h + 16} viewBox={`0 0 ${w} ${h + 16}`}>
      {data.map((d, i) => {
        const v = d.n ?? d.value ?? 0;
        const bh = (v / max) * h;
        return (
          <g key={i}>
            <rect x={i * bw + 1} y={h - bh} width={bw - 3} height={Math.max(1, bh)} rx={2} fill={accent} />
            <text x={i * bw + bw / 2} y={h + 12} textAnchor="middle" className="chart-axis">{d.hour || d.label || d.day}</text>
          </g>
        );
      })}
    </svg>
  );
}

/* Sparkline */
function Sparkline({ data, w = 240, h = 56, color = 'var(--brand)' }) {
  if (!data || data.length === 0) return null;
  const max = Math.max(...data, 1);
  const min = Math.min(...data);
  const r = max - min || 1;
  const step = w / (data.length - 1 || 1);
  const pts = data.map((v, i) => [i * step, h - ((v - min) / r) * h]);
  const path = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
  const area = `${path} L${w},${h} L0,${h} Z`;
  return (
    <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
      <path d={area} className="chart-area" />
      <path d={path} className="chart-line" />
      {pts.length > 0 && <circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r="3" className="chart-dot" />}
    </svg>
  );
}

/* Bar chart (multi-series ready) */
function BarChart({ data, w = 600, h = 200, valueKey = 'value', labelKey = 'label', color = 'var(--brand)' }) {
  const max = Math.max(...data.map(d => d[valueKey]), 1);
  const padL = 28, padB = 22, padT = 8, padR = 8;
  const inner = { w: w - padL - padR, h: h - padB - padT };
  const bw = inner.w / data.length;
  const yTicks = [0, max / 2, max];
  return (
    <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
      <g className="chart-grid">
        {yTicks.map((t, i) => {
          const y = padT + inner.h - (t / max) * inner.h;
          return <line key={i} x1={padL} x2={w - padR} y1={y} y2={y} />;
        })}
      </g>
      <g className="chart-axis">
        {yTicks.map((t, i) => {
          const y = padT + inner.h - (t / max) * inner.h;
          return <text key={i} x={padL - 6} y={y + 3} textAnchor="end">{Math.round(t)}</text>;
        })}
      </g>
      {data.map((d, i) => {
        const v = d[valueKey];
        const bh = (v / max) * inner.h;
        const x = padL + i * bw + 3;
        const y = padT + inner.h - bh;
        return (
          <g key={i}>
            <rect className={d.muted ? 'chart-bar muted' : 'chart-bar'} x={x} y={y} width={bw - 6} height={Math.max(1, bh)} rx={3} style={{ fill: d.color || color }}>
              <title>{`${d[labelKey]}: ${v}`}</title>
            </rect>
            <text className="chart-axis" x={x + (bw - 6) / 2} y={h - 6} textAnchor="middle">{d[labelKey]}</text>
          </g>
        );
      })}
    </svg>
  );
}

/* UserPie — inline SVG donut for presence breakdown */
function UserPie({ online = 0, away = 0, offline = 0, size = 64 }) {
  const total = online + away + offline || 1;
  const r = size / 2 - 6;
  const c = 2 * Math.PI * r;
  const cx = size / 2, cy = size / 2;
  let off = 0;
  const make = (v, color, label) => {
    if (!(v > 0)) return null;
    const len = (v / total) * c;
    const node = (
      <circle key={color} cx={cx} cy={cy} r={r} fill="none" stroke={color} strokeWidth={8}
        strokeDasharray={`${len} ${c - len}`} strokeDashoffset={-off}>
        <title>{label}: {Math.round(v)} {(window.appT||(s=>s))('min')}</title>
      </circle>
    );
    off += len;
    return node;
  };
  return (
    <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ flexShrink: 0 }}>
      <circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--line)" strokeWidth={8} />
      {make(online, 'var(--ok)', (window.appT||(s=>s))('Online'))}
      {make(away, 'var(--warn)', (window.appT||(s=>s))('Away'))}
      {make(offline, 'var(--text-3)', (window.appT||(s=>s))('Offline'))}
    </svg>
  );
}

/* Refresh button */
function Refresh({ onClick, loading, label = (window.appT||(s=>s))('Refresh') }) {
  // Silent refresh: trigger data bootstrap refresh event instead of full reload.
  const handleClick = async (e) => {
    try { if (typeof onClick === 'function') await onClick(e); } catch(_) {}
    try { window.dispatchEvent(new CustomEvent('status-data-refresh-requested')); } catch(_) {}
  };
  return (
    <button className="btn btn-ghost" onClick={handleClick} disabled={loading}>
      <Icon name="refresh" size={14} /> <span className="refresh-label">{loading ? (window.appT||(s=>s))('Refreshing…') : label}</span>
    </button>
  );
}

/* SearchInput */
function SearchInput({ value, onChange, placeholder = (window.appT||(s=>s))('Search…'), style }) {
  return (
    <div className="search" style={style}>
      <Icon name="search" size={14} />
      <input value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder} />
    </div>
  );
}

/* Heatmap — rows=users, cols=days, cell fn returns {value, color, label} */
function Heatmap({ rows = [], cols = [], cell, cellSize = 22, gap = 3 }) {
  if (!rows.length || !cols.length) return <Empty icon="·" title={(window.appT||(s=>s))("No heatmap data")} />;
  const w = cols.length * (cellSize + gap);
  const h = rows.length * (cellSize + gap);
  return (
    <div style={{ overflowX: 'auto' }}>
      <svg width={w + 80} height={h + 28} viewBox={`0 0 ${w + 80} ${h + 28}`}>
        {cols.map((col, ci) => (
          <text key={ci} className="chart-axis" x={80 + ci * (cellSize + gap) + cellSize / 2}
            y={14} textAnchor="middle" style={{ fontSize: 10 }}>
            {typeof col === 'string' ? col.slice(0, 3) : col}
          </text>
        ))}
        {rows.map((row, ri) => (
          <g key={ri}>
            <text className="chart-axis" x={75} y={28 + ri * (cellSize + gap) + cellSize / 2 + 4}
              textAnchor="end" style={{ fontSize: 11 }}>
              {typeof row === 'string' ? row.slice(0, 12) : (row.display_name || row).slice(0, 12)}
            </text>
            {cols.map((col, ci) => {
              const info = cell ? cell(ri, ci, row, col) : {};
              const fill = info.color || 'var(--bg-elev-2)';
              return (
                <rect key={ci}
                  x={80 + ci * (cellSize + gap)} y={28 + ri * (cellSize + gap)}
                  width={cellSize} height={cellSize} rx={3} fill={fill}>
                  {info.label && <title>{info.label}</title>}
                </rect>
              );
            })}
          </g>
        ))}
      </svg>
    </div>
  );
}

/* Pagination */
function Pagination({ page, pageSize, total, onChange }) {
  if (total <= pageSize) return null;
  const pages = Math.ceil(total / pageSize);
  const start = (page - 1) * pageSize + 1;
  const end = Math.min(page * pageSize, total);
  return (
    <div style={{ display:'flex', alignItems:'center', gap: 8, marginTop: 12, fontSize: 12.5, color:'var(--text-2)' }}>
      <span>{(window.appLang&&window.appLang()==='ar') ? `${start}–${end} من ${total.toLocaleString()}` : `${start}–${end} of ${total.toLocaleString()}`}</span>
      <div style={{ display:'flex', gap: 4, marginLeft:'auto' }}>
        <button className="btn btn-ghost" style={{ padding:'3px 8px' }} disabled={page === 1} onClick={() => onChange(1)}>«</button>
        <button className="btn btn-ghost" style={{ padding:'3px 8px' }} disabled={page === 1} onClick={() => onChange(page - 1)}>‹</button>
        {Array.from({ length: Math.min(7, pages) }, (_, i) => {
          let p;
          if (pages <= 7) {
            p = i + 1;
          } else if (page <= 4) {
            p = i + 1;
            if (i === 6) p = pages;
          } else if (page >= pages - 3) {
            p = pages - 6 + i;
            if (i === 0) p = 1;
          } else {
            const offs = [1, page - 2, page - 1, page, page + 1, page + 2, pages];
            p = offs[i];
          }
          const isSep = i > 0 && p - (i === 1 ? 1 : i === 5 ? pages - 6 + (i - 1) : page - 2 + (i - 2)) > 1;
          return (
            <React.Fragment key={i}>
              {isSep && <span style={{ padding:'0 2px' }}>…</span>}
              <button
                className={`btn ${p === page ? 'btn-pri' : 'btn-ghost'}`}
                style={{ padding:'3px 8px', minWidth: 28 }}
                onClick={() => onChange(p)}
              >{p}</button>
            </React.Fragment>
          );
        })}
        <button className="btn btn-ghost" style={{ padding:'3px 8px' }} disabled={page === pages} onClick={() => onChange(page + 1)}>›</button>
        <button className="btn btn-ghost" style={{ padding:'3px 8px' }} disabled={page === pages} onClick={() => onChange(pages)}>»</button>
      </div>
    </div>
  );
}

/* StatusPill — connectivity/health indicator */
function StatusPill({ kind = 'mute', children }) {
  const colors = {
    good: { bg: 'var(--ok)', text: '#fff' },
    warn: { bg: 'var(--warn)', text: '#fff' },
    bad:  { bg: 'var(--err)', text: '#fff' },
    mute: { bg: 'var(--bg-elev-2)', text: 'var(--text-2)' },
  };
  const c = colors[kind] || colors.mute;
  return (
    <span style={{
      display: 'inline-flex', alignItems: 'center', gap: 5,
      padding: '2px 9px', borderRadius: 99,
      background: c.bg, color: c.text,
      fontSize: 11, fontWeight: 600, letterSpacing: '0.04em',
    }}>
      <span style={{ width: 6, height: 6, borderRadius: '50%', background: c.text, opacity: 0.85, flexShrink: 0 }} />
      {children}
    </span>
  );
}

/* parseDetail — parse FastAPI/Pydantic error response bodies into readable strings.
   `detail` can be a string, array of {loc,msg,type}, or an object. */
function parseDetail(body, status) {
  // Friendly auth-expired message + dispatch a global event so the shell
  // can show a re-login prompt and reload the page (one-shot per session).
  if (status === 401) {
    try {
      if (!window.__authExpiredFired) {
        window.__authExpiredFired = true;
        window.dispatchEvent(new CustomEvent('auth-expired'));
      }
    } catch(_) {}
    return (window.appT||(s=>s))('Session expired — please log in again.');
  }
  const d = body?.detail;
  if (typeof d === 'string') return d;
  if (Array.isArray(d)) {
    return d.map(e => {
      const loc = Array.isArray(e?.loc) ? e.loc.filter(p => p !== 'body').join('.') : '';
      return loc ? `${loc}: ${e.msg}` : e.msg;
    }).filter(Boolean).join('; ') || `HTTP ${status}`;
  }
  if (d && typeof d === 'object') {
    try { return JSON.stringify(d); } catch(_) {}
  }
  return body?.message || `HTTP ${status}`;
}

/* ── AnimatedHeading — word-by-word fade-up for page hero titles ── */
function AnimatedHeading({ text, tag: Tag = 'h2', className = '', style = {} }) {
  const words = (text || '').split(' ');
  return (
    <Tag className={className} style={style}>
      {words.map((word, i) => (
        <span
          key={i}
          className="animated-word"
          style={{ animationDelay: `${i * 60}ms`, marginRight: i < words.length - 1 ? '0.3em' : 0 }}
        >
          {word}
        </span>
      ))}
    </Tag>
  );
}

/* ── useFadeInOnScroll — attaches IntersectionObserver to elements with .fade-in-on-scroll ── */
function useFadeInOnScroll(containerRef) {
  useEffect(() => {
    const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (prefersReduced) return;

    const root = containerRef ? containerRef.current : document;
    if (!root) return;

    const targets = root.querySelectorAll ? root.querySelectorAll('.fade-in-on-scroll') : [];
    if (!targets.length) return;

    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          entry.target.classList.add('in-view');
          observer.unobserve(entry.target); // trigger once only
        }
      });
    }, { threshold: 0.1, rootMargin: '0px 0px -30px 0px' });

    targets.forEach(el => observer.observe(el));
    return () => observer.disconnect();
  }, []);
}

/* Global IntersectionObserver boot — runs once after DOM settles.
   Pages that add .fade-in-on-scroll elements dynamically call window.bindFadeIn(). */
window.bindFadeIn = function(root) {
  const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (prefersReduced) return;
  const targets = (root || document).querySelectorAll('.fade-in-on-scroll:not(.in-view)');
  if (!targets.length) return;
  const obs = new IntersectionObserver((entries) => {
    entries.forEach(e => {
      if (e.isIntersecting) { e.target.classList.add('in-view'); obs.unobserve(e.target); }
    });
  }, { threshold: 0.1, rootMargin: '0px 0px -30px 0px' });
  targets.forEach(el => obs.observe(el));
};

/* Parallax scroll handler — drifts the #parallax-layer dot grid at 0.2×
 * scroll speed. The transform is written directly on the dedicated fixed
 * layer: writing a CSS variable on <body> (the previous approach) forces a
 * style recalculation of the ENTIRE document on every scroll frame, which
 * starved the rasterizer on card-heavy pages (Employees grid checkerboarded
 * blank on scroll). A composited transform on one element costs nothing. */
/* Scroll-idle flag — html[data-scrolling] is set while the user scrolls and
 * cleared 150ms after the last scroll event. CSS uses it to suspend hover
 * hit-testing on the card grid: without it, every card passing under a
 * stationary cursor flashes its hover state in sequence (visible flicker). */
(function initScrollIdleFlag() {
  let idleTimer = 0;
  window.addEventListener('scroll', () => {
    if (!document.documentElement.hasAttribute('data-scrolling')) {
      document.documentElement.setAttribute('data-scrolling', '');
    }
    clearTimeout(idleTimer);
    idleTimer = setTimeout(() => document.documentElement.removeAttribute('data-scrolling'), 150);
  }, { passive: true });
})();

(function initParallax() {
  const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const isMobile = window.innerWidth < 768;
  if (prefersReduced || isMobile) return;
  const layer = document.createElement('div');
  layer.id = 'parallax-layer';
  layer.setAttribute('aria-hidden', 'true');
  document.body.prepend(layer);
  let ticking = false;
  window.addEventListener('scroll', () => {
    if (!ticking) {
      requestAnimationFrame(() => {
        layer.style.transform = `translate3d(0, ${window.scrollY * 0.2}px, 0)`;
        ticking = false;
      });
      ticking = true;
    }
  }, { passive: true });
})();


/* ── VPSieServerSelect ─────────────────────────────────────────────────── */
function VPSieServerSelect({ value, onChange, placeholder = (window.appT||(s=>s))('Select a server…'), allowEmpty = true, onConnectClick, reloadKey }) {
  const [state, setState] = useState({ loading: true, servers: [], connected: null, error: null });

  useEffect(() => {
    let cancelled = false;
    setState(s => ({ ...s, loading: true, error: null }));
    fetch('/api/me/vpsie/servers', { credentials: 'include' })
      .then(r => r.ok ? r.json() : Promise.reject(r.status))
      .then(d => {
        if (cancelled) return;
        try { window.__vpsieServersCache = d.servers || []; } catch (_e) {}
        setState({ loading: false, servers: d.servers || [], connected: d.connected, error: d.error || null });
      })
      .catch(() => {
        if (cancelled) return;
        setState({ loading: false, servers: [], connected: false, error: (window.appT||(s=>s))('Could not reach the server') });
      });
    return () => { cancelled = true; };
  }, [reloadKey]);

  const retry = () => {
    setState(s => ({ ...s, loading: true, error: null }));
    fetch('/api/me/vpsie/refresh', { method: 'POST', credentials: 'include' })
      .finally(() => {
        fetch('/api/me/vpsie/servers', { credentials: 'include' })
          .then(r => r.ok ? r.json() : Promise.reject(r.status))
          .then(d => {
            try { window.__vpsieServersCache = d.servers || []; } catch (_e) {}
            setState({ loading: false, servers: d.servers || [], connected: d.connected, error: d.error || null });
          })
          .catch(() => setState({ loading: false, servers: [], connected: false, error: (window.appT||(s=>s))('Could not reach the server') }));
      });
  };

  const disconnect = () => {
    if (!confirm((window.appT||(s=>s))('Disconnect your VPSie account? Your stored credentials will be deleted.'))) return;
    fetch('/api/me/vpsie/connect', { method: 'DELETE', credentials: 'include' })
      .finally(() => {
        try { window.__vpsieServersCache = []; } catch (_e) {}
        setState({ loading: false, servers: [], connected: false, error: null });
      });
  };

  const baseStyle = {
    width: '100%', boxSizing: 'border-box', padding: '8px 12px',
    background: 'var(--bg-1)', border: '1px solid var(--border-1, var(--line))',
    borderRadius: 6, color: 'var(--text-1)', fontSize: 13,
    appearance: 'none', WebkitAppearance: 'none',
    backgroundImage: 'url("data:image/svg+xml;charset=US-ASCII,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'10\' height=\'6\' viewBox=\'0 0 10 6\'%3E%3Cpath fill=\'%2394a3b8\' d=\'M0 0l5 6 5-6z\'/%3E%3C/svg%3E")',
    backgroundRepeat: 'no-repeat', backgroundPosition: 'right 12px center', paddingRight: 32,
    fontFamily: 'inherit',
  };

  if (state.loading) {
    return <select style={baseStyle} disabled><option>{(window.appT||(s=>s))('Loading VPSie servers…')}</option></select>;
  }

  if (!state.connected) {
    const linkStyle = { color: 'var(--brand)', textDecoration: 'none', fontWeight: 500, cursor: 'pointer', background: 'none', border: 'none', padding: 0, font: 'inherit' };
    return (
      <div style={{ fontSize: 12, color: 'var(--text-3)', padding: '8px 2px' }}>
        {(window.appT||(s=>s))('No VPSie account connected.')}{' '}
        {onConnectClick ? (
          <button type="button" onClick={onConnectClick} style={linkStyle}>
            {(window.appT||(s=>s))('Connect VPSie')} &rarr;
          </button>
        ) : (
          <a href="https://my.vpsie.com/auth/login" target="_blank" rel="noopener noreferrer" style={linkStyle}>
            {(window.appT||(s=>s))('Connect VPSie')} &rarr;
          </a>
        )}
        <span style={{ display: 'block', marginTop: 4, fontSize: 11, color: 'var(--text-3)' }}>
          {onConnectClick
            ? (window.appT||(s=>s))('Provide a VPSie API client ID and secret — stored encrypted, scoped to you.')
            : <>{(window.appT||(s=>s))('DM the bot:')} <code style={{ fontFamily: 'var(--font-mono)', background: 'var(--bg-elev-2)', padding: '1px 4px', borderRadius: 3 }}>connect vpsie</code></>}
        </span>
      </div>
    );
  }

  if (state.error) {
    return (
      <div style={{ fontSize: 12, color: 'var(--warn)', padding: '6px 2px', display: 'flex', alignItems: 'center', gap: 8 }}>
        <span>{state.error}</span>
        <button onClick={retry} style={{ fontSize: 11, padding: '3px 8px', background: 'var(--bg-elev-2)', border: '1px solid var(--line)', borderRadius: 4, cursor: 'pointer', color: 'var(--text-2)', fontFamily: 'inherit' }}>{(window.appT||(s=>s))('Retry')}</button>
      </div>
    );
  }

  if (state.servers.length === 0) {
    return <div style={{ fontSize: 12, color: 'var(--text-3)', padding: '6px 2px' }}>{(window.appT||(s=>s))('No VPSie servers found on your account.')}</div>;
  }

  return (
    <div>
      <select
        style={baseStyle}
        value={value || ''}
        onChange={e => onChange && onChange(e.target.value)}
      >
        {allowEmpty && <option value="">{placeholder}</option>}
        {state.servers.map(s => (
          <option key={s.id} value={s.id}>
            {s.hostname}{s.status ? ` (${s.status})` : ''}{s.ip ? ' · ' + s.ip : ''}{s.region ? ' · ' + s.region : ''}
          </option>
        ))}
      </select>
      <div style={{ marginTop: 4, fontSize: 11, color: 'var(--text-3)', textAlign: 'right' }}>
        {(window.appT||(s=>s))('VPSie connected')} ·{' '}
        <button type="button" onClick={disconnect}
          style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', color: 'var(--text-3)', textDecoration: 'underline', cursor: 'pointer' }}>
          {(window.appT||(s=>s))('disconnect')}
        </button>
      </div>
    </div>
  );
}

window.Avatar = Avatar;
window.PersonCell = PersonCell;
window.Badge = Badge;
window.StatusBadge = StatusBadge;
window.StatusPill = StatusPill;
window.SectionHeader = SectionHeader;
window.PageHeader = PageHeader;
window.KPI = KPI;
window.Empty = Empty;
window.Skel = Skel;
window.Loading = Loading;
window.LoadingRow = LoadingRow;
window.Segmented = Segmented;
window.useSortable = useSortable;
window.SortHead = SortHead;
window.ToastProvider = ToastProvider;
window.useToast = useToast;
window.TimeBar = TimeBar;
window.WeekHoursBar = WeekHoursBar;
window.MiniBars = MiniBars;
window.Sparkline = Sparkline;
window.BarChart = BarChart;
window.UserPie = UserPie;
window.Refresh = Refresh;
window.SearchInput = SearchInput;
window.Heatmap = Heatmap;
window.Pagination = Pagination;
window.parseDetail = parseDetail;
window.useTheme = useTheme;
window.resolveAvatarUrl = resolveAvatarUrl;
window.AnimatedHeading = AnimatedHeading;
window.useFadeInOnScroll = useFadeInOnScroll;
window.VPSieServerSelect = VPSieServerSelect;
// Phase 1 — admin Edit Mode primitives
window.useEditMode = useEditMode;
window.setEditMode = setEditMode;
window.EditableCell = EditableCell;
window.canEditUser = canEditUser;
window.showUndoToast = showUndoToast;
