<script>
  import { onMount } from 'svelte';
  import { goto } from '$app/navigation';
  import { page } from '$app/stores';
  import { getJSON, postJSON, delJSON } from '$lib/api.js';
  import { auth, refresh } from '$lib/auth.svelte.js';
  import EmojiPicker from '$lib/EmojiPicker.svelte';

  let stats = $state(null);
  let feedback = $state([]);
  let error = $state('');

  // Analytics window (days). Windowed metrics — visitors, retention, funnel,
  // sharing, daily trend, top lists — rescale to this; corpus totals don't.
  let range = $state(30);
  async function loadStats() {
    stats = await getJSON('/api/admin/stats?days=' + range);
  }
  async function setRange(n) {
    if (n === range) return;
    range = n;
    try { await loadStats(); } catch { error = "Couldn't load stats."; }
  }

  onMount(async () => {
    if (!auth.ready) await refresh();
    if (!auth.user || !auth.user.is_admin) {
      goto('/', { replaceState: true });
      return;
    }
    try {
      // Load all panels in PARALLEL — these were sequential awaits, so the page
      // paid the (uncached, origin) round-trip six times back-to-back. One batch
      // instead of a chain.
      const [, fb, cand, wp, ce, ws, bq] = await Promise.all([
        loadStats(),
        getJSON('/api/admin/feedback'),
        getJSON('/api/admin/candidates'),
        getJSON('/api/admin/word/pool'),
        getJSON('/api/admin/client-errors'),
        getJSON('/api/admin/wordsearch/themes'),
        getJSON('/api/admin/bloom/reports').catch(() => null),
      ]);
      feedback = fb;
      candidates = cand;
      wpPool = wp;
      clientErrors = ce;
      wsThemes = ws;
      if (bq) { bloomReports = bq.reports || []; bloomOverrides = bq.overrides || []; }
    } catch {
      error = "Couldn't load stats.";
    }
  });

  // --- Publishing Desk: build the X share queue, write blurbs, open in X ---------
  let pubItems = $state([]);        // active: queued/drafting/opened
  let pubArchived = $state([]);     // recoverable tray: skipped/snoozed
  let pubBuilding = $state(false);
  let pubLast = $state(null);       // last build summary {added, active, ranked_by}
  let pubError = $state(null);      // last build error (e.g. non-model failure), surfaced to the user
  let pubLoaded = $state(false);
  let pubHandleInput = $state({});  // keyed "itemId:entity" → handle being saved
  let pubPollTimer = null;
  const pubDraftTimers = {};

  async function loadPublish() {
    try {
      const r = await getJSON('/api/admin/publishing/queue?archived=true');
      pubBuilding = r.building; pubLast = r.last; pubError = r.error || null;
      const items = r.items || [];
      pubItems = items.filter((i) => ['queued', 'drafting', 'opened'].includes(i.status));
      pubArchived = items.filter((i) => ['skipped', 'snoozed'].includes(i.status));
      pubLoaded = true;
    } catch { /* leave as-is */ }
  }
  function pollPublish() {
    clearTimeout(pubPollTimer);
    pubPollTimer = setTimeout(async () => {
      await loadPublish();
      if (pubBuilding) pollPublish();
    }, 1500);
  }
  async function buildPublish() {
    pubBuilding = true;
    try { await postJSON('/api/admin/publishing/build', {}); pollPublish(); }
    catch { pubBuilding = false; }
  }
  function onDraftInput(item) {
    clearTimeout(pubDraftTimers[item.id]);
    pubDraftTimers[item.id] = setTimeout(() => {
      postJSON(`/api/admin/publishing/${item.id}/draft`, { draft_text: item.draft_text || '' }).catch(() => {});
    }, 700);   // debounced autosave
  }
  async function pubSetStatus(item, status, extra = {}) {
    clearTimeout(pubDraftTimers[item.id]);   // cancel a pending autosave so it can't land after the transition
    try { await postJSON(`/api/admin/publishing/${item.id}/status`, { status, ...extra }); await loadPublish(); }
    catch (e) { item._err = e?.message || 'Action failed.'; }
  }
  // "Posted" is a confirm step: the user may have edited the text inside X, so we let
  // them correct the saved text + paste the real post URL before recording it.
  async function pubConfirmPosted(item) {
    await pubSetStatus(item, 'posted', {
      final_text: item.draft_text || '',
      post_url: (item._postUrl || '').trim() || null,
    });
  }
  async function pubRestore(item) {
    try { await postJSON(`/api/admin/publishing/${item.id}/restore`, {}); await loadPublish(); } catch { /* ignore */ }
  }
  function markOpened(item) {
    // fire-and-forget so the X tab opens immediately from the click (no awaited call
    // before navigation, or the browser may block the popup)
    if (item.status !== 'opened') {
      postJSON(`/api/admin/publishing/${item.id}/status`, { status: 'opened' }).catch(() => {});
      item.status = 'opened';
    }
  }
  async function pubSaveHandle(item, entity) {
    const h = (pubHandleInput[`${item.id}:${entity}`] || '').trim();
    if (!h) return;
    try { await postJSON('/api/admin/publishing/handles', { entity_name: entity, handle: h }); await loadPublish(); }
    catch (e) { item._err = e?.message || 'Bad handle.'; }
  }
  function insertHandle(item, handle) {
    item.draft_text = ((item.draft_text || '').trimEnd() + ' ' + handle + ' ').trimStart();
    onDraftInput(item);
  }
  // Emoji picker: a full searchable set (EmojiPicker.svelte) inserted at the caret so it
  // drops in mid-sentence cleanly. The textarea also still accepts your OS picker.
  let pubEmojiOpen = $state(null);   // id of the card whose palette is open
  const pubTextareas = {};           // id -> <textarea> element, for caret-aware insert
  function regTextarea(node, id) {
    pubTextareas[id] = node;
    return { destroy() { if (pubTextareas[id] === node) delete pubTextareas[id]; } };
  }
  function insertEmoji(item, emoji) {
    const el = pubTextareas[item.id];
    const cur = item.draft_text || '';
    if (el && typeof el.selectionStart === 'number') {
      const s = el.selectionStart, e = el.selectionEnd;
      item.draft_text = cur.slice(0, s) + emoji + cur.slice(e);
      const pos = s + emoji.length;
      requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos); });   // caret after the emoji
    } else {
      item.draft_text = cur + emoji;
    }
    onDraftInput(item);
  }
  function snoozeDate(days) {
    return new Date(Date.now() + days * 86400000).toISOString().slice(0, 19).replace('T', ' ');
  }
  function intentURL(item) {
    return `https://x.com/intent/tweet?text=${encodeURIComponent(item.draft_text || '')}&url=${encodeURIComponent(item.share_url || '')}`;
  }
  function findOnX(entity) {
    return `https://x.com/search?q=${encodeURIComponent(entity)}&f=user`;
  }
  // Weighted length ≈ X's counting: any URL = 23. The share link X auto-appends adds
  // ~24 (a space + the 23-char t.co link), so the live budget reflects the real tweet.
  function weightedLen(t) {
    const urls = (t || '').match(/https?:\/\/\S+/g) || [];
    return (t || '').replace(/https?:\/\/\S+/g, '').length + urls.length * 23;
  }
  function pubRemaining(item) {
    return 280 - (weightedLen(item.draft_text) + 24);
  }
  // Named entities (for Find-on-X + save-a-handle); the verified ones already show as
  // chips above, so this is mostly the not-yet-known ones.
  function namedEntities(item) {
    return (item.entities || []).slice(0, 5);
  }

  $effect(() => { if (section === 'publish' && !pubLoaded) loadPublish(); });

  let clientErrors = $state([]);

  // "X ago" from a UTC 'YYYY-MM-DD HH:MM:SS' created_at — so a morning glance shows
  // whether the newest error is fresh or just last night.
  function ago(ts) {
    if (!ts) return '—';
    const d = new Date(ts.replace(' ', 'T') + 'Z');
    const s = Math.max(0, (Date.now() - d.getTime()) / 1000);
    if (s < 90) return 'just now';
    if (s < 5400) return Math.round(s / 60) + 'm ago';
    if (s < 129600) return Math.round(s / 3600) + 'h ago';
    return Math.round(s / 86400) + 'd ago';
  }
  // Classify a beacon by layer so one scary count doesn't conflate a 30s HTML stall
  // (incident) with a 5s app boot or a post-deploy chunk miss (benign).
  function errType(e) {
    if (e.bot) return { k: 'bot', label: 'bot' };
    const r = e.reason || '';
    if (/preload|dynamically imported|failed to fetch/i.test(r)) return { k: 'preload', label: 'preload' };
    const m = r.match(/^boot-slow.*?\bhtml\s+(\d+)ms/i);
    if (m) return Number(m[1]) >= 3000 ? { k: 'html', label: 'html-slow' } : { k: 'app', label: 'app-slow' };
    if (/^boot-slow/i.test(r)) return { k: 'app', label: 'app-slow' };
    return { k: 'runtime', label: 'runtime' };
  }

  // --- Games: Bloom word curation (reports queue + allow/block overrides) ---
  let bloomReports = $state([]);
  let bloomOverrides = $state([]);
  let bloomOvrWord = $state('');
  async function loadBloomQueue() {
    try { const r = await getJSON('/api/admin/bloom/reports'); bloomReports = r.reports || []; bloomOverrides = r.overrides || []; }
    catch { /* ignore */ }
  }
  async function resolveBloom(id, action) {
    try { await postJSON('/api/admin/bloom/reports/' + id, { action }); await loadBloomQueue(); } catch { /* ignore */ }
  }
  async function addBloomOverride(action) {
    const w = bloomOvrWord.trim().toLowerCase();
    if (!w) return;
    try { await postJSON('/api/admin/bloom/overrides', { word: w, action }); bloomOvrWord = ''; await loadBloomQueue(); }
    catch { /* ignore */ }
  }
  async function removeBloomOverride(w) {
    try { await delJSON('/api/admin/bloom/overrides/' + encodeURIComponent(w)); await loadBloomQueue(); } catch { /* ignore */ }
  }

  // --- Small Joys: On This Day / Quote / Word pools ---
  let joyKind = $state('onthisday');
  let joyItems = $state([]);
  let joyLoading = $state(false);
  let joyAdd = $state({});
  let joyAddErr = $state('');
  async function loadJoys(k) {
    joyLoading = true;
    try { joyItems = await getJSON('/api/admin/joys/' + k); } catch { joyItems = []; }
    joyLoading = false;
  }
  async function joyMutate(id, action, fields) {
    try { await postJSON(`/api/admin/joys/${joyKind}/items/${id}`, { action, fields }); await loadJoys(joyKind); } catch { /* ignore */ }
  }
  async function joyAddSubmit() {
    joyAddErr = '';
    try { await postJSON(`/api/admin/joys/${joyKind}/add`, joyAdd); joyAdd = {}; await loadJoys(joyKind); }
    catch (e) { joyAddErr = e?.message || 'Could not add'; }
  }
  async function joyRepick() {
    try { await postJSON(`/api/admin/joys/${joyKind}/repick`, {}); } catch { /* ignore */ }
  }
  $effect(() => { const k = joyKind; if (section === 'joys') loadJoys(k); });

  // --- Games: Daily Word pool ---
  let wpWord = $state('');
  let wpResult = $state(null);   // lookup result for the current input
  let wpPool = $state(null);     // { '5': {curated, added[], total}, '6': {...} }
  let wpMsg = $state('');
  let wpTimer;
  const canAddWord = $derived(!!wpResult && !!wpResult.variant && wpResult.in_dictionary && !wpResult.in_pool);

  function onWpInput() {
    wpMsg = '';
    clearTimeout(wpTimer);
    const w = wpWord.trim();
    if (!w) { wpResult = null; return; }
    wpTimer = setTimeout(async () => {
      try { wpResult = await getJSON('/api/admin/word/lookup?word=' + encodeURIComponent(w)); }
      catch { wpResult = null; }
    }, 200);
  }
  async function addWord() {
    if (!canAddWord) return;
    try {
      const res = await postJSON('/api/admin/word/pool', { word: wpWord.trim() });
      wpPool = res.pool; wpWord = ''; wpResult = null; wpMsg = `Added “${res.word}” to the ${res.variant}-letter pool.`;
    } catch (e) { wpMsg = e.message || 'Could not add that word.'; }
  }
  // Refresh the live lookup tag after a pool mutation, so the button flips Remove↔Restore↔Add.
  async function refreshLookup() {
    const w = wpWord.trim();
    if (!w) { wpResult = null; return; }
    try { wpResult = await getJSON('/api/admin/word/lookup?word=' + encodeURIComponent(w)); }
    catch { wpResult = null; }
  }
  async function removeWord(w) {
    try {
      wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w));
      wpMsg = `Removed “${w}” from future puzzles (today’s answer is already set). Restore it any time below.`;
      await refreshLookup();
    } catch { /* ignore */ }
  }
  async function restoreWord(w) {
    try {
      wpPool = await postJSON('/api/admin/word/pool/restore', { word: w });
      wpMsg = `Restored “${w}” to the pool.`;
      await refreshLookup();
    } catch { /* ignore */ }
  }

  // --- Games: bulk import (paste or .txt/.csv upload) ---
  let wpImportText = $state('');
  let wpImportResult = $state(null);   // { counts, added[], duplicates[], rejected[] }
  let wpImporting = $state(false);
  function onImportFile(e) {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => {
      const text = String(reader.result || '');
      wpImportText = wpImportText.trim() ? wpImportText.trim() + '\n' + text : text;
    };
    reader.readAsText(file);
    e.target.value = '';   // allow re-picking the same file
  }
  async function importWords() {
    if (!wpImportText.trim() || wpImporting) return;
    wpImporting = true; wpImportResult = null;
    try {
      const res = await postJSON('/api/admin/word/pool/import', { text: wpImportText });
      wpPool = res.pool; wpImportResult = res; wpImportText = '';
      await refreshLookup();
    } catch (e) { wpImportResult = { error: e.message || 'Import failed.' }; }
    finally { wpImporting = false; }
  }

  // --- Games: Word Search themes ---
  const WS_NEEDED = 28;
  let wsThemes = $state([]);
  let wsTheme = $state('');
  let wsWordsText = $state('');
  let wsEditId = $state(null);
  let wsMsg = $state('');
  let wsSuggesting = $state(false);

  function parseWords(text) {
    return [...new Set((text || '').split(/[\s,]+/).map((w) => w.trim().toUpperCase()).filter(Boolean))];
  }
  let wsParsed = $derived(parseWords(wsWordsText));
  let wsValid = $derived(wsParsed.filter((w) => /^[A-Z]{4,8}$/.test(w)));
  let wsInvalid = $derived(wsParsed.filter((w) => !/^[A-Z]{4,8}$/.test(w)));

  async function loadWsThemes() { try { wsThemes = await getJSON('/api/admin/wordsearch/themes'); } catch { /* ignore */ } }
  async function suggestWsWord() {
    if (!wsTheme.trim()) { wsMsg = 'Enter a theme first.'; return; }
    wsSuggesting = true; wsMsg = '';
    try {
      const r = await postJSON('/api/admin/wordsearch/suggest', { theme: wsTheme.trim(), existing: wsValid });
      wsWordsText = (wsWordsText.trim() + ' ' + r.word).trim();
    } catch (e) { wsMsg = e.message || 'No suggestion available.'; }
    finally { wsSuggesting = false; }
  }
  async function saveWsTheme() {
    try {
      const res = await postJSON('/api/admin/wordsearch/themes', { theme: wsTheme.trim(), words: wsValid, id: wsEditId });
      wsThemes = res.themes; wsMsg = `Saved “${wsTheme.trim()}” (${res.count} words).`;
      wsTheme = ''; wsWordsText = ''; wsEditId = null;
    } catch (e) { wsMsg = e.message || 'Could not save.'; }
  }
  function editWsTheme(t) { wsEditId = t.id; wsTheme = t.theme; wsWordsText = t.words.join(' '); wsMsg = ''; }
  function cancelWsEdit() { wsEditId = null; wsTheme = ''; wsWordsText = ''; wsMsg = ''; }
  async function removeWsTheme(t) { try { wsThemes = await delJSON('/api/admin/wordsearch/themes/' + t.id); } catch { /* ignore */ } }

  const TABS = [
    { key: 'overview', label: 'Overview' },
    { key: 'content', label: 'Content' },
    { key: 'sources', label: 'Sources' },
    { key: 'audience', label: 'Audience' },
    { key: 'feedback', label: 'Feedback' },
    { key: 'games', label: 'Games' },
    { key: 'joys', label: 'Small Joys' },
    { key: 'publish', label: 'Publishing' },
  ];
  const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
  // Unknown ?section= values fall back to Overview so the page never renders blank.
  let section = $derived(
    VALID_SECTIONS.has($page.url.searchParams.get('section')) ? $page.url.searchParams.get('section') : 'overview'
  );

  function fdate(s) {
    try { return new Date(s + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }
    catch { return s; }
  }
  // A UTC "YYYY-MM-DD HH:MM:SS" stamp → the viewer's local date + time.
  function fwhen(s) {
    if (!s) return 'now';
    const d = new Date(s.replace(' ', 'T') + 'Z');
    if (isNaN(d)) return s;
    return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
  }

  let sources = $derived(stats?.sources ?? []);
  // status falls back to the legacy active flag during the migration window.
  const sstatus = (s) => s.status || (s.active ? 'active' : 'paused');
  // A 429 rest window that hasn't elapsed — polite wait, not breakage.
  function rateLimited(s) {
    if (!s.retry_after_at) return false;
    const d = new Date(s.retry_after_at.replace(' ', 'T') + 'Z');
    return !isNaN(d) && d > new Date();
  }
  let healthy = $derived(sources.filter((s) => sstatus(s) === 'active' && !s.failures && !s.review_flag).length);
  let resting = $derived(sources.filter((s) => sstatus(s) === 'active' && s.failures > 0).length);
  let flagged = $derived(sources.filter((s) => s.review_flag).length);
  let paused = $derived(sources.filter((s) => sstatus(s) === 'paused').length);
  let retired = $derived(sources.filter((s) => sstatus(s) === 'retired').length);

  // Sources table filter + free-text search (name / category / feed URL).
  let srcFilter = $state('all');
  let srcSearch = $state('');
  let shownSources = $derived(
    (srcFilter === 'healthy' ? sources.filter((s) => sstatus(s) === 'active' && !s.failures && !s.review_flag)
      : srcFilter === 'resting' ? sources.filter((s) => sstatus(s) === 'active' && s.failures > 0)
        : srcFilter === 'flagged' ? sources.filter((s) => s.review_flag)
          : srcFilter === 'paused' ? sources.filter((s) => sstatus(s) === 'paused')
            : srcFilter === 'retired' ? sources.filter((s) => sstatus(s) === 'retired')
              : sources
    ).filter((s) => {
      const q = srcSearch.trim().toLowerCase();
      if (!q) return true;
      return `${s.name} ${s.category || ''} ${s.feed_url || ''} ${s.homepage_url || ''}`.toLowerCase().includes(q);
    })
  );

  // Lifecycle (status: active/paused/retired) keeps `active` mirrored server-side;
  // visibility hides existing articles. Both optimistic with revert-on-failure.
  async function setStatus(s, status) {
    const prev = { status: s.status, active: s.active };
    s.status = status; s.active = status === 'active' ? 1 : 0;
    try { await postJSON(`/api/admin/sources/${s.id}/status`, { status }); }
    catch { s.status = prev.status; s.active = prev.active; }
  }
  async function toggleVisible(s) {
    const next = !s.content_visible;
    if (!next && !confirm(`Hide all of “${s.name}”’s articles from the public feed and brief?\n(Reversible — nothing is deleted.)`)) return;
    const prev = s.content_visible;
    s.content_visible = next ? 1 : 0;
    try { await postJSON(`/api/admin/sources/${s.id}/visibility`, { visible: next }); }
    catch { s.content_visible = prev; }
  }
  // Flagging opens a small inline popover for the reason; clearing is immediate.
  let flagging = $state(null); // the source being flagged
  let flagReason = $state('');
  function openFlag(s) { flagging = s; flagReason = ''; }
  function cancelFlag() { flagging = null; flagReason = ''; }
  async function confirmFlag() {
    const s = flagging;
    const reason = flagReason.trim() || null;
    s.review_flag = 1; s.review_reason = reason; // optimistic
    flagging = null; flagReason = '';
    try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: true, reason }); }
    catch { s.review_flag = 0; s.review_reason = null; }
  }
  async function clearReview(s) {
    const prevR = s.review_reason;
    s.review_flag = 0; s.review_reason = null; // optimistic
    try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: false }); }
    catch { s.review_flag = 1; s.review_reason = prevR; }
  }
  // Read-only spot-check of a live source — never mutates source state.
  async function checkSource(s) {
    s._checking = true; s._checkErr = ''; s._check = null;
    try { s._check = await postJSON(`/api/admin/sources/${s.id}/preview`); }
    catch (e) { s._checkErr = e?.message || 'Could not preview.'; }
    finally { s._checking = false; }
  }
  function dismissCheck(s) { s._check = null; s._checkErr = ''; }

  // --- Source article inspector: the real articles behind the metrics ---
  async function toggleArticles(s) {
    if (s._showArts) { s._showArts = false; return; }
    s._showArts = true;
    if (!s._arts) await loadArticles(s, 'all', true);
  }
  async function loadArticles(s, filter, reset) {
    s._artBusy = true; s._artErr = '';
    if (reset) { s._artFilter = filter; s._artOffset = 0; s._arts = []; }
    try {
      const q = `filter=${s._artFilter}&limit=25&offset=${s._artOffset}`;
      const r = await getJSON(`/api/admin/sources/${s.id}/articles?${q}`);
      s._arts = reset ? r.articles : [...(s._arts || []), ...r.articles];
      if (r.summary) s._artSummary = r.summary;
      s._artMore = r.has_more;
      s._artOffset += r.articles.length;
    } catch (e) { s._artErr = e?.message || 'Could not load articles.'; }
    finally { s._artBusy = false; }
  }
  const ART_FILTERS = [['all', 'All'], ['accepted', 'Accepted'], ['rejected', 'Rejected'], ['held', 'Held'], ['no_image', 'No image'], ['duplicates', 'Duplicates']];
  function pwBasis(sum) {
    if (!sum) return '';
    if (sum.paywall_override === 'free') return 'OFF (override)';
    if (sum.paywall_override === 'paywalled') return 'ON (override)';
    return sum.paywalled ? 'ON (domain)' : 'off';
  }
  // Per-source paywall override — corrects a domain-rule false positive/negative
  // and flows into feed/lead/brief ranking + the table's 🔒. Optimistic in place
  // so the inspector panel stays open.
  async function setPaywall(s, override) {
    const ov = override || null;
    try {
      await postJSON(`/api/admin/sources/${s.id}/paywall`, { override: ov });
      const eff = ov === 'free' ? false : ov === 'paywalled' ? true : (s._artSummary?.paywall_domain ?? s.paywalled);
      s.paywall_override = ov; s.paywalled = eff;              // updates the Media-column 🔒
      if (s._artSummary) { s._artSummary.paywall_override = ov; s._artSummary.paywalled = eff; }
    } catch (e) { s._artErr = e?.message || 'Could not set the override.'; }
  }

  // --- Source candidates: supervised "add a source" pipeline ---
  let candidates = $state([]);
  let newFeedUrl = $state('');
  let newFeedName = $state('');
  let addBusy = $state(false);
  let addErr = $state('');
  let pendingCandidates = $derived(candidates.filter((c) => c.status !== 'promoted' && c.status !== 'rejected'));
  let rejectedCandidates = $derived(candidates.filter((c) => c.status === 'rejected'));

  async function addCandidate() {
    const url = newFeedUrl.trim();
    if (!url || addBusy) return;
    addBusy = true; addErr = '';
    try {
      const c = await postJSON('/api/admin/candidates', { feed_url: url, name: newFeedName.trim() || null });
      candidates = [c, ...candidates.filter((x) => x.id !== c.id)];
      newFeedUrl = ''; newFeedName = '';
    } catch (e) {
      addErr = e?.message || "Couldn't preview that feed.";
    } finally {
      addBusy = false;
    }
  }
  // Merge the server-returned candidate (keeps status/updated_at/preview in sync)
  // while preserving the transient UI-only fields (_cat/_activate/_err).
  async function repreviewCandidate(c) {
    c._err = '';
    try { Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/preview`)); }
    catch (e) { c._err = e?.message || 'Re-preview failed.'; }
  }
  // Deep preview runs the REAL classifier on a small sample (~30-60s) — the
  // model's true acceptance view, not the fast heuristic estimate.
  async function deepPreview(c) {
    c._err = ''; c._deep = true;
    try {
      // ~5-7s/item on the LAN model; bound the wait so a model stall can't pin
      // the button on "Deep-checking…" forever.
      const res = await postJSON(`/api/admin/candidates/${c.id}/preview?deep=true`, undefined, { timeout: 180000 });
      Object.assign(c, res, { _deep: false });
    } catch (e) { c._err = e?.message || 'Deep preview failed.'; c._deep = false; }
  }
  function startRename(c) { c._editName = c.name || ''; c._editing = true; c._err = ''; }
  async function saveRename(c) {
    try {
      Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/rename`, { name: (c._editName || '').trim() }), { _editing: false });
    } catch (e) { c._err = e?.message || 'Rename failed.'; }
  }
  async function promoteCandidate(c) {
    c._err = '';
    try {
      const res = await postJSON(`/api/admin/candidates/${c.id}/promote`, {
        default_category: (c._cat || '').trim() || null,
        active: !!c._activate,
      });
      Object.assign(c, res.candidate);
      await loadStats(); // surface the new (paused) source in the table below
    } catch (e) {
      c._err = e?.message || 'Promote failed.';
    }
  }
  async function rejectCandidate(c) {
    try { Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/reject`)); }
    catch { /* leave as-is */ }
  }
  // Codex's operating rule for the Deep-Preview access verdict, shown on hover.
  const ACC_HELP = {
    fine: 'Sample reads fine — judge mainly on content quality.',
    review: 'Click 2–3 example links before promoting — catches false-positive paywalls and bot-blocks (readable in a browser, blocked to us).',
    'reject-ready': 'Domain rule AND the sample agree it’s walled — usually reject, unless it’s a source you want and the examples open fine in your browser.',
  };
  // Send a rejected candidate back to staging for another look (status → suggested).
  async function restoreCandidate(c) {
    try {
      Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/restore`));
      candidates = [...candidates];   // nudge the derived queue/tray to recompute
    } catch { /* leave as-is */ }
  }

  // Feedback inbox: filter + read/unread + delete.
  let fbCat = $state('all'); // 'all' | 'unread' | a category
  let shownFeedback = $derived(
    fbCat === 'all' ? feedback
      : fbCat === 'unread' ? feedback.filter((f) => !f.read_at)
        : feedback.filter((f) => f.category === fbCat)
  );
  let fbCats = $derived([...new Set(feedback.map((f) => f.category))]);
  let unreadCount = $derived(feedback.filter((f) => !f.read_at).length);

  async function markRead(f, read) {
    const prev = f.read_at;
    f.read_at = read ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null; // optimistic
    feedback = [...feedback];
    try {
      await postJSON(`/api/admin/feedback/${f.id}/read`, { read });
    } catch {
      f.read_at = prev; // revert on failure
      feedback = [...feedback];
    }
  }
  async function removeFeedback(f) {
    if (!confirm('Delete this feedback message? This cannot be undone.')) return;
    const snapshot = feedback;
    feedback = feedback.filter((x) => x.id !== f.id); // optimistic
    try {
      await delJSON(`/api/admin/feedback/${f.id}`);
    } catch {
      feedback = snapshot; // revert on failure
    }
  }

  // In-site reply: small admin-only WYSIWYG (contenteditable + execCommand).
  // The editor's HTML is sanitized SERVER-SIDE; the client just collects it.
  let replyingId = $state(null);
  let replyBusy = $state(false);
  let replyErr = $state('');
  let replyEmpty = $state(true);
  let replyEditor = $state(null);
  const SIZE_PX = { small: 13, normal: 15, large: 18, xlarge: 22 };

  function openReply(f) { replyingId = f.id; replyErr = ''; replyEmpty = true; }
  function cancelReply() { replyingId = null; replyErr = ''; }
  function focusEditor(node) { queueMicrotask(() => node.focus()); }
  function onEditorInput() { replyEmpty = !(replyEditor?.textContent || '').trim(); }
  // Paste as plain text — never let rich/pasted HTML into the editor.
  function onPaste(e) {
    e.preventDefault();
    const text = (e.clipboardData || window.clipboardData).getData('text/plain');
    document.execCommand('insertText', false, text);
  }
  // Bold/italic/lists via execCommand; styleWithCSS=false → semantic <b>/<i>/<ul>.
  function cmd(c) {
    replyEditor?.focus();
    try { document.execCommand('styleWithCSS', false, false); } catch { /* not all browsers */ }
    document.execCommand(c, false, null);
    onEditorInput();
  }
  // Font size: wrap the selection in a span with a fixed, whitelisted px.
  function setSize(key) {
    const px = SIZE_PX[key];
    if (!px) return;
    replyEditor?.focus();
    const sel = window.getSelection();
    if (!sel || !sel.rangeCount) return;
    const range = sel.getRangeAt(0);
    if (range.collapsed) return; // need a selection to size
    const span = document.createElement('span');
    span.style.fontSize = px + 'px';
    try { span.appendChild(range.extractContents()); range.insertNode(span); } catch { /* ignore */ }
    sel.removeAllRanges();
    onEditorInput();
  }
  async function sendReply(f) {
    if (replyEmpty || replyBusy) return;
    const html = replyEditor?.innerHTML || '';
    replyBusy = true; replyErr = '';
    try {
      const res = await postJSON(`/api/admin/feedback/${f.id}/reply`, { html });
      f.replies = [...(f.replies || []), res.reply];
      f.read_at = f.read_at || res.reply.sent_at; // server marked it read
      replyingId = null;
    } catch (e) {
      replyErr = e?.message || 'Could not send — your draft is kept.';
    } finally {
      replyBusy = false;
    }
  }

  const SHARE_LABEL = {
    share_ub: 'Copied UB link',
    native_share: 'Native share',
    copy_source: 'Copied source',
    source_click: 'Source clicks (from /a)',
  };
  function pct(v, m) { return m > 0 ? Math.max(4, Math.round((v / m) * 100)) : 0; }
  const max = (rows, key) => rows.reduce((m, r) => Math.max(m, r[key]), 0);
  function coverage(part, whole) { return whole > 0 ? Math.round((100 * part) / whole) : 0; }
</script>

<header class="bar">
  <div class="container inner">
    <a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
    <a class="back" href="/account"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Account</a>
  </div>
</header>

<main class="container page">
  <h1>Dashboard</h1>
  {#if error}
    <p class="muted">{error}</p>
  {:else if !stats}
    <p class="muted">Loading…</p>
  {:else}
    <nav class="tabs" aria-label="Dashboard sections">
      {#each TABS as t (t.key)}
        <a href={'?section=' + t.key} class:active={section === t.key}>
          {t.label}{#if t.key === 'feedback' && unreadCount}<span class="badge">{unreadCount}</span>{/if}
        </a>
      {/each}
    </nav>

    {#if section === 'overview' || section === 'content' || section === 'audience'}
      <div class="rangepick">
        <span class="rl">Window</span>
        {#each [7, 30, 90] as d (d)}
          <button class="chip" class:on={range === d} onclick={() => setRange(d)}>{d}d</button>
        {/each}
      </div>
    {/if}

    {#if section === 'overview'}
      <!-- Attention Needed -->
      {#if (stats.attention ?? []).length}
        <div class="attn-strip">
          {#each stats.attention as a}
            <div class="attn {a.level}">{a.text}</div>
          {/each}
        </div>
      {:else}
        <div class="attn ok">✓ All clear — nothing needs attention right now.</div>
      {/if}

      <h2>Pulse</h2>
      <div class="cards">
        <div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Visitors today</span></div>
        <div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Visitors (7d)</span></div>
        <div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Visitors ({range}d)</span></div>
        <div class="stat"><span class="n">{stats.content.served}</span><span class="l">Articles live</span></div>
        <div class="stat"><span class="n">{stats.content.accepted_7d}</span><span class="l">Fresh (7d)</span></div>
        <div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
        <div class="stat"><span class="n">{healthy}/{sources.length}</span><span class="l">Sources healthy</span></div>
        <div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Accounts</span></div>
        {#if stats.client_errors}
          <div class="stat" class:alert={stats.client_errors.today > 0}>
            <span class="n">{stats.client_errors.today}</span><span class="l">Load errors today</span>
          </div>
        {/if}
      </div>

      {#if clientErrors.length}
        <h2>Recent load errors <span class="count">(last {clientErrors.length} · newest {ago(clientErrors[0]?.created_at)})</span></h2>
        <ul class="cerrs">
          {#each clientErrors as e (e.created_at + e.reason)}
            {@const t = errType(e)}
            <li class:bot={e.bot}>
              <span class="ce-when">{fdate(e.created_at)}</span>
              <span class="ce-reason"><span class="ce-tag {t.k}">{t.label}</span>{e.reason || '—'}</span>
              <span class="ce-path">{e.path || '/'}</span>
              <span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
            </li>
          {/each}
        </ul>
      {/if}

    {:else if section === 'content'}
      <h2>Corpus</h2>
      <div class="cards">
        <div class="stat"><span class="n">{stats.content.served}</span><span class="l">Articles live</span></div>
        <div class="stat"><span class="n">{stats.content.rejected}</span><span class="l">Filtered out</span></div>
        <div class="stat"><span class="n">{stats.content.added_24h}</span><span class="l">Ingested (24h)</span></div>
        <div class="stat"><span class="n">{stats.content.added_7d}</span><span class="l">Ingested (7d)</span></div>
        <div class="stat"><span class="n">{stats.content.accepted_7d}</span><span class="l">Fresh & live (7d)</span></div>
        <div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
      </div>

      <h2>Coverage</h2>
      <div class="cards">
        <div class="stat"><span class="n">{coverage(stats.content.with_image, stats.content.served)}%</span><span class="l">Articles with image ({stats.content.with_image}/{stats.content.served})</span></div>
        <div class="stat"><span class="n">{coverage(stats.content.summaries, stats.content.served)}%</span><span class="l">Summaries ({stats.content.summaries}/{stats.content.served})</span></div>
        <div class="stat"><span class="n">{coverage(stats.content.summaries_with_image, stats.content.summaries)}%</span><span class="l">Summary pages w/ image</span></div>
        <div class="stat"><span class="n">{stats.content.brief_with_image}/{stats.content.latest_brief_size}</span><span class="l">Brief w/ image</span></div>
        <div class="stat"><span class="n">{stats.content.recent_enrich_fail}</span><span class="l">Image misses (24h)</span></div>
      </div>

      <section>
        <h2>Most-opened articles</h2>
        {#if stats.top_articles.length}
          <ul class="bars">
            {#each stats.top_articles as a (a.id)}
              <li>
                <a href={'/a/' + a.id} class="lbl" title={a.title}>{a.title}</a>
                <span class="bar" style="width:{pct(a.opens, max(stats.top_articles, 'opens'))}%"></span>
                <span class="v">{a.opens}</span>
              </li>
            {/each}
          </ul>
        {:else}<p class="muted">No opens yet.</p>{/if}
      </section>

      <div class="two">
        <section>
          <h2>Popular groupings</h2>
          {#if stats.top_groupings.length}
            <ul class="bars">
              {#each stats.top_groupings as g (g.tag)}
                <li>
                  <span class="lbl">{g.tag.replace('-', ' ')}</span>
                  <span class="bar" style="width:{pct(g.opens, max(stats.top_groupings, 'opens'))}%"></span>
                  <span class="v">{g.opens}</span>
                </li>
              {/each}
            </ul>
          {:else}<p class="muted">No data yet.</p>{/if}
        </section>
        <section>
          <h2>Popular topics</h2>
          {#if stats.top_topics.length}
            <ul class="bars">
              {#each stats.top_topics as t (t.topic)}
                <li>
                  <span class="lbl">{t.topic}</span>
                  <span class="bar" style="width:{pct(t.opens, max(stats.top_topics, 'opens'))}%"></span>
                  <span class="v">{t.opens}</span>
                </li>
              {/each}
            </ul>
          {:else}<p class="muted">No data yet.</p>{/if}
        </section>
      </div>

    {:else if section === 'sources'}
      <section class="addsrc">
        <h2>Add a source</h2>
        <div class="addrow">
          <input type="url" placeholder="Feed URL (RSS / Atom)" bind:value={newFeedUrl} onkeydown={(e) => e.key === 'Enter' && addCandidate()} />
          <input type="text" placeholder="Name (optional)" bind:value={newFeedName} />
          <button class="csend" onclick={addCandidate} disabled={addBusy || !newFeedUrl.trim()}>{addBusy ? 'Previewing…' : 'Preview & add'}</button>
        </div>
        {#if addErr}<p class="cerr">{addErr}</p>{/if}
        <p class="legend2">Fetches a sample (safely) and stages it as a candidate — nothing is added live until you Promote.</p>
      </section>

      {#if pendingCandidates.length}
        <section>
          <h2>Candidate queue <span class="count">({pendingCandidates.length})</span></h2>
          <ul class="candlist">
            {#each pendingCandidates as c (c.id)}
              <li>
                <div class="chead">
                  {#if c._editing}
                    <input class="crename" bind:value={c._editName} placeholder="Source name"
                           onkeydown={(e) => (e.key === 'Enter' ? saveRename(c) : e.key === 'Escape' ? (c._editing = false) : null)} />
                    <button class="act" onclick={() => saveRename(c)}>Save</button>
                    <button class="act" onclick={() => (c._editing = false)}>Cancel</button>
                  {:else}
                    <span class="cname">{c.name || c.feed_url}</span>
                    <button class="act mini" onclick={() => startRename(c)}>Rename</button>
                    <span class="cstatus">{c.status}</span>
                  {/if}
                </div>
                <div class="curl">{c.feed_url}</div>
                {#if c.preview}
                  <div class="cprev">
                    {c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.non_english} · <span class="held" title="Non-English items are held (English-only feed for now), not counted as rejections">{c.preview.non_english} held · non-English</span>{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if}
                    {#if c.preview.classified}<span class="vbadge model" title="Scored by the real classifier — the true acceptance view">model-checked</span>{:else}<span class="vbadge fast" title="Fast keyword heuristic — an estimate. Run Deep preview for the model's real verdict.">quick estimate</span>{/if}
                    {#if c.preview.paywall_rule}<span class="vbadge wall" title="This domain is on the paywall list (a hint, not a verdict)">paywall domain</span>{/if}
                    {#if c.preview.examples_accepted?.length}<div class="cex">e.g. {c.preview.examples_accepted.slice(0, 3).join(' · ')}</div>{/if}
                    {#if c.preview.access}
                      <div class="caccess">
                        <span class="acc-verdict {c.preview.access_verdict}" title={ACC_HELP[c.preview.access_verdict] || ''}>{c.preview.access_verdict}</span>
                        <span class="acc-counts">Access: {c.preview.access.readable} readable · {c.preview.access.paywalled} paywalled{#if c.preview.access.blocked} · <span title="Couldn't fetch — may be a bot-block (readable in a browser), not a reader paywall">{c.preview.access.blocked} blocked</span>{/if}{#if c.preview.access.unknown} · {c.preview.access.unknown} unknown{/if} <span class="acc-of">({c.preview.access.checked} sampled)</span></span>
                        {#if c.preview.access.examples?.length}
                          <div class="acc-ex-row">{#each c.preview.access.examples as ex (ex.url)}<a class="acc-ex {ex.access}" href={ex.url} target="_blank" rel="noopener">{ex.access}↗</a>{/each}</div>
                        {/if}
                      </div>
                    {/if}
                  </div>
                {/if}
                {#if c._err}<p class="cerr">{c._err}</p>{/if}
                <div class="cactions">
                  <input class="ccat" type="text" placeholder="category (optional)" bind:value={c._cat} />
                  <label class="cchk"><input type="checkbox" bind:checked={c._activate} /> Activate immediately</label>
                  <button class="csend" onclick={() => promoteCandidate(c)}>Promote{c._activate ? '' : ' as paused'}</button>
                  <button class="act" onclick={() => repreviewCandidate(c)}>Re-preview</button>
                  <button class="act" title="Run the real model on a sample (~30-60s)" onclick={() => deepPreview(c)} disabled={c._deep}>{c._deep ? 'Deep-checking…' : '🔬 Deep preview'}</button>
                  <button class="act del" onclick={() => rejectCandidate(c)}>Reject</button>
                </div>
              </li>
            {/each}
          </ul>
        </section>
      {/if}

      {#if rejectedCandidates.length}
        <section>
          <details class="rejtray">
            <summary>Rejected candidates <span class="count">({rejectedCandidates.length})</span></summary>
            <ul class="candlist rejlist">
              {#each rejectedCandidates as c (c.id)}
                <li>
                  <div class="chead">
                    <span class="cname">{c.name || c.feed_url}</span>
                    <span class="cstatus">rejected</span>
                  </div>
                  <div class="curl">{c.feed_url}</div>
                  {#if c._err}<p class="cerr">{c._err}</p>{/if}
                  <div class="cactions">
                    <button class="csend" onclick={() => restoreCandidate(c)}>Send back to staging</button>
                  </div>
                </li>
              {/each}
            </ul>
          </details>
        </section>
      {/if}

      <h2>Sources <a class="exportlink" href="/api/admin/export/sources.csv" download>export CSV ↓</a></h2>
      <p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total</p>
      <div class="srctools">
        <div class="filterchips">
          {#each [['all', 'All'], ['healthy', 'Healthy'], ['resting', 'Resting'], ['flagged', 'Flagged'], ['paused', 'Paused'], ['retired', 'Retired']] as [key, label] (key)}
            <button class="chip" class:on={srcFilter === key} onclick={() => (srcFilter = key)}>{label}</button>
          {/each}
        </div>
        <div class="srcsearch">
          <input type="search" placeholder="Search name, category, or URL…" bind:value={srcSearch} autocapitalize="off" autocomplete="off" spellcheck="false" />
          {#if srcSearch.trim()}<span class="srccount">{shownSources.length} match{shownSources.length === 1 ? '' : 'es'}</span>{/if}
        </div>
      </div>
      <div class="tablewrap">
        <table class="srctable">
          <thead>
            <tr>
              <th>Source</th><th class="num">Served</th><th class="num">Accept</th><th class="num">Dup</th><th class="num">Media</th>
              <th>Last success</th><th>Next poll</th><th class="num">Fails</th><th>Status</th><th>Actions</th>
            </tr>
          </thead>
          <tbody>
            {#each shownSources as s (s.id)}
              {@const st = sstatus(s)}
              <tr class:warn={st === 'active' && s.failures > 0} class:flag={s.review_flag} class:paused={st !== 'active'}>
                <td class="sname">
                  {s.name}{#if s.category}<span class="cat">{s.category}</span>{/if}
                  {#if s.review_flag && s.review_reason}<span class="rr">“{s.review_reason}”</span>{/if}
                </td>
                <td class="num">{s.served}</td>
                <td class="num">{s.acceptance_rate != null ? s.acceptance_rate + '%' : '—'}</td>
                <td class="num" title={s.accepted_dup_rate != null ? `${s.accepted_dup_rate}% of accepted were duplicates` : ''}>{s.duplicate_rate != null ? s.duplicate_rate + '%' : '—'}</td>
                <td class="num media" title={`${s.image_coverage != null ? s.image_coverage + '% of served have an image' : 'no served articles yet'}${s.paywalled ? ' · subscription / paywalled domain' : ''}`}>
                  {s.image_coverage != null ? s.image_coverage + '%' : '—'}{#if s.paywalled} <span class="pw">🔒</span>{/if}
                </td>
                <td class="dim">{s.last_success_at ? fwhen(s.last_success_at) : '—'}</td>
                <td class="dim">{st === 'active' ? fwhen(s.next_due_at) : '—'}</td>
                <td class="num">{s.failures || ''}</td>
                <td class="status">
                  {#if st === 'retired'}<span class="paustxt">retired</span>
                  {:else if st === 'paused'}<span class="paustxt">paused</span>
                  {:else if rateLimited(s)}<span class="bad" title={s.last_error || ''}>rate-limited · rests until {fwhen(s.retry_after_at)}{#if s.review_flag} · review{/if}</span>
                  {:else if s.failures > 0}<span class="bad" title={s.last_error || ''}>⚠ resting{#if s.review_flag} · review{/if}</span>
                  {:else if s.review_flag}<span class="flagtxt">⚑ review</span>
                  {:else}<span class="good">✓ ok</span>{/if}
                  {#if !s.content_visible}<span class="hidtxt" title="Articles hidden from the public feed">· hidden</span>{/if}
                </td>
                <td class="rowactions">
                  {#if st === 'retired'}
                    <button class="act" onclick={() => setStatus(s, 'active')}>Restore</button>
                  {:else}
                    <button class="act" onclick={() => setStatus(s, st === 'active' ? 'paused' : 'active')}>{st === 'active' ? 'Pause' : 'Resume'}</button>
                    <button class="act" onclick={() => setStatus(s, 'retired')}>Retire</button>
                  {/if}
                  <button class="act" onclick={() => (s.review_flag ? clearReview(s) : openFlag(s))}>{s.review_flag ? 'Clear' : 'Flag'}</button>
                  <button class="act" onclick={() => toggleVisible(s)}>{s.content_visible ? 'Hide' : 'Show'}</button>
                  <button class="act" title="Read-only spot-check of the live feed" onclick={() => checkSource(s)} disabled={s._checking}>{s._checking ? 'Checking…' : 'Check'}</button>
                  <button class="act" title="Inspect this source's real ingested articles" onclick={() => toggleArticles(s)}>{s._showArts ? 'Hide' : 'Articles'}</button>
                </td>
              </tr>
              {#if s._checking || s._check || s._checkErr}
                <tr class="checkrow">
                  <td colspan="10">
                    {#if s._checking}
                      <span class="muted">Checking {s.name}…</span>
                    {:else if s._checkErr}
                      <span class="cerr">Couldn't preview: {s._checkErr} <button class="act" onclick={() => dismissCheck(s)}>dismiss</button></span>
                    {:else if s._check}
                      <div class="chkhead">
                        <strong>{s._check.accepted ?? 0}/{s._check.sampled ?? 0}</strong> would pass{#if s._check.acceptance_rate != null} · {Math.round(s._check.acceptance_rate * 100)}%{/if}{#if s._check.recent_7d != null} · {s._check.recent_7d} in last 7d{/if}
                        <span class="chkwhen">checked just now</span>
                        <button class="act" onclick={() => dismissCheck(s)}>dismiss</button>
                      </div>
                      {#if s._check.examples_accepted?.length}<div class="chkex"><span class="chklbl">would accept:</span> {s._check.examples_accepted.slice(0, 3).join(' · ')}</div>{/if}
                      {#if s._check.examples_rejected?.length}<div class="chkex chkrej"><span class="chklbl">would skip:</span> {s._check.examples_rejected.slice(0, 3).map((r) => r.title).join(' · ')}</div>{/if}
                    {/if}
                  </td>
                </tr>
              {/if}
              {#if s._showArts}
                <tr class="artrow">
                  <td colspan="10">
                    {#if s._artSummary}
                      <div class="artsum">
                        <strong>{s._artSummary.total}</strong> ingested · {s._artSummary.accepted} accepted ·
                        {s._artSummary.rejected} rejected{#if s._artSummary.non_english} · <span class="held">{s._artSummary.non_english} held · non-English</span>{/if} · {s._artSummary.no_image} no image · {s._artSummary.duplicates} dup
                      </div>
                      <div class="pwctl">
                        <span>Paywall: <span class="pwrule" class:on={s._artSummary.paywalled}>{pwBasis(s._artSummary)}</span></span>
                        <select class="pwsel" onchange={(e) => setPaywall(s, e.currentTarget.value)}>
                          <option value="" selected={!s._artSummary.paywall_override}>Use domain rule ({s._artSummary.paywall_domain ? 'on' : 'off'})</option>
                          <option value="free" selected={s._artSummary.paywall_override === 'free'}>Treat as free</option>
                          <option value="paywalled" selected={s._artSummary.paywall_override === 'paywalled'}>Treat as paywalled</option>
                        </select>
                      </div>
                    {/if}
                    <div class="artfilters">
                      {#each ART_FILTERS as [key, label] (key)}
                        <button class="chip sm" class:on={(s._artFilter || 'all') === key} onclick={() => loadArticles(s, key, true)}>{label}</button>
                      {/each}
                      <button class="act" onclick={() => (s._showArts = false)}>close</button>
                    </div>
                    {#if s._artErr}<p class="cerr">{s._artErr}</p>{/if}
                    {#if s._arts?.length}
                      <ul class="artlist">
                        {#each s._arts as a (a.id)}
                          <li>
                            <div class="art-row">
                              {#if typeof a.url === 'string' && /^https?:\/\//.test(a.url)}
                                <a class="art-title" href={a.url} target="_blank" rel="noopener">{a.title}</a>
                              {:else}<span class="art-title plain">{a.title}</span>{/if}
                              {#if a.accepted === 1}<span class="badge ok">accepted</span>{:else if a.held}<span class="badge held">held · non-English</span>{:else if a.accepted === 0}<span class="badge no">rejected</span>{/if}
                              {#if a.paywalled}<span class="pw" title="domain paywall rule">🔒</span>{/if}
                              {#if !a.has_image}<span class="art-flag" title="no image extracted">no img</span>{/if}
                              {#if a.duplicate}<span class="art-flag" title="marked duplicate">dup</span>{/if}
                              {#if a.topic}<span class="art-cat">{a.topic}</span>{/if}
                              <span class="art-when">{a.published_at ? fdate(a.published_at) : ''}</span>
                            </div>
                            {#if a.reason}<div class="art-reason" title={a.reason}>{a.reason}</div>{/if}
                          </li>
                        {/each}
                      </ul>
                      {#if s._artMore}<button class="act more" onclick={() => loadArticles(s, s._artFilter, false)} disabled={s._artBusy}>{s._artBusy ? 'Loading…' : 'Load more'}</button>{/if}
                    {:else if !s._artBusy}
                      <p class="muted small">No articles{s._artFilter && s._artFilter !== 'all' ? ' match this filter' : ' yet'}.</p>
                    {/if}
                    {#if s._artBusy && !s._arts?.length}<p class="muted small">Loading articles…</p>{/if}
                  </td>
                </tr>
              {/if}
            {:else}
              <tr><td colspan="10" class="srcempty">{srcSearch.trim() ? `No sources match “${srcSearch.trim()}”.` : 'No sources in this view.'}</td></tr>
            {/each}
          </tbody>
        </table>
      </div>
      <p class="legend2">“served” = accepted, non-duplicate articles live · accept/dup % is of all ingested · pausing stops polling but keeps existing articles live · times in your local zone</p>

    {:else if section === 'audience'}
      <p class="exporthdr"><a class="exportlink" href={'/api/admin/export/audience.csv?days=' + range} download>export audience CSV ({range}d) ↓</a></p>
      <section>
        <h2>Visitors</h2>
        <div class="cards">
          <div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Today</span></div>
          <div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Last 7 days</span></div>
          <div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Last {range} days</span></div>
        </div>
      </section>
      <section>
        <h2>Returning visitors ({range}d)</h2>
        <div class="cards">
          <div class="stat"><span class="n">{stats.retention.new}</span><span class="l">New (1 day)</span></div>
          <div class="stat"><span class="n">{stats.retention['2-3']}</span><span class="l">2–3 days</span></div>
          <div class="stat"><span class="n">{stats.retention['4-7']}</span><span class="l">4–7 days</span></div>
          <div class="stat"><span class="n">{stats.retention['8+']}</span><span class="l">8+ days</span></div>
        </div>
      </section>
      <section>
        <h2>Accounts</h2>
        <div class="cards">
          <div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Total</span></div>
          <div class="stat"><span class="n">{stats.accounts.new_today}</span><span class="l">New today</span></div>
          <div class="stat"><span class="n">{stats.accounts.new_7d}</span><span class="l">New this week</span></div>
          <div class="stat"><span class="n">{stats.accounts.active_7d}</span><span class="l">Active this week</span></div>
        </div>
      </section>
      <section>
        <h2>Reading funnel</h2>
        <div class="cards">
          <div class="stat"><span class="n">{stats.funnel.summary_viewed}</span><span class="l">Summaries read</span></div>
          <div class="stat"><span class="n">{stats.funnel.source_click}</span><span class="l">→ Source (from summary)</span></div>
          <div class="stat"><span class="n">{stats.funnel.source_rate}%</span><span class="l">Summary → source rate</span></div>
          <div class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
        </div>
      </section>
      {#if stats.games}
      <section>
        <h2>Games funnel <span class="muted small">· the share loop</span></h2>
        <div class="cards">
          <div class="stat"><span class="n">{stats.games.totals.arrival}</span><span class="l">Share arrivals</span></div>
          <div class="stat"><span class="n">{stats.games.totals.started}</span><span class="l">Engaged (first move)</span></div>
          <div class="stat"><span class="n">{stats.games.totals.completed}</span><span class="l">Completed</span></div>
          <div class="stat"><span class="n">{stats.games.totals.shared}</span><span class="l">Shared</span></div>
        </div>
        <table class="gfunnel">
          <thead><tr><th>Game</th><th>Arrivals</th><th>Engaged</th><th>Completed</th><th>Shared</th></tr></thead>
          <tbody>
            {#each [['word', 'Daily Word'], ['wordsearch', 'Word Search'], ['bloom', 'Bloom'], ['match', 'Memory Match']] as [k, label] (k)}
              <tr><td>{label}</td><td>{stats.games.by_game[k].arrival}</td><td>{stats.games.by_game[k].started}</td><td>{stats.games.by_game[k].completed}</td><td>{stats.games.by_game[k].shared}</td></tr>
            {/each}
          </tbody>
        </table>
        <p class="muted small">“Share arrivals” = landings via a shared game link (utm_source=game_share). “Engaged” = first move (guess/find/flip), not just opening the page.</p>
      </section>
      {/if}
      <section>
        <h2>Emotional mix &amp; friction</h2>
        <div class="cards">
          <div class="stat"><span class="n">{stats.emotional_mix.not_today}</span><span class="l">Not today</span></div>
          <div class="stat"><span class="n">{stats.emotional_mix.less_like_this}</span><span class="l">Less like this</span></div>
          <div class="stat"><span class="n">{stats.emotional_mix.hide_topic}</span><span class="l">Hide topic</span></div>
          <div class="stat"><span class="n">{stats.replace.used}</span><span class="l">Replaces</span></div>
          <div class="stat"><span class="n">{stats.paywall.paywalled_source_open}</span><span class="l">Paywalled opens</span></div>
        </div>
      </section>
      <section>
        <h2>Sharing</h2>
        <div class="cards">
          {#each Object.entries(stats.shares) as [kind, n]}
            <div class="stat"><span class="n">{n}</span><span class="l">{SHARE_LABEL[kind] ?? kind}</span></div>
          {/each}
        </div>
      </section>
      <section>
        <h2>Daily trend</h2>
        {#if stats.daily.length}
          {@const dmax = Math.max(1, ...stats.daily.map((d) => Math.max(d.opens, d.visits)))}
          <div class="trend">
            {#each stats.daily as d (d.day)}
              <div class="col" title={`${d.day}: ${d.visits} visits, ${d.opens} opens`}>
                <span class="visits" style="height:{pct(d.visits, dmax)}%"></span>
                <span class="opens" style="height:{pct(d.opens, dmax)}%"></span>
              </div>
            {/each}
          </div>
          <p class="legend"><span class="sw visits"></span> visits &nbsp; <span class="sw opens"></span> opens</p>
        {:else}<p class="muted">No data yet.</p>{/if}
      </section>

    {:else if section === 'feedback'}
      <h2>Feedback {#if feedback.length}<span class="count">({unreadCount} unread / {feedback.length})</span>{/if}</h2>
      {#if feedback.length}
        <div class="filterchips">
          <button class="chip" class:on={fbCat === 'all'} onclick={() => (fbCat = 'all')}>All</button>
          <button class="chip" class:on={fbCat === 'unread'} onclick={() => (fbCat = 'unread')}>Unread{#if unreadCount} ({unreadCount}){/if}</button>
          {#each fbCats as cat (cat)}
            <button class="chip" class:on={fbCat === cat} onclick={() => (fbCat = cat)}>{cat}</button>
          {/each}
        </div>
        {#if shownFeedback.length}
          <ul class="fb">
            {#each shownFeedback as f (f.id)}
              <li class:unread={!f.read_at}>
                <div class="fhead">
                  {#if !f.read_at}<span class="dot" title="Unread"></span>{/if}
                  <span class="cat">{f.category}</span>
                  <span class="when">{fdate(f.created_at)}</span>
                  {#if f.contact_email}<span class="addr">{f.contact_email}</span>{:else}<span class="anon">anonymous</span>{/if}
                </div>
                <p class="msg">{f.message}</p>

                {#if f.replies?.length}
                  <div class="thread">
                    {#each f.replies as r (r.id)}
                      <div class="rep">
                        <span class="rl">↪ Replied · {fwhen(r.sent_at)}</span>
                        {#if r.message_html}
                          <!-- server-sanitized HTML (tiny Markdown subset) -->
                          <div class="repmsg">{@html r.message_html}</div>
                        {:else}
                          <p class="repmsg">{r.message}</p>
                        {/if}
                      </div>
                    {/each}
                  </div>
                {/if}

                {#if replyingId === f.id}
                  <div class="composer">
                    <div class="rtbar">
                      <button type="button" class="rtbtn" title="Bold" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('bold')}><b>B</b></button>
                      <button type="button" class="rtbtn" title="Italic" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('italic')}><i>I</i></button>
                      <select class="szsel" title="Text size" onmousedown={(e) => e.stopPropagation()} onchange={(e) => { setSize(e.currentTarget.value); e.currentTarget.value = ''; }}>
                        <option value="">Size</option>
                        <option value="small">Small</option>
                        <option value="normal">Normal</option>
                        <option value="large">Large</option>
                        <option value="xlarge">X-Large</option>
                      </select>
                      <button type="button" class="rtbtn" title="Bullet list" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('insertUnorderedList')}>• List</button>
                      <button type="button" class="rtbtn" title="Numbered list" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('insertOrderedList')}>1. List</button>
                    </div>
                    <div
                      class="rtedit"
                      contenteditable="true"
                      bind:this={replyEditor}
                      use:focusEditor
                      oninput={onEditorInput}
                      onpaste={onPaste}
                      role="textbox"
                      aria-multiline="true"
                      aria-label="Write a reply"
                    ></div>
                    {#if replyErr}<p class="cerr">{replyErr}</p>{/if}
                    <div class="cbtns">
                      <button class="csend" onclick={() => sendReply(f)} disabled={replyBusy || replyEmpty}>
                        {replyBusy ? 'Sending…' : 'Send reply'}
                      </button>
                      <button class="act" onclick={cancelReply}>Cancel</button>
                    </div>
                  </div>
                {/if}

                <div class="factions">
                  {#if f.contact_email && replyingId !== f.id}
                    <button class="act" onclick={() => openReply(f)}>Reply</button>
                  {:else if !f.contact_email}
                    <span class="noreply">No reply address</span>
                  {/if}
                  <button class="act" onclick={() => markRead(f, !f.read_at)}>{f.read_at ? 'Mark unread' : 'Mark read'}</button>
                  <button class="act del" onclick={() => removeFeedback(f)}>Delete</button>
                </div>
              </li>
            {/each}
          </ul>
        {:else}<p class="muted">Nothing in this filter.</p>{/if}
      {:else}<p class="muted">No feedback yet.</p>{/if}

    {:else if section === 'games'}
      <h2>Bloom words <span class="count">({bloomReports.length} to review)</span></h2>
      <p class="muted">Acceptance is broad (every valid dictionary word) — these are player
        “should this count?” reports. <strong>Approve</strong> allows the word everywhere
        (takes effect immediately, no deploy); <strong>Block</strong> hides it; <strong>Dismiss</strong>
        clears the report without a rule.</p>
      {#if bloomReports.length}
        <ul class="bloom-reports">
          {#each bloomReports as r (r.id)}
            <li>
              <span class="br-word">{r.word}</span>
              <span class="br-ctx">{r.format || ''}{r.letters ? ' · ' + r.letters.toUpperCase() : ''}{r.puzzle_date ? ' · ' + r.puzzle_date : ''}</span>
              <span class="br-acts">
                <button class="wp-add" onclick={() => resolveBloom(r.id, 'approve')}>Approve</button>
                <button class="act del" onclick={() => resolveBloom(r.id, 'block')}>Block</button>
                <button class="link" onclick={() => resolveBloom(r.id, 'dismiss')}>Dismiss</button>
              </span>
            </li>
          {/each}
        </ul>
      {:else}
        <p class="empty">No pending word reports.</p>
      {/if}

      <div class="wp-lookup">
        <input type="text" bind:value={bloomOvrWord} maxlength="24" autocapitalize="off"
               autocomplete="off" spellcheck="false" placeholder="Manually allow/block a word…" />
        <button class="wp-add" onclick={() => addBloomOverride('allow')}>Allow</button>
        <button class="act del" onclick={() => addBloomOverride('block')}>Block</button>
      </div>
      {#if bloomOverrides.length}
        <div class="bloom-ovr">
          {#each bloomOverrides as o (o.word)}
            <span class="ovr-chip {o.action}">{o.word} · {o.action}
              <button class="ovr-x" aria-label="Remove override" onclick={() => removeBloomOverride(o.word)}>×</button>
            </span>
          {/each}
        </div>
      {/if}

      <h2 style="margin-top:32px">Daily Word pool</h2>
      <p class="muted">Look up a word to add or remove it from the answer pool. Only real, 5- or 6-letter
        words in the guess dictionary qualify, so the daily answer is always solvable. Removals take
        effect for future puzzles and can be restored any time.</p>

      <div class="wp-lookup">
        <input type="text" bind:value={wpWord} oninput={onWpInput} maxlength="6" autocapitalize="off"
               autocomplete="off" spellcheck="false" placeholder="Type a word to check…" />
        {#if wpResult && wpResult.word}
          {#if !wpResult.variant}
            <span class="wp-tag bad">Must be 5 or 6 letters</span>
          {:else if wpResult.in_pool}
            <span class="wp-tag ok">In the pool</span>
            <button class="act del" onclick={() => removeWord(wpResult.word)}>Remove</button>
          {:else if wpResult.removed}
            <span class="wp-tag bad">Removed</span>
            <button class="wp-add" onclick={() => restoreWord(wpResult.word)}>Restore</button>
          {:else if wpResult.in_dictionary}
            <span class="wp-tag ok">Valid {wpResult.variant}-letter word</span>
            <button class="wp-add" onclick={addWord}>Add to pool</button>
          {:else}
            <span class="wp-tag bad">Not in the guess dictionary</span>
          {/if}
        {/if}
      </div>
      {#if wpMsg}<p class="wp-msg">{wpMsg}</p>{/if}

      {#if wpPool}
        <div class="wp-cols">
          {#each ['5', '6'] as v (v)}
            <div class="wp-col">
              <h3>{v === '6' ? 'Long Word' : 'Daily Word'} <span class="count">· {v} letters · {wpPool[v].total} words</span></h3>
              <p class="muted small">{wpPool[v].curated} curated + {wpPool[v].added.length} added by you</p>
              {#if wpPool[v].added.length}
                <ul class="wp-added">
                  {#each wpPool[v].added as w (w)}
                    <li>{w}<button class="x" onclick={() => removeWord(w)} aria-label={'Remove ' + w}>×</button></li>
                  {/each}
                </ul>
              {:else}<p class="muted small">No words added yet — the curated pool is in use.</p>{/if}
              {#if wpPool[v].removed && wpPool[v].removed.length}
                <p class="muted small" style="margin-top:10px">Removed ({wpPool[v].removed.length}) — tap to restore:</p>
                <ul class="wp-added removed">
                  {#each wpPool[v].removed as w (w)}
                    <li>{w}<button class="x" onclick={() => restoreWord(w)} aria-label={'Restore ' + w}>↺</button></li>
                  {/each}
                </ul>
              {/if}
            </div>
          {/each}
        </div>
      {/if}

      <h3 style="margin-top:24px">Import a vetted list</h3>
      <p class="muted small">Paste words (any separators) or upload a .txt/.csv. Each is checked —
        real word · 5–6 letters · in the guess dictionary — and duplicates are ignored.</p>
      <div class="wp-import">
        <textarea bind:value={wpImportText} rows="4"
                  placeholder="clear, bloom, kindle, ripple…  (or upload a file below)"></textarea>
        <div class="wp-import-row">
          <label class="act file">Choose file…
            <input type="file" accept=".txt,.csv,text/plain,text/csv" onchange={onImportFile} hidden />
          </label>
          <button class="wp-add" onclick={importWords} disabled={!wpImportText.trim() || wpImporting}>
            {wpImporting ? 'Importing…' : 'Import'}
          </button>
        </div>
        {#if wpImportResult}
          {#if wpImportResult.error}
            <p class="wp-msg">{wpImportResult.error}</p>
          {:else}
            <p class="wp-msg">Added {wpImportResult.counts.added} · ignored {wpImportResult.counts.duplicates} duplicate(s) · rejected {wpImportResult.counts.rejected}.</p>
            {#if wpImportResult.rejected.length}
              <details class="wp-rejects">
                <summary>{wpImportResult.rejected.length} rejected — see why</summary>
                <ul class="muted small">
                  {#each wpImportResult.rejected as r (r.word)}
                    <li>{r.word} — {r.reason}</li>
                  {/each}
                </ul>
              </details>
            {/if}
          {/if}
        {/if}
      </div>

      <h2 style="margin-top:38px">Word Search themes</h2>
      <p class="muted">Add a theme and its words — the system splits them across the three sizes and places
        them. You need {WS_NEEDED}+ valid words (4–8 letters) to fill all three puzzles. Stuck? Let the AI
        suggest one that fits.</p>

      <div class="ws-form">
        <input class="ws-name" type="text" bind:value={wsTheme} maxlength="40"
               placeholder="Theme name (e.g. In the kitchen)" />
        <textarea class="ws-words" bind:value={wsWordsText} rows="4"
                  placeholder="Words, separated by spaces or commas…"></textarea>
        <div class="ws-row">
          <span class="ws-count" class:ok={wsValid.length >= WS_NEEDED}>
            {wsValid.length} / {WS_NEEDED} valid{#if wsInvalid.length} · {wsInvalid.length} skipped{/if}
          </span>
          <button class="ws-ai" onclick={suggestWsWord} disabled={wsSuggesting}>
            {wsSuggesting ? 'Thinking…' : '✨ Suggest a word'}
          </button>
          <button class="wp-add" onclick={saveWsTheme} disabled={wsValid.length < WS_NEEDED || !wsTheme.trim()}>
            {wsEditId ? 'Update theme' : 'Save theme'}
          </button>
          {#if wsEditId}<button class="act" onclick={cancelWsEdit}>Cancel</button>{/if}
        </div>
        {#if wsInvalid.length}<p class="muted small">Skipped (not 4–8 letters): {wsInvalid.join(', ')}</p>{/if}
      </div>
      {#if wsMsg}<p class="wp-msg">{wsMsg}</p>{/if}

      {#if wsThemes.length}
        <ul class="ws-themes">
          {#each wsThemes as t (t.id)}
            <li>
              <span class="wt-name">{t.theme}</span>
              <span class="wt-count">{t.count} words</span>
              <button class="act" onclick={() => editWsTheme(t)}>Edit</button>
              <button class="act del" onclick={() => removeWsTheme(t)}>Remove</button>
            </li>
          {/each}
        </ul>
      {:else}<p class="muted small">No custom themes yet — the daily rotation uses the built-in ones.</p>{/if}

    {:else if section === 'publish'}
      <h2>Publishing Desk <span class="count">({pubItems.length})</span></h2>
      <p class="sub2">Build a queue of share-worthy stories, write the blurb in your own voice, and open it in X. Posts your <code>/a/</code> share link (your card, drives to UB, still credits the source). Opening X can't confirm a post — you mark Posted.</p>
      <div class="pubtools">
        <button class="csend" onclick={buildPublish} disabled={pubBuilding}>{pubBuilding ? 'Building…' : 'Build queue'}</button>
        {#if pubLast}<span class="muted small">Last build: +{pubLast.added} · {pubLast.active} active · ranked {pubLast.ranked_by}</span>{/if}
      </div>
      {#if pubError}<p class="cerr">Build error: {pubError}</p>{/if}

      {#if !pubItems.length && !pubBuilding}
        <p class="muted small">Queue is empty — hit “Build queue” to gather candidates.</p>
      {/if}

      <ul class="publist">
        {#each pubItems as item (item.id)}
          {@const remaining = pubRemaining(item)}
          <li class="pubcard" class:opened={item.status === 'opened'}>
            <div class="pub-head">
              {#if item.image_url}<img class="pub-img" src={item.image_url} alt="" loading="lazy" />{/if}
              <div class="pub-meta">
                <a class="pub-title" href={item.share_url} target="_blank" rel="noopener">{item.title}</a>
                <span class="pub-src">{item.source_name}{#if item.social_score != null} · interest {item.social_score}/10{/if}{#if item.status === 'opened'} · <span class="pub-openedtag">opened</span>{/if}</span>
              </div>
            </div>
            {#if item.rationale}<p class="pub-why">{item.rationale}</p>{/if}
            {#if item.talking_points?.length}
              <ul class="pub-points">{#each item.talking_points as p}<li>{p}</li>{/each}</ul>
            {/if}
            {#if item.angle}<p class="pub-angle"><span class="hlbl">Angle:</span> {item.angle}</p>{/if}

            {#if item.suggested_handles?.length}
              <div class="pub-handles">
                <span class="hlbl">Tag:</span>
                {#each item.suggested_handles as h}<button class="hchip" onclick={() => insertHandle(item, h.handle)} title="Insert into your post">{h.handle}</button>{/each}
              </div>
            {/if}

            <div class="pub-toolbar">
              <button type="button" class="emoji-toggle" class:on={pubEmojiOpen === item.id}
                      onclick={() => (pubEmojiOpen = pubEmojiOpen === item.id ? null : item.id)}
                      title="Insert emoji">😊 Emoji</button>
              {#if pubEmojiOpen === item.id}
                <div class="emoji-pop">
                  <EmojiPicker onpick={(em) => insertEmoji(item, em)} />
                </div>
              {/if}
            </div>
            <textarea class="pub-draft" rows="3" placeholder="Write your post — in your voice…"
                      use:regTextarea={item.id}
                      bind:value={item.draft_text} oninput={() => onDraftInput(item)}></textarea>
            <div class="pub-count" class:over={remaining < 0}>{remaining} left <span class="muted">(+ your /a/ link)</span></div>

            {#if namedEntities(item).length}
              <details class="pub-find">
                <summary>Find / save handles</summary>
                {#each namedEntities(item) as ent (ent)}
                  <div class="pub-ent">
                    <span class="pub-entname">{ent}</span>
                    <a class="act mini" href={findOnX(ent)} target="_blank" rel="noopener">Find on X ↗</a>
                    <input class="hin" placeholder="@handle" bind:value={pubHandleInput[`${item.id}:${ent}`]} />
                    <button class="act mini" onclick={() => pubSaveHandle(item, ent)}>Save</button>
                  </div>
                {/each}
              </details>
            {/if}

            {#if item._err}<p class="cerr">{item._err}</p>{/if}
            {#if item._confirm}
              <div class="pub-confirm">
                <p class="muted small">If you tweaked the post inside X, edit the text above to match before confirming — it's saved as your final wording.</p>
                <input class="hin wide" placeholder="Post URL (optional)" bind:value={item._postUrl} />
                <div class="pub-actions">
                  <button class="csend" onclick={() => pubConfirmPosted(item)}>Confirm posted</button>
                  <button class="act" onclick={() => (item._confirm = false)}>Cancel</button>
                </div>
              </div>
            {:else}
              <div class="pub-actions">
                <a class="csend" href={intentURL(item)} target="_blank" rel="noopener" onclick={() => markOpened(item)}>Open in X ↗</a>
                <button class="act" onclick={() => (item._confirm = true)}>Posted ✓</button>
                <button class="act" onclick={() => pubSetStatus(item, 'snoozed', { snooze_until: snoozeDate(1) })}>Snooze 1d</button>
                <button class="act del" onclick={() => pubSetStatus(item, 'skipped')}>Skip</button>
              </div>
            {/if}
          </li>
        {/each}
      </ul>

      {#if pubArchived.length}
        <details class="rejtray">
          <summary>Archived · skipped &amp; snoozed <span class="count">({pubArchived.length})</span></summary>
          <ul class="candlist">
            {#each pubArchived as item (item.id)}
              <li>
                <div class="chead"><span class="cname">{item.title}</span><span class="cstatus">{item.status}{#if item.snooze_until} · until {item.snooze_until.slice(0, 16)}{/if}</span></div>
                <div class="curl">{item.source_name}</div>
                <div class="cactions"><button class="csend" onclick={() => pubRestore(item)}>Restore to queue</button></div>
              </li>
            {/each}
          </ul>
        </details>
      {/if}

    {:else if section === 'joys'}
      <div class="joy-bar">
        <div class="joy-kinds">
          <button class:on={joyKind === 'onthisday'} onclick={() => (joyKind = 'onthisday')}>On This Day</button>
          <button class:on={joyKind === 'quote'} onclick={() => (joyKind = 'quote')}>Quote</button>
          <button class:on={joyKind === 'word'} onclick={() => (joyKind = 'word')}>Word</button>
        </div>
        <button class="joy-repick" onclick={joyRepick}>Re-pick today</button>
      </div>

      <details class="joy-add">
        <summary>Add {joyKind === 'word' ? 'a word' : joyKind === 'quote' ? 'a quote' : 'a fact'}</summary>
        <div class="joy-add-fields">
          {#if joyKind === 'word'}
            <input placeholder="word (looked up in the dictionary)" bind:value={joyAdd.word} />
          {:else if joyKind === 'quote'}
            <textarea placeholder="quote text" bind:value={joyAdd.text}></textarea>
            <input placeholder="author" bind:value={joyAdd.author} />
            <input placeholder="work / source (optional)" bind:value={joyAdd.work} />
          {:else}
            <input placeholder="MM-DD (e.g. 06-22)" bind:value={joyAdd.md} />
            <input placeholder="year" type="number" bind:value={joyAdd.year} />
            <textarea placeholder="what happened" bind:value={joyAdd.text}></textarea>
            <textarea placeholder="summary (optional)" bind:value={joyAdd.summary}></textarea>
          {/if}
          <button onclick={joyAddSubmit}>Add</button>
          {#if joyAddErr}<span class="cerr">{joyAddErr}</span>{/if}
        </div>
      </details>

      {#if joyLoading}<p class="muted">Loading…</p>{/if}
      <ul class="joy-list">
        {#each joyItems as it (it.id)}
          <li class="joy-row" class:blocked={it.blocked}>
            <div class="joy-main">
              {#if joyKind === 'word'}
                <div><strong>{it.word}</strong> <span class="muted small">{it.part_of_speech ?? ''} {it.phonetic ?? ''}</span></div>
                <div class="joy-sub">{it.definition}</div>
              {:else if joyKind === 'quote'}
                <div class="joy-sub">“{it.text}”</div>
                <span class="muted small">— {it.author}{#if it.work}, {it.work}{/if}</span>
              {:else}
                <span class="muted small">{it.md} · {it.year ?? ''}</span>
                <div class="joy-sub">{it.text}</div>
              {/if}
            </div>
            <div class="joy-acts">
              {#if it.featured}<span class="joy-badge">featured</span>{/if}
              {#if it.shown_at}<span class="muted small">shown {it.shown_at}</span>{/if}
              <button onclick={() => joyMutate(it.id, it.featured ? 'unfeature' : 'feature')}>{it.featured ? 'Unfeature' : 'Feature'}</button>
              <button onclick={() => joyMutate(it.id, it.blocked ? 'unblock' : 'block')}>{it.blocked ? 'Unblock' : 'Block'}</button>
              <button class="joy-del" onclick={() => joyMutate(it.id, 'delete')}>Delete</button>
            </div>
          </li>
        {/each}
      </ul>
    {/if}
  {/if}
</main>

{#if flagging}
  <!-- Flag-reason popover: backdrop closes; Escape/Cancel too (keyboard path). -->
  <!-- svelte-ignore a11y_click_events_have_key_events -->
  <div class="modal-overlay" role="presentation" onclick={(e) => e.target === e.currentTarget && cancelFlag()}>
    <div class="modal" role="dialog" aria-modal="true" aria-label="Flag source for review" tabindex="-1">
      <h3>Flag “{flagging.name}” for review</h3>
      <p class="msub">An optional note on why — it shows under the source in the table.</p>
      <input
        type="text"
        bind:value={flagReason}
        placeholder="e.g. acceptance dropping, off-topic lately"
        onkeydown={(e) => (e.key === 'Enter' ? confirmFlag() : e.key === 'Escape' ? cancelFlag() : null)}
      />
      <div class="mbtns">
        <button class="msend" onclick={confirmFlag}>Flag for review</button>
        <button class="act" onclick={cancelFlag}>Cancel</button>
      </div>
    </div>
  </div>
{/if}

<style>
  header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
  .inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
  .logo { height: 40px; display: block; }
  .back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; }
  .back svg { width: 17px; height: 17px; display: block; }
  .page { padding: 22px 20px 70px; }
  h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 14px; }
  h2 { font-size: 1.1rem; margin: 28px 0 12px; }
  .muted { color: var(--muted); }

  /* Sticky section tabs */
  .tabs {
    display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 22px;
    position: sticky; top: 64px; z-index: 15; background: var(--bg);
    padding: 10px 0; border-bottom: 1px solid var(--line);
  }
  .tabs a {
    border: 1px solid var(--line); background: var(--surface); color: var(--ink);
    border-radius: 999px; padding: 7px 15px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 6px;
  }
  .tabs a:hover { border-color: var(--accent); color: var(--accent-deep); }
  .tabs a.active { background: var(--accent); border-color: var(--accent); color: #fff; }
  .tabs .badge { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 0 7px; font-size: 0.74rem; }
  .tabs a.active .badge { background: rgba(255,255,255,0.25); color: #fff; }

  /* Attention Needed — soft amber/blue, never alarming red unless truly broken */
  .attn-strip { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
  .attn { border-radius: 12px; padding: 11px 15px; font-size: 0.9rem; border-left: 3px solid; }
  .attn.warn { background: #fdf3e3; color: #875a16; border-color: #e0a648; }
  .attn.info { background: #eef4f8; color: var(--accent-deep); border-color: var(--accent); }
  .attn.ok { background: #eef5ee; color: #3f7048; border-left: 3px solid #6aa86a; border-radius: 12px; padding: 11px 15px; font-size: 0.9rem; }

  .cards { display: flex; flex-wrap: wrap; gap: 12px; }
  .stat {
    flex: 1 1 130px; background: var(--surface); border: 1px solid var(--line);
    border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 3px;
  }
  .stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); }
  .stat .l { color: var(--muted); font-size: 0.8rem; }

  .gfunnel { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.9rem; }
  .gfunnel th, .gfunnel td { text-align: right; padding: 6px 10px; border-bottom: 1px solid var(--line); }
  .gfunnel th:first-child, .gfunnel td:first-child { text-align: left; }
  .gfunnel th { color: var(--muted); font-weight: 600; font-size: 0.78rem; }
  .gfunnel td { font-variant-numeric: tabular-nums; }

  .two { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
  @media (max-width: 620px) { .two { grid-template-columns: 1fr; } }

  ul.bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
  ul.bars li { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; position: relative; }
  ul.bars .lbl {
    grid-column: 1; z-index: 1; font-size: 0.86rem; color: var(--ink);
    text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 4px 0;
  }
  a.lbl { text-decoration: none; }
  a.lbl:hover { color: var(--accent-deep); }
  ul.bars .bar {
    grid-column: 1 / 2; grid-row: 1; height: 100%; min-height: 26px; align-self: stretch;
    background: var(--accent-soft); border-radius: 8px; z-index: 0;
  }
  ul.bars .v { grid-column: 2; color: var(--muted); font-size: 0.82rem; font-variant-numeric: tabular-nums; }

  .trend { display: flex; align-items: flex-end; gap: 3px; height: 120px; }
  .trend .col { flex: 1; display: flex; align-items: flex-end; gap: 1px; height: 100%; }
  .trend .visits { flex: 1; background: var(--accent-soft); border-radius: 2px 2px 0 0; }
  .trend .opens { flex: 1; background: var(--accent); border-radius: 2px 2px 0 0; }
  .legend { color: var(--muted); font-size: 0.78rem; margin-top: 8px; }
  .legend .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: middle; }
  .legend .sw.visits { background: var(--accent-soft); }
  .legend .sw.opens { background: var(--accent); }

  .sub2 { color: var(--muted); font-size: 0.84rem; margin: 0 0 12px; }
  .exporthdr { margin: 0 0 14px; }
  .exportlink { font-size: 0.78rem; font-weight: 400; color: var(--accent-deep); margin-left: 10px; }
  .exportlink:hover { text-decoration: underline; }
  .legend2 { color: var(--muted); font-size: 0.76rem; margin: 10px 0 0; font-style: italic; }

  /* Analytics window picker */
  .rangepick { display: flex; align-items: center; gap: 7px; margin: 0 0 18px; }
  .rangepick .rl { font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-right: 2px; }
  .rangepick .chip {
    border: 1px solid var(--line); background: var(--surface); color: var(--ink);
    border-radius: 999px; padding: 4px 12px; font-size: 0.8rem; cursor: pointer;
  }
  .rangepick .chip:hover { border-color: var(--accent); }
  .rangepick .chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }

  /* Filter chips (sources + feedback) */
  .filterchips { display: flex; gap: 7px; flex-wrap: wrap; margin: 0 0 14px; }
  .filterchips .chip {
    border: 1px solid var(--line); background: var(--surface); color: var(--ink);
    border-radius: 999px; padding: 5px 13px; font-size: 0.82rem; cursor: pointer; text-transform: capitalize;
  }
  .filterchips .chip:hover { border-color: var(--accent); }
  .filterchips .chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
  /* Sources filter row + search */
  .srctools { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 14px; }
  .srctools .filterchips { margin: 0; }
  .srcsearch { display: inline-flex; align-items: center; gap: 8px; }
  .srcsearch input {
    font: inherit; font-size: 0.84rem; padding: 6px 12px; border: 1px solid var(--line);
    border-radius: 999px; background: var(--surface); color: var(--ink); width: 230px;
  }
  .srcsearch input:focus { outline: none; border-color: var(--accent); }
  .srccount { font-size: 0.78rem; color: var(--muted); white-space: nowrap; }
  .srcempty { text-align: center; color: var(--muted); font-style: italic; padding: 22px 10px; }

  /* Add a source + candidate queue */
  .addsrc { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; margin-bottom: 6px; }
  .addrow { display: flex; gap: 8px; flex-wrap: wrap; }
  .addrow input { flex: 1 1 200px; box-sizing: border-box; font: inherit; font-size: 0.9rem; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg); color: var(--ink); }
  .addrow input:focus { outline: none; border-color: var(--accent); }
  ul.candlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
  .rejtray > summary { cursor: pointer; font-family: var(--label); font-weight: 600; color: var(--muted); margin-bottom: 10px; }
  .rejtray .rejlist { opacity: 0.85; }   /* tucked-away, lower-emphasis tray */
  ul.candlist li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
  .chead { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
  .chead .cname { font-weight: 600; color: var(--ink); }
  .chead .cstatus { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
  .chead .crename { font: inherit; font-size: 0.9rem; font-weight: 600; padding: 4px 9px;
                    border: 1px solid var(--accent); border-radius: 8px; background: var(--bg); color: var(--ink); }
  .chead .crename:focus { outline: none; }
  .act.mini { font-size: 0.72rem; padding: 2px 8px; color: var(--accent-deep);
              background: none; border: 1px solid var(--line); border-radius: 999px; cursor: pointer; }
  .act.mini:hover { border-color: var(--accent); }
  .curl { font-size: 0.76rem; color: var(--muted); word-break: break-all; margin-top: 2px; }
  .cprev { font-size: 0.84rem; color: var(--ink); margin-top: 7px; }
  .cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; }
  .held { color: #8a5a18; font-weight: 600; }   /* language-held (not a rejection) */
  /* Heuristic-vs-model preview badge */
  .vbadge { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
            font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; cursor: help; }
  .vbadge.model { background: #e3efe4; color: #3f7048; }
  .vbadge.fast { background: var(--line); color: var(--muted); }
  .vbadge.wall { background: #fdf0d8; color: #8a5a18; }
  /* Deep-preview accessibility sample */
  .caccess { margin-top: 6px; font-size: 0.8rem; color: var(--ink); display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
  .acc-verdict { padding: 1px 8px; border-radius: 999px; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
  .acc-verdict.fine { background: #e3efe4; color: #3f7048; }
  .acc-verdict.review { background: #fdf0d8; color: #8a5a18; }
  .acc-verdict.reject-ready { background: #fbe3e3; color: #9a3b3b; }
  .acc-of { color: var(--muted); }
  .acc-ex-row { display: flex; gap: 6px; flex-wrap: wrap; flex-basis: 100%; }
  .acc-ex { font-size: 0.7rem; padding: 1px 7px; border-radius: 7px; text-decoration: none; }
  .acc-ex.readable { background: #e3efe4; color: #3f7048; }
  .acc-ex.paywalled { background: #fbe3e3; color: #9a3b3b; }
  .acc-ex.blocked { background: #eceff2; color: #67727d; }
  .acc-ex.unknown { background: var(--line); color: var(--muted); }
  .cactions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
  .cactions .ccat { font: inherit; font-size: 0.8rem; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); width: 150px; }
  .cactions .cchk { font-size: 0.8rem; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; }

  /* Source health table */
  /* Constrained scroll box: the table scrolls BOTH ways inside its own panel, so
     the horizontal scrollbar stays on-screen instead of living at the bottom of a
     46-row table you'd have to page down to reach. Header row stays sticky. */
  .tablewrap {
    max-height: 65vh; overflow: auto;
    border: 1px solid var(--line); border-radius: 12px; background: var(--surface);
  }
  .srctable { width: 100%; border-collapse: collapse; font-size: 0.86rem; min-width: 560px; }
  .srctable thead th {
    position: sticky; top: 0; z-index: 1; background: var(--surface);
    /* box-shadow draws the divider reliably under position:sticky (a plain
       border-bottom can drop out while scrolling with border-collapse). */
    box-shadow: inset 0 -1px 0 var(--line);
  }
  .srctable th {
    text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
    color: var(--muted); font-weight: 600; padding: 8px 10px;
  }
  .srctable td { padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: baseline; }
  .srctable .num { text-align: right; font-variant-numeric: tabular-nums; }
  .srctable .media { white-space: nowrap; }
  .srctable .media .pw { font-size: 0.78rem; opacity: 0.75; }
  .srctable .dim { color: var(--muted); white-space: nowrap; font-size: 0.82rem; }
  .srctable .sname { font-weight: 600; color: var(--ink); }
  .srctable .sname .cat { display: block; font-weight: 400; font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
  .srctable .sname .rr { display: block; font-weight: 400; font-size: 0.72rem; color: var(--accent-deep); font-style: italic; }
  .srctable tr.warn { background: #fdf3e3; }
  .srctable tr.flag { background: #eef4f8; }
  .srctable tr.paused { opacity: 0.6; }
  .srctable .status .bad { color: #875a16; }
  .srctable .status .flagtxt { color: var(--accent-deep); }
  .srctable .status .good { color: #3f7048; }
  .srctable .status .paustxt { color: var(--muted); font-style: italic; }
  .srctable .status .hidtxt { color: #9a3b3b; font-size: 0.78rem; }
  .srctable tr.checkrow td { background: var(--bg); font-size: 0.85rem; padding: 10px 12px; }
  .chkhead { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
  .chkhead .chkwhen { color: var(--muted); font-size: 0.76rem; }
  .chkex { margin-top: 5px; color: var(--ink); }
  .chkex .chklbl { color: var(--muted); }
  .chkex.chkrej { color: var(--muted); }

  /* Source article inspector */
  .srctable tr.artrow td { background: var(--bg); font-size: 0.84rem; padding: 10px 12px; }
  .artsum { color: var(--ink); margin-bottom: 8px; }
  .pwrule { color: var(--muted); font-weight: 600; }
  .pwrule.on { color: #9a3b3b; }
  .pwctl { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; font-size: 0.8rem; color: var(--muted); }
  .pwsel { font: inherit; font-size: 0.78rem; padding: 3px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); }
  .artfilters { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 8px; }
  .chip.sm { font-size: 0.74rem; padding: 3px 10px; }
  .artlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; max-height: 360px; overflow-y: auto; }
  .artlist li { border-bottom: 1px solid var(--line); padding-bottom: 6px; }
  .art-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
  .art-title { color: var(--accent-deep); font-weight: 600; text-decoration: none; }
  .art-title:hover { text-decoration: underline; }
  .art-row .badge { font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 7px; border-radius: 999px; }
  .badge.ok { background: #e3efe4; color: #3f7048; }
  .badge.no { background: #f3e0e0; color: #9a3b3b; }
  .badge.held { background: #fdf0d8; color: #8a5a18; }   /* language-held, not rejected */
  .art-row .pw { font-size: 0.78rem; }
  .art-flag { font-size: 0.7rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; }
  .art-cat { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
  .art-when { font-size: 0.72rem; color: var(--muted); margin-left: auto; white-space: nowrap; }
  .art-title.plain { color: var(--ink); font-weight: 600; }
  .art-reason { font-size: 0.76rem; color: var(--muted); font-style: italic; margin-top: 2px;
                display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; cursor: help; }
  .act.more { margin-top: 8px; }
  .srctable .rowactions { white-space: nowrap; }
  .srctable .rowactions .act {
    background: none; border: 1px solid var(--line); color: var(--accent-deep);
    border-radius: 999px; padding: 3px 11px; font-size: 0.76rem; cursor: pointer; margin-right: 5px;
  }
  .srctable .rowactions .act:hover { border-color: var(--accent); }

  .count { color: var(--muted); font-weight: 400; font-size: 0.9rem; }
  ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
  ul.fb li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
  ul.fb li.unread { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }
  .fhead { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; font-size: 0.78rem; }
  .fhead .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
  .fhead .cat { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 2px 9px; text-transform: capitalize; }
  .fhead .when { color: var(--muted); }
  .fhead .addr { color: var(--accent-deep); margin-left: auto; }
  .fhead .anon { color: var(--muted); margin-left: auto; font-style: italic; }
  .msg { margin: 0; color: var(--ink); font-size: 0.92rem; white-space: pre-wrap; }

  /* Reply thread + composer */
  .thread { margin: 10px 0 0; display: flex; flex-direction: column; gap: 8px; }
  .rep { border-left: 2px solid var(--accent-soft); padding: 2px 0 2px 12px; }
  .rep .rl { font-size: 0.72rem; color: var(--muted); }
  .rep .repmsg { margin: 2px 0 0; font-size: 0.9rem; color: var(--ink); }
  .rep .repmsg :global(p) { margin: 0 0 6px; white-space: pre-wrap; }
  .rep .repmsg :global(ul) { margin: 4px 0; padding-left: 20px; }
  .rep .repmsg :global(h3),
  .rep .repmsg :global(h4),
  .rep .repmsg :global(h5) { margin: 6px 0 4px; font-size: 0.95rem; }
  .composer { margin: 10px 0 0; }
  .rtbar { display: flex; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; align-items: center; }
  .rtbtn {
    font: inherit; font-size: 0.82rem; background: var(--surface); border: 1px solid var(--line);
    color: var(--ink); border-radius: 7px; padding: 3px 10px; cursor: pointer; line-height: 1.4; min-width: 30px;
  }
  .rtbtn:hover { border-color: var(--accent); color: var(--accent-deep); }
  .szsel {
    font: inherit; font-size: 0.8rem; background: var(--surface); border: 1px solid var(--line);
    color: var(--ink); border-radius: 7px; padding: 3px 6px; cursor: pointer;
  }
  .rtedit {
    min-height: 90px; box-sizing: border-box; font-size: 0.92rem; line-height: 1.5;
    padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px;
    background: var(--bg); color: var(--ink);
  }
  .rtedit:focus { outline: none; border-color: var(--accent); }
  .rtedit :global(ul), .rtedit :global(ol) { margin: 4px 0; padding-left: 22px; }
  .rtedit :global(p) { margin: 0 0 6px; }
  .cerr { margin: 6px 0 0; color: #9a3b3b; font-size: 0.82rem; }
  .cbtns { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
  .csend {
    font: inherit; font-size: 0.82rem; font-weight: 600; background: var(--accent); color: #fff;
    border: none; border-radius: 999px; padding: 7px 18px; cursor: pointer;
  }
  .csend:hover { background: var(--accent-deep); }
  .csend:disabled { opacity: 0.55; cursor: default; }
  .noreply { color: var(--muted); font-size: 0.76rem; font-style: italic; }

  /* Flag-reason popover */
  .modal-overlay {
    position: fixed; inset: 0; background: rgba(10, 22, 38, 0.32);
    display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 60;
  }
  .modal {
    background: var(--surface); border: 1px solid var(--line); border-radius: 16px;
    box-shadow: 0 12px 34px rgba(40, 38, 28, 0.16); width: 100%; max-width: 420px; padding: 22px 24px;
  }
  .modal h3 { margin: 0 0 6px; font-size: 1.2rem; }
  .modal .msub { margin: 0 0 14px; color: var(--muted); font-size: 0.85rem; }
  .modal input {
    width: 100%; box-sizing: border-box; font: inherit; font-size: 0.92rem;
    padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--ink);
  }
  .modal input:focus { outline: none; border-color: var(--accent); }
  .mbtns { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
  .msend {
    font: inherit; font-size: 0.85rem; font-weight: 600; background: var(--accent); color: #fff;
    border: none; border-radius: 999px; padding: 8px 18px; cursor: pointer;
  }
  .msend:hover { background: var(--accent-deep); }
  .factions { display: flex; gap: 14px; margin-top: 9px; }
  .factions .act {
    background: none; border: none; padding: 0; cursor: pointer;
    color: var(--muted); font-size: 0.76rem; border-bottom: 1px dotted var(--line);
  }
  .factions .act:hover { color: var(--accent-deep); border-bottom-color: var(--accent); }
  .factions .act.del:hover { color: #9a3b3b; border-bottom-color: #9a3b3b; }

  .stat.alert { background: #f3e0e0; }
  .stat.alert .n { color: #9a3b3b; }
  .cerrs { list-style: none; padding: 0; margin: 10px 0 0; display: flex; flex-direction: column; gap: 6px; }
  .cerrs li { display: grid; grid-template-columns: auto 1fr auto; gap: 6px 12px; align-items: baseline;
              font-size: 0.82rem; padding: 8px 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; }
  .ce-when { color: var(--muted); white-space: nowrap; }
  .ce-reason { font-family: var(--label); color: #9a3b3b; }
  /* Layer tag — html-slow is the only incident-grade one, so it reads loudest. */
  .ce-tag { display: inline-block; margin-right: 8px; padding: 1px 7px; border-radius: 999px;
            font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
            vertical-align: middle; }
  .ce-tag.html { background: #fbe3e3; color: #9a3b3b; }
  .ce-tag.app { background: #fdf0d8; color: #8a5a18; }
  .ce-tag.preload { background: var(--accent-soft); color: var(--accent-deep); }
  .ce-tag.runtime { background: #fbe3e3; color: #9a3b3b; }
  .ce-tag.bot { background: #eceff2; color: #67727d; }
  .cerrs li.bot { opacity: 0.6; }
  .cerrs li.bot .ce-reason { color: var(--muted); }
  .ce-bot { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
            background: var(--accent-soft); color: var(--accent-deep);
            font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
  .ce-path { color: var(--accent-deep); white-space: nowrap; }
  .ce-ua { grid-column: 1 / -1; color: var(--muted); font-size: 0.72rem;
           overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

  /* Games — Daily Word pool */
  .wp-lookup { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 14px 0 6px; }
  .wp-lookup input {
    font: inherit; font-size: 1.05rem; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px;
    text-transform: uppercase; letter-spacing: 0.06em; width: 210px; background: var(--surface); color: var(--ink);
  }
  .wp-tag { font-size: 0.84rem; font-weight: 600; padding: 4px 11px; border-radius: 999px; }
  .wp-tag.ok { background: var(--accent-soft); color: var(--accent-deep); }
  .wp-tag.bad { background: #f3e0e0; color: #9a3b3b; }
  .wp-add { background: var(--accent); color: #fff; border: none; border-radius: 999px; padding: 8px 18px;
            font: inherit; font-weight: 600; cursor: pointer; }
  .wp-add:hover { background: var(--accent-deep); }
  .wp-msg { color: var(--accent-deep); font-size: 0.9rem; margin: 6px 0 0; }
  .bloom-reports { list-style: none; padding: 0; margin: 10px 0 18px; display: flex; flex-direction: column; gap: 8px; }
  .bloom-reports li { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
                      background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }
  .br-word { font-weight: 700; text-transform: capitalize; }
  .br-ctx { color: var(--muted); font-size: 0.82rem; }
  .br-acts { margin-left: auto; display: flex; align-items: center; gap: 10px; }
  .br-acts .wp-add { padding: 5px 13px; font-size: 0.85rem; }
  .bloom-ovr { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 8px; }
  .ovr-chip { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 4px 6px 4px 12px;
              font-size: 0.85rem; color: #fff; }
  .ovr-chip.allow { background: #2e8b57; }
  .ovr-chip.block { background: #b8553f; }
  .ovr-x { background: rgba(255,255,255,0.25); border: none; color: #fff; border-radius: 50%; width: 18px; height: 18px;
           line-height: 1; cursor: pointer; font-size: 0.9rem; }
  .wp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 22px; margin-top: 24px; }
  .wp-col h3 { margin: 0 0 2px; font-size: 1.05rem; }
  .wp-col .count { font-size: 0.85rem; }
  .small { font-size: 0.82rem; }
  .wp-added { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; padding: 0; margin: 10px 0 0;
              max-height: 230px; overflow-y: auto; }
  .wp-added li {
    display: inline-flex; align-items: center; gap: 4px; background: var(--surface); border: 1px solid var(--line);
    border-radius: 999px; padding: 4px 6px 4px 12px; font-family: var(--label); text-transform: uppercase;
    font-size: 0.82rem; letter-spacing: 0.04em; color: var(--ink);
  }
  .wp-added .x { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1.15rem;
                 line-height: 1; padding: 0 5px; border-radius: 50%; }
  .wp-added .x:hover { color: #9a3b3b; }
  .wp-added.removed li { opacity: 0.7; border-style: dashed; }
  .wp-added.removed .x { font-size: 0.95rem; }
  .wp-added.removed .x:hover { color: var(--accent-deep); }
  .wp-lookup .act { font: inherit; font-size: 0.82rem; background: var(--surface); border: 1px solid var(--line);
                    border-radius: 999px; padding: 7px 16px; cursor: pointer; color: #9a3b3b; }
  .wp-lookup .act:hover { border-color: #9a3b3b; }
  /* Bulk import */
  .wp-import { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 10px 0 6px; }
  .wp-import textarea { font: inherit; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px;
                        background: var(--surface); color: var(--ink); resize: vertical; line-height: 1.6;
                        text-transform: uppercase; letter-spacing: 0.03em; }
  .wp-import-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
  .act.file { font: inherit; font-size: 0.84rem; background: var(--accent-soft); color: var(--accent-deep);
              border: none; border-radius: 999px; padding: 8px 16px; cursor: pointer; }
  .act.file:hover { filter: brightness(0.97); }
  .wp-rejects { margin: 4px 0 0; }
  .wp-rejects summary { cursor: pointer; color: var(--accent-deep); font-size: 0.84rem; }
  .wp-rejects ul { margin: 8px 0 0; padding-left: 18px; }

  /* Word Search theme authoring */
  .ws-form { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 14px 0 6px; }
  .ws-name { font: inherit; font-size: 1.05rem; padding: 10px 14px; border: 1px solid var(--line);
             border-radius: 10px; background: var(--surface); color: var(--ink); }
  .ws-words { font: inherit; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px;
              background: var(--surface); color: var(--ink); resize: vertical; line-height: 1.6;
              text-transform: uppercase; letter-spacing: 0.03em; }
  .ws-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
  .ws-count { font-size: 0.86rem; font-weight: 600; color: var(--muted); }
  .ws-count.ok { color: var(--accent-deep); }
  .ws-ai { background: var(--accent-soft); color: var(--accent-deep); border: none; border-radius: 999px;
           padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
  .ws-ai:hover:not(:disabled) { filter: brightness(0.97); }
  .ws-ai:disabled, .wp-add:disabled { opacity: 0.5; cursor: default; }
  .ws-themes { list-style: none; padding: 0; margin: 14px 0 0; display: flex; flex-direction: column; gap: 8px; max-width: 560px; }
  .ws-themes li { display: flex; align-items: center; gap: 12px; background: var(--surface);
                  border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
  .wt-name { font-weight: 600; }
  .wt-count { color: var(--muted); font-size: 0.84rem; margin-right: auto; }

  /* Publishing Desk */
  .pubtools { display: flex; align-items: center; gap: 12px; margin: 6px 0 16px; }
  .publist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 14px; }
  .pubcard { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; }
  .pubcard.opened { border-color: var(--accent); }
  .pub-head { display: flex; gap: 12px; align-items: flex-start; }
  .pub-img { width: 88px; height: 64px; object-fit: cover; border-radius: 9px; flex-shrink: 0; }
  .pub-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
  .pub-title { font-weight: 600; color: var(--ink); text-decoration: none; }
  .pub-title:hover { text-decoration: underline; }
  .pub-src { font-size: 0.8rem; color: var(--muted); }
  .pub-openedtag { color: var(--accent-deep); font-weight: 600; }
  .pub-why { margin: 10px 0 4px; font-style: italic; color: var(--ink); }
  .pub-points { margin: 4px 0; padding-left: 18px; color: var(--ink); font-size: 0.9rem; }
  .pub-points li { margin: 1px 0; }
  .pub-angle { margin: 4px 0; font-size: 0.88rem; color: var(--muted); }
  .hlbl { font-family: var(--label); font-weight: 600; font-size: 0.78rem; color: var(--muted); }
  .pub-handles { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 8px 0; }
  .hchip { font-size: 0.8rem; padding: 2px 9px; border: 1px solid var(--accent); border-radius: 999px;
           background: var(--accent-soft); color: var(--accent-deep); cursor: pointer; }
  .pub-draft { width: 100%; box-sizing: border-box; margin-top: 8px; padding: 9px 11px; font: inherit;
               border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--ink); resize: vertical; }
  .pub-toolbar { position: relative; margin-top: 8px; }
  .emoji-toggle { font: inherit; font-size: 0.8rem; padding: 3px 10px; border: 1px solid var(--line);
                  border-radius: 999px; background: var(--bg); color: var(--ink); cursor: pointer; }
  .emoji-toggle.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-deep); }
  .emoji-pop { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; padding: 8px;
               border: 1px solid var(--line); border-radius: 10px; background: var(--card, var(--bg));
               box-shadow: 0 6px 20px rgba(0,0,0,0.18); }
  .pub-count { font-size: 0.78rem; color: var(--muted); text-align: right; margin-top: 2px; font-variant-numeric: tabular-nums; }
  .pub-count.over { color: #9a3b3b; font-weight: 700; }
  .pub-find { margin: 8px 0; font-size: 0.85rem; }
  .pub-find > summary { cursor: pointer; color: var(--accent-deep); }
  .pub-ent { display: flex; align-items: center; gap: 8px; margin: 6px 0; flex-wrap: wrap; }
  .pub-entname { min-width: 120px; }
  .hin { font: inherit; font-size: 0.82rem; padding: 4px 8px; border: 1px solid var(--line); border-radius: 8px;
         background: var(--bg); color: var(--ink); width: 130px; }
  .pub-actions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
  .pub-confirm { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 10px; background: var(--accent-soft); }
  .hin.wide { width: 100%; box-sizing: border-box; margin: 4px 0; }

  /* Small Joys */
  .joy-bar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; }
  .joy-kinds { display: flex; gap: 8px; }
  .joy-kinds button { padding: 6px 14px; border: 1px solid var(--line); border-radius: 999px; background: #fff; cursor: pointer; font-size: 0.9rem; }
  .joy-kinds button.on { background: var(--accent); color: #fff; border-color: var(--accent); }
  .joy-repick { padding: 6px 14px; border: 1px solid var(--line); border-radius: 8px; background: #fff; cursor: pointer; font-size: 0.9rem; }
  .joy-add { margin-bottom: 14px; }
  .joy-add summary { cursor: pointer; font-weight: 600; color: var(--accent); }
  .joy-add-fields { display: flex; flex-direction: column; gap: 8px; max-width: 540px; margin-top: 10px; }
  .joy-add-fields input, .joy-add-fields textarea { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; font: inherit; }
  .joy-add-fields textarea { min-height: 60px; resize: vertical; }
  .joy-add-fields button { align-self: flex-start; padding: 7px 16px; border: none; border-radius: 8px; background: var(--accent); color: #fff; cursor: pointer; }
  .joy-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
  .joy-row { display: flex; justify-content: space-between; gap: 14px; align-items: flex-start; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
  .joy-row.blocked { opacity: 0.5; }
  .joy-main { min-width: 0; flex: 1; }
  .joy-sub { margin-top: 3px; line-height: 1.4; }
  .joy-acts { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; flex-shrink: 0; }
  .joy-acts button { padding: 4px 10px; border: 1px solid var(--line); border-radius: 7px; background: #fff; cursor: pointer; font-size: 0.82rem; }
  .joy-acts .joy-del { color: #b23b3b; border-color: #e6c4c4; }
  .joy-badge { font-size: 0.72rem; font-weight: 700; color: var(--accent); background: var(--accent-soft); border-radius: 999px; padding: 2px 8px; }
</style>
