// auth.jsx — Supabase auth client + login widget for the CLA Practice Planner.
// Exposes:
//   window.claSupabase           — Supabase JS client (auto-init on script load)
//   window.useClaSession()       — React hook returning { user, loading, signOut }
//   window.ClaAuthBar             — sign-in chip / user menu (top-right of page)
//   window.signInWithPassword(email, pw) / window.signUpWithPassword(email, pw)
//   window.sendMagicLink(email)
//
// Persistence helpers (live in this file for proximity to auth):
//   window.loadUserData(), window.saveUserData(patch)
//   window.listUserPractices(), window.saveUserPractice(id|null, name, data)
//   window.deleteUserPractice(id), window.loadUserPractice(id)

(function(){
  const SB_URL  = 'https://blpvjuykqxdnuyiqivwu.supabase.co';
  const SB_ANON = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJscHZqdXlrcXhkbnV5aXFpdnd1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzE2NDYxODAsImV4cCI6MjA4NzIyMjE4MH0.6WT8NB4CjVWsxjBCRqIzevsT7HgL04cKNMbcz7JqQnw';

  if (!window.supabase || !window.supabase.createClient) {
    console.warn('[cla-auth] supabase-js not loaded yet — auth widget will be a no-op');
    window.claSupabase = null;
  } else {
    window.claSupabase = window.supabase.createClient(SB_URL, SB_ANON, {
      auth: { persistSession: true, autoRefreshToken: true, storageKey: 'cla-auth' },
    });
  }
  const sb = window.claSupabase;

  // v8.102 (Tim 2026-08-03, airplane): getSession() can HANG offline once the
  // access token has expired — gotrue's init awaits its refresh retry loop
  // (network failures count as "retryable"), and EVERY postgrest request
  // awaits getSession for its auth header, so one hung refresh freezes the
  // boot gate AND all data reads: the app sat on "Loading…" for the whole
  // flight. Race it against a deadline; past it, answer with the session
  // stored under 'cla-auth' (its token may be expired → requests then FAIL
  // FAST → every read's existing IndexedDB cache fallback fires, which is the
  // designed offline path). Repro + gate: scripts/smoke-offline-signedin.mjs.
  if (sb && sb.auth && sb.auth.getSession) {
    const claRealGetSession = sb.auth.getSession.bind(sb.auth);
    let claSessHung = false;   // first timeout → later calls stop waiting the full deadline
    sb.auth.getSession = () => new Promise((resolve) => {
      const ms = (claSessHung || (typeof navigator !== 'undefined' && navigator.onLine === false)) ? 400 : 3500;
      let done = false;
      const timer = setTimeout(() => {
        if (done) return;
        done = true; claSessHung = true;
        try { resolve({ data: { session: JSON.parse(localStorage.getItem('cla-auth') || 'null') }, error: null }); }
        catch (_) { resolve({ data: { session: null }, error: null }); }
      }, ms);
      claRealGetSession().then(
        (r) => { clearTimeout(timer); claSessHung = false; if (!done) { done = true; resolve(r); } },
        () => { clearTimeout(timer); if (!done) { done = true; resolve({ data: { session: null }, error: null }); } });
    });
  }

  // ─── Offline layer (Tim 2026-07-19: "make the tool able to be used offline") ──
  // Three pieces, all in this file because they wrap the persistence helpers:
  //   1. claGetUser() — LOCAL session read (getSession + a localStorage fallback
  //      for sessions the client couldn't refresh while offline). Replaces the
  //      per-helper sb.auth.getUser() network round-trip; RLS still enforces
  //      identity server-side, so the local read is UI-routing only.
  //   2. An IndexedDB read-cache (cla-offline/kv): every core read caches its
  //      last good result and falls back to it when the network fails. Writes
  //      queue too since phase 2 (v8.35, extended v8.45) — see the block below.
  //   3. sw.js registration + the offline pill. The service worker only caches
  //      the app SHELL (pages/scripts/CDN libs); data offline lives here.
  async function claGetUser(){
    if (!sb) return null;
    try {
      const { data } = await sb.auth.getSession();
      if (data && data.session && data.session.user) {
        claAuthReadyKick();   // v8.97: first resolved user = the real auth-ready point → drain queue
        return data.session.user;
      }
    } catch (_) {}
    // Offline with an expired access token: getSession can't refresh and yields
    // null, but the stored session still names the user. signOut() clears the
    // key, so a signed-out browser never lands here with a value.
    try {
      const raw = JSON.parse(localStorage.getItem('cla-auth') || 'null');
      const u = (raw && raw.user) || null;
      if (u) claAuthReadyKick();
      return u;
    } catch (_) { return null; }
  }

  function claIdb(){
    return new Promise((resolve, reject) => {
      const r = indexedDB.open('cla-offline', 1);
      r.onupgradeneeded = () => r.result.createObjectStore('kv');
      r.onsuccess = () => resolve(r.result);
      r.onerror = () => reject(r.error);
    });
  }
  async function claCacheGet(key){
    try {
      const db = await claIdb();
      return await new Promise((resolve) => {
        const t = db.transaction('kv').objectStore('kv').get(key);
        t.onsuccess = () => resolve(t.result === undefined ? null : t.result);
        t.onerror = () => resolve(null);
      });
    } catch (_) { return null; }
  }
  function claCachePut(key, val){
    return claIdb().then((db) => new Promise((resolve) => {
      const t = db.transaction('kv', 'readwrite');
      t.objectStore('kv').put(val, key);
      t.oncomplete = () => resolve(true);
      t.onerror = () => resolve(false);
      t.onabort = () => resolve(false);
    })).catch(() => false);
  }

  // ─── Offline WRITE queue (phase 2, Tim 2026-07-19: "build phase 2 for vocab
  // and practice saves and think through the safest way … not to clobber") ────
  // Scope (v8.45, Tim 2026-07-22 v9 P5: "i do want offline to function until
  // the next time it can be synced up"): practice saves, team vocab/emphasis,
  // TEAM patches (calendar/drill bank/roster/identity/name), account settings
  // (cla_user_data — tier stripped, never queue-writable), and rotations.
  // DELETES stay online-only BY DESIGN — a blind-replayed delete is dangerous;
  // an offline delete attempt gets an honest toast (claOfflineDeleteToast).
  //
  // The no-clobber contract:
  //  • Queue = '__queue' array in cla-offline/kv — survives reload/quit. Ops
  //    COALESCE: N offline saves of the same practice/vocab doc keep ONE op
  //    with the FIRST op's base (server state before we started) + LATEST
  //    content. Queue mutations + replay run under navigator.locks, so two
  //    tabs can't double-replay or drop each other's ops.
  //  • PRACTICES replay by compare-and-swap on updated_at: the UPDATE lands
  //    only if the server row is exactly the one this device edited
  //    (.eq('updated_at', base)). Solo-owned practices (one editor, one
  //    device) always pass → clean replay. If anyone else saved meanwhile —
  //    a shared-team coach OR another of my own devices — we NEVER overwrite:
  //    the offline version becomes its own "<name> — offline copy (date)"
  //    practice. Both versions survive; the coach merges by eye. An update
  //    with NO known base also goes the copy route (never clobber blind).
  //  • VOCAB/EMPHASIS are one shared doc per team, so whole-doc replay WOULD
  //    clobber concurrent edits. Replay three-way merges per item instead
  //    (terms by id, cats by key, emphasis by id) against the CURRENT server
  //    doc: offline adds add; offline deletes only remove items the server
  //    still holds in base form; both-sides-edited-the-same-item → the SERVER
  //    version wins (losing my edit is visible and recoverable; silently
  //    losing a teammate's is not).
  //  • An op is dropped ONLY on a hard non-network refusal (RLS etc.) — and a
  //    practice op is first rescued as a personal (team_id NULL) practice,
  //    plus every dropped op is stashed under 'dead:<uid>'. Content is never
  //    destroyed.
  function claIsNetErr(e){
    if (!navigator.onLine) return true;
    const m = String((e && e.message) || e || '');
    return /fetch|network|load failed|connection|timed? ?out|socket/i.test(m);
  }
  function claUid(){ return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
  function claQLock(name, fn){
    try {
      if (navigator.locks && navigator.locks.request) return navigator.locks.request(name, fn);
    } catch (_) {}
    return fn();
  }
  async function claQueue(){ return (await claCacheGet('__queue')) || []; }
  async function claEnqueue(op){
    op.uid = claUid(); op.ts = new Date().toISOString();
    await claQLock('cla-offline-queue', async () => {
      const q = await claQueue();
      const i = q.findIndex((o) =>
        ((op.kind === 'practice' || op.kind === 'rotation') && o.kind === op.kind && o.id === op.id) ||
        ((op.kind === 'vocab' || op.kind === 'emphasis' || op.kind === 'team') && o.kind === op.kind && o.teamId === op.teamId) ||
        (op.kind === 'userdata' && o.kind === 'userdata'));
      if (i >= 0) { // coalesce: keep the FIRST base, take the latest content
        if (op.kind === 'team' || op.kind === 'userdata') {
          // per-key patch docs: the FIRST enqueue's base wins per key (base
          // snapshots for keys NEW to this op merge in); latest content per key
          op.base = Object.assign({}, op.base || {}, q[i].base || {});
          op.patch = Object.assign({}, q[i].patch || {}, op.patch || {});
        } else {
          op.base = q[i].base;
        }
        op.baseUpdatedAt = q[i].baseUpdatedAt; op.insert = q[i].insert;
        q[i] = op;
      } else q.push(op);
      await claCachePut('__queue', q);
    });
    claOfflinePillSync();
    claReplayHeartbeat();   // v8.97: keep retrying until this op lands (survives a missed drain)
    if (navigator.onLine) setTimeout(claReplayQueue, 1500); // net-blip queue → drain soon
    return op;
  }
  async function claDequeue(uid){
    await claQLock('cla-offline-queue', async () => {
      await claCachePut('__queue', (await claQueue()).filter((o) => o.uid !== uid));
    });
    claOfflinePillSync();
  }

  // Pending practice ops overlaid on a server list, so a reconnect's fresh
  // fetch doesn't make not-yet-replayed offline work "disappear" from the UI.
  async function claOverlayPendingPractices(teamId, rows){
    const q = await claQueue();
    let out = rows;
    for (const op of q) {
      if (op.kind !== 'practice' || (op.teamId || null) !== (teamId || null)) continue;
      const row = { id: op.id, name: op.name, data: op.data, team_id: op.teamId,
        created_at: op.ts, updated_at: op.ts };
      out = [row, ...out.filter((r) => r.id !== op.id)];
    }
    return out;
  }

  // Three-way merge of one vocab-style list. base = what this device edited
  // FROM, next = what it edited TO, server = the doc as it is NOW. Returns the
  // merged list + how many offline changes lost to newer server edits.
  function claKeyOf(item, kind){
    // Plain-string lists (scout "their terms" are bare uppercase strings): the
    // string IS the identity. Object kinds below never receive strings today —
    // a stringly item used to key to '' for ALL of them, which is strictly worse.
    if (typeof item === 'string') return item.trim().toUpperCase();
    // Scout roster rows carry NO ids ({no,first,last,pos}) — key on the jersey
    // number + name so two dossiers merge per PLAYER (renumbering reads as
    // delete+add, which nets out to the same row).
    if (kind === 'scout-roster') return [String(item.no == null ? '' : item.no).trim(), String(item.last || '').trim().toUpperCase(), String(item.first || '').trim().toUpperCase()].join('|');
    if (kind === 'scout-attachments' || kind === 'scout-playlists') return String(item.path || item.id || item.name || '');
    if (kind === 'cats') return String(item.key || item.id || String(item.label || '').toLowerCase());
    // default branch also keys calendar events + drill-bank rows (both carry id;
    // `name` is a defensive fallback for id-less drills — vocab behavior unchanged)
    return String(item.id || String(item.term || item.text || item.name || '').toLowerCase());
  }
  function claMergeList(base, next, server, kind){
    const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
    const idx = (arr) => { const m = new Map(); (arr || []).forEach((x) => m.set(claKeyOf(x, kind), x)); return m; };
    const B = idx(base), N = idx(next), S = idx(server);
    let list = (server || []).slice(), lost = 0;
    for (const item of (next || [])) {
      const k = claKeyOf(item, kind);
      const b = B.get(k), s = S.get(k);
      if (!b) { if (!s) list.push(item); }                       // added offline (server also added same key → theirs stays)
      else if (!same(b, item)) {                                  // edited offline
        if (s && same(s, b)) list = list.map((x) => claKeyOf(x, kind) === k ? item : x); // server untouched → apply
        else if (s) lost++;                                       // both edited → server wins
        else list.push(item);                                     // server deleted what I edited → my edit survives
      }
    }
    for (const item of (base || [])) {
      const k = claKeyOf(item, kind);
      if (N.has(k)) continue;                                     // not deleted offline
      const s = S.get(k);
      if (s && same(s, item)) list = list.filter((x) => claKeyOf(x, kind) !== k); // still base form → delete lands
      else if (s) lost++;                                         // they edited what I deleted → theirs survives
    }
    return { list, lost };
  }

  // ── v8.45 team/settings merges (same 3-way contract as claMergeList) ──
  // Shallow per-subkey 3-way of one jsonb OBJECT (identity, default_roster,
  // settings): subkey unchanged on server vs base → take local; both changed →
  // SERVER wins (counted `lost`, surfaces in the pill's "kept the newer team
  // edit"). Local subkey deletes land only while the server still holds the
  // base value. Pure — extracted verbatim by scripts/test-offline-merge.mjs.
  function claMergeObj(base, next, server){
    const same = (a, b) => JSON.stringify(a === undefined ? null : a) === JSON.stringify(b === undefined ? null : b);
    const out = { ...(server || {}) };
    let lost = 0;
    const keys = new Set(Object.keys(next || {}).concat(Object.keys(base || {})));
    for (const k of keys) {
      const b = (base || {})[k], n = (next || {})[k], s = (server || {})[k];
      if (same(b, n)) continue;                        // untouched locally → server stands
      if (same(s, b)) {                                // server untouched → my change lands
        if (n === undefined) delete out[k]; else out[k] = n;
      } else lost++;                                   // both changed → server wins
    }
    return { obj: out, lost };
  }
  // Per-key 3-way merge of a queued TEAM patch (op kind 'team') against the
  // CURRENT server row. `base` holds per-key snapshots captured at first
  // enqueue. Arrays of {id,…} (calendar, drill_bank) merge per ITEM via
  // claMergeList — local adds add, local deletes only remove base-form server
  // items, both-edited-same-item → server wins. Objects (identity,
  // default_roster) merge per subkey; scalars (name…) replay only while the
  // server still holds the base value. Returns { body, kept } — body carries
  // ONLY the patched keys, kept counts offline changes that lost to newer
  // server edits.
  function claMergeTeamPatch(base, patch, server){
    const same = (a, b) => JSON.stringify(a === undefined ? null : a) === JSON.stringify(b === undefined ? null : b);
    const body = {};
    let kept = 0;
    for (const k of Object.keys(patch || {})) {
      const b = (base || {})[k], n = patch[k], s = (server || {})[k];
      if (k === 'calendar' || k === 'drill_bank' || k === 'templates' || k === 'scouts') {   // arrays of {id,…} — per-item 3-way
        const m = claMergeList(b || [], n || [], s || [], k);
        body[k] = m.list; kept += m.lost;
      } else if (n && typeof n === 'object' && !Array.isArray(n)) {   // identity, default_roster, settings…
        const m = claMergeObj(b || {}, n || {}, (s && typeof s === 'object' && !Array.isArray(s)) ? s : {});
        body[k] = m.obj; kept += m.lost;
      } else if (same(b, n)) {                        // key rode along unchanged → keep server truth
        body[k] = s === undefined ? n : s;
      } else if (same(s, b)) {                        // server untouched → local wins
        body[k] = n;
      } else {                                        // both changed → server wins
        body[k] = s; kept++;
      }
    }
    return { body, kept };
  }
  // Queued account-settings replay (op kind 'userdata'). `tier` is stripped
  // UNCONDITIONALLY — no client write path may grant a tier (v6 tier signal is
  // read-only; Tim/billing set the column). drill_bank is per-team, never here.
  function claMergeUserData(base, patch, server){
    const p = { ...(patch || {}) };
    delete p.tier; delete p.drill_bank; delete p.user_id;
    const m = claMergeTeamPatch(base || {}, p, server || {});
    delete m.body.tier;
    return m;
  }

  // Scout-row patch 3-way (v8.3, MC 28adecc3 — write-conflict audit HIGH #2):
  // backs SCT_saveScout's online CAS-miss rebase in scouts.jsx. roster (id-less
  // {no,first,last,pos} rows → keyed no|LAST|FIRST), terms (plain strings →
  // keyed by their uppercased selves) and attachments/playlists (keyed
  // path/id) merge per ITEM via claMergeList; every other patched key follows
  // the claMergeTeamPatch scalar contract (server untouched vs base → local
  // wins; both changed → server wins, counted `kept`). Pure — extracted
  // verbatim by scripts/test-offline-merge.mjs.
  function claMergeScoutPatch(base, patch, server){
    const same = (a, b) => JSON.stringify(a === undefined ? null : a) === JSON.stringify(b === undefined ? null : b);
    const body = {};
    let kept = 0;
    for (const k of Object.keys(patch || {})) {
      const b = (base || {})[k], n = patch[k], s = (server || {})[k];
      if (k === 'roster' || k === 'terms' || k === 'attachments' || k === 'playlists') {
        const m = claMergeList(b || [], n || [], s || [], 'scout-' + k);
        body[k] = m.list; kept += m.lost;
      } else if (same(b, n)) {                        // key rode along unchanged → keep server truth
        body[k] = s === undefined ? n : s;
      } else if (same(s, b)) {                        // server untouched → local wins
        body[k] = n;
      } else {                                        // both changed → server wins
        body[k] = s; kept++;
      }
    }
    return { body, kept };
  }

  // Practice-data 3-way (online CAS misses, multi-window Wave 6): blocks (array
  // of {id,…}) per item via claMergeList; the scalar remainder (notes, emphasis,
  // date, start, num…) per key via claMergeObj. Pure — extracted verbatim by
  // scripts/test-offline-merge.mjs like its siblings above.
  function claMergePracticeData(base, next, server){
    const split = (d) => { const { blocks, ...rest } = d || {}; return { blocks: blocks || [], rest }; };
    const b = split(base), n = split(next), s = split(server);
    const mList = claMergeList(b.blocks, n.blocks, s.blocks, 'blocks');
    const mObj = claMergeObj(b.rest, n.rest, s.rest);
    return { merged: { ...mObj.obj, blocks: mList.list }, lost: mList.lost + mObj.lost };
  }

  async function claConflictCopy(op, user){
    const stamp = new Date(op.ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
    const name = op.name + ' — offline copy (' + stamp + ')';
    const r = await sb.from('cla_practices').insert({ user_id: user.id, team_id: op.teamId, name, data: op.data }).select('id').single();
    if (r.error) {
      if (claIsNetErr(r.error)) throw r.error;
      await claRescuePractice(op, user, r.error);
    }
  }
  async function claRescuePractice(op, user, cause){
    // Last resort — a personal practice always passes owner RLS.
    console.warn('[cla] practice op rescued to a personal practice after:', cause);
    const r = await sb.from('cla_practices').insert({ user_id: user.id, team_id: null,
      name: op.name + ' — offline rescue', data: op.data });
    if (r.error && claIsNetErr(r.error)) throw r.error;
  }

  async function claReplayOp(op, user, idMap, stats){
    if (op.kind === 'practice') {
      const now = new Date().toISOString();
      const rid = idMap[op.id] || op.id;
      if (op.insert || String(rid).startsWith('offline-')) {
        const r = await sb.from('cla_practices').insert({ user_id: user.id, team_id: op.teamId, name: op.name, data: op.data }).select('id').single();
        if (r.error) {
          if (claIsNetErr(r.error)) throw r.error;
          await claRescuePractice(op, user, r.error); stats.copies++; return;
        }
        idMap[op.id] = r.data.id;
        return;
      }
      if (!op.baseUpdatedAt) { await claConflictCopy(op, user); stats.copies++; return; } // no known base → never clobber blind
      const r = await sb.from('cla_practices')
        .update({ name: op.name, data: op.data, updated_at: now })
        .eq('id', rid).eq('updated_at', op.baseUpdatedAt)   // ← the CAS
        .select('id');
      if (r.error) {
        if (claIsNetErr(r.error)) throw r.error;
        await claRescuePractice(op, user, r.error); stats.copies++; return;
      }
      if (r.data && r.data.length) return;                   // clean replay
      await claConflictCopy(op, user); stats.copies++;       // row changed (or deleted) while offline
      return;
    }
    if (op.kind === 'vocab' || op.kind === 'emphasis') {
      const cur = await sb.from('cla_team_vocab').select('cats, terms, emphasis').eq('team_id', op.teamId).maybeSingle();
      if (cur.error) throw cur.error;
      const server = cur.data || { cats: [], terms: [], emphasis: [] };
      if (op.kind === 'vocab') {
        const cats = claMergeList(op.base.cats, op.next.cats, server.cats, 'cats');
        const terms = claMergeList(op.base.terms, op.next.terms, server.terms, 'terms');
        stats.kept += cats.lost + terms.lost;
        const r = await sb.from('cla_team_vocab')
          .upsert({ team_id: op.teamId, cats: cats.list, terms: terms.list, updated_at: new Date().toISOString() }, { onConflict: 'team_id' })
          .select('cats, terms, emphasis').single();
        if (r.error) throw r.error;
        await claCachePut('t:' + op.teamId + ':vocab', { cats: r.data.cats, terms: r.data.terms, emphasis: r.data.emphasis });
        return;
      }
      const merged = claMergeList(op.base, op.next, server.emphasis || [], 'emphasis');
      stats.kept += merged.lost;
      if (cur.data) {
        const r = await sb.from('cla_team_vocab').update({ emphasis: merged.list, updated_at: new Date().toISOString() }).eq('team_id', op.teamId);
        if (r.error) throw r.error;
      } else {
        const r = await sb.from('cla_team_vocab').insert({ team_id: op.teamId, cats: [], terms: [], emphasis: merged.list });
        if (r.error) throw r.error;
      }
      const c = (await claCacheGet('t:' + op.teamId + ':vocab')) || { cats: [], terms: [] };
      await claCachePut('t:' + op.teamId + ':vocab', { ...c, emphasis: merged.list });
      return;
    }
    if (op.kind === 'team') {
      // TEAM patch (v8.45): 3-way merge per top-level key against the CURRENT
      // server row (claMergeTeamPatch), then the normal UPDATE. A GONE server
      // row means the team was deleted while offline — NEVER recreate it: the
      // throw below routes the op to the dead-letter stash + "could not sync".
      const cur = await sb.from('cla_teams').select('*').eq('id', op.teamId).maybeSingle();
      if (cur.error) throw cur.error;
      if (!cur.data) throw new Error('team row gone — a deleted team is never recreated from the queue');
      const m = claMergeTeamPatch(op.base || {}, op.patch || {}, cur.data);
      stats.kept += m.kept;
      const r = await sb.from('cla_teams')
        .update({ ...m.body, updated_at: new Date().toISOString() })
        .eq('id', op.teamId).select().single();
      if (r.error) throw r.error;
      const key = 'u:' + user.id + ':teams';
      const cached = (await claCacheGet(key)) || [];
      await claCachePut(key, cached.map((t) => t.id === op.teamId ? r.data : t));
      return;
    }
    if (op.kind === 'userdata') {
      // Account settings (v8.45): shallow per-key 3-way on the settings jsonb;
      // `tier` stripped at enqueue AND here — the queue can never grant a tier.
      const cur = await sb.from('cla_user_data').select('*').eq('user_id', user.id).maybeSingle();
      if (cur.error) throw cur.error;
      const m = claMergeUserData(op.base || {}, op.patch || {}, cur.data || {});
      stats.kept += m.kept;
      const r = await sb.from('cla_user_data')
        .upsert({ user_id: user.id, ...m.body, updated_at: new Date().toISOString() }, { onConflict: 'user_id' })
        .select().single();
      if (r.error) throw r.error;
      if (r.data) await claCachePut('u:' + user.id + ':userdata', r.data);
      return;
    }
    if (op.kind === 'rotation') {
      // Rotations (v8.45): same CAS-or-copy contract as practices. The UPDATE
      // lands only if the server row is exactly the one this device edited;
      // any mismatch (newer save, or no known base) becomes its own
      // "<name> — offline copy (date)" rotation. NEVER overwrite, NEVER delete.
      async function rotationCopy(){
        const stamp = new Date(op.ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
        const c = await sb.from('cla_rotations').insert({ user_id: user.id, team_id: op.teamId || null,
          name: op.name + ' — offline copy (' + stamp + ')', data: op.data }).select('id').single();
        if (c.error) throw c.error;
        stats.copies++;
      }
      if (op.insert || String(op.id).startsWith('offline-')) {
        const r = await sb.from('cla_rotations').insert({ user_id: user.id, team_id: op.teamId || null,
          name: op.name, data: op.data }).select('id').single();
        if (r.error) throw r.error;
        return;
      }
      if (!op.baseUpdatedAt) { await rotationCopy(); return; }   // no known base → never clobber blind
      const r = await sb.from('cla_rotations')
        .update({ name: op.name, data: op.data, updated_at: new Date().toISOString() })
        .eq('id', op.id).eq('updated_at', op.baseUpdatedAt)   // ← the CAS (no user_id — teammate edits replay via member policy)
        .select('id');
      if (r.error) throw r.error;
      if (r.data && r.data.length) return;             // clean replay
      await rotationCopy();                            // server row is newer (or gone) — both versions survive
      return;
    }
    throw new Error('unknown offline op kind: ' + op.kind);
  }

  // ─── Replay scheduling + lock hardening (v8.97, MC 366aae34) ─────────────
  // The FIST bug: the boot drain was ONE-SHOT (a blind 3.5s timer that no-ops
  // if auth isn't restored yet) and the only other trigger was the window
  // 'online' event — which never fires for a device that stays online. A
  // queued vocab op could therefore survive across sessions forever, and the
  // pending-op overlay kept re-serving the stale snapshot over server truth.
  // Three fixes, none touching merge semantics:
  //   1. claAuthReadyKick — drain the moment claGetUser FIRST resolves a user
  //      (the real auth-ready point), not on a timer race.
  //   2. claReplayHeartbeat — while anything is queued and we're online,
  //      retry every 60s until the queue is empty. Self-clears; re-armed by
  //      every enqueue and every boot.
  //   3. claReplayLock — plain navigator.locks.request waits FOREVER on a
  //      lock held by a suspended/hung sibling window (frozen ≠ closed, so
  //      the lock never releases). Try ifAvailable first ("one tab replays"
  //      fast path); if held, wait 20s and check for progress — a LIVE
  //      holder shrinks the shared queue, so we back off and let the
  //      heartbeat re-check. Zero progress after 20s = dead holder → steal.
  //      Every op is CAS-or-copy / 3-way-merge, so even a worst-case overlap
  //      cannot clobber content.
  let claReplayBusy = false;
  function claAuthReadyKick(){
    if (window.__claAuthReadyKicked) return;
    window.__claAuthReadyKicked = true;
    setTimeout(claReplayQueue, 500);
  }
  function claReplayHeartbeat(){
    if (window.__claReplayHeartbeat) return;
    window.__claReplayHeartbeat = setInterval(async () => {
      const q = await claQueue();
      if (!q.length) {
        clearInterval(window.__claReplayHeartbeat);
        window.__claReplayHeartbeat = null;
        return;
      }
      claOfflinePillSync();                    // keep "waiting to sync" honest
      if (navigator.onLine) claReplayQueue();
    }, 60000);
  }
  async function claReplayLock(fn){
    if (!(navigator.locks && navigator.locks.request)) return fn();
    const HELD = '__cla_replay_lock_held__';
    const attempt = () => navigator.locks
      .request('cla-offline-replay', { ifAvailable: true }, (lock) => (lock ? fn() : HELD))
      .catch(() => fn());
    if ((await attempt()) !== HELD) return;
    const before = (await claQueue()).length;
    await new Promise((r) => setTimeout(r, 20000));
    const after = (await claQueue()).length;
    if (!after) return;                         // holder drained it — done
    if (after < before) return;                 // holder is ALIVE — back off, heartbeat re-checks
    if ((await attempt()) !== HELD) return;     // freed while we waited
    try {
      await navigator.locks.request('cla-offline-replay', { steal: true }, () => fn());
    } catch (_) {}                              // our steal was itself stolen — someone replays, fine
  }
  async function claReplayQueue(){
    if (!sb || !navigator.onLine) return;
    if (!(await claQueue()).length) return;
    const user = await claGetUser();
    if (!user) return;
    if (claReplayBusy) return;   // same-tab re-entrancy (heartbeat + event overlap)
    claReplayBusy = true;
    try {
    await claReplayLock(async () => {
      const stats = { synced: 0, copies: 0, kept: 0, dropped: 0 };
      const idMap = {};
      const practiceTeams = new Set(), vocabTeams = new Set();
      let teamsTouched = false;
      let guard = 0;
      while (guard++ < 300) {
        const op = (await claQueue())[0];
        if (!op) break;
        if (op.kind === 'team') teamsTouched = true;   // refresh even if the op drops (resets the optimistic cache)
        try {
          await claReplayOp(op, user, idMap, stats);
          stats.synced++;
          if (op.kind === 'practice') practiceTeams.add(op.teamId || null);
          else if (op.kind === 'vocab' || op.kind === 'emphasis') vocabTeams.add(op.teamId);
        } catch (e) {
          if (claIsNetErr(e)) break;   // connection dropped again — op stays queued
          console.warn('[cla] offline replay dropped op:', op.kind, e);
          await claCachePut('dead:' + op.uid, op);   // never destroy content
          stats.dropped++;
        }
        await claDequeue(op.uid);
      }
      if (stats.synced || stats.dropped) {
        vocabTeams.forEach((t) => claVocabChanged(t, 'offline-replay'));
        for (const t of practiceTeams) { try { await listUserPractices(t); } catch (_) {} } // re-cache server truth + real ids
        if (teamsTouched) {
          // re-fetch merged server truth + broadcast so open views (calendar,
          // bank, roster) refresh — same signal useClaTeams' reload() sends
          try {
            const list = await listTeams();
            teamsCache = list;
            window.dispatchEvent(new CustomEvent(TEAMS_DATA_EVENT, { detail: { teams: list } }));
          } catch (_) {}
        }
        try { window.dispatchEvent(new CustomEvent('cla:offline-replayed', { detail: stats })); } catch (_) {}
        // Landed replay ops are real writes — sibling windows refresh too.
        for (const t of practiceTeams) claSyncAnnounce('practice', null, t, null, { replay: true });
        vocabTeams.forEach((t) => claSyncAnnounce('vocab', null, t));
        if (teamsTouched) claSyncAnnounce('team', null, null, null, { replay: true });
        claOfflinePillFlash(stats);
        // Outcome telemetry (v8.47): ONE event per replay run per nonzero
        // count — the same numbers the pill flash shows, never op content.
        if (stats.synced) claUxEmit('offline_synced', String(stats.synced));
        if (stats.copies) claUxEmit('offline_conflict', String(stats.copies));
        if (stats.kept) claUxEmit('offline_kept', String(stats.kept));
        if (stats.dropped) claUxEmit('offline_failed', String(stats.dropped));
      }
    });
    } finally { claReplayBusy = false; }
    claOfflinePillSync();
  }

  // Service worker — app-shell cache so the planner loads with no network.
  // The SW itself serves OUR files network-first (see sw.js), so an open with
  // a connection always runs the just-deployed code. This block covers the one
  // case that can't: when the SERVICE WORKER file itself changed, the page you
  // are looking at is still controlled by the old one. Reload once as soon as
  // the new worker takes over — but ONLY within the first seconds after open,
  // so a coach mid-practice is never yanked out from under (Tim 2026-07-20:
  // "is it set to automatically update whenever it opens? i want it to do that").
  if ('serviceWorker' in navigator && location.protocol === 'https:') {
    try {
      const bootedAt = Date.now();
      // register() and update() both reject benignly (page unloads mid
      // update-check → "Operation has been aborted"; flaky network). The old
      // try/catch only caught sync throws, so the rejection reached the
      // unhandledrejection beacon → cla_errors → an unactionable A5 card
      // (flag 3116c35b). Swallow like the tagger's self-heal block does —
      // the update check reruns on every open anyway.
      navigator.serviceWorker.register('/sw.js').then((reg) => reg.update()).catch(() => {});
      navigator.serviceWorker.addEventListener('controllerchange', () => {
        if (window.__claSwReloaded) return;          // one reload, never a loop
        if (Date.now() - bootedAt > 10000) return;   // working already — leave them be
        window.__claSwReloaded = true;
        window.location.reload();
      });
    } catch (_) {}
  }

  // ─── In-app navigation (Tim 2026-07-20, tightened 2026-07-30) ─────────────
  // 07-20: "when i am in the desktop web app … and i go to the tagger it opens
  // me up in chrome in the browser" — `_blank` from an INSTALLED app hands the
  // URL to the default browser. 07-30, the mirror image: "when i open the
  // tagger in the cla planner in the web … it automatically opens up the app on
  // the desktop … if it's in web it's in web" — `_blank` from a BROWSER TAB
  // creates a new top-level context, which Chrome captures into the installed
  // PWA. Same lesson both directions: an app surface (/tagger, /app,
  // /dashboard…) navigates THIS window, in every display mode. New tabs stay
  // the user's explicit choice (⌘/ctrl/middle-click on real <a> hrefs);
  // external URLs (WhatsApp, signed file links) still use `_blank` on purpose.
  function claInstalled(){
    try {
      if (window.navigator.standalone === true) return true;   // iOS home-screen
      return ['standalone', 'fullscreen', 'minimal-ui', 'window-controls-overlay']
        .some((m) => window.matchMedia('(display-mode: ' + m + ')').matches);
    } catch (_) { return false; }
  }
  function claOpenApp(url){
    window.location.assign(url);
    return null;
  }
  // Spread onto an <a> so href stays real (⌘-click / middle-click open a real
  // new tab when the coach asks for one) while a plain click navigates in place.
  function claAppLinkProps(url){
    return { href: url, rel: 'noopener',
      onClick: (e) => {
        if (e.metaKey || e.ctrlKey || e.shiftKey || e.button) return;
        e.preventDefault();
        window.location.assign(url);
      } };
  }

  // Park Chrome/Edge's install offer so UI (landing L6_InstallChip) can pop the
  // REAL install dialog on click instead of hoping the user spots the tiny
  // address-bar icon. Fires only when installable and not already installed.
  window.addEventListener('beforeinstallprompt', (e) => {
    e.preventDefault();
    window.__claInstallPrompt = e;
    try { window.dispatchEvent(new Event('cla:can-install')); } catch (_) {}
  });

  // Offline pill — plain DOM (works on every page, no React mount needed).
  // Quiet neutral chip per DESIGN-TRUE-NORTH; visible while offline (text
  // reflects the write queue) + a short "synced" flash after a replay.
  let claPillEl = null, claPillFlashTimer = null;
  function claOfflinePillSync(){
    if (!claPillEl || claPillFlashTimer) return;
    claQueue().then((q) => {
      if (!claPillEl || claPillFlashTimer) return;
      if (navigator.onLine) {
        // v8.97 (MC 366aae34): a non-empty queue while ONLINE means replay
        // hasn't landed yet — say so instead of hiding, so a stuck queue is
        // visible to the coach instead of silently shadowing server truth.
        if (!q.length) { claPillEl.style.display = 'none'; return; }
        claPillEl.textContent = q.length + (q.length === 1 ? ' change' : ' changes') + ' waiting to sync…';
        claPillEl.style.display = 'block';
        return;
      }
      claPillEl.textContent = q.length
        ? 'Offline — ' + q.length + (q.length === 1 ? ' change' : ' changes') + ' saved on this device · will sync when back online'
        : 'Offline — showing saved data · edits are kept and sync when back online';
      claPillEl.style.display = 'block';
    });
  }
  function claOfflinePillFlash(stats){
    if (!claPillEl) return;
    const bits = [];
    if (stats.synced) bits.push('✓ ' + stats.synced + ' offline ' + (stats.synced === 1 ? 'change' : 'changes') + ' synced');
    if (stats.copies) bits.push(stats.copies + ' saved as a copy (someone saved meanwhile)');
    if (stats.kept) bits.push(stats.kept + ' kept the newer team edit');
    if (stats.dropped) bits.push(stats.dropped + ' could not sync');
    if (!bits.length) return;
    claPillEl.textContent = bits.join(' · ');
    claPillEl.style.display = 'block';
    clearTimeout(claPillFlashTimer);
    claPillFlashTimer = setTimeout(() => { claPillFlashTimer = null; claOfflinePillSync(); }, 8000);
  }
  // Deletes stay ONLINE-ONLY by design (standing decision — a blind-replayed
  // delete is dangerous). v8.45 makes that honest: an offline delete attempt
  // (team, practice, rotation) flashes this instead of silently failing.
  function claOfflineDeleteToast(){
    if (!claPillEl) return;
    claPillEl.textContent = "Offline — deletes don't queue; reconnect first";
    claPillEl.style.display = 'block';
    clearTimeout(claPillFlashTimer);
    claPillFlashTimer = setTimeout(() => { claPillFlashTimer = null; claOfflinePillSync(); }, 5000);
  }
  (function(){
    if (window.__claOfflinePill) return;
    window.__claOfflinePill = true;
    function mount(){
      const el = document.createElement('div');
      el.id = 'cla-offline-pill';
      el.style.cssText = 'position:fixed;left:14px;bottom:14px;z-index:10005;display:none;' +
        'padding:6px 12px;border-radius:99px;background:rgba(22,17,14,.92);color:#f4ede2;' +
        'font:600 11.5px/1.4 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;' +
        'box-shadow:0 4px 14px rgba(0,0,0,.25);pointer-events:none;';
      const style = document.createElement('style');
      style.textContent = '@media print { #cla-offline-pill { display:none !important } }';
      document.head.appendChild(style);
      document.body.appendChild(el);
      claPillEl = el;
      window.addEventListener('online', () => { claOfflinePillSync(); setTimeout(claReplayQueue, 1200); });
      window.addEventListener('offline', claOfflinePillSync);
      claOfflinePillSync();
      setTimeout(claReplayQueue, 3500);   // boot drain — anything queued last session
      claReplayHeartbeat();               // v8.97: and KEEP draining — the boot timer alone can lose the auth race
    }
    if (document.body) mount();
    else document.addEventListener('DOMContentLoaded', mount);
  })();

  // ─── Open-in-app nudge (v8.103, Tim 08-04: "every session … suggest to open
  // in clap app … they can do it or close it but next time we would promote
  // same thing") ──────────────────────────────────────────────────────────────
  // Browser-tab visits get ONE quiet pill per session: [Open app] when CLAP is
  // installed on this device, [Install app] when Chrome says it's installable.
  // × dismisses for THIS session only (sessionStorage) — it returns next visit.
  // Never shown inside the installed app. "Installed here" is tracked in
  // localStorage ('cla-app-installed'): stamped by the `appinstalled` event and
  // by any standalone boot; a parked beforeinstallprompt means Chrome considers
  // it NOT installed, which outranks (and clears) a stale stamp — so after an
  // uninstall the pill correctly flips back to [Install app]. Opening uses
  // window.open(_blank): Chrome captures new top-level contexts into the
  // installed app (the 07-30 "web stays web" ruling reserved _blank for the
  // user's EXPLICIT choice — a clicked button is exactly that); with capturing
  // off it degrades to a plain new tab.
  (function(){
    if (window.__claAppNudge) return;
    window.__claAppNudge = true;
    const SEEN = 'cla-app-nudge-dismissed';           // sessionStorage — per visit
    const INSTALLED = 'cla-app-installed';            // localStorage — per device
    const stamp = (v) => { try { localStorage.setItem(INSTALLED, v); } catch (_) {} };
    window.addEventListener('appinstalled', () => stamp('1'));
    function dismissed(){ try { return sessionStorage.getItem(SEEN) === '1'; } catch (_) { return false; } }
    function dismiss(){ try { sessionStorage.setItem(SEEN, '1'); } catch (_) {} const el = document.getElementById('cla-app-nudge'); if (el) el.remove(); }
    async function decide(){
      if (dismissed()) return null;
      try { if (claInstalled()) { stamp('1'); return null; } } catch (_) {}
      if (window.__claInstallPrompt) { stamp(''); return 'install'; }   // Chrome: not installed
      try {
        if (navigator.getInstalledRelatedApps) {
          const apps = await navigator.getInstalledRelatedApps();
          if (apps && apps.length) { stamp('1'); return 'open'; }
        }
      } catch (_) {}
      try { if (localStorage.getItem(INSTALLED) === '1') return 'open'; } catch (_) {}
      return null;
    }
    function mount(kind){
      if (document.getElementById('cla-app-nudge')) return;
      const el = document.createElement('div');
      el.id = 'cla-app-nudge';
      el.style.cssText = 'position:fixed;right:14px;bottom:14px;z-index:10005;display:flex;align-items:center;gap:10px;' +
        'padding:7px 8px 7px 14px;border-radius:99px;background:rgba(22,17,14,.92);color:#f4ede2;' +
        'font:600 11.5px/1.4 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;box-shadow:0 4px 14px rgba(0,0,0,.25);';
      const label = document.createElement('span');
      label.textContent = kind === 'open' ? 'CLAP is installed on this device' : 'Use CLAP as its own app?';
      const act = document.createElement('button');
      act.textContent = kind === 'open' ? 'Open app' : 'Install app';
      act.style.cssText = 'border:0;border-radius:99px;padding:5px 12px;background:#f4ede2;color:#16110e;' +
        'font:700 11.5px/1.2 inherit;font-family:inherit;cursor:pointer;';
      act.addEventListener('click', () => {
        if (kind === 'open') { try { window.open(window.location.href, '_blank'); } catch (_) {} dismiss(); return; }
        const p = window.__claInstallPrompt;
        if (p && p.prompt) { window.__claInstallPrompt = null; p.prompt(); if (p.userChoice && p.userChoice.then) p.userChoice.catch(() => {}); }
        dismiss();
      });
      const x = document.createElement('button');
      x.textContent = '×';
      x.title = 'Not now — offered again next session';
      x.setAttribute('aria-label', 'Dismiss for this session');
      x.style.cssText = 'border:0;background:transparent;color:rgba(244,237,226,.65);font:700 15px/1 inherit;' +
        'font-family:inherit;cursor:pointer;padding:2px 6px;';
      x.addEventListener('click', dismiss);
      el.appendChild(label); el.appendChild(act); el.appendChild(x);
      const style = document.createElement('style');
      style.textContent = '@media print { #cla-app-nudge { display:none !important } }';
      document.head.appendChild(style);
      document.body.appendChild(el);
    }
    function onPublicLanding(){
      const path = String(window.location.pathname || '').replace(/\/+$/, '');
      return path === '' || path === '/index.html';
    }
    // The public page already owns an explicit Install app control beside its
    // one primary Start-free action. A second floating prompt competed with
    // that decision and obscured the product specimen on phone.
    function boot(){ if (onPublicLanding()) return; decide().then((kind) => { if (kind) mount(kind); }); }
    if (document.body) boot(); else document.addEventListener('DOMContentLoaded', boot);
    // beforeinstallprompt lands AFTER boot: Chrome says "not installed", so an
    // 'open' pill from a stale stamp flips to the honest [Install app] offer.
    window.addEventListener('cla:can-install', () => {
      if (onPublicLanding()) return;
      if (dismissed()) return;
      const el = document.getElementById('cla-app-nudge');
      if (el) el.remove();
      stamp('');
      decide().then((kind) => { if (kind) mount(kind); });
    });
  })();

  // ─── Error beacon (self-improvement step 2, 2026-07-05) ─────────────
  // Uncaught errors / rejections POST to cla_errors (insert-only anon policy;
  // nobody can read it with this key). The nightly self-auditor clusters the
  // rows and files auto: flags — white-screens get noticed without Tim.
  // Plain fetch (not the supabase client) so it works even if client init died.
  (function(){
    if (window.__claBeaconWired) return;
    window.__claBeaconWired = true;
    // v8.60 — prod telemetry ONLY. Local serves (live-preview ports, headless
    // CDP checks against a working tree) were POSTing transient mid-edit errors
    // here, and the nightly A5 clusterer filed them as prod flags (24× a
    // v7-account.jsx SyntaxError + 3× React #311, both 2026-07-25, both from
    // 127.0.0.1 sessions — neither state ever reached prod).
    var beaconHost = location.hostname;
    if (beaconHost === 'localhost' || beaconHost === '127.0.0.1' || beaconHost === '0.0.0.0' ||
        beaconHost === '::1' || beaconHost === '[::1]' || location.protocol === 'file:') return;
    let sent = 0;
    function beam(message, stack){
      if (sent >= 3) return; // max 3 per page load — don't spam on render loops
      sent++;
      try {
        fetch(SB_URL + '/rest/v1/cla_errors', {
          method: 'POST',
          headers: { apikey: SB_ANON, Authorization: 'Bearer ' + SB_ANON, 'Content-Type': 'application/json', Prefer: 'return=minimal' },
          // v8.47 privacy (v9 P8 audit): pathname ONLY — location.search was an
          // uncontrolled sink (invite codes, tokens) landing in cla_errors.
          body: JSON.stringify({ page: location.pathname,
            message: String(message || 'unknown').slice(0, 600),
            stack: String(stack || '').slice(0, 2500),
            ua: navigator.userAgent.slice(0, 250) }),
        }).catch(function(){});
      } catch (_) {}
    }
    window.addEventListener('error', function(e){
      beam(e.message, e.error && e.error.stack);
    });
    window.addEventListener('unhandledrejection', function(e){
      var r = e.reason || {};
      beam(r.message || String(e.reason), r.stack);
    });
  })();

  // ─── Usage beacon (self-improvement, 2026-07-09 — Tim: "build in ux stats
  // so we can recursively self-improve based on what users are using") ─────
  // Batched, WRITE-ONLY click/usage telemetry → cla_usage_events (insert-only
  // anon policy — nothing readable with this key). scripts/usage-rollup.mjs
  // turns it into "what's used / what's never touched" so redesigns are driven
  // by real behavior. Zero content capture: element labels only, never what's
  // typed in a field. `window.claUx(ev, label)` is the explicit hook for
  // components (and MUST survive redesigns); the delegated listeners below
  // cover every button/menu/select automatically, current AND future.
  (function(){
    if (window.__claUxWired) return;
    window.__claUxWired = true;
    // FU95 (2026-08-05) — telemetry hygiene. Local serves never beacon (same
    // rule the error beacon adopted in v8.60: mid-edit working trees polluted
    // the rollup). Automation and demo traffic still beacons — the flows can be
    // useful — but is MARKED with its own app id ('planner-test') so
    // usage-rollup "what do real coaches use" rankings exclude it by default.
    // navigator.webdriver covers postdeploy-check/headless CDP; ?demo=1/?smoke=1
    // covers the signed-out sample shell. Zero content capture, unchanged.
    var uxHost = location.hostname;
    if (uxHost === 'localhost' || uxHost === '127.0.0.1' || uxHost === '0.0.0.0' ||
        uxHost === '::1' || uxHost === '[::1]' || location.protocol === 'file:') return;
    var uxApp = 'planner';
    try {
      var uxQs = new URLSearchParams(location.search || '');
      if (navigator.webdriver || uxQs.get('demo') === '1' || uxQs.get('smoke') === '1') uxApp = 'planner-test';
    } catch (_) { if (navigator.webdriver) uxApp = 'planner-test'; }
    var buf = [], sent = 0, MAX = 150, KEY = Math.random().toString(36).slice(2, 10);
    // Flag-context trail (v8.101, Tim: "what his 25 recent actions were") —
    // last 25 events kept in memory REGARDLESS of the flush queue and the MAX
    // cap, so flags.jsx can snapshot "what the coach just did" into a filed
    // flag. Same zero-content rule as the beacon: labels only.
    var TRAIL_MAX = 25, trail = [];
    window.claUxTrail = function(){
      var now = Date.now();
      return trail.map(function(e){ return { ago_s: Math.round((now - e.t) / 1000), ev: e.ev, label: e.label }; });
    };
    window.claUxSessionKey = KEY;
    function uid(){
      try { var s = JSON.parse(localStorage.getItem('cla-auth') || 'null'); return (s && s.user && s.user.id) || null; } catch (_) { return null; }
    }
    // Flow id (P8, 2026-07-24) — a privacy-safe VISIT id shared across Planner
    // and Tagger pages (same-origin localStorage 'cla-flow'): random token,
    // 30-minute sliding window, refreshed on every event. Lets the usage rollup
    // reconstruct one coach visit across page loads/apps. No content, no names.
    function flow(){
      try {
        var now = Date.now(), raw = (localStorage.getItem('cla-flow') || '').split('|');
        var id = raw[0], ts = parseInt(raw[1], 10) || 0;
        if (!id || id.length > 16 || (now - ts) > 1800000) id = Math.random().toString(36).slice(2, 12);
        localStorage.setItem('cla-flow', id + '|' + now);
        return id;
      } catch (_) { return null; }
    }
    function flush(){
      if (!buf.length) return;
      var rows = buf.splice(0, buf.length);
      try {
        fetch(SB_URL + '/rest/v1/cla_usage_events', {
          method: 'POST', keepalive: true,
          headers: { apikey: SB_ANON, Authorization: 'Bearer ' + SB_ANON, 'Content-Type': 'application/json', Prefer: 'return=minimal' },
          body: JSON.stringify(rows),
        }).catch(function(){});
      } catch (_) {}
    }
    function log(ev, label){
      if (!ev) return;
      // The MAX cap throttles click/set spam per page load. 'view' (one pageview
      // per load) and 'dwell' (active-time heartbeats) are low-volume signal we
      // never want starved by a busy clicker, so they bypass the cap and don't
      // count toward it.
      var heartbeat = (ev === 'view' || ev === 'dwell');
      if (ev !== 'dwell') {           // trail records even past the MAX cap; dwell heartbeats are noise
        trail.push({ t: Date.now(), ev: String(ev).slice(0, 80), label: label == null ? null : String(label).slice(0, 80) });
        if (trail.length > TRAIL_MAX) trail.shift();
      }
      if (!heartbeat) { if (sent >= MAX) return; sent++; }
      buf.push({ app: uxApp, page: location.pathname, session_key: KEY, flow_id: flow(),
        user_id: uid(), team_id: localStorage.getItem('cla-current-team') || null,
        ev: String(ev).slice(0, 80), label: label == null ? null : String(label).slice(0, 80) });
      if (buf.length >= 20) flush();
    }
    window.claUx = log;
    // Pageview — one per page load, EVERY page that loads auth.jsx (incl. the
    // marketing home '/' and /dashboard). Answers "which pages get visited?"
    // including bounces that never click. session_key is per-load so views≈loads.
    log('view');
    // Context event (P8) — once per load, AFTER the whole manifest has executed
    // (auth.jsx runs before planner-v6-tokens.jsx, so V6_VERSION isn't defined
    // yet here). Bounded label: app version + viewport bucket — segments funnels
    // by version/device without any per-event bloat. No content, ever.
    window.setTimeout(function(){
      try {
        var w = window.innerWidth || 0;
        var vw = w && w < 600 ? 'phone' : w < 1024 ? 'tablet' : 'desktop';
        var ver = (typeof V6_VERSION !== 'undefined') ? V6_VERSION : '?';
        log('ctx', ver + '|' + vw);
      } catch (_) {}
    }, 1500);
    function keyFor(el){
      var ux = el.getAttribute && (el.getAttribute('data-ux') || el.getAttribute('aria-label') || el.getAttribute('title'));
      if (ux) return ux;
      if (el.tagName === 'SELECT' || el.tagName === 'INPUT') return el.id || el.name || el.tagName.toLowerCase();
      var t = (el.textContent || '').trim().replace(/\s+/g, ' ');
      return t ? t.slice(0, 40) : el.tagName.toLowerCase();
    }
    document.addEventListener('click', function(e){
      try {
        var el = e.target && e.target.closest && e.target.closest('button,[role="button"],a,summary,[data-ux]');
        if (el) log('click:' + keyFor(el));
      } catch (_) {}
    }, true);
    document.addEventListener('change', function(e){
      try {
        var el = e.target;
        if (!el || (el.tagName !== 'SELECT' && el.type !== 'checkbox' && el.type !== 'radio')) return;
        log('set:' + keyFor(el), el.tagName === 'SELECT' ? String(el.value).slice(0, 40) : String(el.checked));
      } catch (_) {}
    }, true);
    setInterval(flush, 15000);
    window.addEventListener('pagehide', flush);
    document.addEventListener('visibilitychange', function(){ if (document.visibilityState === 'hidden') flush(); });

    // ─── Active-time heartbeat (real time-in-app, 2026-07-15 — Tim: "actually
    // track the time in app as new users go in") ────────────────────────────
    // Accumulates only ACTIVE seconds — tab visible AND an interaction within
    // the idle window — and flushes them as ev='dwell', label=<seconds>. Summed
    // server-side (cla_admin_usage) into real minutes. Idle time (tab hidden, or
    // no interaction for IDLE_MS) is NOT counted, so this is honest engagement
    // time, not "tab left open". Coarse cadence keeps row volume sane; the exact
    // second-count is cadence-independent. Zero content capture (seconds only).
    (function(){
      var IDLE_MS = 90000;       // 90s without interaction ⇒ treat as idle
      var FLUSH_S = 300;         // emit a dwell row per ~5 active minutes
      var acc = 0;               // unflushed active seconds
      var lastActive = Date.now();
      var lastTick = Date.now();
      function visible(){ return document.visibilityState === 'visible'; }
      function isActive(){ return visible() && (Date.now() - lastActive) < IDLE_MS; }
      function pushDwell(){ var s = Math.round(acc); if (s <= 0) return; acc = 0; log('dwell', String(s)); }
      function tick(){
        var now = Date.now();
        if (isActive()) { acc += (now - lastTick) / 1000; if (acc >= FLUSH_S) pushDwell(); }
        lastTick = now;          // gaps while hidden/idle are skipped, not banked
      }
      ['pointerdown','keydown','scroll','touchstart','pointermove','wheel'].forEach(function(t){
        window.addEventListener(t, function(){ lastActive = Date.now(); }, { passive: true, capture: true });
      });
      document.addEventListener('visibilitychange', function(){
        lastTick = Date.now();                       // don't bank the hidden gap
        if (!visible()) pushDwell();                  // bank + send what we have
      });
      window.addEventListener('pagehide', pushDwell);
      setInterval(tick, 5000);
    })();
  })();

  // ─── Outcome telemetry (v8.47, v9 P8 — Tim 2026-07-23: run the semantic
  // events ALONGSIDE the click/dwell beacon; he'll compare which to rely on) ──
  // Semantic workflow events ride the SAME beacon (same app/page/session/user/
  // team columns, same insert-only anon policy). Emitted at ≤1 per user action;
  // labels are op kinds / small counts — NEVER coach content. typeof-guarded so
  // an old cached page without claUx can't throw. Event names are the contract —
  // see FUNCTIONALITY.md "Outcome telemetry".
  function claUxEmit(ev, label){
    try { if (typeof window.claUx === 'function') window.claUx(ev, label); } catch (_) {}
  }

  // ─── Session hook ────────────────────────────────────────────────────
  // IMPROVEMENT-REGISTER R3 (2026-07-02) — once per page load after sign-in,
  // pull cla_user_data and hand settings.view_prefs to shared.jsx's hydrate
  // (applies the cloud copy when it's newer than this device's; fires
  // `cla:viewprefs-hydrated`). Guarded so the many useClaSession callers only
  // trigger one fetch.
  function hydrateViewPrefsOnce(){
    if (window.__claPrefsHydrateStarted) return;
    window.__claPrefsHydrateStarted = true;
    loadUserData().then((d) => {
      // Tagger-only accounts (cla_user_data.settings.app_role === 'tagger') never
      // see the planner: every authed page that loads auth.jsx hands off to
      // /tagger/, which reads this same session from localStorage 'cla-auth'.
      // The tagger page does NOT load auth.jsx, so this can't loop.
      if (d && d.settings && d.settings.app_role === 'tagger'
          && !window.location.pathname.startsWith('/tagger')) {
        window.location.replace('/tagger/');
        return;
      }
      try { if (window.v5HydrateViewPrefs) window.v5HydrateViewPrefs(d ? d.settings : null); } catch (_) {}
      // v6 tier signal rides the same fetch (NEW-PRIMITIVES "production wiring")
      claApplyTier(d ? d.tier : null);
    }).catch(() => { window.__claPrefsHydrateStarted = false; }); // allow a retry on transient failure
  }

  function useClaSession(){
    const [user, setUser] = React.useState(null);
    const [loading, setLoading] = React.useState(!!sb);
    React.useEffect(() => {
      if (!sb) { setLoading(false); return; }
      let live = true;
      // claGetUser (not raw getSession): offline with an expired access token,
      // getSession can't refresh and reports signed-out — the stored session
      // fallback keeps the gate open so cached data renders (writes still fail
      // server-side; the offline pill says so).
      claGetUser().then((u) => {
        if (!live) return;
        setUser(u);
        setLoading(false);
        if (u) { claApplyGrandfather(u); hydrateViewPrefsOnce(); }
      });
      const { data: sub } = sb.auth.onAuthStateChange((evt, session) => {
        if (!live) return;
        // An offline token lapse is NOT a sign-out — only a real SIGNED_OUT
        // (which also clears the stored session) may close the gate offline.
        if (!session && !navigator.onLine && evt !== 'SIGNED_OUT') return;
        setUser(session ? session.user : null);
        if (session) { claApplyGrandfather(session.user); hydrateViewPrefsOnce(); }
      });
      return () => { live = false; sub && sub.subscription && sub.subscription.unsubscribe(); };
    }, []);
    const signOut = React.useCallback(async () => { if (sb) await sb.auth.signOut(); }, []);
    return { user, loading, signOut };
  }

  // ─── Auth actions ────────────────────────────────────────────────────
  async function signInWithPassword(email, password){
    if (!sb) throw new Error('Supabase unavailable');
    const { data, error } = await sb.auth.signInWithPassword({ email, password });
    if (error) throw error;
    return data.user;
  }
  async function signUpWithPassword(email, password){
    if (!sb) throw new Error('Supabase unavailable');
    const { data, error } = await sb.auth.signUp({ email, password,
      options: { emailRedirectTo: window.location.origin + '/app' } });
    if (error) throw error;
    return data.user;
  }
  async function sendMagicLink(email){
    if (!sb) throw new Error('Supabase unavailable');
    const { error } = await sb.auth.signInWithOtp({ email,
      options: { emailRedirectTo: window.location.origin + '/app' } });
    if (error) throw error;
  }
  async function sendPasswordReset(email){
    if (!sb) throw new Error('Supabase unavailable');
    const { error } = await sb.auth.resetPasswordForEmail(email, {
      redirectTo: window.location.origin + '/app?recovery=1',
    });
    if (error) throw error;
  }

  // ─── Persistence helpers ─────────────────────────────────────────────
  // ACCOUNT-LEVEL data (one row per user): tier, settings, etc. Drill bank
  // lives per-team in cla_teams now — do NOT write drill_bank here.
  async function loadUserData(){
    if (!sb) return null;
    const user = await claGetUser();
    if (!user) return null;
    const key = 'u:' + user.id + ':userdata';
    try {
      const { data, error } = await sb.from('cla_user_data').select('*').eq('user_id', user.id).maybeSingle();
      if (error) throw error;
      if (data) { claCachePut(key, data); return data; }
      const insert = await sb.from('cla_user_data').insert({ user_id: user.id }).select().single();
      if (insert.data) claCachePut(key, insert.data);
      return insert.data || null;
    } catch (e) {
      console.warn('[cla] loadUserData → offline cache:', e && e.message);
      return await claCacheGet(key);   // tier + settings survive offline
    }
  }
  // Offline path for a settings save (v8.45): queue kind 'userdata' (coalesces
  // per user, FIRST base per key) + patch the read-cache so prefs stick locally.
  async function claQueueUserData(user, patch){
    const key = 'u:' + user.id + ':userdata';
    const cur = (await claCacheGet(key)) || {};
    const base = {};
    Object.keys(patch).forEach((k) => { base[k] = cur[k]; });   // server state before we started
    await claEnqueue({ kind: 'userdata', patch, base });
    const row = { ...cur, ...patch, updated_at: new Date().toISOString() };
    await claCachePut(key, row);
    claUxEmit('save_queued', 'userdata');
    return { ...row, _offline: true };
  }
  async function saveUserData(patch){
    if (!sb) return null;
    const user = await claGetUser();
    if (!user) return null;
    const safe = { ...patch };
    delete safe.drill_bank; // drill_bank is per-team, not per-user
    delete safe.tier;       // the tier NEVER moves through a client write path (read-only signal)
    if (!navigator.onLine) return await claQueueUserData(user, safe);
    const row = { user_id: user.id, ...safe, updated_at: new Date().toISOString() };
    const { data, error } = await sb.from('cla_user_data').upsert(row, { onConflict: 'user_id' }).select().single();
    if (error) {
      if (claIsNetErr(error)) return await claQueueUserData(user, safe);
      console.warn(error); claUxEmit('save_failed', 'userdata'); return null;
    }
    if (data) claCachePut('u:' + user.id + ':userdata', data);
    claUxEmit('save_ok', 'userdata');
    return data;
  }

  // ─── Tier signal (v6) ────────────────────────────────────────────────
  // cla_user_data.tier → window.__claTier + `cla:tier-changed`. The v6 UI
  // only READS the signal (useV6Tier / v6Allows in planner-v6-tokens.jsx).
  // No client path grants a paid tier: the Upgrade sheet's setTier sends an
  // upgrade-request email and leaves the tier unchanged — Tim (later:
  // billing) sets the column value.
  function claApplyTier(t){
    // Ladder v2 (2026-07-25): free/coach/staff/proteam. Legacy stored values
    // 'pro'/'premium' normalize via alias — the DB column is never rewritten.
    const alias = { pro:'coach', premium:'staff' };
    const known = ['free','coach','staff','proteam'];
    const tier = known.includes(alias[t] || t) ? (alias[t] || t) : 'free';
    if (window.__claTier === tier) return;
    window.__claTier = tier;
    try { window.dispatchEvent(new Event('cla:tier-changed')); } catch (_) {}
  }
  // D7 grandfathering (permanent): accounts that existed before the ladder-v2
  // launch keep today's ungated capabilities (downloads / vocab⇄video sync /
  // assistant) forever — v6Allows honors this flag for V6_GRANDFATHER_KEYS.
  const CLA_LADDER_LAUNCH = '2026-07-26T00:00:00Z';
  function claApplyGrandfather(user){
    try {
      const created = user && user.created_at ? Date.parse(user.created_at) : NaN;
      window.__claGrandfathered = Number.isFinite(created) && created < Date.parse(CLA_LADDER_LAUNCH);
    } catch (_) { window.__claGrandfathered = false; }
  }
  function useClaTier(){
    const [tier, setTier] = React.useState(() =>
      (typeof window.__claTier === 'string' ? window.__claTier : 'free'));
    React.useEffect(() => {
      const on = () => setTier(typeof window.__claTier === 'string' ? window.__claTier : 'free');
      window.addEventListener('cla:tier-changed', on);
      return () => window.removeEventListener('cla:tier-changed', on);
    }, []);
    const requestTier = React.useCallback((t) => {
      const labels = { coach:'Coach', staff:'Staff', proteam:'Pro Team', pro:'Coach', premium:'Staff' };
      const label = labels[t] || 'Coach';
      const body = encodeURIComponent(`Hi Tim,\n\nI'd like to upgrade my CLAP account to ${label}.\n\n(sent from the in-app upgrade sheet)`);
      try { window.open(`mailto:timothyfanning@gmail.com?subject=${encodeURIComponent('CLAP — upgrade to ' + label)}&body=${body}`, '_self'); } catch (_) {}
    }, []);
    return { tier, setTier: requestTier };
  }

  // TEAM-SCOPED data (many rows per user)
  const CURRENT_TEAM_KEY = 'cla-current-team';
  const TEAM_CHANGE_EVENT = 'cla:team-changed';
  function getCurrentTeamId(){ try { return localStorage.getItem(CURRENT_TEAM_KEY) || null; } catch (_) { return null; } }
  function setCurrentTeamId(id){
    try {
      if (id) localStorage.setItem(CURRENT_TEAM_KEY, id);
      else localStorage.removeItem(CURRENT_TEAM_KEY);
    } catch (_) {}
    window.dispatchEvent(new CustomEvent(TEAM_CHANGE_EVENT, { detail: { teamId: id } }));
  }

  async function listTeams(){
    if (!sb) { console.warn('[cla] listTeams: no supabase'); return []; }
    const user = await claGetUser();
    if (!user) { console.warn('[cla] listTeams: no user'); return []; }
    const key = 'u:' + user.id + ':teams';
    const { data, error } = await sb.from('cla_teams')
      .select('id, name, identity, default_roster, drill_bank, templates, calendar, scouts, sort_order, created_at, updated_at')
      // Team visibility (owned + shared) is gated by RLS via cla_is_team_member —
      // do NOT filter by user_id here or shared-team members lose access.
      .order('sort_order', { ascending: true })
      .order('created_at', { ascending: true });
    if (error) {
      console.warn('[cla] listTeams error → offline cache:', error);
      return (await claCacheGet(key)) || [];
    }
    console.log('[cla] listTeams →', data && data.length, 'teams');
    // queued-but-not-yet-replayed team patches stay visible on top of the fresh
    // server list (same rule as practices — a reconnect fetch must not "lose" them)
    const rows = await claOverlayPendingTeams(data || []);
    claCachePut(key, rows);
    return rows;
  }
  async function claOverlayPendingTeams(rows){
    const q = await claQueue();
    let out = rows;
    for (const op of q) {
      if (op.kind !== 'team') continue;
      out = out.map((t) => t.id === op.teamId ? { ...t, ...op.patch, updated_at: op.ts } : t);
    }
    return out;
  }
  async function createTeam({ name, identity, default_roster, drill_bank, sport }){
    if (!sb) { console.warn('[cla] createTeam: no supabase'); return null; }
    const user = await claGetUser();
    if (!user) { console.warn('[cla] createTeam: no user'); return null; }
    const SPORTS = ['basketball','soccer','volleyball','hockey']; // cla_teams_sport_check
    const payload = {
      user_id: user.id,
      name: name || 'New team',
      identity: identity || {},
      default_roster: default_roster || null,
      drill_bank: drill_bank || [],
      sport: SPORTS.includes(sport) ? sport : 'basketball',
    };
    const r = await sb.from('cla_teams').insert(payload).select().single();
    if (r.error) { console.warn('[cla] createTeam error:', r.error); return null; }
    console.log('[cla] createTeam ok:', r.data && r.data.id);
    return r.data;
  }
  // Offline path for a team patch (v8.45): queue kind 'team' (coalesces per
  // team; the eldest op keeps per-key base snapshots from the cached row) +
  // patch the read-cache so calendar/bank/roster edits stay visible offline.
  async function claQueueTeamPatch(user, id, patch){
    const key = 'u:' + user.id + ':teams';
    const list = (await claCacheGet(key)) || [];
    const cur = list.find((t) => t.id === id) || {};
    const base = {};
    Object.keys(patch || {}).forEach((k) => { base[k] = cur[k]; });   // server state before we started
    await claEnqueue({ kind: 'team', teamId: id, patch: { ...patch }, base });
    const row = { ...cur, id, ...patch, updated_at: new Date().toISOString() };
    const next = list.some((t) => t.id === id) ? list.map((t) => t.id === id ? row : t) : list;
    await claCachePut(key, next);
    teamsCache = next;
    try { window.dispatchEvent(new CustomEvent(TEAMS_DATA_EVENT, { detail: { teams: next } })); } catch (_) {}
    claUxEmit('save_queued', 'team');
    return { ...row, _offline: true };
  }
  async function updateTeam(id, patch){
    if (!sb) return null;
    const user = await claGetUser();
    if (!user) return null;
    if (!navigator.onLine) return await claQueueTeamPatch(user, id, patch);
    const key = 'u:' + user.id + ':teams';
    const cachedList = (await claCacheGet(key)) || [];
    const cached = cachedList.find((t) => t.id === id) || null;
    const refreshCache = async (row) => {
      try { if (cachedList.some((t) => t.id === id)) await claCachePut(key, cachedList.map((t) => t.id === id ? row : t)); } catch (_) {}
    };
    const done = async (row, ev, label) => {
      await refreshCache(row);
      claUxEmit(ev, label);
      claSyncAnnounce('team', id, id, row && row.updated_at);
      return row;
    };
    // Multi-window Wave 6: CAS on the cached row's updated_at — whole-array
    // keys (drill_bank / calendar / scouts / templates) were last-write-wins
    // before, so two windows could silently swallow each other's edits. A CAS
    // miss field-merges the PATCHED KEYS ONLY onto the server row
    // (claMergeTeamPatch — the tested offline-replay merge) and retries once;
    // the final fallback applies that bounded merged body plain — never a
    // blind whole-row clobber.
    // Shared-team members may edit the team (roster/drill-bank/calendar); RLS
    // (cla_teams_member_update) gates it. Owner-only actions are delete/insert.
    if (cached && cached.updated_at) {
      const r = await sb.from('cla_teams')
        .update({ ...patch, updated_at: new Date().toISOString() })
        .eq('id', id).eq('updated_at', cached.updated_at).select();
      if (r.error) {
        if (claIsNetErr(r.error)) return await claQueueTeamPatch(user, id, patch);
        console.warn(r.error); claUxEmit('save_failed', 'team'); return null;
      }
      if (r.data && r.data.length) return await done(r.data[0], 'save_ok', 'team');
      const cur = await sb.from('cla_teams').select('*').eq('id', id).single();
      if (cur.error || !cur.data) {
        if (cur.error && claIsNetErr(cur.error)) return await claQueueTeamPatch(user, id, patch);
        claUxEmit('save_failed', 'team'); return null;
      }
      const baseKeys = {};
      Object.keys(patch || {}).forEach((k) => { baseKeys[k] = cached[k]; });
      const m = claMergeTeamPatch(baseKeys, patch, cur.data);
      const r2 = await sb.from('cla_teams')
        .update({ ...m.body, updated_at: new Date().toISOString() })
        .eq('id', id).eq('updated_at', cur.data.updated_at).select();
      if (!r2.error && r2.data && r2.data.length) return await done(r2.data[0], 'xwin_cas_merge', 'team');
      // Hot row: apply the bounded merged body plain (patched keys only).
      const r3 = await sb.from('cla_teams')
        .update({ ...m.body, updated_at: new Date().toISOString() })
        .eq('id', id).select().single();
      if (r3.error) {
        if (claIsNetErr(r3.error)) return await claQueueTeamPatch(user, id, patch);
        console.warn(r3.error); claUxEmit('save_failed', 'team'); return null;
      }
      return await done(r3.data, 'xwin_cas_merge', 'team-hot');
    }
    const body = { ...patch, updated_at: new Date().toISOString() };
    const r = await sb.from('cla_teams').update(body).eq('id', id).select().single();
    if (r.error) {
      if (claIsNetErr(r.error)) return await claQueueTeamPatch(user, id, patch);
      console.warn(r.error); claUxEmit('save_failed', 'team'); return null;
    }
    return await done(r.data, 'save_ok', 'team');
  }
  async function deleteTeam(id){
    // CLA flag 3c14112f — return whether the team row was actually deleted (mirrors
    // deleteUserPractice: a silent 0-row delete from a missing DELETE RLS policy
    // would otherwise look like success and the team would reappear on reload).
    if (!sb) return false;
    const user = await claGetUser();
    if (!user) return false;
    if (!navigator.onLine) { claOfflineDeleteToast(); return false; }   // deletes never queue (by design)
    const { data, error } = await sb.from('cla_teams').delete()
      .eq('id', id).eq('user_id', user.id).select('id');
    if (error) {
      if (claIsNetErr(error)) claOfflineDeleteToast();
      console.warn('deleteTeam', error); return false;
    }
    const ok = Array.isArray(data) && data.length > 0;
    if (ok && getCurrentTeamId() === id) setCurrentTeamId(null);
    return ok;
  }

  // TEAM VOCABULARY (CLA flag ed34ebc0) — lives in its own team-scoped table
  // `cla_team_vocab` (keyed by team_id, not user_id) so it persists across
  // devices and is shared by every member of a team once College-tier teams can
  // have multiple users. Shape: { cats:[{key,label,ink}], terms:[...] }.
  async function getTeamVocab(teamId){
    if (!sb || !teamId) return null;
    const key = 't:' + teamId + ':vocab';
    const { data, error } = await sb.from('cla_team_vocab')
      .select('cats, terms, emphasis').eq('team_id', teamId).maybeSingle();
    if (error){
      console.warn('[cla] getTeamVocab error → offline cache:', error);
      return await claCacheGet(key);
    }
    // queued-but-not-yet-replayed offline edits win over the server copy until
    // replay lands them (otherwise a reconnect fetch "loses" the pending edit)
    const pend = (await claQueue()).filter((o) => o.teamId === teamId);
    const pv = pend.find((o) => o.kind === 'vocab'), pe = pend.find((o) => o.kind === 'emphasis');
    if (pv || pe) {
      const out = {
        cats: pv ? pv.next.cats : ((data && data.cats) || []),
        terms: pv ? pv.next.terms : ((data && data.terms) || []),
        emphasis: pe ? pe.next : ((data && data.emphasis) || []),
      };
      await claCachePut(key, out);
      return out;
    }
    if (data) claCachePut(key, data);
    return data || null;   // null = no row yet → empty vocab for this team
  }
  // GAME PLAY EMPHASIS (CLA flag 90d5d4e4) — coach's "things to work on" list, stored
  // beside the vocab doc (cla_team_vocab.emphasis jsonb: [{id,text,added}]). Written on
  // its own so it never clobbers a concurrent cats/terms save (and vice versa — the
  // vocab editors upsert only cats/terms).
  async function saveTeamEmphasis(teamId, emphasis){
    if (!sb || !teamId) return null;
    const list = Array.isArray(emphasis) ? emphasis : [];
    const key = 't:' + teamId + ':vocab';
    async function queueIt(){
      const cached = (await claCacheGet(key)) || {};
      await claEnqueue({ kind: 'emphasis', teamId, base: cached.emphasis || [], next: list });
      await claCachePut(key, { ...cached, emphasis: list });
      return true;
    }
    if (!navigator.onLine) return await queueIt();
    const u = await sb.from('cla_team_vocab')
      .update({ emphasis: list, updated_at: new Date().toISOString() })
      .eq('team_id', teamId).select('team_id');
    if (u.error){
      if (claIsNetErr(u.error)) return await queueIt();
      console.warn('[cla] saveTeamEmphasis error:', u.error); return null;
    }
    if (u.data && u.data.length) {
      const cached = (await claCacheGet(key)) || {};
      await claCachePut(key, { ...cached, emphasis: list });
      return true;
    }
    // no vocab row for this team yet → create one
    const i = await sb.from('cla_team_vocab').insert({ team_id: teamId, cats: [], terms: [], emphasis: list });
    if (i.error){ console.warn('[cla] saveTeamEmphasis insert error:', i.error); return null; }
    return true;
  }
  // v8.107 (MC d9641505 — Tim's vanishing FIST): the ONLINE path used to upsert
  // the WHOLE doc from this editor's memory, so a vocabulary page loaded before
  // a term was added elsewhere (the tagger's add-to-vocab mid-game, another
  // device, another window) silently DELETED that term on its next save — the
  // tagger's next sync then obediently removed it from the label rail. Now the
  // online path replays the same 3-way contract as the offline queue:
  // base = what THIS editor edited from (callers pass it; cache is the
  // fallback), next = the editor's doc, server = the row as it is NOW.
  // claMergeList keeps foreign adds/edits and still lands local deletes.
  async function saveTeamVocab(teamId, vocab, baseArg){
    if (!sb || !teamId) return null;
    const next = { cats: (vocab && vocab.cats) || [], terms: (vocab && vocab.terms) || [] };
    const key = 't:' + teamId + ':vocab';
    if (navigator.onLine) {
      const cached = (await claCacheGet(key)) || null;
      const fallback = baseArg || cached;
      const cur = await sb.from('cla_team_vocab').select('cats, terms, emphasis').eq('team_id', teamId).maybeSingle();
      if (!cur.error) {
        const server = cur.data || null;
        // No server row yet, or no base at all (first ever save on a fresh
        // device): nothing to merge against — write the doc as-is.
        const merged = (server && fallback) ? {
          cats:  claMergeList(fallback.cats  || [], next.cats,  server.cats  || [], 'cats').list,
          terms: claMergeList(fallback.terms || [], next.terms, server.terms || [], 'terms').list,
        } : next;
        const row = { team_id: teamId, ...merged, updated_at: new Date().toISOString() };
        const r = await sb.from('cla_team_vocab').upsert(row, { onConflict: 'team_id' }).select().single();
        if (!r.error) {
          await claCachePut(key, { cats: r.data.cats, terms: r.data.terms, emphasis: r.data.emphasis });
          claSyncAnnounce('vocab', null, teamId, r.data.updated_at);   // dual-post — 'cla-vocab' rides via claVocabChanged at the call sites
          return r.data;
        }
        if (!claIsNetErr(r.error)) { console.warn('[cla] saveTeamVocab error:', r.error); return null; }
      } else if (!claIsNetErr(cur.error)) { console.warn('[cla] saveTeamVocab read error:', cur.error); return null; }
    }
    // offline (or the write died on the network): queue a delta vs the last
    // KNOWN server doc — replay three-way merges it, never whole-doc clobbers.
    const cachedDoc = (await claCacheGet(key)) || null;
    const base = baseArg || cachedDoc || { cats: [], terms: [] };
    await claEnqueue({ kind: 'vocab', teamId,
      base: { cats: base.cats || [], terms: base.terms || [] }, next });
    await claCachePut(key, { ...(cachedDoc || {}), ...next });   // optimistic local read (keeps cached emphasis)
    return { team_id: teamId, ...next, _offline: true };
  }

  // NOTE: vocab-term clips (flag 8f6666ae) are read LOCALLY from the tagger's own
  // IndexedDB (same-origin) and played from the local film handle — see
  // VS_readTaggerProjects / VS_ClipPlayer in vocabulary.jsx. No cloud table, no MP4.

  // Vocab changed — tell everyone, in BOTH the ways that are needed (Tim 2026-07-16).
  // THE ONE announcer: every vocab writer calls this instead of dispatching by hand.
  //   1. `cla:vocab-changed` CustomEvent — the planner's own views (print copy, the
  //      open V7_Vocab). Confined to THIS document by construction.
  //   2. BroadcastChannel 'cla-vocab' — the tagger. It's a separate document at
  //      /tagger/, so the CustomEvent above cannot reach it; that's exactly why a
  //      planner edit used to stay invisible there until a full reload. Same origin,
  //      so the channel is allowed. The tagger debounces and re-syncs quietly.
  // `origin` (optional) names the view that made the change so that view can ignore
  // its OWN echo. A saver's in-memory doc is always newer than the row it just
  // wrote, so re-reading it can only lose newer keystrokes (CLA flag cddb9c76).
  function claVocabChanged(teamId, origin){
    if (!teamId) return;
    try { window.dispatchEvent(new CustomEvent('cla:vocab-changed', { detail: { teamId, origin: origin || null } })); } catch(_){}
    // v8.107: carry the origin over the channel too — a posted message is also
    // delivered to OTHER BroadcastChannel objects in the SAME document, so the
    // xwin bridge below re-dispatches a saver's own post back at it; `from`
    // lets that view recognise and ignore its own echo.
    try { const bus = new BroadcastChannel('cla-vocab'); bus.postMessage({ teamId, at: Date.now(), origin: origin || null }); bus.close(); } catch(_){}
  }

  // ── cross-window sync bus (multi-window Wave 3, Tim 07-27) ──────────────────
  // Every successful write ANNOUNCES on BroadcastChannel 'cla-sync' (ids only,
  // never data); sibling windows — planner AND tagger — re-read their source of
  // truth and re-render. The 'cla-vocab' channel above stays as-is for vocab
  // (dual-post; the tagger's long-lived listener keeps working across deploys).
  // Message: {v:1, appVersion, app, entity, id, teamId, origin, savedAt, meta}
  const CLA_SYNC_CH = 'cla-sync';
  const CLA_SYNC_PING = 'cla-sync-ping';
  const claSyncWindowId = (crypto.randomUUID ? crypto.randomUUID() : 'w' + Math.random().toString(36).slice(2));
  let claSyncBus = null;
  function claSyncBusGet(){
    if (claSyncBus === null) { try { claSyncBus = new BroadcastChannel(CLA_SYNC_CH); } catch(_) { claSyncBus = false; } }
    return claSyncBus || null;
  }
  function claSyncAnnounce(entity, id, teamId, savedAt, meta){
    const msg = { v: 1, appVersion: (typeof V6_VERSION !== 'undefined' ? V6_VERSION : ''), app: 'planner',
      entity, id: id || null, teamId: teamId || null, origin: claSyncWindowId,
      savedAt: savedAt || new Date().toISOString(), meta: meta || null };
    const bus = claSyncBusGet();
    if (bus) { try { bus.postMessage(msg); return; } catch(_){} }
    try { localStorage.setItem(CLA_SYNC_PING, JSON.stringify(msg) + ':' + Date.now()); } catch(_){}
  }
  const claSyncSubs = [];
  const claSyncTimers = new Map();
  let claSyncListening = false;
  // Network-first SW: two windows straddling a deploy run DIFFERENT bundles.
  // A message from a strictly-newer sibling shows a one-time reload banner —
  // never an auto-reload mid-edit (Wave 6 handshake).
  let claSyncStaleShown = false;
  function claSyncVersionCheck(msg){
    if (claSyncStaleShown || msg.app !== 'planner' || !msg.appVersion) return;
    const mine = parseFloat(String(typeof V6_VERSION !== 'undefined' ? V6_VERSION : '').replace(/^v/, ''));
    const theirs = parseFloat(String(msg.appVersion).replace(/^v/, ''));
    if (!Number.isFinite(mine) || !Number.isFinite(theirs) || theirs <= mine) return;
    claSyncStaleShown = true;
    const bar = document.createElement('div');
    bar.style.cssText = 'position:fixed;top:0;left:50%;transform:translateX(-50%);z-index:10008;display:flex;gap:10px;align-items:center;background:#17191d;color:#e8e9ec;border:1px solid rgba(255,255,255,.16);border-top:none;border-radius:0 0 10px 10px;padding:7px 14px;font:500 12.5px -apple-system,BlinkMacSystemFont,sans-serif;';
    bar.innerHTML = '<span>Another window saved from a newer version.</span>'
      + '<button style="background:none;border:1px solid rgba(255,255,255,.3);color:#fff;border-radius:6px;padding:3px 10px;font-weight:600;cursor:pointer">Reload to update</button>'
      + '<button data-x style="background:none;border:none;color:#9aa0a8;cursor:pointer">✕</button>';
    bar.querySelector('button').onclick = () => location.reload();
    bar.querySelector('[data-x]').onclick = () => bar.remove();
    document.body.appendChild(bar);
  }
  function claSyncDispatch(msg){
    if (!msg || msg.v !== 1 || msg.origin === claSyncWindowId) return;
    claSyncVersionCheck(msg);
    claSyncSubs.forEach((fn, slot) => {
      const key = msg.entity + ':' + (msg.id || '') + ':' + slot;   // per-subscriber trailing debounce
      clearTimeout(claSyncTimers.get(key));
      claSyncTimers.set(key, setTimeout(() => { try { fn(msg); } catch(e){ console.warn('[cla-sync] handler', e); } }, 800));
    });
  }
  function claSyncListenOnce(){
    if (claSyncListening) return;
    claSyncListening = true;
    const bus = claSyncBusGet();
    if (bus) bus.onmessage = (event) => claSyncDispatch(event.data);
    window.addEventListener('storage', (event) => {
      if (event.key !== CLA_SYNC_PING || !event.newValue) return;
      try { claSyncDispatch(JSON.parse(event.newValue.slice(0, event.newValue.lastIndexOf(':')))); } catch(_){}
    });
  }
  function claSyncSubscribe(fn){
    claSyncListenOnce();
    claSyncSubs.push(fn);
    return () => { const i = claSyncSubs.indexOf(fn); if (i >= 0) claSyncSubs.splice(i, 1); };
  }
  // Baseline handlers that live HERE (they own auth.jsx state): a sibling's
  // team write refreshes the teams cache exactly like offline replay does; a
  // sibling's vocab write re-fires the in-document vocab event (BroadcastChannel
  // never echoes to the poster, so no origin loop); a sibling planner window's
  // vocab post on 'cla-vocab' is also picked up for tagger-parity.
  claSyncSubscribe(async (msg) => {
    if (msg.entity === 'team'){
      try {
        const list = await listTeams();
        teamsCache = list;
        window.dispatchEvent(new CustomEvent(TEAMS_DATA_EVENT, { detail: { teams: list } }));
      } catch(_){}
    } else if (msg.entity === 'vocab' && msg.teamId){
      try { window.dispatchEvent(new CustomEvent('cla:vocab-changed', { detail: { teamId: msg.teamId, origin: 'xwin' } })); } catch(_){}
    }
  });
  try {
    const vocabXwin = new BroadcastChannel('cla-vocab');
    vocabXwin.onmessage = (event) => {   // planner↔planner vocab gap: 'cla-vocab' had posters here but no listener
      const teamId = event.data && event.data.teamId;
      const from = (event.data && event.data.origin) || null;   // v8.107: the poster's view id, so a view can skip its own echo
      if (teamId) { try { window.dispatchEvent(new CustomEvent('cla:vocab-changed', { detail: { teamId, origin: 'xwin', from } })); } catch(_){} }
    };
  } catch(_){}

  // PRACTICES — team-scoped.
  // Accepts an explicit teamId so callers (the builder load effect) can pass the
  // React state value the effect keyed on, instead of re-reading localStorage —
  // that read can lag a rapid team switch and leak the previous team's practices.
  // Pass `undefined` to fall back to the stored current-team id.
  async function listUserPractices(teamIdArg){
    if (!sb) return [];
    const user = await claGetUser();
    if (!user) return [];
    const teamId = teamIdArg === undefined ? getCurrentTeamId() : teamIdArg;
    let q = sb.from('cla_practices')
      .select('id, name, updated_at, created_at, data, team_id')
      // Practice visibility is team-scoped via RLS (cla_practices_team) so shared
      // members see the team's practices; the team filter below still applies.
      .order('updated_at', { ascending: false });
    // Always scope by team. With a team in context, only that team's rows show.
    // With NO team in context, only show legacy rows that have no team_id —
    // never every team's practices (that was the cross-team leak).
    if (teamId) q = q.eq('team_id', teamId);
    else q = q.is('team_id', null);
    const key = 't:' + (teamId || 'none') + ':practices';
    const { data, error } = await q;
    if (error) {
      console.warn('[cla] listUserPractices error → offline cache:', error);
      return (await claCacheGet(key)) || [];
    }
    // rows carry full `data` jsonb → practices open offline; queued-but-not-yet-
    // replayed offline saves stay visible on top of the fresh server list
    const rows = await claOverlayPendingPractices(teamId, data || []);
    await claCachePut(key, rows);
    return rows;
  }
  // Offline path for a practice save: queue the op + patch the read-cache so
  // the edit is visible on this device immediately (and after reloads).
  async function claQueuePracticeSave(user, teamId, id, name, data){
    const listKey = 't:' + (teamId || 'none') + ':practices';
    const list = (await claCacheGet(listKey)) || [];
    const now = new Date().toISOString();
    let row, baseUpdatedAt = null, insert = false;
    if (id) {
      const cur = list.find((r) => r.id === id) || (await claCacheGet('p:' + id));
      baseUpdatedAt = (cur && cur.updated_at) || null;   // null base → replay goes the copy route
      row = { ...(cur || {}), id, name, data, team_id: teamId || null, updated_at: now };
    } else {
      insert = true;
      id = 'offline-' + claUid();
      row = { id, name, data, team_id: teamId || null, user_id: user.id, created_at: now, updated_at: now };
    }
    await claEnqueue({ kind: 'practice', id, teamId: teamId || null, name, data, baseUpdatedAt, insert });
    await claCachePut(listKey, [row, ...list.filter((r) => r.id !== id)]);
    await claCachePut('p:' + id, row);
    claUxEmit('save_queued', 'practice');
    return { ...row, _offline: true };
  }
  // teamIdArg + baseUpdatedAt + baseData are the multi-window Wave 6 additions
  // (all optional — every pre-existing caller stays valid): an explicit teamId
  // kills the read-localStorage-at-save-time cross-team hazard (window A on
  // Team 1 must never save into window B's Team 2); baseUpdatedAt arms a CAS
  // (`.eq('updated_at', base)`) on the online UPDATE — a miss means another
  // window/device saved since we read, so we field-merge our edit onto the
  // server row (claMergePracticeData vs baseData = the content this window
  // last persisted) and retry ONCE with a fresh CAS; a second miss falls back
  // to the honest claConflictCopy ("— offline copy"), never a blind clobber.
  async function saveUserPractice(id, name, data, teamIdArg, baseUpdatedAt, baseData){
    if (!sb) return null;
    const user = await claGetUser();
    if (!user) return null;
    const teamId = (teamIdArg !== undefined && teamIdArg !== null) ? teamIdArg : getCurrentTeamId();
    if (!navigator.onLine) return await claQueuePracticeSave(user, teamId, id, name, data);
    // an offline-created practice that hasn't replayed yet: keep editing its queued op
    if (id && String(id).startsWith('offline-')) return await claQueuePracticeSave(user, teamId, id, name, data);
    if (id) {
      // CAS updates use .select() + length — 0 rows is a MISS, not an error
      // (the .single() idiom would turn every miss into a thrown PGRST116).
      let q = sb.from('cla_practices').update({ name, data, updated_at: new Date().toISOString() }).eq('id', id);
      if (baseUpdatedAt) q = q.eq('updated_at', baseUpdatedAt);
      const r = await q.select();
      if (r.error) {
        if (claIsNetErr(r.error)) return await claQueuePracticeSave(user, teamId, id, name, data);
        console.warn(r.error); claUxEmit('save_failed', 'update'); return null;
      }
      if (r.data && r.data.length) {
        claUxEmit('save_ok', 'update');
        claSyncAnnounce('practice', id, teamId, r.data[0].updated_at, { date: data && data.date });
        return r.data[0];
      }
      if (!baseUpdatedAt) { claUxEmit('save_failed', 'update'); return null; }   // no-CAS 0-row = RLS/id problem, keep old behavior
      // CAS miss — rebase onto the server row and retry once.
      const cur = await sb.from('cla_practices').select('*').eq('id', id).single();
      if (cur.error || !cur.data) {
        if (cur.error && claIsNetErr(cur.error)) return await claQueuePracticeSave(user, teamId, id, name, data);
        claUxEmit('save_failed', 'update'); return null;
      }
      const m = claMergePracticeData(baseData || null, data, cur.data.data || {});
      const r2 = await sb.from('cla_practices')
        .update({ name, data: m.merged, updated_at: new Date().toISOString() })
        .eq('id', id).eq('updated_at', cur.data.updated_at).select();
      if (!r2.error && r2.data && r2.data.length) {
        claUxEmit('xwin_cas_merge', m.lost ? String(m.lost) : undefined);
        claSyncAnnounce('practice', id, teamId, r2.data[0].updated_at, { date: data && data.date });
        return r2.data[0];
      }
      // Second miss (a very hot row) — never clobber blind: honest copy.
      await claConflictCopy({ ts: Date.now(), name, data, teamId }, user);
      claUxEmit('xwin_cas_copy');
      claSyncAnnounce('practice', null, teamId, null, { date: data && data.date, created: true });
      return { id, _conflictCopy: true };
    }
    const r = await sb.from('cla_practices').insert({ user_id: user.id, team_id: teamId, name, data }).select().single();
    if (r.error) {
      if (claIsNetErr(r.error)) return await claQueuePracticeSave(user, teamId, null, name, data);
      console.warn(r.error); claUxEmit('save_failed', 'insert'); return null;
    }
    claUxEmit('save_ok', 'insert');
    claSyncAnnounce('practice', r.data && r.data.id, teamId, r.data && r.data.updated_at, { date: data && data.date, created: true });
    return r.data;
  }
  async function deleteUserPractice(id){
    // CLA flag 8f774fa8 — return whether the row was ACTUALLY deleted. A missing /
    // non-permissive DELETE RLS policy makes the call succeed with 0 rows and no
    // error, so the old fire-and-forget version silently "deleted" nothing and the
    // practice came back on reload. `.select('id')` returns the deleted rows, so an
    // empty result reliably means "DB kept it" → surface that to the caller.
    if (!sb) return false;
    const user = await claGetUser();
    if (!user) return false;
    if (!navigator.onLine) { claOfflineDeleteToast(); return false; }   // deletes never queue (by design)
    const { data, error } = await sb.from('cla_practices').delete()
      .eq('id', id).select('id');   // RLS (cla_practices_team) gates by membership
    if (error) {
      if (claIsNetErr(error)) claOfflineDeleteToast();
      console.warn('deleteUserPractice', error); return false;
    }
    return Array.isArray(data) && data.length > 0;
  }
  async function loadUserPractice(id){
    if (!sb) return null;
    const user = await claGetUser();
    if (!user) return null;
    const { data, error } = await sb.from('cla_practices').select('*')
      .eq('id', id).maybeSingle();   // RLS gates read to owned + shared teams
    if (error) {
      console.warn('[cla] loadUserPractice error → offline cache:', error);
      return await claCacheGet('p:' + id);
    }
    if (data) claCachePut('p:' + id, data);
    return data;
  }

  // ROTATIONS (v8.45, team-shared v8.50) — cla_rotations, offline-aware. Lives
  // here (not rotation-v6.jsx) for proximity to the queue; ROT_listRotations /
  // ROT_saveRotation delegate to these. Read-cache key: 't:<teamId>:rotations'
  // (last good list, rows carry full `data` jsonb → rotations open offline).
  // v8.50: reads/updates deliberately do NOT filter by user_id — RLS gates
  // visibility (owner policy cla_rotations_self + member policy
  // cla_rotations_member_all), mirroring the cla_practices shared-team idiom.
  // INSERTs still stamp user_id (owner attribution).
  async function claOverlayPendingRotations(teamId, rows){
    const q = await claQueue();
    let out = rows;
    for (const op of q) {
      if (op.kind !== 'rotation' || (op.teamId || null) !== (teamId || null)) continue;
      const row = { id: op.id, name: op.name, data: op.data, team_id: op.teamId || null,
        created_at: op.ts, updated_at: op.ts };
      out = [row, ...out.filter((r) => r.id !== op.id)];
    }
    return out;
  }
  async function claListRotations(teamId){
    if (!sb) return [];
    const user = await claGetUser();
    if (!user) return [];
    const key = 't:' + (teamId || 'none') + ':rotations';
    // No .eq('user_id') — team members see each other's rotations via RLS.
    // The team_id IS NULL branch still returns only your own rows (the member
    // policy requires a non-null team_id, so RLS reduces it to the owner policy).
    let q = sb.from('cla_rotations').select('id, user_id, name, data, team_id, created_at, updated_at')
      .order('updated_at', { ascending: false });
    q = teamId ? q.eq('team_id', teamId) : q.is('team_id', null);
    const res = await q;
    if (res.error) {
      console.warn('[cla] claListRotations error → offline cache:', res.error);
      return (await claCacheGet(key)) || [];
    }
    const rows = await claOverlayPendingRotations(teamId, res.data || []);
    await claCachePut(key, rows);
    return rows;
  }
  // Offline path for a rotation save: queue kind 'rotation' (coalesces per
  // rotation, FIRST base wins) + patch the read-cache. Replay is CAS on
  // updated_at — a mismatch becomes "<name> — offline copy (date)".
  async function claQueueRotationSave(user, teamId, id, name, data){
    const key = 't:' + (teamId || 'none') + ':rotations';
    const list = (await claCacheGet(key)) || [];
    const now = new Date().toISOString();
    let baseUpdatedAt = null, insert = false;
    if (id) {
      const cur = list.find((r) => r.id === id);
      baseUpdatedAt = (cur && cur.updated_at) || null;   // null base → replay goes the copy route
    } else { insert = true; id = 'offline-' + claUid(); }
    await claEnqueue({ kind: 'rotation', id, teamId: teamId || null, name, data, baseUpdatedAt, insert });
    const row = { id, name, data, team_id: teamId || null, user_id: user.id, created_at: now, updated_at: now };
    await claCachePut(key, [row, ...list.filter((r) => r.id !== id)]);
    claUxEmit('save_queued', 'rotation');
    return { ...row, _offline: true };
  }
  // v8.3 (MC 28adecc3, write-conflict audit HIGH #1): the ONLINE update now
  // carries the same CAS-or-copy contract the offline replay has had since
  // v8.45 — it used to be a plain whole-doc .update() that clobbered whichever
  // teammate/tab saved last (rotations are team-shared, autosave fires 700ms
  // after any grid edit). Base stamp = baseUpdatedAt from the CALLER's own row
  // (rotation-v6 passes its plans-state updated_at, so a stale TAB can't
  // borrow a fresher stamp from the shared read-cache); the cache is only the
  // fallback for stampless callers. A CAS miss means someone saved since this
  // editor read → the edit becomes "<name> — conflict copy (date)" and the
  // COPY row is returned, so the editor adopts it (no clobber, no copy storm).
  async function claSaveRotation(id, name, data, teamId, baseUpdatedAt){
    if (!sb) return null;
    const user = await claGetUser();
    if (!user) return null;
    if (!navigator.onLine) return await claQueueRotationSave(user, teamId, id, name, data);
    // an offline-created rotation that hasn't replayed yet: keep editing its queued op
    if (id && String(id).startsWith('offline-')) return await claQueueRotationSave(user, teamId, id, name, data);
    if (id) {
      let base = baseUpdatedAt || null;
      if (!base) {
        const list = (await claCacheGet('t:' + (teamId || 'none') + ':rotations')) || [];
        const cur = list.find((r) => r.id === id);
        base = (cur && cur.updated_at) || null;
      }
      // CAS updates use .select() + length — 0 rows is a MISS, not an error
      // (the .single() idiom would turn every miss into a thrown PGRST116).
      let q = sb.from('cla_rotations')
        .update({ name, data, updated_at: new Date().toISOString() })
        .eq('id', id);   // no user_id filter — a teammate's edit lands via the member policy
      if (base) q = q.eq('updated_at', base);
      const res = await q.select();
      if (res.error) {
        if (claIsNetErr(res.error)) return await claQueueRotationSave(user, teamId, id, name, data);
        console.warn('[rotation] save', res.error); claUxEmit('save_failed', 'rotation'); return null;
      }
      if (res.data && res.data.length) {
        await claCacheRotationRow(teamId, res.data[0]);
        claUxEmit('save_ok', 'rotation');
        claSyncAnnounce('rotation', id, teamId, res.data[0].updated_at);
        return res.data[0];
      }
      if (!base) { claUxEmit('save_failed', 'rotation'); return null; }   // no-CAS 0-row = RLS/id problem, keep old behavior
      // CAS miss — the row moved under us (or is gone). NEVER overwrite, NEVER
      // recreate the id: both versions survive, this editor adopts the copy.
      const stamp = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
      const copy = await sb.from('cla_rotations').insert({ user_id: user.id, team_id: teamId || null,
        name: (name || 'Untitled rotation') + ' — conflict copy (' + stamp + ')', data }).select().single();
      if (copy.error) {
        if (claIsNetErr(copy.error)) return await claQueueRotationSave(user, teamId, id, name, data);
        console.warn('[rotation] conflict copy', copy.error); claUxEmit('save_failed', 'rotation'); return null;
      }
      await claCacheRotationRow(teamId, copy.data);
      claUxEmit('xwin_cas_copy', 'rotation');
      claSyncAnnounce('rotation', copy.data && copy.data.id, teamId, copy.data && copy.data.updated_at);
      return copy.data;
    }
    const res = await sb.from('cla_rotations')
      .insert({ user_id: user.id, team_id: teamId || null, name, data }).select().single();
    if (res.error) {
      if (claIsNetErr(res.error)) return await claQueueRotationSave(user, teamId, null, name, data);
      console.warn('[rotation] insert', res.error); claUxEmit('save_failed', 'rotation'); return null;
    }
    await claCacheRotationRow(teamId, res.data);
    claUxEmit('save_ok', 'rotation');
    claSyncAnnounce('rotation', res.data && res.data.id, teamId, res.data && res.data.updated_at);
    return res.data;
  }
  // keep the cached list fresh after an ONLINE save so a later offline enqueue
  // snapshots the true server updated_at (else every replay would CAS-miss → copy)
  async function claCacheRotationRow(teamId, row){
    try {
      if (!row) return;
      const key = 't:' + (teamId || 'none') + ':rotations';
      const list = (await claCacheGet(key)) || [];
      await claCachePut(key, [row, ...list.filter((r) => r.id !== row.id)]);
    } catch (_) {}
  }

  // LOAD SESSIONS — Kinexon (or other tracking) sessions synced into
  // cla_load_sessions, team-scoped. The list intentionally EXCLUDES the
  // phases_players jsonb (per-phase per-player detail, the bulk of each row);
  // getLoadSessionDetail fetches it for one session when a drill-down opens.
  // Reads are TEAM assets (P7, 2026-07-24): RLS gates visibility (owner ALL +
  // cla_load_sessions_member_select via cla_is_team_member) — no client
  // user_id filter, so shared staff see the same team data. Writes stay
  // owner-scoped (member UPDATE/INSERT hits 0 rows by policy).
  async function listLoadSessions(teamIdArg){
    if (!sb) return [];
    const user = await claGetUser();
    if (!user) return [];
    const teamId = teamIdArg === undefined ? getCurrentTeamId() : teamIdArg;
    if (!teamId) return [];
    const { data, error } = await sb.from('cla_load_sessions')
      .select('id, kx_session_id, session_date, start_at, end_at, session_type, description, summary, players')
      .eq('team_id', teamId)
      .order('session_date', { ascending: true });
    if (error) { console.warn('listLoadSessions', error); return []; }
    return data || [];
  }
  // Lightweight existence probe — does this team have ANY load sessions?
  // Drives the left-rail "Load" tab visibility (flag edc85523): one row, no
  // jsonb, instead of pulling the whole session list just to count it.
  async function teamHasLoadData(teamId){
    if (!sb || !teamId) return false;
    const user = await claGetUser();
    if (!user) return false;
    const { data, error } = await sb.from('cla_load_sessions')
      .select('id').eq('team_id', teamId).limit(1);
    if (error) { console.warn('teamHasLoadData', error); return false; }
    return !!(data && data.length);
  }
  // DRILL CATEGORIZATION DEV — Q&A rows the import agent files when it can't
  // confidently categorize a drill; Tim answers in the Drill bank section.
  async function listDrillDev(teamIdArg){
    if (!sb) return [];
    const user = await claGetUser();
    if (!user) return [];
    const teamId = teamIdArg === undefined ? getCurrentTeamId() : teamIdArg;
    if (!teamId) return [];
    const { data, error } = await sb.from('cla_drill_dev')
      .select('id, drill_name, context, question, my_guess, answer, status')
      .eq('user_id', user.id).eq('team_id', teamId)
      .order('created_at', { ascending: true });
    if (error) { console.warn('listDrillDev', error); return []; }
    return data || [];
  }
  async function answerDrillDev(id, answer){
    if (!sb) return false;
    const user = await claGetUser();
    if (!user) return false;
    const { error } = await sb.from('cla_drill_dev')
      .update({ answer, status: 'answered', updated_at: new Date().toISOString() })
      .eq('id', id).eq('user_id', user.id);
    if (error) { console.warn('answerDrillDev', error); return false; }
    return true;
  }

  async function getLoadSessionDetail(kxSessionId){
    if (!sb) return null;
    const user = await claGetUser();
    if (!user) return null;
    const teamId = getCurrentTeamId();
    if (!teamId) return null;
    const { data, error } = await sb.from('cla_load_sessions')
      .select('kx_session_id, phases_players')
      .eq('team_id', teamId).eq('kx_session_id', kxSessionId).maybeSingle();
    if (error) { console.warn('getLoadSessionDetail', error); return null; }
    return data;
  }

  // LOAD SYNC STATUS + "Update now" (Tim, 2026-07-06) — the Load tab shows the data
  // source, when it was last synced, and a button that REQUESTS a sync. The client
  // never talks to Kinexon (creds stay agent-side): the button files a READY Mission
  // Control card that the Hermes sweep / execution bridge picks up and runs.
  async function getLoadSyncStatus(teamId){
    if (!sb || !teamId) return null;
    const { data, count, error } = await sb.from('cla_load_sessions')
      .select('created_at, kx_session_id', { count: 'exact' })
      .eq('team_id', teamId).order('created_at', { ascending: false }).limit(1);
    if (error){ console.warn('[cla] getLoadSyncStatus', error); return null; }
    const newest = (data && data[0]) || null;
    return { count: count || 0,
      lastSync: newest ? newest.created_at : null,
      source: newest && newest.kx_session_id ? 'Kinexon' : (count ? 'tracking sync' : null) };
  }
  async function requestLoadSync(teamId, teamName){
    if (!sb || !teamId) return false;
    const name = `🔄 Load data sync requested — ${teamName || 'team'}`;
    // dedupe: an unstarted request for this team is still in the queue
    const existing = await sb.from('tasks').select('id').eq('name', name)
      .in('status', ['READY', 'NOT STARTED', 'IN-PROGRESS']).limit(1);
    if (!existing.error && existing.data && existing.data.length) return 'queued';
    const { error } = await sb.from('tasks').insert({
      name, status: 'READY', project: 'Basketball', category: 'CLA Planner',
      priority: 'HIGH', assigned_to: 'Coach E Coder',
      notes: [{ ts: new Date().toISOString().slice(0, 10), by: 'cla-app',
        note: `Coach pressed "Update now" on the Load tab (team_id ${teamId}). Run the Kinexon load sync for this team into cla_load_sessions per the established import method (memory: byu-practice-plans-cla-import.md). Kinexon credentials: cla_flags 7d84dd3c / ask Tim — NEVER store them in this card. When done: verify row count + newest session_date, then NEEDS REVIEW with counts.` }],
    });
    if (error){ console.warn('[cla] requestLoadSync', error); return false; }
    return true;
  }

  // DATA CONNECTIONS (Tim, 2026-07-16) — coach-entered load-data sources
  // (Kinexon / Smartabase / "request another system"). The SECRET NEVER touches
  // the browser twice: the client sends it ONCE to the SECURITY DEFINER RPC
  // cla_store_connection, which files it into Supabase Vault and returns only a
  // row id. Reads below get the connection ROW (provider/status/masked hint) but
  // never the credential — that decrypts server-side only, for the agent runner.
  const CLA_PROVIDERS = {
    kinexon:    { label: 'Kinexon',    secretLabel: 'API key',  fields: [
      { key:'org_id',   label:'Team / org ID', placeholder:'e.g. 12345' },
      { key:'api_url',  label:'API endpoint (optional)', placeholder:'https://…kinexon.com/…' } ] },
    smartabase: { label: 'Smartabase', secretLabel: 'Password', fields: [
      { key:'site_url', label:'Site URL', placeholder:'https://<site>.smartabase.com/<site>' },
      { key:'username', label:'Username', placeholder:'coach@club.com' } ] },
  };

  async function listDataConnections(teamIdArg){
    if (!sb) return [];
    const teamId = teamIdArg === undefined ? getCurrentTeamId() : teamIdArg;
    if (!teamId) return [];
    const { data, error } = await sb.from('cla_data_connections')
      .select('id, provider, label, config, secret_id, secret_hint, status, last_sync_at, last_error, updated_at')
      .eq('team_id', teamId)
      .order('provider', { ascending: true });
    if (error){ console.warn('[cla] listDataConnections', error); return []; }
    // never expose the pointer id to render logic beyond "has a secret" boolean
    return (data || []).map(c => ({ ...c, hasSecret: !!c.secret_id, secret_id: undefined }));
  }

  // Stores/updates a connection. `secret` is optional (omit for "request only" or
  // config-only edits). Returns the row id, or false. After storing, files a
  // Mission Control task so the agent runner syncs this source into
  // cla_load_sessions using the Vault-held credential (creds NEVER in the card).
  async function storeDataConnection({ teamId, teamName, provider, label = '', config = {}, secret = null, secretHint = null } = {}){
    if (!sb) return false;
    const tid = teamId || getCurrentTeamId();
    if (!tid || !provider) return false;
    const { data, error } = await sb.rpc('cla_store_connection', {
      p_team_id: tid, p_provider: provider, p_label: label,
      p_config: config || {}, p_secret: secret || null, p_secret_hint: secretHint || null });
    if (error){ console.warn('[cla] storeDataConnection', error); return false; }
    const rowId = data;
    // file the sync/connect request for the agent runner
    const prov = (CLA_PROVIDERS[provider] && CLA_PROVIDERS[provider].label) || label || provider;
    const isOther = provider === 'other';
    const name = isOther
      ? `🔌 Data-source connection requested — ${label || 'new system'} (${teamName || 'team'})`
      : `🔌 ${prov} connection — ${teamName || 'team'}`;
    const existing = await sb.from('tasks').select('id').eq('name', name)
      .in('status', ['READY', 'NOT STARTED', 'IN-PROGRESS']).limit(1);
    if (existing.error || !(existing.data && existing.data.length)){
      const note = isOther
        ? `Coach requested a NEW load-data integration: "${label}". Config: ${JSON.stringify(config)}. No credential stored yet — reach out to scope the provider API, then add it to CLA_PROVIDERS + the sync runner.`
        : `Coach connected ${prov} on the Account ▸ Data connections panel (team_id ${tid}, connection ${rowId}). Credential is in Supabase Vault — read it server-side via: select decrypted_secret from vault.decrypted_secrets where id = (select secret_id from cla_data_connections where id='${rowId}'). NEVER copy the secret into this card. Sync sessions into cla_load_sessions per the established import method, then set cla_data_connections.status/last_sync_at and mark NEEDS REVIEW with row counts.`;
      await sb.from('tasks').insert({
        name, status: 'READY', project: 'Basketball', category: 'CLA Planner',
        priority: 'HIGH', assigned_to: 'Coach E Coder',
        notes: [{ ts: new Date().toISOString().slice(0, 10), by: 'cla-app', note }] });
    }
    return rowId;
  }

  async function forgetDataConnection(id){
    if (!sb || !id) return false;
    const { data, error } = await sb.rpc('cla_forget_connection', { p_id: id });
    if (error){ console.warn('[cla] forgetDataConnection', error); return false; }
    return !!data;
  }

  // Every saved practice across ALL of the user's teams (carries team_id so the
  // builder can offer "import a practice from another team").
  async function listUserPracticesAllTeams(){
    if (!sb) return [];
    const user = await claGetUser();
    if (!user) return [];
    const { data, error } = await sb.from('cla_practices')
      .select('id, name, updated_at, created_at, data, team_id')
      // Owned + shared teams' practices (RLS-gated) so cross-team import can
      // reach a shared team too.
      .order('updated_at', { ascending: false });
    if (error) { console.warn(error); return []; }
    return data || [];
  }

  // ─── Team session hook ───────────────────────────────────────────────
  // Returns { teams, currentTeam, loading, reload(), setCurrent(id), createTeam(...), updateTeam(...), deleteTeam(...) }
  // teamsCache: the first instance pays the listTeams() round-trip; every later
  // mount (roster panel, team sheet, outputs) starts from the cached list so
  // currentTeam resolves on first render — no flash of the demo/BYU fallback
  // while a fresh instance refetches (Tim, 2026-07-06). Cache lives inside this
  // IIFE and is refreshed by every reload(), incl. on sign-in/sign-out.
  let teamsCache = null;
  // CLA flag 3fd12e64 (Tim, 2026-07-06: "updated the roster then print wasn't updated") —
  // every useClaTeams() instance holds its own `teams` state, so a save through ONE
  // instance (e.g. RosterSidebar's updateTeam→reload) refreshed only that instance and
  // the cache; the planner core's instance (which feeds printRoster/staff/brand) stayed
  // stale until a full page reload. Fix: reload() broadcasts the fresh list and every
  // instance listens, so team DATA changes propagate exactly like currentId changes do.
  const TEAMS_DATA_EVENT = 'cla:teams-data';
  function useClaTeams(){
    const [teams, setTeams] = React.useState(teamsCache || []);
    const [currentId, setCurrent] = React.useState(getCurrentTeamId());
    const [loading, setLoading] = React.useState(!teamsCache);
    const reload = React.useCallback(async () => {
      if (!teamsCache) setLoading(true); // warm cache → refresh silently, no loading flicker
      const list = await listTeams();
      teamsCache = list;
      setTeams(list);
      try { window.dispatchEvent(new CustomEvent(TEAMS_DATA_EVENT, { detail: { teams: list } })); } catch (_) {}
      const stored = getCurrentTeamId();
      // CLA flag 8373a0f6 (Tim) — a stored team (even one momentarily not in the list)
      // always wins, so the view never flashes the first team (BYU) before resolving.
      if (stored) setCurrent(stored);
      else {
        // CLA flag 07ccab9b (Tim) — no local team? load the user's SAVED last team
        // (cla_user_data.last_team_id) before falling back to the first team, so a
        // refresh (or a fresh device) returns to the last team worked in.
        let target = null;
        // last_team_id lives INSIDE the settings jsonb — cla_user_data has no such
        // column, so the old top-level read/write silently no-oped (found 2026-07-06).
        try { const ud = await loadUserData(); const ltid = ud && ((ud.settings && ud.settings.last_team_id) || ud.last_team_id); if (ltid && list.some(t => t.id === ltid)) target = ltid; } catch (_) {}
        if (target) { setCurrentTeamId(target); setCurrent(target); }
        else if (list.length) { setCurrentTeamId(list[0].id); setCurrent(list[0].id); }
        else { setCurrentTeamId(null); setCurrent(null); }
      }
      setLoading(false);
    }, []);
    React.useEffect(() => { reload(); }, [reload]);
    React.useEffect(() => {
      if (!sb) return;
      const { data: sub } = sb.auth.onAuthStateChange((evt) => {
        // Reload on sign-in or sign-out; ignore TOKEN_REFRESHED noise.
        if (evt === 'SIGNED_IN' || evt === 'SIGNED_OUT' || evt === 'USER_UPDATED') reload();
      });
      return () => sub && sub.subscription && sub.subscription.unsubscribe();
    }, [reload]);
    // Keep every useClaTeams() instance in sync. The profile menu and the
    // builder each mount their own instance; without this, switching teams in
    // one (e.g. the profile dropdown) leaves the other's currentId stale, so
    // the builder never reloads the practice/roster for the new team.
    React.useEffect(() => {
      const onTeamChange = (e) => {
        const id = (e && e.detail && e.detail.teamId) || getCurrentTeamId();
        setCurrent(id || null); // raw setter — does NOT re-dispatch, so no loop
      };
      window.addEventListener(TEAM_CHANGE_EVENT, onTeamChange);
      return () => window.removeEventListener(TEAM_CHANGE_EVENT, onTeamChange);
    }, []);
    // Sync team DATA (roster, breakdowns, identity…) across instances — see
    // TEAMS_DATA_EVENT note above. Raw setTeams only; no re-dispatch, no loop.
    React.useEffect(() => {
      const onTeamsData = (e) => {
        const list = (e && e.detail && e.detail.teams) || teamsCache;
        if (Array.isArray(list)) setTeams(list);
      };
      window.addEventListener(TEAMS_DATA_EVENT, onTeamsData);
      return () => window.removeEventListener(TEAMS_DATA_EVENT, onTeamsData);
    }, []);
    // CLA flag 07ccab9b (Tim) — persist the last team per user (durable across devices)
    // in addition to localStorage; fire-and-forget so a missing column just no-ops.
    const setCurrentApi = React.useCallback((id) => {
      setCurrentTeamId(id); setCurrent(id);
      if (id) {
        // Persist into settings jsonb (no last_team_id column exists — the old
        // top-level write failed the whole upsert). Merge over the server settings
        // so view_prefs and other keys survive; fetch them first if the boot
        // hydrate hasn't cached them yet (else a quick team-switch wipes prefs).
        Promise.resolve(window.__claServerSettings ? null : loadUserData()).then((d) => {
          const base = (window.__claServerSettings && typeof window.__claServerSettings === 'object')
            ? window.__claServerSettings
            : ((d && d.settings && typeof d.settings === 'object') ? d.settings : {});
          const settings = { ...base, last_team_id: id };
          window.__claServerSettings = settings;
          return saveUserData({ settings });
        }).catch(() => {});
      }
    }, []);
    return {
      teams, currentId, loading,
      currentTeam: teams.find(t => t.id === currentId) || null,
      reload, setCurrent: setCurrentApi,
      createTeam: async (data) => { const t = await createTeam(data); await reload(); if (t) setCurrentApi(t.id); return t; },
      updateTeam: async (id, patch) => { const t = await updateTeam(id, patch); await reload(); return t; },
      deleteTeam: async (id) => { const ok = await deleteTeam(id); await reload(); return ok; },
    };
  }

  // ─── Username → email resolver (THE canonical one — 2026-07-02) ─────
  // Every login surface must use this. Bare usernames get ONE domain
  // (@claplanner.com); "tim" aliases to Tim's gmail. History: /app and the
  // dashboard modal used to append DIFFERENT domains (@claplanner.com vs
  // @cla.local), so the same bare username could become two accounts.
  // Verified 2026-07-02 that ZERO accounts existed under either fake domain,
  // so unifying was a zero-risk code fix. See IMPROVEMENT-REGISTER.md (resolved).
  // ⚠ Mirrored in the tagger's auth gate (tagger source parts/14-gate.js,
  // tgResolveLogin) — the tagger can't import this JSX. Change BOTH together.
  function claResolveLogin(input){
    const v = (input || '').trim();
    if (!v) return v;
    // CLA flag (Tim) — "tim" is Tim's account (Netherlands etc.), keyed to his gmail.
    if (v.toLowerCase() === 'tim') return 'timothyfanning@gmail.com';
    return v.includes('@') ? v : `${v.toLowerCase()}@claplanner.com`;
  }

  // ─── Shared identity panel ────────────────────────────────────────────
  // D47/D49/D50: the hard gate and every account modal use ONE form
  // contract. It keeps the canonical username resolver and all four auth
  // methods, while labels, recovery, scrolling, and responsive behavior stop
  // drifting between entry points.
  const AUTH_SYSTEM = (window.CLA_DESIGN && window.CLA_DESIGN.color && window.CLA_DESIGN.color.toolLight) || {};
  const AUTH_FONT = (window.CLA_DESIGN && window.CLA_DESIGN.font && window.CLA_DESIGN.font.sans)
    || '"Geist", -apple-system, BlinkMacSystemFont, "Helvetica Neue", system-ui, sans-serif';
  const AUTH_INK = AUTH_SYSTEM.text || '#15171a';
  const AUTH_SUB = AUTH_SYSTEM.textSubtle || '#626872';
  const AUTH_FAINT = AUTH_SYSTEM.textFaint || '#858b94';
  const AUTH_HAIR = AUTH_SYSTEM.hairline || '#dfe1e4';
  const AUTH_SURFACE = AUTH_SYSTEM.surface || '#ffffff';
  const AUTH_SURFACE_2 = AUTH_SYSTEM.surfaceSubtle || '#f8f8f6';
  const AUTH_ACCENT = AUTH_SYSTEM.accent || '#1f5bb5';
  const AUTH_RED = AUTH_SYSTEM.danger || '#a83a38';
  const AUTH_GREEN = AUTH_SYSTEM.success || '#187a47';

  function ClaIdentityPanel({
    initialMode = 'signin',
    onAuthenticated,
    onClose,
    collectTeam = false,
    showBrand = true,
    destination = 'CLAP',
  }){
    const validInitial = ['signin','signup','magic','reset'].includes(initialMode) ? initialMode : 'signin';
    const [mode, setMode] = React.useState(validInitial);
    const [email, setEmail] = React.useState('');
    const [pw, setPw] = React.useState('');
    const [showPassword, setShowPassword] = React.useState(false);
    const [teamName, setTeamName] = React.useState('');
    const [teamSport, setTeamSport] = React.useState('basketball');
    const [busy, setBusy] = React.useState(false);
    const [msg, setMsg] = React.useState('');
    const [err, setErr] = React.useState('');
    const reactId = React.useId().replace(/:/g, '');
    const titleId = `cla-identity-title-${reactId}`;
    const errorId = `cla-identity-error-${reactId}`;
    const messageId = `cla-identity-message-${reactId}`;
    const passwordMode = mode === 'signin' || mode === 'signup';
    const emailOnlyMode = mode === 'magic' || mode === 'reset';

    function chooseMode(next){
      setMode(next);
      setErr('');
      setMsg('');
      setShowPassword(false);
    }

    async function submit(e){
      e.preventDefault();
      if (busy) return;
      setBusy(true); setErr(''); setMsg('');
      try {
        if (mode === 'signin'){
          await signInWithPassword(claResolveLogin(email), pw);
          if (onAuthenticated) onAuthenticated();
          return;
        }

        const addr = email.trim();
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(addr)){
          throw new Error('Enter a real email address so CLAP can send the link.');
        }

        if (mode === 'signup'){
          if (pw.length < 8) throw new Error('Use at least 8 characters for your password.');
          if (collectTeam){
            try {
              if (teamName.trim()) localStorage.setItem('cla-pending-team-name', teamName.trim());
              localStorage.setItem('cla-pending-team-sport', teamSport);
            } catch(_){}
          }
          await signUpWithPassword(addr, pw);
          claUxEmit('signup_ok');
          try {
            await signInWithPassword(addr, pw);
            if (onAuthenticated) onAuthenticated();
            return;
          } catch (signInError) {
            const signInMessage = (signInError && signInError.message) || '';
            if (/confirm/i.test(signInMessage)){
              setMsg('Check your inbox to confirm your email, then sign in.');
              setMode('signin');
              setBusy(false);
              return;
            }
            throw signInError;
          }
        } else if (mode === 'magic'){
          await sendMagicLink(addr);
          setMsg('Sign-in link sent. Check your inbox.');
        } else {
          await sendPasswordReset(addr);
          setMsg('Password reset link sent. Check your inbox.');
        }
      } catch (e2) {
        const raw = (e2 && e2.message) || 'CLAP could not complete that request.';
        setErr(/invalid login/i.test(raw) ? 'That email or password does not match.'
          : /already registered/i.test(raw) ? 'That email already has an account. Sign in instead.'
          : raw);
      }
      setBusy(false);
    }

    const labelStyle = {
      display:'grid', gap: 7, marginBottom: 14, color: AUTH_INK,
      fontSize: 12.5, fontWeight: 650,
    };
    const fieldStyle = {
      width:'100%', height: 48, boxSizing:'border-box', padding:'0 14px',
      border:`1px solid ${AUTH_HAIR}`, borderRadius: 11, background: AUTH_SURFACE,
      color: AUTH_INK, fontFamily:'inherit', fontSize: 15, outline:'none',
    };
    const modeButton = (key, label) => (
      <button type="button" onClick={()=>chooseMode(key)}
        aria-pressed={mode === key}
        data-ux={key === 'signup' ? 'gate-mode-toggle' : undefined}
        className="cla-control"
        style={{ flex: 1, minHeight: 44, padding:'0 14px', border: 0, borderRadius: 9,
          background: mode === key ? AUTH_SURFACE : 'transparent',
          color: mode === key ? AUTH_INK : AUTH_SUB, cursor:'pointer',
          fontFamily:'inherit', fontSize: 13.5, fontWeight: mode === key ? 680 : 560,
          boxShadow: mode === key ? `inset 0 0 0 1px ${AUTH_HAIR}` : 'none' }}>
        {label}
      </button>
    );
    const title = mode === 'signup' ? `Create your ${destination} account`
      : mode === 'magic' ? 'Email me a sign-in link'
      : mode === 'reset' ? 'Reset your password'
      : `Sign in to ${destination}`;
    const description = mode === 'signup'
      ? 'Keep your practices, teams, and film connected wherever you coach.'
      : mode === 'magic' ? 'We’ll send a secure, one-time link to your email.'
      : mode === 'reset' ? 'We’ll email you a secure link to choose a new password.'
      : 'Your plan, your staff, and your film—ready where you left them.';
    const actionLabel = mode === 'signup' ? 'Create account'
      : mode === 'magic' ? 'Send sign-in link'
      : mode === 'reset' ? 'Send reset link'
      : 'Sign in';

    return (
      <form onSubmit={submit} aria-labelledby={titleId}
        style={{ width:'min(100%, 430px)', boxSizing:'border-box', position:'relative',
          background: AUTH_SURFACE, border:`1px solid ${AUTH_HAIR}`, borderRadius: 20,
          padding:'clamp(24px, 5vw, 36px)', color: AUTH_INK, fontFamily: AUTH_FONT,
          boxShadow:'0 22px 60px rgba(25,31,40,.12)' }}>
        {onClose && (
          <button type="button" onClick={onClose} aria-label="Close sign-in"
            className="cla-control"
            style={{ position:'absolute', top: 14, right: 14, width: 44, height: 44,
              border: 0, borderRadius: 10, background:'transparent', color: AUTH_SUB,
              cursor:'pointer', fontFamily:'inherit', fontSize: 22 }}>×</button>
        )}
        {showBrand && (
          <a href="/" aria-label="CLAP home"
            style={{ display:'inline-grid', width: 42, height: 42, placeItems:'center',
              borderRadius: 11, background:'#111317', color:'#fff', textDecoration:'none',
              fontSize: 11, fontWeight: 820, letterSpacing:'-.02em', marginBottom: 24 }}>
            CLAP
          </a>
        )}
        <div style={{ display:'flex', padding: 3, gap: 3, borderRadius: 12,
          background: AUTH_SURFACE_2, marginBottom: 28 }} aria-label="Account action">
          {modeButton('signin','Sign in')}
          {modeButton('signup','Create account')}
        </div>

        {emailOnlyMode && (
          <button type="button" onClick={()=>chooseMode('signin')} className="cla-control"
            style={{ minHeight: 44, margin:'-12px 0 8px', padding: 0, border: 0,
              background:'transparent', color: AUTH_SUB, cursor:'pointer',
              fontFamily:'inherit', fontSize: 12.5, fontWeight: 620 }}>
            ← Back to sign in
          </button>
        )}

        <h1 id={titleId} style={{ margin:0, maxWidth: 340, fontSize:'clamp(25px, 6vw, 32px)',
          lineHeight:1.05, letterSpacing:'-.04em', fontWeight:760 }}>{title}</h1>
        <p style={{ margin:'10px 0 26px', color:AUTH_SUB, fontSize:14, lineHeight:1.5 }}>{description}</p>

        <label style={labelStyle}>
          <span>{mode === 'signin' ? 'Username or email' : 'Email address'}</span>
          <input type={mode === 'signin' ? 'text' : 'email'} value={email}
            onChange={(e)=>setEmail(e.target.value)} required autoFocus
            placeholder={mode === 'signin' ? 'name or coach@school.edu' : 'coach@school.edu'}
            autoComplete={mode === 'signin' ? 'username' : 'email'}
            autoCapitalize="none" autoCorrect="off" spellCheck="false"
            aria-describedby={[err && errorId, msg && messageId].filter(Boolean).join(' ') || undefined}
            style={fieldStyle} />
        </label>

        {passwordMode && (
          <label style={labelStyle}>
            <span>Password{mode === 'signup' ? ' · 8+ characters' : ''}</span>
            <span style={{ position:'relative', display:'block' }}>
              <input type={showPassword ? 'text' : 'password'} value={pw}
                onChange={(e)=>setPw(e.target.value)} required
                minLength={mode === 'signup' ? 8 : undefined}
                autoComplete={mode === 'signup' ? 'new-password' : 'current-password'}
                style={{ ...fieldStyle, paddingRight: 82 }} />
              <button type="button" onClick={()=>setShowPassword((value)=>!value)}
                aria-label={showPassword ? 'Hide password' : 'Show password'}
                className="cla-control"
                style={{ position:'absolute', right: 5, top: 2, minWidth: 68, height: 44,
                  padding:'0 10px', border:0, borderRadius: 9, background:'transparent',
                  color:AUTH_SUB, cursor:'pointer', fontFamily:'inherit', fontSize:12,
                  fontWeight:650 }}>
                {showPassword ? 'Hide' : 'Show'}
              </button>
            </span>
          </label>
        )}

        {mode === 'signup' && collectTeam && (
          <details data-ux="gate-team-setup"
            style={{ margin:'2px 0 18px', borderTop:`1px solid ${AUTH_HAIR}`,
              borderBottom:`1px solid ${AUTH_HAIR}`, padding:'2px 0' }}>
            <summary style={{ minHeight: 48, display:'flex', alignItems:'center',
              justifyContent:'space-between', gap:12, cursor:'pointer',
              color:AUTH_INK, fontSize:13, fontWeight:650 }}>
              <span>Set up a team now</span>
              <span style={{ color:AUTH_FAINT, fontSize:11.5, fontWeight:560 }}>Optional · can wait</span>
            </summary>
            <div style={{ padding:'6px 0 16px' }}>
              <label style={labelStyle}>
                <span>Team name</span>
                <input type="text" value={teamName} onChange={(e)=>setTeamName(e.target.value)}
                  placeholder="Netherlands U18" autoComplete="organization" style={fieldStyle} />
              </label>
              <fieldset data-ux="gate-sport" style={{ margin:0, padding:0, border:0 }}>
                <legend style={{ marginBottom:8, color:AUTH_INK, fontSize:12.5, fontWeight:650 }}>Sport</legend>
                <div style={{ display:'grid', gridTemplateColumns:'repeat(2,minmax(0,1fr))', gap:8 }}>
                  {[['basketball','Basketball'],['soccer','Soccer'],['volleyball','Volleyball'],['hockey','Hockey']].map(([key,label])=>(
                    <button key={key} type="button" onClick={()=>setTeamSport(key)}
                      aria-pressed={teamSport === key} className="cla-control"
                      style={{ minHeight:44, border:`1px solid ${teamSport === key ? AUTH_ACCENT : AUTH_HAIR}`,
                        borderRadius:10, background:teamSport === key ? '#edf3ff' : AUTH_SURFACE,
                        color:teamSport === key ? AUTH_ACCENT : AUTH_SUB, cursor:'pointer',
                        fontFamily:'inherit', fontSize:12.5, fontWeight:650 }}>
                      {label}
                    </button>
                  ))}
                </div>
              </fieldset>
            </div>
          </details>
        )}

        {err && <div id={errorId} role="alert"
          style={{ margin:'2px 0 12px', color:AUTH_RED, fontSize:13, lineHeight:1.45 }}>{err}</div>}
        {msg && <div id={messageId} role="status" aria-live="polite"
          style={{ margin:'2px 0 12px', color:AUTH_GREEN, fontSize:13, lineHeight:1.45 }}>{msg}</div>}

        <button type="submit" disabled={busy}
          data-ux={mode === 'signup' ? 'gate-signup' : mode === 'signin' ? 'gate-signin' : `gate-${mode}`}
          className="cla-control"
          style={{ width:'100%', minHeight:48, border:0, borderRadius:11,
            background:busy ? '#9aa8bd' : AUTH_ACCENT, color:'#fff', cursor:busy ? 'wait' : 'pointer',
            fontFamily:'inherit', fontSize:14.5, fontWeight:680 }}>
          {busy ? 'Working…' : actionLabel}
        </button>

        {mode === 'signin' && (
          <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:8, marginTop:8 }}>
            <button type="button" onClick={()=>chooseMode('magic')} className="cla-control"
              style={{ minHeight:44, border:0, borderRadius:10, background:'transparent',
                color:AUTH_SUB, cursor:'pointer', fontFamily:'inherit', fontSize:12.5, fontWeight:620 }}>
              Email me a link
            </button>
            <button type="button" onClick={()=>chooseMode('reset')}
              data-ux="gate-reset-password" className="cla-control"
              style={{ minHeight:44, border:0, borderRadius:10, background:'transparent',
                color:AUTH_SUB, cursor:'pointer', fontFamily:'inherit', fontSize:12.5, fontWeight:620 }}>
              Forgot password?
            </button>
          </div>
        )}
        <p style={{ margin:'18px 0 0', textAlign:'center', color:AUTH_FAINT,
          fontSize:11.5, lineHeight:1.5 }}>
          Your work stays private until you choose to share it.
        </p>
      </form>
    );
  }

  // ─── Sign-in modal ───────────────────────────────────────────────────
  function ClaSignInModal({ onClose }){
    const dialogRef = React.useRef(null);
    React.useEffect(()=>{
      const opener = document.activeElement;
      const node = dialogRef.current;
      const focusFirst = ()=> node && (node.querySelector('input')
        || node.querySelector('button,a[href]'))?.focus();
      const timer = setTimeout(focusFirst, 0);
      function keydown(event){
        if (event.key === 'Escape'){
          event.preventDefault();
          onClose();
          return;
        }
        if (event.key !== 'Tab' || !node) return;
        const focusable = [...node.querySelectorAll('button:not([disabled]),input:not([disabled]),a[href]')]
          .filter((element)=> element.getClientRects().length);
        if (!focusable.length) return;
        const first = focusable[0], last = focusable[focusable.length - 1];
        if (event.shiftKey && document.activeElement === first){ event.preventDefault(); last.focus(); }
        else if (!event.shiftKey && document.activeElement === last){ event.preventDefault(); first.focus(); }
      }
      document.addEventListener('keydown', keydown);
      return ()=>{
        clearTimeout(timer);
        document.removeEventListener('keydown', keydown);
        if (opener && opener.focus) opener.focus();
      };
    }, [onClose]);
    return (
      <div onClick={onClose} style={{ position:'fixed', inset:0, zIndex:9998,
        overflowY:'auto', overscrollBehavior:'contain', background:'rgba(15,18,22,.48)',
        padding:'max(18px, env(safe-area-inset-top)) 18px max(18px, env(safe-area-inset-bottom))' }}>
        <div ref={dialogRef} role="dialog" aria-modal="true" aria-label="CLAP account"
          onClick={(event)=>event.stopPropagation()}
          style={{ minHeight:'100%', display:'grid', alignItems:'safe center', justifyItems:'center' }}>
          <ClaIdentityPanel onAuthenticated={onClose} onClose={onClose} />
        </div>
      </div>
    );
  }

  // ─── Auth bar — sign-in button or user menu ──────────────────────────
  function ClaAuthBar({ accent = '#16110e' }){
    const { user, loading, signOut } = useClaSession();
    const teamsApi = useClaTeams();
    // v6 tier gate (CHANGES-FOR-SIGNOFF §1): a 2nd+ team is Pro. Existing teams
    // are never hidden or locked — only ADDING another one is gated. Guarded so
    // the bar still works on pages without the v6 tokens loaded.
    const { tier } = useClaTier();
    const multiTeamAllowed = (typeof v6Allows === 'function') ? v6Allows(tier, 'multiTeam') : true;
    const addTeamLocked = !multiTeamAllowed && teamsApi.teams.length >= 1;
    const [showLogin, setShowLogin] = React.useState(false);
    const [menuOpen, setMenuOpen] = React.useState(false);
    const [accountOpen, setAccountOpen] = React.useState(false); // v7 P4 — real Account sheet
    const ref = React.useRef(null);
    const btnRef = React.useRef(null);
    // Outside-click / scroll-close is handled by ClaPortalMenu below (the menu is
    // rendered in a body portal so it isn't clipped by the overflow:hidden shell —
    // CLA flag 035c1176).

    if (loading) return null;
    if (!user) {
      return (
        <React.Fragment>
          <button onClick={()=>setShowLogin(true)}
            style={{ height: 30, padding:'0 14px', border:'.5px solid rgba(0,0,0,.16)', borderRadius: 8,
              background:'#fff', cursor:'pointer', fontFamily:'inherit', fontSize: 12.5, fontWeight: 600,
              color:'#1d1d1f' }}>
            Sign in
          </button>
          {showLogin && <ClaSignInModal onClose={()=>setShowLogin(false)} />}
        </React.Fragment>
      );
    }
    const initial = (user.email || '?').charAt(0).toUpperCase();
    return (
      <div ref={ref} style={{ position:'relative' }}>
        <button ref={btnRef} onClick={()=>setMenuOpen((o)=>!o)} title={user.email}
          style={{ width: 30, height: 30, borderRadius:'50%', background: accent, color:'#fff',
            display:'flex', alignItems:'center', justifyContent:'center', border: 0, cursor:'pointer',
            fontFamily:'inherit', fontSize: 12, fontWeight: 700, letterSpacing:'.04em' }}>
          {initial}
        </button>
        {/* CLA flag 035c1176 — body-portal menu (was position:absolute, clipped by the overflow:hidden shell) */}
        {/* v7 P3 — the menu slims to ACCOUNT concerns only (menu body from V7_AuthBar,
            v7-account.jsx). Team switch / ⚙ manage / ＋ add team moved to the
            identity-line team chip (planner-v7-header.jsx). */}
        <ClaPortalMenu open={menuOpen} onClose={()=>setMenuOpen(false)} anchorRef={btnRef} align="right" width={220} style={{ padding: 5 }}>
            <div style={{ padding:'8px 10px', fontSize: 11.5, color:'rgba(0,0,0,.55)' }}>
              Signed in as <div style={{ color:'#1d1d1f', fontWeight: 600, marginTop: 2, fontSize: 12.5, whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>{user.email}</div>
            </div>
            <div style={{ height: 1, background:'rgba(0,0,0,.08)', margin:'4px 0' }} />
            <button type="button" data-ux="menu-account"
              onClick={()=>{
                setMenuOpen(false);
                // v8.56 (settings review, Tim: "one settings section and control
                // everything from there"): this lands in Settings ▸ Account — the
                // ONE section — instead of the old standalone sheet whose contents
                // had drifted from it. On pages without the Settings surface
                // (/dashboard, /rotation…) it navigates to /app carrying the zone.
                const onApp = window.location.pathname.replace(/\.html$/, '').replace(/\/$/, '') === '/app';
                if (onApp && typeof v7OpenSettings === 'function') v7OpenSettings('account');
                else window.location.assign('/app?settings=account');
              }}
              style={{ ...menuItemStyle(), width:'100%', textAlign:'left', background:'transparent', border: 0,
                cursor:'pointer', fontFamily:'inherit', fontWeight: 600 }}>
              Account &amp; settings
            </button>
            <div style={{ height: 1, background:'rgba(0,0,0,.08)', margin:'4px 0' }} />
            <a href="/dashboard" data-ux="menu-dashboard" style={menuItemStyle()}>Dashboard</a>
            <a href="/app" data-ux="menu-builder" style={menuItemStyle()}>Builder</a>
            <div style={{ height: 1, background:'rgba(0,0,0,.08)', margin:'4px 0' }} />
            <button onClick={()=>{ setMenuOpen(false); signOut(); }} data-ux="menu-signout"
              style={{ ...menuItemStyle(), width:'100%', textAlign:'left', background:'transparent', border:0, cursor:'pointer', color:'#c0492c', fontFamily:'inherit' }}>
              Sign out
            </button>
        </ClaPortalMenu>
        {accountOpen && typeof V7_AccountSheet !== 'undefined' && ReactDOM.createPortal(
          <div onClick={()=>setAccountOpen(false)}
            style={{ position:'fixed', inset: 0, zIndex: 10001, background:'rgba(20,20,24,.4)', backdropFilter:'blur(3px)',
              display:'flex', alignItems:'center', justifyContent:'center', padding: 28 }}>
            <div onClick={(e)=>e.stopPropagation()}>
              <V7_AccountSheet accent={accent} onClose={()=>setAccountOpen(false)}
                onUpgrade={()=>{ setAccountOpen(false); if (typeof v6OpenUpgrade === 'function') v6OpenUpgrade(); }} />
            </div>
          </div>,
          document.body
        )}
      </div>
    );
  }
  function menuItemStyle(){
    return { display:'block', padding:'7px 10px', fontSize: 12.5, color:'#1d1d1f',
      textDecoration:'none', borderRadius: 6, lineHeight: 1.3 };
  }

  // Coach Tim 2026-07-10: film timelines saved in the Coach Tagger against a team
  // activity (cla_tagger_projects.activity_key — 'practice:<id>' for practice sheets,
  // 'calendar:<event-id>' for calendar games/events (legacy
  // 'cal:<date>:<SLUG>' remains readable); team-read RLS lets members SELECT
  // attached rows). Practice sheets AND game tabs show a chip per coach timeline.
  async function listActivityTimelines(teamId, activityKey){
    if (!sb || !teamId || !activityKey) return [];
    const keys = (Array.isArray(activityKey) ? activityKey : [activityKey]).filter(Boolean);
    let query = sb.from('cla_tagger_projects')
      .select('id, owner_label, updated_at, data')
      .eq('team_id', teamId)
      .eq('deleted', false)
      .order('updated_at', { ascending: false });
    query = keys.length === 1 ? query.eq('activity_key', keys[0]) : query.in('activity_key', keys);
    const { data, error } = await query;
    if (error) { console.warn(error); return []; }
    return data || [];
  }
  async function listPracticeTimelines(teamId, practiceId){
    if (!practiceId) return [];
    return listActivityTimelines(teamId, 'practice:' + practiceId);
  }
  // Every visible tagger timeline for a team (own rows + team-attached rows) —
  // feeds the Drill Bank's per-drill film versions (Tim 2026-07-10).
  async function listTeamTimelines(teamId){
    if (!sb || !teamId) return [];
    const { data, error } = await sb.from('cla_tagger_projects')
      .select('id, name, owner_label, activity_key, updated_at, data')
      .eq('team_id', teamId)
      .eq('deleted', false)
      .order('updated_at', { ascending: false })
      .limit(40);
    if (error) { console.warn(error); return []; }
    return data || [];
  }

  // Box-score files (Tim 2026-07-10) live in the private cla-team-clips bucket
  // under <teamId>/boxscores/… — same team-member storage RLS as shared clips.
  // Opened via short-lived signed URLs; the calendar event stores only the path.
  async function uploadBoxScore(teamId, eventId, file){
    if (!sb) throw new Error('Not signed in');
    const safe = String(file.name || 'boxscore').replace(/[^\w.\-]+/g, '_').slice(-80);
    const path = teamId + '/boxscores/' + eventId + '-' + safe;
    const { error } = await sb.storage.from('cla-team-clips').upload(path, file, { upsert: true, contentType: file.type || undefined });
    if (error) throw error;
    return path;
  }
  // Generic signer for any team file in the private cla-team-clips bucket
  // (RLS: team members only, path <teamId>/…). 24h TTL.
  async function claSignTeamFile(path){
    if (!sb || !path) return '';
    const { data, error } = await sb.storage.from('cla-team-clips').createSignedUrl(path, 60 * 60 * 24);
    if (error) { console.warn(error); return ''; }
    return (data && data.signedUrl) || '';
  }
  async function signBoxScore(path){ return claSignTeamFile(path); }

  // Vocab-term images (Tim 2026-07-19) — stored at <teamId>/vocab/<termId>/…,
  // referenced from the term object as images:[{path,name}] in cla_team_vocab.
  async function uploadVocabImage(teamId, termId, file){
    if (!sb) throw new Error('Not signed in');
    const safe = String(file.name || 'image').replace(/[^\w.\-]+/g, '_').slice(-60);
    const path = teamId + '/vocab/' + termId + '/' + Date.now() + '-' + safe;
    const { error } = await sb.storage.from('cla-team-clips').upload(path, file, { upsert: true, contentType: file.type || undefined });
    if (error) throw error;
    return path;
  }
  async function removeVocabImage(path){
    if (!sb || !path) return false;
    const { error } = await sb.storage.from('cla-team-clips').remove([path]);
    if (error) { console.warn(error); return false; }
    return true;
  }

  // Imported play images (v8.66, .fdb-spike fallback path) — FastDraw exports
  // per-frame images/PDFs; coaches import those as plays. Stored at
  // <teamId>/plays/… in the SAME private cla-team-clips bucket (team-member
  // storage RLS); the drill stores only the PATH (drill.diagramImg), signed
  // on render via claSignTeamFile.
  async function uploadPlayImage(teamId, blob, name){
    if (!sb) throw new Error('Not signed in');
    const safe = String(name || 'play').replace(/[^\w.\-]+/g, '_').slice(-60);
    const path = teamId + '/plays/' + Date.now() + '-' + Math.random().toString(36).slice(2, 8) + '-' + safe + '.jpg';
    const { error } = await sb.storage.from('cla-team-clips').upload(path, blob, { upsert: false, contentType: 'image/jpeg' });
    if (error) throw error;
    return path;
  }

  // Team docs (v8.111): PDFs/files attached to scout dossiers and practice
  // sheets. Stored at <teamId>/<feature>/<entityId>/docs/<ts>-<name> in the
  // private cla-team-clips bucket (team-member storage RLS — first path
  // segment = teamId). Rows keep only {path,name,size,type,added}; opens mint
  // a 24h signed URL at click time via claSignTeamFile (never stored).
  async function claUploadTeamDoc(teamId, feature, entityId, file){
    if (!sb) throw new Error('Not signed in');
    const safe = String(file.name || 'file').replace(/[^\w.\-]+/g, '_').slice(-80);
    const path = teamId + '/' + feature + '/' + entityId + '/docs/' + Date.now() + '-' + safe;
    const { error } = await sb.storage.from('cla-team-clips').upload(path, file, { upsert: false, contentType: file.type || undefined });
    if (error) throw error;
    return { path, name: file.name || safe, size: file.size || 0, type: file.type || '', added: new Date().toISOString() };
  }
  async function claRemoveTeamDoc(path){
    if (!sb || !path) return false;
    const { error } = await sb.storage.from('cla-team-clips').remove([path]);
    if (error) { console.warn(error); return false; }
    return true;
  }
  // Team playlists for the attach pickers (scout dossier Film tab, practice
  // sheet). Mirrors the tagger's team-playlist read but INCLUDES own rows.
  // NEVER select the full data blob — playlist rows carry clip snapshots and
  // telestrations that can be megabytes; owner_label is the only data-> field.
  async function claListTeamPlaylists(teamId){
    if (!sb || !teamId) return [];
    const { data, error } = await sb.from('cla_tagger_playlists')
      .select('id, name, user_id, updated_at, owner:data->>owner_label')
      .eq('team_id', teamId)
      .order('updated_at', { ascending: false })
      .limit(50);
    if (error) { console.warn(error); return []; }
    return data || [];
  }

  // ─── Multi-staff (v8.44, v9 P4): members · invites ───────────────────
  // Membership itself is RLS (cla_team_members + cla_is_team_member); these
  // helpers give it a product surface. All additive — no existing read/write
  // path changes. Invites are single-use codes, 14-day expiry, owner-created.
  async function claListTeamMembers(teamId){
    const u = await claGetUser(); if (!u || !teamId || !sb) return [];
    const { data, error } = await sb.rpc('cla_team_member_list', { tid: teamId });
    if (error){ console.warn('claListTeamMembers', error.message); return []; }
    return data || [];
  }
  async function claCreateTeamInvite(teamId, role){
    const u = await claGetUser(); if (!u || !teamId || !sb) return null;
    const alpha = 'abcdefghjkmnpqrstuvwxyz23456789';
    const code = Array.from(crypto.getRandomValues(new Uint8Array(10))).map((b)=>alpha[b % alpha.length]).join('');
    const { error } = await sb.from('cla_team_invites')
      .insert({ team_id: teamId, code, role: role === 'tagger' ? 'tagger' : 'head_coach', created_by: u.id });
    if (error){ console.warn('claCreateTeamInvite', error.message); return null; }
    return code;
  }
  async function claAcceptTeamInvite(code){
    if (!sb) return { error: 'offline' };
    const { data, error } = await sb.rpc('cla_team_invite_accept', { p_code: String(code || '').trim() });
    if (error) return { error: error.message };
    return data || {};
  }
  async function claRemoveTeamMember(teamId, userId){
    if (!sb || !teamId || !userId) return false;
    const { data, error } = await sb.from('cla_team_members')
      .delete().eq('team_id', teamId).eq('user_id', userId).select('user_id');
    return !error && Array.isArray(data) && data.length > 0;   // 0 rows = RLS refused (not owner)
  }

  Object.assign(window, {
    claListTeamMembers, claCreateTeamInvite, claAcceptTeamInvite, claRemoveTeamMember,
    uploadBoxScore, signBoxScore, claSignTeamFile, uploadVocabImage, removeVocabImage, uploadPlayImage,
    claUploadTeamDoc, claRemoveTeamDoc, claListTeamPlaylists,   // team docs + playlist attach (v8.111)
    listPracticeTimelines, listActivityTimelines, listTeamTimelines,
    useClaSession, useClaTier, ClaAuthBar, ClaSignInModal, ClaIdentityPanel, claResolveLogin,
    signInWithPassword, signUpWithPassword, sendMagicLink, sendPasswordReset,
    loadUserData, saveUserData,
    listUserPractices, listUserPracticesAllTeams, saveUserPractice, deleteUserPractice, loadUserPractice,
    listLoadSessions, getLoadSessionDetail, teamHasLoadData, getLoadSyncStatus, requestLoadSync, listDrillDev, answerDrillDev,
    CLA_PROVIDERS, listDataConnections, storeDataConnection, forgetDataConnection,
    useClaTeams, listTeams, createTeam, updateTeam, deleteTeam,
    getTeamVocab, saveTeamVocab, saveTeamEmphasis, claVocabChanged,
    getCurrentTeamId, setCurrentTeamId,
    claReplayQueue,   // offline write-queue drain (also runs on 'online' + boot)
    claQueue,         // READ-ONLY view of that queue (its length is the truth the P2 save-state chip rides — it never writes)
    claGetUser,       // LOCAL session read — rotation page (ROT_getUser) uses it so an offline reopen keeps the session
    claListRotations, claSaveRotation,   // offline-aware rotation persistence (v8.45; online CAS-or-copy v8.3)
    claMergeScoutPatch,                  // scouts.jsx CAS-miss rebase (v8.3, MC 28adecc3)
    claOfflineDeleteToast,               // honest "deletes don't queue" toast for offline delete attempts
    claInstalled, claOpenApp, claAppLinkProps,   // in-app nav — never _blank an app surface
    claSync: { announce: claSyncAnnounce, subscribe: claSyncSubscribe, windowId: claSyncWindowId },   // cross-window sync bus (Wave 3)
  });
})();
