From a41ff594065d2fc5f3c6a0894ec537ca17f5db95 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Mon, 24 Aug 2026 19:16:02 -0400 Subject: [PATCH] srs-api: dark-mode charts matching shieldcn zinc style --- api/src/charts.ts | 96 +++++++++++++++++++++++------------------------ 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/api/src/charts.ts b/api/src/charts.ts index 35ddf65..37ba631 100644 --- a/api/src/charts.ts +++ b/api/src/charts.ts @@ -8,15 +8,26 @@ * Everything is string-built: no chart library, no runtime imports. The only * dynamic values entering the markup are numbers, percentages, and dates the * Worker itself computes, plus the fixed phase/stage labels — so nothing here - * needs XML escaping. GitHub renders these on white; no dark-mode variants. - * Every function renders a valid (if empty-looking) SVG on a zero-row DB. + * needs XML escaping. Styling is shadcn dark zinc to match the README's + * shieldcn badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub + * dark-mode green ramp. Every function renders valid SVG on a zero-row DB. */ // D1Database comes from the generated worker-configuration.d.ts runtime types. const FONT = "Verdana,DejaVu Sans,sans-serif"; -// Green ramp shared by the heatmap and the ladder (GitHub contribution hues). -const GREENS = ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"]; +// shadcn dark-zinc palette, matching the README's shieldcn badges. +const BG = "#09090b"; // zinc-950 card +const FG = "#fafafa"; // zinc-50 text +const MUTED = "#a1a1aa"; // zinc-400 secondary text +const LINE = "#27272a"; // zinc-800 structure / empty +const GREEN = "#16a34a"; // green-600 (core / pass) +const BLUE = "#2563eb"; // blue-600 (optional) +const RED = "#f87171"; // red-400 (fail, on dark) + +// Green ramp shared by the heatmap and the ladder (GitHub dark-mode +// contribution hues; level 0 is the empty zinc cell). +const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"]; // Labels are stored SVG-ready: phase II's "&" is pre-escaped since these // strings go straight into markup and nothing else here needs escaping. @@ -32,12 +43,12 @@ const STAGES = ["new", "+2", "+5", "+10", "retired"]; // ── svg helpers ────────────────────────────────────────────────── -/** Opening tag plus the white background rect every chart starts with. */ +/** Opening tag plus the rounded dark card every chart starts with. */ function svgOpen(width: number, height: number): string { return ( `` + - `` + `` ); } @@ -48,7 +59,7 @@ function text( content: string, attrs: { size?: number; fill?: string; anchor?: string; weight?: string } = {}, ): string { - const { size = 12, fill = "#1f2328", anchor = "start", weight } = attrs; + const { size = 12, fill = FG, anchor = "start", weight } = attrs; const bold = weight ? ` font-weight="${weight}"` : ""; return `${content}`; } @@ -101,14 +112,14 @@ export async function progressChart(db: D1Database): Promise { // Legend on top. const legend: [string, string][] = [ - ["#2da44e", "core done"], - ["#0969da", "optional done"], - ["#d0d7de", "remaining"], + [GREEN, "core done"], + [BLUE, "optional done"], + [LINE, "remaining"], ]; let lx = barX; for (const [color, label] of legend) { parts.push(``); - parts.push(text(lx + 17, 22, label, { size: 11, fill: "#57606a" })); + parts.push(text(lx + 17, 22, label, { size: 11, fill: MUTED })); lx += 17 + label.length * 7 + 24; } @@ -119,9 +130,9 @@ export async function progressChart(db: D1Database): Promise { let x = barX; const segments: [number, string][] = [ - [p.coreDone, "#2da44e"], - [p.optionalDone, "#0969da"], - [p.remaining, "#d0d7de"], + [p.coreDone, GREEN], + [p.optionalDone, BLUE], + [p.remaining, LINE], ]; for (const [count, color] of segments) { const w = (count / scale) * barMaxW; @@ -129,13 +140,13 @@ export async function progressChart(db: D1Database): Promise { parts.push(``); // Count inside the segment when it fits; tiny slivers stay unlabeled. if (w >= 20) { - const labelFill = color === "#d0d7de" ? "#57606a" : "#ffffff"; + const labelFill = color === LINE ? MUTED : FG; parts.push(text(x + w / 2, midY, String(count), { size: 11, fill: labelFill, anchor: "middle" })); } x += w; } } - parts.push(text(x + 8, midY, `${p.done}/${p.total}`, { size: 11, fill: "#57606a" })); + parts.push(text(x + 8, midY, `${p.done}/${p.total}`, { size: 11, fill: MUTED })); }); parts.push(""); @@ -153,7 +164,7 @@ export async function ladderChart(db: D1Database): Promise { const width = 640; const height = 220; - const plotTop = 34; + const plotTop = 26; const plotBottom = height - 32; const plotH = plotBottom - plotTop; const slotW = (width - 80) / STAGES.length; @@ -161,7 +172,6 @@ export async function ladderChart(db: D1Database): Promise { const scale = Math.max(1, ...counts); const parts = [svgOpen(width, height)]; - parts.push(text(40, 20, "review ladder", { size: 13, weight: "bold" })); counts.forEach((count, i) => { const cx = 40 + slotW * i + slotW / 2; @@ -172,11 +182,11 @@ export async function ladderChart(db: D1Database): Promise { ``, ); } - parts.push(text(cx, y - 6, String(count), { size: 12, anchor: "middle", weight: "bold" })); - parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: "#57606a", anchor: "middle" })); + parts.push(text(cx, Math.max(y - 6, 16), String(count), { size: 12, anchor: "middle", weight: "bold" })); + parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: MUTED, anchor: "middle" })); }); - parts.push(``); + parts.push(``); parts.push(""); return parts.join(""); } @@ -211,7 +221,7 @@ export async function heatmapChart(db: D1Database, start: string, days: number): // Week numbers across the top, Mon/Wed/Fri down the left. for (let w = 0; w < weeks; w++) { - parts.push(text(gridX + w * step + cell / 2, gridY - 7, `W${w + 1}`, { size: 10, fill: "#57606a", anchor: "middle" })); + parts.push(text(gridX + w * step + cell / 2, gridY - 7, `W${w + 1}`, { size: 10, fill: MUTED, anchor: "middle" })); } const weekdays: [number, string][] = [ [0, "Mon"], @@ -219,7 +229,7 @@ export async function heatmapChart(db: D1Database, start: string, days: number): [4, "Fri"], ]; for (const [row, label] of weekdays) { - parts.push(text(gridX - 6, gridY + row * step + cell - 4, label, { size: 10, fill: "#57606a", anchor: "end" })); + parts.push(text(gridX - 6, gridY + row * step + cell - 4, label, { size: 10, fill: MUTED, anchor: "end" })); } // One cell per campaign day; the start date is a Monday, so day i sits at @@ -239,17 +249,17 @@ export async function heatmapChart(db: D1Database, start: string, days: number): // Less → More ramp fills the space right of the grid. const legendX = gridX + weeks * step + 40; const legendY = gridY + 3 * step; - parts.push(text(legendX - 6, legendY + cell - 4, "Less", { size: 10, fill: "#57606a", anchor: "end" })); + parts.push(text(legendX - 6, legendY + cell - 4, "Less", { size: 10, fill: MUTED, anchor: "end" })); GREENS.forEach((color, i) => { parts.push(``); }); - parts.push(text(legendX + GREENS.length * step + 3, legendY + cell - 4, "More", { size: 10, fill: "#57606a" })); + parts.push(text(legendX + GREENS.length * step + 3, legendY + cell - 4, "More", { size: 10, fill: MUTED })); parts.push(""); return parts.join(""); } -// ── gate badge: shields.io flat pill ───────────────────────────── +// ── gate badge: shieldcn-style dark pill ───────────────────────── export async function gateBadge(db: D1Database): Promise { const row = await db @@ -258,31 +268,21 @@ export async function gateBadge(db: D1Database): Promise { const label = "gate"; const value = row ? `${Math.round(row.pass_rate * 100)}%` : "none yet"; - const color = row ? (row.pass_rate >= 0.7 ? "#4c1" : "#e05d44") : "#9f9f9f"; + const valueFill = row ? (row.pass_rate >= 0.7 ? "#4ade80" : RED) : MUTED; - // Shields flat geometry: height 20, rx 3, verdana 11px, ~6.5px per char - // plus 5px padding each side. Each caption is drawn twice — a dark - // 30%-opacity copy 1px low as the shadow, then white on top. - const labelW = Math.round(label.length * 6.5) + 10; - const valueW = Math.round(value.length * 6.5) + 10; - const total = labelW + valueW; - const labelX = labelW / 2; - const valueX = labelW + valueW / 2; + // shieldcn geometry: height 32, rx 6, one flat zinc-900 pill. Verdana 13px + // ≈ 7.5px per char; 12px outer padding, 8px between label and value. + const labelW = Math.round(label.length * 7.5); + const valueW = Math.round(value.length * 7.5); + const total = 12 + labelW + 8 + valueW + 12; return ( - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `${label}` + - `${label}` + - `${value}` + - `${value}` + + `` + + `` + + `` + + `${label}` + + `${value}` + `` + `` );