diff --git a/src/components/ui/Sparkline.jsx b/src/components/ui/Sparkline.jsx
new file mode 100644
index 000000000..6dea651f6
--- /dev/null
+++ b/src/components/ui/Sparkline.jsx
@@ -0,0 +1,49 @@
+/**
+ * Sparkline — tiny inline SVG trend line for dashboard cards.
+ *
+ * This is a charting primitive, not an icon, so the inline here is the
+ * sanctioned exception to the MoonIcons-only rule. The default stroke is the
+ * mm-design blue token; pass `color` (a token value) to override.
+ *
+ * data: array of { day: 'YYYY-MM-DD', count: number }. Missing days render as 0.
+ */
+import { colors } from '../../design/tokens'
+
+export default function Sparkline({ data, color = colors.blue, days = 30 }) {
+ if (!data || data.length === 0) {
+ return No data
+ }
+
+ // Fill in all days (missing days = 0)
+ const filled = []
+ const today = new Date()
+ for (let i = days - 1; i >= 0; i--) {
+ const d = new Date(today)
+ d.setDate(d.getDate() - i)
+ const key = d.toISOString().split('T')[0]
+ const found = data.find(r => r.day === key)
+ filled.push(found ? found.count : 0)
+ }
+
+ const max = Math.max(...filled, 1)
+ const W = 200
+ const H = 40
+ const pts = filled.map((v, i) => {
+ const x = (i / (filled.length - 1)) * W
+ const y = H - (v / max) * H
+ return `${x},${y}`
+ }).join(' ')
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/ui/StatCard.jsx b/src/components/ui/StatCard.jsx
new file mode 100644
index 000000000..4111865a1
--- /dev/null
+++ b/src/components/ui/StatCard.jsx
@@ -0,0 +1,19 @@
+/**
+ * StatCard — a labelled KPI number on the canonical flat Card.
+ *
+ * The value renders in the inherited body font. The previous admin markup
+ * applied an undefined --mm-font-heading variable, which resolved to the
+ * inherited font anyway; dropping it keeps the exact same rendering without
+ * the phantom variable.
+ */
+import Card from './Card'
+
+export default function StatCard({ label, value, sub }) {
+ return (
+
+ {label}
+ {value ?? '—'}
+ {sub && {sub} }
+
+ )
+}
diff --git a/src/components/ui/index.js b/src/components/ui/index.js
index 4745510a6..929a0a488 100644
--- a/src/components/ui/index.js
+++ b/src/components/ui/index.js
@@ -3,3 +3,5 @@ export { default as Card } from './Card'
export { default as Badge } from './Badge'
export { default as SectionLabel } from './SectionLabel'
export { default as DisplayHeading } from './DisplayHeading'
+export { default as StatCard } from './StatCard'
+export { default as Sparkline } from './Sparkline'
diff --git a/src/pages/AdminDashboardPage.jsx b/src/pages/AdminDashboardPage.jsx
index 003d4d835..12a7675fe 100644
--- a/src/pages/AdminDashboardPage.jsx
+++ b/src/pages/AdminDashboardPage.jsx
@@ -33,6 +33,8 @@ import {
ResponsiveContainer, LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, CartesianGrid,
} from 'recharts'
+import { Card, StatCard, Sparkline } from '../components/ui'
+import { colors } from '../design/tokens'
// ---------------------------------------------------------------------------
// Shared helpers
@@ -57,18 +59,6 @@ function fmt(dt) {
// Shared UI primitives
// ---------------------------------------------------------------------------
-function StatCard({ label, value, sub }) {
- return (
-
- {label}
-
- {value ?? '—'}
-
- {sub && {sub} }
-
- )
-}
-
function TabButton({ label, active, onClick }) {
return (
No data
- }
-
- // Fill in all days (missing days = 0)
- const filled = []
- const today = new Date()
- for (let i = days - 1; i >= 0; i--) {
- const d = new Date(today)
- d.setDate(d.getDate() - i)
- const key = d.toISOString().split('T')[0]
- const found = data.find(r => r.day === key)
- filled.push(found ? found.count : 0)
- }
-
- const max = Math.max(...filled, 1)
- const W = 200
- const H = 40
- const pts = filled.map((v, i) => {
- const x = (i / (filled.length - 1)) * W
- const y = H - (v / max) * H
- return `${x},${y}`
- }).join(' ')
-
- return (
-
-
-
- )
-}
-
// ---------------------------------------------------------------------------
// Overview tab
// ---------------------------------------------------------------------------
@@ -308,24 +255,24 @@ function OverviewTab() {
Last 30 days
-
+
New registrations
-
+
{activity.registrations.reduce((s, r) => s + r.count, 0)} total
-
-
+
+
Tests completed
-
+
{activity.results.reduce((s, r) => s + r.count, 0)} total
-
+
)}
@@ -388,7 +335,7 @@ function UsersTab() {
)}
-
+
@@ -439,7 +386,7 @@ function UsersTab() {
{!loading && items.length === 0 && }
-
+
)
@@ -481,7 +428,7 @@ function ResultsTab() {
)}
-
+
@@ -516,7 +463,7 @@ function ResultsTab() {
{!loading && items.length === 0 && }
-
+
)
@@ -605,7 +552,7 @@ function NormsTab() {
)}
-
+
Language Active tier Sample n
@@ -623,7 +570,7 @@ function NormsTab() {
})}
-
+
)
})}
@@ -677,11 +624,11 @@ function formatPct(n, digits = 2) {
function SeoStatCard({ label, value, sub }) {
return (
-
+
{label}
{value}
{sub && {sub}
}
-
+
)
}
@@ -698,15 +645,15 @@ function SourcesGrid({ sources, gsc }) {
return (
{sources.map(s => (
-
+
{s.name}
{formatNum(s.row_count)} rows
{s.last_update ? `last ${s.last_update}` : 'no data yet'}
-
+
))}
-
+
gsc bulk export
{gsc?.bulk_export_ready ? `${gsc.tables_present.length} tables` : 'pending'}
@@ -714,7 +661,7 @@ function SourcesGrid({ sources, gsc }) {
{gsc?.bulk_export_ready ? 'ready' : '~48h after GSC config'}
-
+
)
}
@@ -752,20 +699,20 @@ function HealthSection({ health }) {
function CrawlByBotChart({ data }) {
if (!data || !data.length) return null
return (
-
+
Crawler hits last 7 days
-
+
-
+
)
}
@@ -782,7 +729,7 @@ function QuickWinsTable({ queries }) {
)
return (
-
+
@@ -805,7 +752,7 @@ function QuickWinsTable({ queries }) {
))}
-
+
)
}
@@ -818,9 +765,9 @@ function AnomaliesList({ anomalies }) {
{anomalies.slice(0, 15).map(a => {
const up = a.change_pct > 0
return (
-
{up ? '+' : ''}{a.change_pct.toFixed(0)}%
@@ -829,7 +776,7 @@ function AnomaliesList({ anomalies }) {
{formatNum(a.prior_impressions)} -> {formatNum(a.recent_impressions)}
-
+
)
})}
@@ -934,7 +881,7 @@ function SeoTab() {
href={url}
target="_blank"
rel="noopener noreferrer"
- className="flex items-center gap-2 px-3 py-2.5 bg-white rounded-xl border border-gray-100 shadow-sm text-sm text-gray-700 hover:border-[var(--mm-color-blue)]/30 hover:text-[var(--mm-color-blue)] transition-colors"
+ className="flex items-center gap-2 px-3 py-2.5 bg-white rounded border border-gray-200 text-sm text-gray-700 hover:border-[var(--mm-color-blue)]/30 hover:text-[var(--mm-color-blue)] transition-colors"
>
{emoji}
{label}
@@ -954,7 +901,7 @@ function SeoTab() {
{LLM_QUERIES.map(q => (
-
+
"{q}"
{LLM_ENGINES.map(({ name, url }) => (
@@ -976,7 +923,7 @@ function SeoTab() {
{copiedQuery === q ? '✓ copied' : 'copy'}
-
+
))}
@@ -1122,7 +1069,7 @@ function BlogTab() {
{/* Post list */}
{error && {error}
}
-
+
@@ -1195,11 +1142,11 @@ function BlogTab() {
})}
-
+
{/* Create / Edit form */}
{form && (
-
+
{isNew ? 'New post' : `Edit: ${form.slug}`}
@@ -1321,7 +1268,7 @@ function BlogTab() {
{saving ? 'Saving…' : 'Publish'}
-
+
)}
@@ -1347,10 +1294,7 @@ export default function AdminDashboardPage() {
return (
-
+
Admin Dashboard
Staff-only. Handle with care.