Admin: tabbed operator console (Overview/Content/Sources/Audience/Feedback)

Reshape the long single-page dashboard into a sectioned console (one route,
?section= tabs, sticky subnav) focused on "what needs my attention" first.

* Overview: an "Attention Needed" strip (soft amber/blue, never alarming red)
  derived from the same data — sources resting/flagged, image coverage <70%,
  thin brief, recent feedback — plus at-a-glance pulse cards.
* Content: corpus health + image/summary coverage (with_image, summaries_with_
  image, brief image coverage, 24h image misses) + top opened / topics / tags.
* Sources: filterable table (All/Healthy/Resting/Flagged) — served, last
  success, next poll, failure streak, status — instead of a card pile.
* Audience: visitors, retention, accounts, funnel, sharing, daily trend.
* Feedback: inbox with category filter, newest first, quick mailto reply.

Backend: content_stats gains added_7d + image-coverage fields; source_health
gains review_flag; admin_stats adds attention[] + feedback_7d.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-08 13:03:23 -04:00
parent 489c34d2f2
commit 575f562ad5
3 changed files with 375 additions and 179 deletions
+284 -175
View File
@@ -1,6 +1,7 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { getJSON } from '$lib/api.js';
import { auth, refresh } from '$lib/auth.svelte.js';
@@ -22,6 +23,15 @@
}
});
const TABS = [
{ key: 'overview', label: 'Overview' },
{ key: 'content', label: 'Content' },
{ key: 'sources', label: 'Sources' },
{ key: 'audience', label: 'Audience' },
{ key: 'feedback', label: 'Feedback' },
];
let section = $derived($page.url.searchParams.get('section') || 'overview');
function fdate(s) {
try { return new Date(s + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }
catch { return s; }
@@ -33,8 +43,25 @@
if (isNaN(d)) return s;
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
let healthy = $derived((stats?.sources ?? []).filter((s) => !s.failures).length);
let ailing = $derived((stats?.sources ?? []).filter((s) => s.failures > 0).length);
let sources = $derived(stats?.sources ?? []);
let healthy = $derived(sources.filter((s) => !s.failures && !s.review_flag).length);
let resting = $derived(sources.filter((s) => s.failures > 0).length);
let flagged = $derived(sources.filter((s) => s.review_flag).length);
// Sources table filter.
let srcFilter = $state('all');
let shownSources = $derived(
srcFilter === 'healthy' ? sources.filter((s) => !s.failures && !s.review_flag)
: srcFilter === 'resting' ? sources.filter((s) => s.failures > 0)
: srcFilter === 'flagged' ? sources.filter((s) => s.review_flag)
: sources
);
// Feedback inbox filter.
let fbCat = $state('all');
let shownFeedback = $derived(fbCat === 'all' ? feedback : feedback.filter((f) => f.category === fbCat));
let fbCats = $derived([...new Set(feedback.map((f) => f.category))]);
const SHARE_LABEL = {
share_ub: 'Copied UB link',
@@ -42,11 +69,9 @@
copy_source: 'Copied source',
source_click: 'Source clicks (from /a)',
};
// Width % for a simple CSS bar, relative to the top value in a list.
function pct(v, max) {
return max > 0 ? Math.max(4, Math.round((v / max) * 100)) : 0;
}
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">
@@ -63,183 +88,230 @@
{:else if !stats}
<p class="muted">Loading…</p>
{:else}
<p class="sub">Aggregate, anonymous — last {stats.days} days. No personal data.</p>
<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' && feedback.length}<span class="badge">{feedback.length}</span>{/if}
</a>
{/each}
</nav>
<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">Last 7 days</span></div>
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Last 30 days</span></div>
</div>
{#if stats.content}
<section>
<h2>Content served</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.accepted_7d}</span><span class="l">Fresh (last 7d)</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.summaries}</span><span class="l">Summaries</span></div>
<div class="stat"><span class="n">{stats.content.active_sources}</span><span class="l">Active sources</span></div>
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
</div>
</section>
{/if}
<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>Returning visitors (30d)</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>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.replace.none}</span><span class="l">No replacement</span></div>
<div class="stat"><span class="n">{stats.paywall.paywall_replace}</span><span class="l">Paywall 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>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>
{#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}
</ul>
{:else}<p class="muted">No opens yet.</p>{/if}
</section>
</div>
{:else}
<div class="attn ok">✓ All clear — nothing needs attention right now.</div>
{/if}
<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>
<section>
<h2>Sharing</h2>
<h2>Pulse</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>
<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 (30d)</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>
</div>
{: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'}
<h2>Source health</h2>
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {sources.length} active</p>
<div class="filterchips">
{#each [['all', 'All'], ['healthy', 'Healthy'], ['resting', 'Resting'], ['flagged', 'Flagged']] as [key, label] (key)}
<button class="chip" class:on={srcFilter === key} onclick={() => (srcFilter = key)}>{label}</button>
{/each}
</div>
</section>
<div class="tablewrap">
<table class="srctable">
<thead>
<tr><th>Source</th><th class="num">Served</th><th>Last success</th><th>Next poll</th><th class="num">Fails</th><th>Status</th></tr>
</thead>
<tbody>
{#each shownSources as s (s.id)}
<tr class:warn={s.failures > 0} class:flag={s.review_flag}>
<td class="sname">{s.name}{#if s.category}<span class="cat">{s.category}</span>{/if}</td>
<td class="num">{s.served}</td>
<td class="dim">{s.last_success_at ? fwhen(s.last_success_at) : '—'}</td>
<td class="dim">{fwhen(s.next_due_at)}</td>
<td class="num">{s.failures || ''}</td>
<td class="status">
{#if s.failures > 0}<span class="bad" title={s.last_error || ''}> resting</span>
{:else if s.review_flag}<span class="flagtxt">⚑ review</span>
{:else}<span class="good">✓ ok</span>{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="legend2">“served” = accepted articles live from that source · times in your local zone</p>
<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>
{:else if section === 'audience'}
<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 30 days</span></div>
</div>
</section>
<section>
<h2>Returning visitors (30d)</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>
<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>
{#if stats.sources?.length}
<section>
<h2>Source health</h2>
<p class="sub2">{healthy} healthy · {ailing} backing off · {stats.sources.length} active</p>
<ul class="srclist">
{#each stats.sources as s (s.id)}
<li class:warn={s.failures > 0}>
<span class="sname" title={s.last_error || ''}>{s.name}</span>
<span class="sserved">{s.served}</span>
<span class="sstatus">
{#if s.failures > 0}
{s.failures} fail{s.failures > 1 ? 's' : ''} · resting until {fwhen(s.next_due_at)}
{:else}
✓ next poll {fwhen(s.next_due_at)}
{/if}
</span>
</li>
{/each}
</ul>
<p class="legend2">“served” = accepted articles live from that source · times in your local zone</p>
</section>
{/if}
<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>
<section>
{:else if section === 'feedback'}
<h2>Feedback {#if feedback.length}<span class="count">({feedback.length})</span>{/if}</h2>
{#if feedback.length}
<div class="filterchips">
<button class="chip" class:on={fbCat === 'all'} onclick={() => (fbCat = 'all')}>All</button>
{#each fbCats as cat (cat)}
<button class="chip" class:on={fbCat === cat} onclick={() => (fbCat = cat)}>{cat}</button>
{/each}
</div>
<ul class="fb">
{#each feedback as f (f.id)}
{#each shownFeedback as f (f.id)}
<li>
<div class="fhead">
<span class="cat">{f.category}</span>
<span class="when">{fdate(f.created_at)}</span>
{#if f.contact_email}<a class="reply" href={'mailto:' + f.contact_email}>{f.contact_email}</a>{/if}
{#if f.contact_email}<a class="reply" href={'mailto:' + f.contact_email}>Reply ↩</a>{:else}<span class="anon">anonymous</span>{/if}
</div>
<p class="msg">{f.message}</p>
</li>
{/each}
</ul>
{:else}<p class="muted">No feedback yet.</p>{/if}
</section>
{/if}
{/if}
</main>
@@ -250,14 +322,35 @@
.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 2px; }
.sub { color: var(--muted); font-size: 0.9rem; margin: 0 0 22px; }
h2 { font-size: 1.1rem; margin: 30px 0 12px; }
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 120px; background: var(--surface); border: 1px solid var(--line);
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); }
@@ -270,8 +363,7 @@
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;
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); }
@@ -292,17 +384,33 @@
.sub2 { color: var(--muted); font-size: 0.84rem; margin: 0 0 12px; }
.legend2 { color: var(--muted); font-size: 0.76rem; margin: 10px 0 0; font-style: italic; }
ul.srclist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
ul.srclist li {
display: grid; grid-template-columns: 1fr auto auto; align-items: baseline; gap: 12px;
padding: 7px 10px; border-radius: 8px; font-size: 0.86rem;
/* 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;
}
ul.srclist li:nth-child(odd) { background: var(--surface); }
ul.srclist li.warn { background: #fbeaea; }
ul.srclist .sname { color: var(--ink); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
ul.srclist .sserved { color: var(--muted); font-variant-numeric: tabular-nums; }
ul.srclist .sstatus { color: var(--muted); font-size: 0.8rem; white-space: nowrap; }
ul.srclist li.warn .sstatus { color: #9a3b3b; }
.filterchips .chip:hover { border-color: var(--accent); }
.filterchips .chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Source health table */
.tablewrap { overflow-x: auto; }
.srctable { width: 100%; border-collapse: collapse; font-size: 0.86rem; min-width: 560px; }
.srctable th {
text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--muted); font-weight: 600; padding: 6px 10px; border-bottom: 1px solid var(--line);
}
.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 .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 tr.warn { background: #fdf3e3; }
.srctable tr.flag { background: #eef4f8; }
.srctable .status .bad { color: #875a16; }
.srctable .status .flagtxt { color: var(--accent-deep); }
.srctable .status .good { color: #3f7048; }
.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; }
@@ -311,5 +419,6 @@
.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 .reply { 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; }
</style>