Files
upbeatBytes/frontend/src/routes/admin/+page.svelte
T
thejayman77 ddcfab3a11 Admin: source Articles inspector (verify metrics against real evidence)
New per-row "Articles" button on the Sources table expands a read-only inline
panel of the source's ACTUAL ingested articles — so the automated metrics
(paywall/image/acceptance/duplicate) can be verified against evidence instead of
trusted blind. Distinct from "Check" (which re-samples the LIVE feed for
would-pass quality); this shows what's already in the DB, which is what the table
metrics are computed from.

- Backend: GET /api/admin/sources/{id}/articles?filter=&limit=&offset= (admin,
  read-only). queries.source_articles + source_articles_summary — per article:
  title, url, date, accepted, reason (the "why"), topic/flavor, paywalled
  (domain rule), has_image, duplicate. Summary = counts + source-level paywall
  rule.
- Frontend: expandable panel with a summary header ("27 ingested · 18 accepted
  · … · paywall rule: ON (domain)"), filter chips (All/Accepted/Rejected/No
  image/Duplicates), compact rows with title→link + badges + reason, Load more.

So "100% paywall" or "0% images" becomes clickable evidence: open two articles
to tell a real paywall from a mis-flagged domain, or a true image gap from an
enrichment failure. Test: test_source_articles_inspector. 241 pytest + 11 vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:37:51 -04:00

1448 lines
78 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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';
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] = 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'),
]);
feedback = fb;
candidates = cand;
wpPool = wp;
clientErrors = ce;
wsThemes = ws;
} catch {
error = "Couldn't load stats.";
}
});
let clientErrors = $state([]);
// --- 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 (todays 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' },
];
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'], ['no_image', 'No image'], ['duplicates', 'Duplicates']];
// --- 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'));
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: 120000 });
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 */ }
}
// 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})</span></h2>
<ul class="cerrs">
{#each clientErrors as e (e.created_at + e.reason)}
<li class:bot={e.bot}>
<span class="ce-when">{fdate(e.created_at)}</span>
<span class="ce-reason">{e.reason || '—'}{#if e.bot}<span class="ce-bot">bot</span>{/if}</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.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.examples_accepted?.length}<div class="cex">e.g. {c.preview.examples_accepted.slice(0, 3).join(' · ')}</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}
<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 · {s._artSummary.no_image} no image ·
{s._artSummary.duplicates} dup ·
paywall rule: <span class="pwrule" class:on={s._artSummary.paywalled}>{s._artSummary.paywalled ? 'ON (domain)' : 'off'}</span>
</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">
<a class="art-title" href={a.url} target="_blank" rel="noopener">{a.title}</a>
{#if a.accepted === 1}<span class="badge ok">accepted</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">{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">23 days</span></div>
<div class="stat"><span class="n">{stats.retention['4-7']}</span><span class="l">47 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>
<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>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 · 56 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 (48 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 48 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}
{/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; }
.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; }
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; }
/* 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); }
.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; }
.artsum .pwrule { color: var(--muted); font-weight: 600; }
.artsum .pwrule.on { color: #9a3b3b; }
.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; }
.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-reason { font-size: 0.76rem; color: var(--muted); font-style: italic; margin-top: 2px; }
.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; }
.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; }
.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; }
</style>