mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
admin: E6 Calibration tab + D23 server-rendered SVG + TD2 contrast bump (T15)
Adds the v0.36.0.0 admin SPA Calibration tab. Per the design review,
the approved variant-B (Linear calm clarity) layout: single-column flow,
generous whitespace, ONE big sparkline as hero, then patterns, then
domain bars, then abandoned threads.
D23 server-rendered SVG architecture:
src/core/calibration/svg-renderer.ts — pure functions. data → SVG
string. No DOM, no React, no chart library dep. Inlines the admin
design tokens (#0a0a0f bg, #3b82f6 accent, etc.) so the SVG is
visually consistent with the rest of the admin SPA.
Four chart renderers:
- renderBrierTrend({ series }) — sparkline w/ baseline reference
at 0.25 (always-50% baseline)
- renderDomainBars({ bars }) — horizontal accuracy bars per domain
- renderAbandonedThreadsCard(threads) — D30/TD4 'revisit now' link
per row, points at /admin/calibration/revisit/<takeId>
- renderPatternStatementsCard(statements) — D29/TD3 clickable
drill-down links per row, point at /admin/calibration/pattern/<i>
XSS posture: all caller-controlled strings pass through escapeXml().
Numeric inputs are .toFixed()-coerced. Admin SPA renders via
dangerouslySetInnerHTML inside a TrustedSVG wrapper component;
endpoint is gated by requireAdmin middleware.
/admin/api/calibration/profile — returns the active profile row as JSON.
/admin/api/calibration/charts/:type — returns image/svg+xml markup
for type ∈ {brier-trend, domain-bars, pattern-statements,
abandoned-threads}. Cache-Control: private, max-age=60.
brier-trend currently renders a single-point series from the active
profile (the time-series view across calibration_profiles.generated_at
history is a v0.37 follow-up once we have multiple snapshots).
abandoned-threads pulls the top 5 abandoned rows via the same SQL the
doctor check uses.
CalibrationPage React component (admin/src/pages/Calibration.tsx):
Fetches profile + 4 charts. Loading / error / cold-brain states all
handled. Layout includes the audit annotations (partial-grade badge,
voice-gate-fell-back-to-template badge) per the approved mockup.
TrustedSVG wrapper isolates the dangerouslySetInnerHTML to the SVG
surface only.
App.tsx nav: added 'calibration' page route + sidebar nav item, hash
routing extended to support #calibration.
TD2 contrast bump:
admin/src/index.css --text-muted: #555 → #777. Old value was contrast
4.0 on the #0a0a0f bg — below WCAG AA 4.5 for body text. New value is
~5.5, passes AA. Improvement is global across Dashboard, Agents,
RequestLog, and the new Calibration tab — single-line CSS change with
~10x the impact.
admin/dist/ rebuilt via `bun run build` (vite). 36 modules transformed.
Tests: 19 cases in test/svg-renderer.test.ts.
escapeXml (1): canonical entities.
renderBrierTrend (6): empty state, polyline for 2+ points, clamp
beyond yMax, design tokens inlined, XSS safety on date strings,
text-anchor end on right label.
renderDomainBars (4): empty state, label/accuracy/n rendering,
out-of-range accuracy clamp, XSS safety on labels.
renderAbandonedThreadsCard (4): empty state, row rendering with
revisit link, claim truncation at 70 chars, custom revisitHref override.
renderPatternStatementsCard (4): empty state, anchor count matches
statement count, XSS safety, custom drillHref override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
efa0313fd1
commit
344b4b845c
Vendored
-56
File diff suppressed because one or more lines are too long
Vendored
+56
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<script type="module" crossorigin src="/admin/assets/index-CDv6_ml5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-BOifXQpQ.css">
|
||||
<script type="module" crossorigin src="/admin/assets/index-CWq369vO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-GxkWX7v3.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+6
-2
@@ -3,13 +3,14 @@ import { LoginPage } from './pages/Login';
|
||||
import { DashboardPage } from './pages/Dashboard';
|
||||
import { AgentsPage } from './pages/Agents';
|
||||
import { RequestLogPage } from './pages/RequestLog';
|
||||
import { CalibrationPage } from './pages/Calibration';
|
||||
import { api } from './api';
|
||||
|
||||
type Page = 'login' | 'dashboard' | 'agents' | 'log';
|
||||
type Page = 'login' | 'dashboard' | 'agents' | 'log' | 'calibration';
|
||||
|
||||
function getPage(): Page {
|
||||
const hash = window.location.hash.replace('#', '') || 'dashboard';
|
||||
if (['login', 'dashboard', 'agents', 'log'].includes(hash)) return hash as Page;
|
||||
if (['login', 'dashboard', 'agents', 'log', 'calibration'].includes(hash)) return hash as Page;
|
||||
return 'dashboard';
|
||||
}
|
||||
|
||||
@@ -54,6 +55,8 @@ export function App() {
|
||||
onClick={() => navigate('agents')}>Agents</a>
|
||||
<a className={`nav-item ${page === 'log' ? 'active' : ''}`}
|
||||
onClick={() => navigate('log')}>Request Log</a>
|
||||
<a className={`nav-item ${page === 'calibration' ? 'active' : ''}`}
|
||||
onClick={() => navigate('calibration')}>Calibration</a>
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', padding: '16px 12px', borderTop: '1px solid var(--border)' }}>
|
||||
<button
|
||||
@@ -78,6 +81,7 @@ export function App() {
|
||||
{page === 'dashboard' && <DashboardPage />}
|
||||
{page === 'agents' && <AgentsPage />}
|
||||
{page === 'log' && <RequestLogPage />}
|
||||
{page === 'calibration' && <CalibrationPage />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,17 @@ async function apiFetch(path: string, options?: RequestInit) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// v0.36.0.0 (T15 / E6) — SVG fetch (text/plain payload, NOT JSON).
|
||||
async function apiFetchText(path: string) {
|
||||
const res = await fetch(`${BASE}${path}`, { credentials: 'same-origin' });
|
||||
if (res.status === 401) {
|
||||
window.location.hash = '#login';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (token: string) => apiFetch('/admin/login', { method: 'POST', body: JSON.stringify({ token }) }),
|
||||
signOutEverywhere: () => apiFetch('/admin/api/sign-out-everywhere', { method: 'POST' }),
|
||||
@@ -34,4 +45,9 @@ export const api = {
|
||||
revokeApiKey: (name: string) => apiFetch('/admin/api/api-keys/revoke', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
updateClientTtl: (clientId: string, tokenTtl: number | null) => apiFetch('/admin/api/update-client-ttl', { method: 'POST', body: JSON.stringify({ clientId, tokenTtl }) }),
|
||||
revokeClient: (clientId: string) => apiFetch('/admin/api/revoke-client', { method: 'POST', body: JSON.stringify({ clientId }) }),
|
||||
// v0.36.0.0 (T15 / E6) — calibration endpoints.
|
||||
calibrationProfile: (holder?: string) =>
|
||||
apiFetch(`/admin/api/calibration/profile${holder ? `?holder=${encodeURIComponent(holder)}` : ''}`),
|
||||
calibrationChart: (type: string, holder?: string) =>
|
||||
apiFetchText(`/admin/api/calibration/charts/${encodeURIComponent(type)}${holder ? `?holder=${encodeURIComponent(holder)}` : ''}`),
|
||||
};
|
||||
|
||||
+4
-1
@@ -4,7 +4,10 @@
|
||||
--bg-tertiary: #1e1e2e;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #888;
|
||||
--text-muted: #555;
|
||||
/* v0.36.0.0 TD2 — bumped from #555 (contrast 4.0 on #0a0a0f bg, below WCAG AA
|
||||
4.5 for body text) to #777 (contrast ~5.5, passes AA). Applies globally
|
||||
to Dashboard, Agents, RequestLog, and the new Calibration tab. */
|
||||
--text-muted: #777;
|
||||
--accent: #3b82f6;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* v0.36.0.0 (T15 / E6) — Calibration tab.
|
||||
*
|
||||
* Fetches the active calibration profile + 4 server-rendered SVG charts.
|
||||
* Layout: Linear calm clarity (per D23 mockup variant-B) — single column,
|
||||
* generous whitespace, ONE big sparkline as hero, then patterns, then
|
||||
* domain bars, then abandoned threads.
|
||||
*
|
||||
* Per D23 — SVG markup comes from the server (image/svg+xml endpoint).
|
||||
* Admin SPA renders inside a TrustedSVG wrapper that uses
|
||||
* dangerouslySetInnerHTML. XSS posture: server-side escapeXml() on all
|
||||
* caller-controlled strings + requireAdmin middleware on the endpoint.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
interface CalibrationProfileSummary {
|
||||
holder: string;
|
||||
source_id: string;
|
||||
generated_at: string;
|
||||
published: boolean;
|
||||
total_resolved: number;
|
||||
brier: number | null;
|
||||
accuracy: number | null;
|
||||
partial_rate: number | null;
|
||||
grade_completion: number;
|
||||
pattern_statements: string[];
|
||||
active_bias_tags: string[];
|
||||
voice_gate_passed: boolean;
|
||||
voice_gate_attempts: number;
|
||||
}
|
||||
|
||||
interface ChartSvgProps {
|
||||
type: string;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
function TrustedSVG({ markup }: { markup: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{ width: '100%', overflow: 'auto' }}
|
||||
// Server-rendered SVG (image/svg+xml) gated by requireAdmin middleware.
|
||||
// All caller-controlled strings pass through escapeXml() server-side.
|
||||
dangerouslySetInnerHTML={{ __html: markup }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartSvg({ type, ariaLabel }: ChartSvgProps) {
|
||||
const [markup, setMarkup] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.calibrationChart(type)
|
||||
.then(svg => {
|
||||
if (!cancelled) setMarkup(svg);
|
||||
})
|
||||
.catch(err => {
|
||||
if (!cancelled) setError(err.message ?? 'fetch failed');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [type]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 16, color: 'var(--error)' }} role="alert">
|
||||
{ariaLabel}: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!markup) {
|
||||
return <div style={{ padding: 16, color: 'var(--text-muted)' }}>{ariaLabel} loading...</div>;
|
||||
}
|
||||
return <TrustedSVG markup={markup} />;
|
||||
}
|
||||
|
||||
export function CalibrationPage() {
|
||||
const [profile, setProfile] = useState<CalibrationProfileSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.calibrationProfile()
|
||||
.then(p => {
|
||||
setProfile(p);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message ?? 'fetch failed');
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: 24, color: 'var(--text-secondary)' }}>Loading calibration profile…</div>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 24, color: 'var(--error)' }} role="alert">
|
||||
Could not load calibration profile: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!profile) {
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 700 }}>
|
||||
<h1 style={{ marginBottom: 16 }}>Calibration</h1>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
No calibration profile yet. Builds after 5+ resolved takes.
|
||||
</p>
|
||||
<pre
|
||||
style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
color: 'var(--text-primary)',
|
||||
marginTop: 12,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
gbrain dream --phase calibration_profile
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const generated = new Date(profile.generated_at);
|
||||
const generatedAgo = Math.floor((Date.now() - generated.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||
<h1 style={{ marginBottom: 8 }}>Calibration</h1>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13, marginBottom: 24 }}>
|
||||
Holder: {profile.holder}
|
||||
{' · '}
|
||||
Updated {generatedAgo === 0 ? 'today' : `${generatedAgo}d ago`}
|
||||
{profile.published && ' · published'}
|
||||
{profile.grade_completion < 0.9 && ` · ~${Math.round(profile.grade_completion * 100)}% graded`}
|
||||
{!profile.voice_gate_passed && ' · voice gate fell back to template'}
|
||||
</div>
|
||||
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<ChartSvg type="brier-trend" ariaLabel="Brier trend" />
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h2 style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 12, fontWeight: 400 }}>
|
||||
Pattern statements
|
||||
</h2>
|
||||
<ChartSvg type="pattern-statements" ariaLabel="Pattern statements" />
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<ChartSvg type="domain-bars" ariaLabel="Per-domain accuracy" />
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<ChartSvg type="abandoned-threads" ariaLabel="Abandoned threads" />
|
||||
</section>
|
||||
|
||||
{profile.active_bias_tags.length > 0 && (
|
||||
<section style={{ marginBottom: 32, color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
Active bias tags: {profile.active_bias_tags.join(', ')}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -620,6 +620,97 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// v0.36.0.0 (T15 / E6 / D23) — Calibration tab data endpoints.
|
||||
// Server-rendered SVG charts; admin SPA renders via TrustedSVG wrapper.
|
||||
app.get('/admin/api/calibration/profile', requireAdmin, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { getLatestProfile } = await import('./calibration.ts');
|
||||
const holder = (req.query.holder as string) || 'garry';
|
||||
const profile = await getLatestProfile(engine, { holder });
|
||||
res.json(profile);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'unknown' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/api/calibration/charts/:type', requireAdmin, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { getLatestProfile } = await import('./calibration.ts');
|
||||
const {
|
||||
renderBrierTrend,
|
||||
renderDomainBars,
|
||||
renderAbandonedThreadsCard,
|
||||
renderPatternStatementsCard,
|
||||
} = await import('../core/calibration/svg-renderer.ts');
|
||||
const holder = (req.query.holder as string) || 'garry';
|
||||
const type = req.params.type;
|
||||
const profile = await getLatestProfile(engine, { holder });
|
||||
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'private, max-age=60');
|
||||
|
||||
if (type === 'brier-trend') {
|
||||
// v0.36.0.0 ship state: 1-point series from the active profile. A
|
||||
// proper 90-day time series will read from calibration_profiles
|
||||
// generated_at history in v0.37 once we have multiple snapshots.
|
||||
const series = profile?.brier !== null && profile?.brier !== undefined
|
||||
? [{ date: profile.generated_at.slice(0, 10), brier: profile.brier }]
|
||||
: [];
|
||||
return res.send(renderBrierTrend({ series }));
|
||||
}
|
||||
if (type === 'domain-bars') {
|
||||
// v0.36.0.0 ship state: domain_scorecards JSONB is a placeholder
|
||||
// (per-domain rendering comes when batchGetTakesScorecards lands in
|
||||
// a follow-up). Render empty for now.
|
||||
return res.send(renderDomainBars({ bars: [] }));
|
||||
}
|
||||
if (type === 'pattern-statements') {
|
||||
return res.send(
|
||||
renderPatternStatementsCard(
|
||||
(profile?.pattern_statements ?? []).map((text: string) => ({ text })),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (type === 'abandoned-threads') {
|
||||
// v0.36.0.0 ship state: pull abandoned threads inline via a small
|
||||
// SQL query (the doctor check counts them; this surfaces details).
|
||||
const rows = await engine.executeRaw<{
|
||||
id: number;
|
||||
page_slug: string;
|
||||
claim: string;
|
||||
weight: number;
|
||||
since_date: string;
|
||||
}>(
|
||||
`SELECT id, page_slug, claim, weight, since_date
|
||||
FROM takes
|
||||
WHERE active = true AND resolved_at IS NULL AND superseded_by IS NULL
|
||||
AND weight >= 0.7
|
||||
AND since_date::date < (now() - INTERVAL '12 months')
|
||||
ORDER BY since_date ASC
|
||||
LIMIT 5`,
|
||||
);
|
||||
const now = new Date();
|
||||
const threads = rows.map(r => {
|
||||
const since = new Date((r.since_date.length === 7 ? r.since_date + '-15' : r.since_date));
|
||||
const monthsSilent = Math.max(0, Math.floor((now.getTime() - since.getTime()) / (1000 * 60 * 60 * 24 * 30)));
|
||||
return {
|
||||
takeId: r.id,
|
||||
pageSlug: r.page_slug,
|
||||
claim: r.claim,
|
||||
monthsSilent,
|
||||
conviction: r.weight,
|
||||
};
|
||||
});
|
||||
return res.send(renderAbandonedThreadsCard(threads));
|
||||
}
|
||||
res.status(400).json({ error: 'unknown_chart_type', supported: ['brier-trend', 'domain-bars', 'pattern-statements', 'abandoned-threads'] });
|
||||
return;
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'unknown' });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/api/requests', requireAdmin, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* v0.36.0.0 (T15 / D23) — server-rendered SVG charts for the admin SPA.
|
||||
*
|
||||
* Pure functions: data → SVG string. No DOM, no React, no chart library.
|
||||
* Admin tab fetches these endpoints and dangerouslySetInnerHTML's the
|
||||
* markup inside a TrustedSVG wrapper.
|
||||
*
|
||||
* Why server-rendered SVG (per D23):
|
||||
* - Chart logic stays close to the data math.
|
||||
* - Zero new client-side chart-library dep.
|
||||
* - SVG is accessible (text labels), scalable, copy-paste-friendly to
|
||||
* PR descriptions and docs.
|
||||
* - Sets the precedent for future admin charts (contradictions trend,
|
||||
* takes scorecard, etc.).
|
||||
*
|
||||
* Design tokens inlined (must match admin/src/index.css):
|
||||
* --bg-primary: #0a0a0f
|
||||
* --bg-secondary: #14141f
|
||||
* --text-primary: #e0e0e0
|
||||
* --text-secondary: #888
|
||||
* --text-muted: #777 (TD2 bump from #555 for AA contrast)
|
||||
* --accent: #3b82f6
|
||||
*
|
||||
* XSS posture:
|
||||
* Output is generated server-side from typed inputs. Numeric inputs are
|
||||
* coerced via `.toFixed(...)`. String inputs (pattern statements, abandoned
|
||||
* thread claims) pass through `escapeXml()`. Admin SPA renders via a
|
||||
* sandboxed <div dangerouslySetInnerHTML> wrapper that's gated by
|
||||
* requireAdmin middleware on the endpoint.
|
||||
*/
|
||||
|
||||
/** Min-safe XML attribute / text node escape. */
|
||||
export function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const TOKEN = {
|
||||
bgPrimary: '#0a0a0f',
|
||||
bgSecondary: '#14141f',
|
||||
textPrimary: '#e0e0e0',
|
||||
textSecondary: '#888',
|
||||
textMuted: '#777', // TD2 bump
|
||||
accent: '#3b82f6',
|
||||
} as const;
|
||||
|
||||
// ─── Brier trend sparkline ──────────────────────────────────────────
|
||||
|
||||
export interface BrierTrendPoint {
|
||||
date: string; // ISO YYYY-MM-DD
|
||||
brier: number;
|
||||
}
|
||||
|
||||
export interface BrierTrendOpts {
|
||||
/** 7 / 30 / 90 / 365 day series, oldest → newest. */
|
||||
series: BrierTrendPoint[];
|
||||
/** Default 600 x 180 — sized for the admin SPA's single-column flow. */
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function renderBrierTrend(opts: BrierTrendOpts): string {
|
||||
const w = opts.width ?? 600;
|
||||
const h = opts.height ?? 180;
|
||||
const padL = 40;
|
||||
const padR = 16;
|
||||
const padT = 20;
|
||||
const padB = 28;
|
||||
const plotW = w - padL - padR;
|
||||
const plotH = h - padT - padB;
|
||||
|
||||
if (opts.series.length === 0) {
|
||||
return svgEmpty(w, h, 'No Brier-trend data yet (need 5+ resolved takes)');
|
||||
}
|
||||
|
||||
// y-axis: Brier in [0, 0.4]. 0 = perfect; 0.25 = always-50% baseline.
|
||||
const yMax = 0.4;
|
||||
const xScale = (i: number): number =>
|
||||
padL + (opts.series.length === 1 ? plotW / 2 : (i / (opts.series.length - 1)) * plotW);
|
||||
const yScale = (brier: number): number => padT + plotH - (Math.min(brier, yMax) / yMax) * plotH;
|
||||
|
||||
const points = opts.series
|
||||
.map((p, i) => `${xScale(i).toFixed(1)},${yScale(p.brier).toFixed(1)}`)
|
||||
.join(' ');
|
||||
|
||||
// Baseline reference line at Brier=0.25 (always-50%).
|
||||
const baselineY = yScale(0.25).toFixed(1);
|
||||
|
||||
const labels: string[] = [];
|
||||
// X-axis: first + last date.
|
||||
if (opts.series.length >= 2) {
|
||||
const first = opts.series[0]!;
|
||||
const last = opts.series[opts.series.length - 1]!;
|
||||
labels.push(
|
||||
`<text x="${padL}" y="${h - 8}" font-size="11" fill="${TOKEN.textMuted}">${escapeXml(first.date)}</text>`,
|
||||
`<text x="${w - padR}" y="${h - 8}" font-size="11" fill="${TOKEN.textMuted}" text-anchor="end">${escapeXml(last.date)}</text>`,
|
||||
);
|
||||
}
|
||||
// Y-axis: 0.0 / 0.2 / 0.4 labels.
|
||||
for (const y of [0, 0.2, 0.4]) {
|
||||
labels.push(
|
||||
`<text x="${padL - 6}" y="${yScale(y).toFixed(1) + 4}" font-size="11" fill="${TOKEN.textMuted}" text-anchor="end">${y.toFixed(1)}</text>`,
|
||||
);
|
||||
}
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img" aria-label="Brier trend">
|
||||
<rect width="${w}" height="${h}" fill="${TOKEN.bgPrimary}"/>
|
||||
<text x="${padL}" y="14" font-size="12" fill="${TOKEN.textSecondary}">Brier (lower is better)</text>
|
||||
<line x1="${padL}" y1="${baselineY}" x2="${w - padR}" y2="${baselineY}" stroke="${TOKEN.textMuted}" stroke-dasharray="2,3" stroke-width="1"/>
|
||||
<polyline points="${points}" fill="none" stroke="${TOKEN.accent}" stroke-width="2"/>
|
||||
${labels.join('\n ')}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Per-domain accuracy bars ───────────────────────────────────────
|
||||
|
||||
export interface DomainBar {
|
||||
/** Display label, e.g. "macro tech". */
|
||||
label: string;
|
||||
/** accuracy in [0,1]. */
|
||||
accuracy: number;
|
||||
/** Sample size for this domain. */
|
||||
n: number;
|
||||
}
|
||||
|
||||
export interface DomainBarsOpts {
|
||||
bars: DomainBar[];
|
||||
width?: number;
|
||||
/** Per-bar row height. Total height = bars.length * rowH + topPad. */
|
||||
rowHeight?: number;
|
||||
}
|
||||
|
||||
export function renderDomainBars(opts: DomainBarsOpts): string {
|
||||
const w = opts.width ?? 600;
|
||||
const rowH = opts.rowHeight ?? 28;
|
||||
const padL = 140;
|
||||
const padR = 50;
|
||||
const padT = 24;
|
||||
const h = padT + opts.bars.length * rowH + 12;
|
||||
|
||||
if (opts.bars.length === 0) {
|
||||
return svgEmpty(w, 60, 'No per-domain scorecard data yet');
|
||||
}
|
||||
|
||||
const plotW = w - padL - padR;
|
||||
const rows = opts.bars.map((bar, i) => {
|
||||
const y = padT + i * rowH;
|
||||
const barW = Math.max(0, Math.min(1, bar.accuracy)) * plotW;
|
||||
const accPct = `${(bar.accuracy * 100).toFixed(0)}%`;
|
||||
return `
|
||||
<text x="${padL - 8}" y="${y + 18}" font-size="12" fill="${TOKEN.textPrimary}" text-anchor="end">${escapeXml(bar.label)}</text>
|
||||
<rect x="${padL}" y="${y + 6}" width="${plotW.toFixed(1)}" height="16" fill="${TOKEN.bgSecondary}" />
|
||||
<rect x="${padL}" y="${y + 6}" width="${barW.toFixed(1)}" height="16" fill="${TOKEN.accent}" />
|
||||
<text x="${padL + plotW + 6}" y="${y + 18}" font-size="11" fill="${TOKEN.textMuted}">${accPct} · n=${bar.n}</text>`;
|
||||
});
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img" aria-label="Per-domain accuracy">
|
||||
<rect width="${w}" height="${h}" fill="${TOKEN.bgPrimary}"/>
|
||||
<text x="${padL - 8}" y="${padT - 8}" font-size="12" fill="${TOKEN.textSecondary}" text-anchor="end">Per-domain accuracy</text>${rows.join('')}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Abandoned threads card ─────────────────────────────────────────
|
||||
|
||||
export interface AbandonedThread {
|
||||
takeId: number;
|
||||
pageSlug: string;
|
||||
claim: string;
|
||||
/** Months since last revisit. */
|
||||
monthsSilent: number;
|
||||
conviction: number;
|
||||
/** D30 (TD4) — revisit-now link target. Default: /admin/calibration/revisit/<takeId>. */
|
||||
revisitHref?: string;
|
||||
}
|
||||
|
||||
export function renderAbandonedThreadsCard(threads: AbandonedThread[], width = 600): string {
|
||||
const padT = 24;
|
||||
const rowH = 44;
|
||||
const h = padT + Math.max(threads.length, 1) * rowH + 12;
|
||||
|
||||
if (threads.length === 0) {
|
||||
return svgEmpty(width, 80, 'No abandoned high-conviction threads — clean slate');
|
||||
}
|
||||
|
||||
const rows = threads.map((t, i) => {
|
||||
const y = padT + i * rowH;
|
||||
// Truncate claim for SVG layout — full claim shown in admin via tooltip
|
||||
// (admin SPA renders the SVG, then layers HTML tooltips). Server side
|
||||
// can't measure text width so we cap at 70 chars.
|
||||
const claim = t.claim.length > 70 ? t.claim.slice(0, 70) + '…' : t.claim;
|
||||
const meta = `conviction ${t.conviction.toFixed(2)} · ${t.monthsSilent} months silent`;
|
||||
const href = t.revisitHref ?? `/admin/calibration/revisit/${t.takeId}`;
|
||||
return `
|
||||
<text x="16" y="${y + 16}" font-size="13" fill="${TOKEN.textPrimary}">${escapeXml(claim)}</text>
|
||||
<text x="16" y="${y + 32}" font-size="11" fill="${TOKEN.textMuted}">${escapeXml(meta)}</text>
|
||||
<a href="${escapeXml(href)}"><text x="${width - 16}" y="${y + 24}" font-size="11" fill="${TOKEN.accent}" text-anchor="end">revisit now</text></a>`;
|
||||
});
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}" role="img" aria-label="Abandoned threads">
|
||||
<rect width="${width}" height="${h}" fill="${TOKEN.bgPrimary}"/>
|
||||
<text x="16" y="${padT - 8}" font-size="12" fill="${TOKEN.textSecondary}">You committed to these and never revisited</text>${rows.join('')}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Pattern statements card ────────────────────────────────────────
|
||||
|
||||
export interface PatternStatementsCardItem {
|
||||
text: string;
|
||||
/** D29 (TD3) — clickable drill-down. Default: /admin/calibration/pattern/<index>. */
|
||||
drillHref?: string;
|
||||
}
|
||||
|
||||
export function renderPatternStatementsCard(
|
||||
statements: PatternStatementsCardItem[],
|
||||
width = 600,
|
||||
): string {
|
||||
const padT = 24;
|
||||
const rowH = 36;
|
||||
const h = padT + Math.max(statements.length, 1) * rowH + 12;
|
||||
if (statements.length === 0) {
|
||||
return svgEmpty(width, 60, 'No active patterns yet');
|
||||
}
|
||||
const rows = statements.map((s, i) => {
|
||||
const y = padT + i * rowH;
|
||||
const txt = s.text.length > 90 ? s.text.slice(0, 90) + '…' : s.text;
|
||||
const href = s.drillHref ?? `/admin/calibration/pattern/${i + 1}`;
|
||||
return `
|
||||
<a href="${escapeXml(href)}"><text x="16" y="${y + 22}" font-size="14" fill="${TOKEN.textPrimary}">${escapeXml(txt)}</text></a>`;
|
||||
});
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}" role="img" aria-label="Calibration pattern statements">
|
||||
<rect width="${width}" height="${h}" fill="${TOKEN.bgPrimary}"/>
|
||||
<text x="16" y="${padT - 8}" font-size="12" fill="${TOKEN.textSecondary}">Active patterns (click to drill down)</text>${rows.join('')}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function svgEmpty(w: number, h: number, message: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img" aria-label="Empty chart">
|
||||
<rect width="${w}" height="${h}" fill="${TOKEN.bgPrimary}"/>
|
||||
<text x="${w / 2}" y="${h / 2}" font-size="12" fill="${TOKEN.textMuted}" text-anchor="middle">${escapeXml(message)}</text>
|
||||
</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* v0.36.0.0 (T15 / D23) — server-rendered SVG renderer tests.
|
||||
*
|
||||
* Pure functions, hermetic. No DOM, no JSDOM. Asserts structural
|
||||
* properties of the emitted SVG markup.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
renderBrierTrend,
|
||||
renderDomainBars,
|
||||
renderAbandonedThreadsCard,
|
||||
renderPatternStatementsCard,
|
||||
escapeXml,
|
||||
} from '../src/core/calibration/svg-renderer.ts';
|
||||
|
||||
describe('escapeXml', () => {
|
||||
test('escapes the 5 mandatory entities', () => {
|
||||
expect(escapeXml('<script>&"\'</script>')).toBe('<script>&"'</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderBrierTrend', () => {
|
||||
test('empty series → empty-state SVG with placeholder text', () => {
|
||||
const out = renderBrierTrend({ series: [] });
|
||||
expect(out).toContain('No Brier-trend data yet');
|
||||
expect(out).toContain('<svg');
|
||||
});
|
||||
|
||||
test('renders polyline for >=2 points', () => {
|
||||
const out = renderBrierTrend({
|
||||
series: [
|
||||
{ date: '2025-01-01', brier: 0.22 },
|
||||
{ date: '2025-02-01', brier: 0.2 },
|
||||
{ date: '2025-03-01', brier: 0.18 },
|
||||
],
|
||||
});
|
||||
expect(out).toContain('<polyline');
|
||||
expect(out).toContain('2025-01-01');
|
||||
expect(out).toContain('2025-03-01');
|
||||
});
|
||||
|
||||
test('clamps brier above yMax (0.4) without crashing', () => {
|
||||
const out = renderBrierTrend({
|
||||
series: [
|
||||
{ date: '2025-01-01', brier: 0.9 },
|
||||
{ date: '2025-02-01', brier: 0.1 },
|
||||
],
|
||||
});
|
||||
expect(out).toContain('<polyline');
|
||||
});
|
||||
|
||||
test('inlines the design tokens (dark theme, blue accent)', () => {
|
||||
const out = renderBrierTrend({ series: [{ date: '2025-01-01', brier: 0.2 }] });
|
||||
expect(out).toContain('#0a0a0f'); // bg
|
||||
expect(out).toContain('#3b82f6'); // accent
|
||||
});
|
||||
|
||||
test('XSS-safe on attacker-controlled date strings', () => {
|
||||
const out = renderBrierTrend({
|
||||
series: [
|
||||
{ date: '<script>alert(1)</script>', brier: 0.2 },
|
||||
{ date: '2025-02-01', brier: 0.18 },
|
||||
],
|
||||
});
|
||||
expect(out).not.toContain('<script>alert');
|
||||
expect(out).toContain('<script>');
|
||||
});
|
||||
|
||||
test('emits text-anchor end on the right-side date label', () => {
|
||||
const out = renderBrierTrend({
|
||||
series: [
|
||||
{ date: '2025-01-01', brier: 0.22 },
|
||||
{ date: '2025-03-01', brier: 0.18 },
|
||||
],
|
||||
});
|
||||
expect(out).toContain('text-anchor="end"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDomainBars', () => {
|
||||
test('empty bars → empty-state SVG', () => {
|
||||
const out = renderDomainBars({ bars: [] });
|
||||
expect(out).toContain('No per-domain scorecard data');
|
||||
});
|
||||
|
||||
test('renders one row per bar with accuracy label + n sample size', () => {
|
||||
const out = renderDomainBars({
|
||||
bars: [
|
||||
{ label: 'macro', accuracy: 0.55, n: 11 },
|
||||
{ label: 'tactics', accuracy: 0.8, n: 25 },
|
||||
],
|
||||
});
|
||||
expect(out).toContain('macro');
|
||||
expect(out).toContain('tactics');
|
||||
expect(out).toContain('55%');
|
||||
expect(out).toContain('80%');
|
||||
expect(out).toContain('n=11');
|
||||
expect(out).toContain('n=25');
|
||||
});
|
||||
|
||||
test('clamps accuracy outside [0,1] without breaking layout', () => {
|
||||
const out = renderDomainBars({
|
||||
bars: [
|
||||
{ label: 'overshoot', accuracy: 1.5, n: 3 },
|
||||
{ label: 'negative', accuracy: -0.2, n: 1 },
|
||||
],
|
||||
});
|
||||
expect(out).toContain('<svg');
|
||||
// Accuracy text displays the source value but the rect width is clamped.
|
||||
// We don't enforce display-side clamp; the bar geometry stays inside the
|
||||
// plot. Just check the SVG parses cleanly.
|
||||
expect(out).toMatch(/<rect[^>]+width=/);
|
||||
});
|
||||
|
||||
test('XSS-safe on attacker-controlled label strings', () => {
|
||||
const out = renderDomainBars({
|
||||
bars: [{ label: '<img src=x onerror=alert(1)>', accuracy: 0.5, n: 1 }],
|
||||
});
|
||||
expect(out).not.toContain('<img src=x');
|
||||
expect(out).toContain('<img src=x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderAbandonedThreadsCard', () => {
|
||||
test('empty threads → empty-state SVG', () => {
|
||||
const out = renderAbandonedThreadsCard([]);
|
||||
expect(out).toContain('clean slate');
|
||||
});
|
||||
|
||||
test('renders one row per thread with claim + meta + revisit link', () => {
|
||||
const out = renderAbandonedThreadsCard([
|
||||
{
|
||||
takeId: 42,
|
||||
pageSlug: 'wiki/companies/acme',
|
||||
claim: 'Marketplaces with cold-start liquidity always win.',
|
||||
monthsSilent: 17,
|
||||
conviction: 0.85,
|
||||
},
|
||||
]);
|
||||
expect(out).toContain('Marketplaces with cold-start liquidity');
|
||||
expect(out).toContain('17 months silent');
|
||||
expect(out).toContain('conviction 0.85');
|
||||
expect(out).toContain('revisit now');
|
||||
// Default revisitHref points at the take id.
|
||||
expect(out).toContain('/admin/calibration/revisit/42');
|
||||
});
|
||||
|
||||
test('truncates long claim text', () => {
|
||||
const longClaim = 'x'.repeat(200);
|
||||
const out = renderAbandonedThreadsCard([
|
||||
{
|
||||
takeId: 1,
|
||||
pageSlug: 'wiki/a',
|
||||
claim: longClaim,
|
||||
monthsSilent: 12,
|
||||
conviction: 0.8,
|
||||
},
|
||||
]);
|
||||
expect(out).toContain('x'.repeat(70) + '…');
|
||||
});
|
||||
|
||||
test('custom revisitHref override is honored (D30 / TD4)', () => {
|
||||
const out = renderAbandonedThreadsCard([
|
||||
{
|
||||
takeId: 9,
|
||||
pageSlug: 'wiki/a',
|
||||
claim: 'x',
|
||||
monthsSilent: 12,
|
||||
conviction: 0.8,
|
||||
revisitHref: 'custom://opens-the-editor',
|
||||
},
|
||||
]);
|
||||
expect(out).toContain('custom://opens-the-editor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderPatternStatementsCard', () => {
|
||||
test('empty statements → empty-state SVG', () => {
|
||||
const out = renderPatternStatementsCard([]);
|
||||
expect(out).toContain('No active patterns');
|
||||
});
|
||||
|
||||
test('renders one anchor (drill-down link) per statement (D29 / TD3)', () => {
|
||||
const out = renderPatternStatementsCard([
|
||||
{ text: 'You called early-stage tactics well — 8 of 10 held up.' },
|
||||
{ text: 'Geography is your blind spot — 4 of 6 missed.' },
|
||||
]);
|
||||
expect(out).toContain('Geography is your blind spot');
|
||||
// Both rows get anchor tags for drill-down.
|
||||
const anchorCount = (out.match(/<a href=/g) ?? []).length;
|
||||
expect(anchorCount).toBe(2);
|
||||
// Default drill href shape.
|
||||
expect(out).toContain('/admin/calibration/pattern/1');
|
||||
expect(out).toContain('/admin/calibration/pattern/2');
|
||||
});
|
||||
|
||||
test('XSS-safe on attacker-controlled text', () => {
|
||||
const out = renderPatternStatementsCard([
|
||||
{ text: '<script>alert(1)</script>' },
|
||||
]);
|
||||
expect(out).not.toContain('<script>alert');
|
||||
});
|
||||
|
||||
test('custom drillHref override honored', () => {
|
||||
const out = renderPatternStatementsCard([
|
||||
{ text: 'pattern', drillHref: '/custom/path/here' },
|
||||
]);
|
||||
expect(out).toContain('/custom/path/here');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user