srs-api: dark-mode charts matching shieldcn zinc style

This commit is contained in:
Prad Nukala
2026-08-24 19:16:02 -04:00
parent ab5c251952
commit a41ff59406
+48 -48
View File
@@ -8,15 +8,26 @@
* Everything is string-built: no chart library, no runtime imports. The only * Everything is string-built: no chart library, no runtime imports. The only
* dynamic values entering the markup are numbers, percentages, and dates the * dynamic values entering the markup are numbers, percentages, and dates the
* Worker itself computes, plus the fixed phase/stage labels — so nothing here * Worker itself computes, plus the fixed phase/stage labels — so nothing here
* needs XML escaping. GitHub renders these on white; no dark-mode variants. * needs XML escaping. Styling is shadcn dark zinc to match the README's
* Every function renders a valid (if empty-looking) SVG on a zero-row DB. * 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. // D1Database comes from the generated worker-configuration.d.ts runtime types.
const FONT = "Verdana,DejaVu Sans,sans-serif"; const FONT = "Verdana,DejaVu Sans,sans-serif";
// Green ramp shared by the heatmap and the ladder (GitHub contribution hues). // shadcn dark-zinc palette, matching the README's shieldcn badges.
const GREENS = ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"]; 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 // Labels are stored SVG-ready: phase II's "&" is pre-escaped since these
// strings go straight into markup and nothing else here needs escaping. // strings go straight into markup and nothing else here needs escaping.
@@ -32,12 +43,12 @@ const STAGES = ["new", "+2", "+5", "+10", "retired"];
// ── svg helpers ────────────────────────────────────────────────── // ── 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 { function svgOpen(width: number, height: number): string {
return ( return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` + `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
`viewBox="0 0 ${width} ${height}" font-family="${FONT}">` + `viewBox="0 0 ${width} ${height}" font-family="${FONT}">` +
`<rect width="${width}" height="${height}" fill="#ffffff"/>` `<rect width="${width}" height="${height}" rx="6" fill="${BG}"/>`
); );
} }
@@ -48,7 +59,7 @@ function text(
content: string, content: string,
attrs: { size?: number; fill?: string; anchor?: string; weight?: string } = {}, attrs: { size?: number; fill?: string; anchor?: string; weight?: string } = {},
): 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}"` : ""; const bold = weight ? ` font-weight="${weight}"` : "";
return `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" text-anchor="${anchor}"${bold}>${content}</text>`; return `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" text-anchor="${anchor}"${bold}>${content}</text>`;
} }
@@ -101,14 +112,14 @@ export async function progressChart(db: D1Database): Promise<string> {
// Legend on top. // Legend on top.
const legend: [string, string][] = [ const legend: [string, string][] = [
["#2da44e", "core done"], [GREEN, "core done"],
["#0969da", "optional done"], [BLUE, "optional done"],
["#d0d7de", "remaining"], [LINE, "remaining"],
]; ];
let lx = barX; let lx = barX;
for (const [color, label] of legend) { for (const [color, label] of legend) {
parts.push(`<rect x="${lx}" y="12" width="12" height="12" rx="2" fill="${color}"/>`); parts.push(`<rect x="${lx}" y="12" width="12" height="12" rx="2" fill="${color}"/>`);
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; lx += 17 + label.length * 7 + 24;
} }
@@ -119,9 +130,9 @@ export async function progressChart(db: D1Database): Promise<string> {
let x = barX; let x = barX;
const segments: [number, string][] = [ const segments: [number, string][] = [
[p.coreDone, "#2da44e"], [p.coreDone, GREEN],
[p.optionalDone, "#0969da"], [p.optionalDone, BLUE],
[p.remaining, "#d0d7de"], [p.remaining, LINE],
]; ];
for (const [count, color] of segments) { for (const [count, color] of segments) {
const w = (count / scale) * barMaxW; const w = (count / scale) * barMaxW;
@@ -129,13 +140,13 @@ export async function progressChart(db: D1Database): Promise<string> {
parts.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="${barH}" fill="${color}"/>`); parts.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="${barH}" fill="${color}"/>`);
// Count inside the segment when it fits; tiny slivers stay unlabeled. // Count inside the segment when it fits; tiny slivers stay unlabeled.
if (w >= 20) { 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" })); parts.push(text(x + w / 2, midY, String(count), { size: 11, fill: labelFill, anchor: "middle" }));
} }
x += w; 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("</svg>"); parts.push("</svg>");
@@ -153,7 +164,7 @@ export async function ladderChart(db: D1Database): Promise<string> {
const width = 640; const width = 640;
const height = 220; const height = 220;
const plotTop = 34; const plotTop = 26;
const plotBottom = height - 32; const plotBottom = height - 32;
const plotH = plotBottom - plotTop; const plotH = plotBottom - plotTop;
const slotW = (width - 80) / STAGES.length; const slotW = (width - 80) / STAGES.length;
@@ -161,7 +172,6 @@ export async function ladderChart(db: D1Database): Promise<string> {
const scale = Math.max(1, ...counts); const scale = Math.max(1, ...counts);
const parts = [svgOpen(width, height)]; const parts = [svgOpen(width, height)];
parts.push(text(40, 20, "review ladder", { size: 13, weight: "bold" }));
counts.forEach((count, i) => { counts.forEach((count, i) => {
const cx = 40 + slotW * i + slotW / 2; const cx = 40 + slotW * i + slotW / 2;
@@ -172,11 +182,11 @@ export async function ladderChart(db: D1Database): Promise<string> {
`<rect x="${(cx - barW / 2).toFixed(1)}" y="${y.toFixed(1)}" width="${barW}" height="${barH.toFixed(1)}" rx="3" fill="${GREENS[i]}"/>`, `<rect x="${(cx - barW / 2).toFixed(1)}" y="${y.toFixed(1)}" width="${barW}" height="${barH.toFixed(1)}" rx="3" fill="${GREENS[i]}"/>`,
); );
} }
parts.push(text(cx, y - 6, String(count), { size: 12, anchor: "middle", weight: "bold" })); 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: "#57606a", anchor: "middle" })); parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: MUTED, anchor: "middle" }));
}); });
parts.push(`<line x1="40" y1="${plotBottom}" x2="${width - 40}" y2="${plotBottom}" stroke="#d0d7de"/>`); parts.push(`<line x1="40" y1="${plotBottom}" x2="${width - 40}" y2="${plotBottom}" stroke="${LINE}"/>`);
parts.push("</svg>"); parts.push("</svg>");
return parts.join(""); 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. // Week numbers across the top, Mon/Wed/Fri down the left.
for (let w = 0; w < weeks; w++) { 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][] = [ const weekdays: [number, string][] = [
[0, "Mon"], [0, "Mon"],
@@ -219,7 +229,7 @@ export async function heatmapChart(db: D1Database, start: string, days: number):
[4, "Fri"], [4, "Fri"],
]; ];
for (const [row, label] of weekdays) { 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 // 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. // Less → More ramp fills the space right of the grid.
const legendX = gridX + weeks * step + 40; const legendX = gridX + weeks * step + 40;
const legendY = gridY + 3 * step; 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) => { GREENS.forEach((color, i) => {
parts.push(`<rect x="${legendX + i * step}" y="${legendY}" width="${cell}" height="${cell}" rx="2" fill="${color}"/>`); parts.push(`<rect x="${legendX + i * step}" y="${legendY}" width="${cell}" height="${cell}" rx="2" fill="${color}"/>`);
}); });
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("</svg>"); parts.push("</svg>");
return parts.join(""); return parts.join("");
} }
// ── gate badge: shields.io flat pill ───────────────────────────── // ── gate badge: shieldcn-style dark pill ─────────────────────────
export async function gateBadge(db: D1Database): Promise<string> { export async function gateBadge(db: D1Database): Promise<string> {
const row = await db const row = await db
@@ -258,31 +268,21 @@ export async function gateBadge(db: D1Database): Promise<string> {
const label = "gate"; const label = "gate";
const value = row ? `${Math.round(row.pass_rate * 100)}%` : "none yet"; 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 // shieldcn geometry: height 32, rx 6, one flat zinc-900 pill. Verdana 13px
// plus 5px padding each side. Each caption is drawn twice — a dark // ≈ 7.5px per char; 12px outer padding, 8px between label and value.
// 30%-opacity copy 1px low as the shadow, then white on top. const labelW = Math.round(label.length * 7.5);
const labelW = Math.round(label.length * 6.5) + 10; const valueW = Math.round(value.length * 7.5);
const valueW = Math.round(value.length * 6.5) + 10; const total = 12 + labelW + 8 + valueW + 12;
const total = labelW + valueW;
const labelX = labelW / 2;
const valueX = labelW + valueW / 2;
return ( return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="20" role="img" aria-label="${label}: ${value}">` + `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="32" ` +
`<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>` + `viewBox="0 0 ${total} 32" role="img" aria-label="${label}: ${value}" font-family="${FONT}">` +
`<clipPath id="r"><rect width="${total}" height="20" rx="3" fill="#fff"/></clipPath>` + `<rect width="${total}" height="32" rx="6" fill="#18181b"/>` +
`<g clip-path="url(#r)">` + `<g font-size="13">` +
`<rect width="${labelW}" height="20" fill="#555"/>` + `<text x="12" y="21" fill="${FG}" fill-opacity=".7">${label}</text>` +
`<rect x="${labelW}" width="${valueW}" height="20" fill="${color}"/>` + `<text x="${12 + labelW + 8}" y="21" fill="${valueFill}" font-weight="bold">${value}</text>` +
`<rect width="${total}" height="20" fill="url(#s)"/>` +
`</g>` +
`<g fill="#fff" text-anchor="middle" font-family="${FONT}" font-size="11">` +
`<text x="${labelX}" y="15" fill="#010101" fill-opacity=".3">${label}</text>` +
`<text x="${labelX}" y="14">${label}</text>` +
`<text x="${valueX}" y="15" fill="#010101" fill-opacity=".3">${value}</text>` +
`<text x="${valueX}" y="14">${value}</text>` +
`</g>` + `</g>` +
`</svg>` `</svg>`
); );