From 2a34e15efc5695b8821c86a4aa846156f2b498e4 Mon Sep 17 00:00:00 2001 From: Joan Leon Date: Thu, 21 May 2026 00:18:28 +0200 Subject: [PATCH 1/3] feat(visualizer): add snippet result visualizer page Client-side page that accepts the JSON return value of any snippet, auto-detects the type (CWV, Fonts, Audit) from the `script` field, and renders a formatted report. Includes "Copy as Markdown" and Reset. Useful for sites that block extended DevTools output (e.g. Akamai). --- SPEC.md | 158 +++++++++++++++ .../SnippetVisualizer/AuditRenderer.jsx | 78 +++++++ components/SnippetVisualizer/CWVRenderer.jsx | 81 ++++++++ .../SnippetVisualizer/FontsRenderer.jsx | 92 +++++++++ .../SnippetVisualizer/exportMarkdown.js | 104 ++++++++++ components/SnippetVisualizer/index.jsx | 190 ++++++++++++++++++ pages/_meta.json | 3 + pages/visualizer.mdx | 13 ++ 8 files changed, 719 insertions(+) create mode 100644 SPEC.md create mode 100644 components/SnippetVisualizer/AuditRenderer.jsx create mode 100644 components/SnippetVisualizer/CWVRenderer.jsx create mode 100644 components/SnippetVisualizer/FontsRenderer.jsx create mode 100644 components/SnippetVisualizer/exportMarkdown.js create mode 100644 components/SnippetVisualizer/index.jsx create mode 100644 pages/visualizer.mdx diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..33039cd --- /dev/null +++ b/SPEC.md @@ -0,0 +1,158 @@ +# Spec: Snippet Result Visualizer + +## Objective + +A client-side page on webperf-snippets that accepts the JSON return value of any snippet (copied +from the browser console), detects the snippet type, and renders a formatted performance report. + +**Problem solved**: Sites protected by Akamai or strict CSP block extended DevTools console output, +but the IIFE return value remains accessible as the last evaluated expression. The visualizer turns +that object into a readable report without requiring any setup or CLI. + +**Target users**: Web performance engineers doing reviews on sites with restrictive security +policies (e.g., FedEx, enterprise sites with Akamai). + +--- + +## Core Features + +1. **Paste area** — textarea accepting a raw JSON object (single result, not array) +2. **Auto-parse** — live parse on input change (debounced 300ms); no submit button +3. **Auto-detect** — identify snippet type from the `script` field (or structural heuristics) +4. **Renderers** — three display modes: + - **CWV** — metric + rating + value + optional LCP subparts + - **Fonts** — loaded fonts table, used-above-fold table, issues + - **Audit** — issues list (severity-colored) + items table (generic) +5. **Export as Markdown** — copy the rendered report as Markdown to clipboard +6. **Error state** — clear message for invalid JSON or unrecognized format + +--- + +## Acceptance Criteria + +- [ ] Pasting a Fonts snippet result renders three sections: Loaded, Used above fold, Issues +- [ ] Pasting an LCP result shows rating badge + value + subParts breakdown when present +- [ ] Pasting any audit snippet result shows issues list + items table (up to 10 rows) +- [ ] Pasting invalid JSON shows an inline error, not a crash +- [ ] "Copy as Markdown" copies formatted Markdown to clipboard and shows confirmation +- [ ] Page appears in the sidebar nav as "Visualizer" +- [ ] No new npm dependencies introduced + +--- + +## Detection Logic + +``` +result.script === "Fonts-Preloaded-Loaded-and-used-above-the-fold" → FontsRenderer +result.rating != null → CWVRenderer +Array.isArray(result.issues) → AuditRenderer +otherwise → RawRenderer (formatted JSON) +``` + +--- + +## Data Shapes (input contracts) + +**CWV metric** (LCP, CLS, INP, FCP, etc.): +```json +{ + "script": "LCP", + "metric": "LCP", + "rating": "good | needs-improvement | poor", + "value": 1234, + "unit": "ms | score", + "details": { "element": "...", "subParts": { "ttfb": {}, ... } } +} +``` + +**Fonts**: +```json +{ + "script": "Fonts-Preloaded-Loaded-and-used-above-the-fold", + "status": "ok", + "details": { "preloadedCount": 2, "loadedCount": 3, "usedAboveFoldCount": 2, ... }, + "items": [{ "family": "...", "weight": "400", "style": "normal", "display": "swap" }], + "usedFonts": [{ "family": "...", "weight": "400", "style": "normal", "elements": 12 }], + "issues": [{ "severity": "warning | error", "message": "..." }] +} +``` + +**Audit** (all other snippets): +```json +{ + "script": "Find-render-blocking-resources", + "status": "ok", + "count": 3, + "items": [{ "url": "...", "type": "script", "durationMs": 120 }], + "issues": [{ "severity": "error | warning | info", "message": "..." }] +} +``` + +--- + +## Project Structure + +``` +pages/ + visualizer.mdx ← Nextra page (imports SnippetVisualizer) +components/ + SnippetVisualizer.jsx ← Main component (textarea + renderer dispatch) + SnippetVisualizer/ + CWVRenderer.jsx + FontsRenderer.jsx + AuditRenderer.jsx + exportMarkdown.js ← Pure function: result → markdown string +``` + +`pages/_meta.json` gets a new entry: +```json +"visualizer": { "title": "Visualizer" } +``` + +--- + +## Code Style + +- **No new dependencies** — React hooks only (`useState`, `useMemo`, `useCallback`) +- **CSS classes** — Nextra `nx-` utility classes for visual consistency; inline styles only for + dynamic values (rating colors) +- **No TypeScript** — plain `.jsx` / `.js`, matching the rest of the project +- **No comments** unless the why is non-obvious + +--- + +## Markdown Export Format + +Single result exported as: +```markdown +## Fonts — Fonts-Preloaded-Loaded-and-used-above-the-fold + +### Loaded Fonts +| Family | Weight | Style | Display | +|--------|--------|-------|---------| +| ... | 400 | normal| swap | + +### Used Above Fold +| Family | Weight | Style | Elements | +... + +### Issues +- ⚠️ warning: Font preloaded without crossorigin... +``` + +For CWV metrics: +```markdown +## LCP — 1.2s ✅ good +... +``` + +--- + +## Boundaries + +| Always | Ask First | Never | +|--------|-----------|-------| +| Handle invalid input gracefully | Adding a new npm dependency | Server-side code / API routes | +| Keep all logic client-side | Changing next.config.js | TypeScript migration | +| Use `nx-` classes for styling | Adding a new page category | Storing paste data anywhere | +| Clear error feedback | | Sending data to any external service | diff --git a/components/SnippetVisualizer/AuditRenderer.jsx b/components/SnippetVisualizer/AuditRenderer.jsx new file mode 100644 index 0000000..63d6079 --- /dev/null +++ b/components/SnippetVisualizer/AuditRenderer.jsx @@ -0,0 +1,78 @@ +const SEVERITY_COLOR = { error: "#ef4444", warning: "#f59e0b", info: "#3b82f6" }; +const SEVERITY_ICON = { error: "✗", warning: "⚠", info: "ℹ" }; + +const IGNORED_COLS = new Set(["raw", "html", "element", "selector"]); + +export function AuditRenderer({ result }) { + const errors = (result.issues ?? []).filter((i) => i.severity === "error"); + const warnings = (result.issues ?? []).filter((i) => i.severity === "warning"); + const statusIcon = errors.length ? "🔴" : warnings.length ? "🟡" : "🟢"; + + const items = result.items ?? []; + const columns = items.length > 0 + ? Object.keys(items[0]).filter((k) => !IGNORED_COLS.has(k)).slice(0, 5) + : []; + + return ( +
+
+ {statusIcon} +
+
{result.script}
+ {result.count != null && ( +
{result.count} item(s)
+ )} +
+
+ + {result.issues?.length > 0 ? ( +
+
Issues
+
    + {result.issues.map((issue, i) => ( +
  • + {SEVERITY_ICON[issue.severity] ?? "·"} {issue.message} +
  • + ))} +
+
+ ) : ( +

✅ No issues found

+ )} + + {items.length > 0 && columns.length > 0 && ( +
+
+ Items ({items.length}) +
+
+ + + + {columns.map((col) => ( + + ))} + + + + {items.map((item, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
{col}
+ {String(item[col] ?? "")} +
+
+
+ )} + + {result.reason && ( +
↳ {result.reason}
+ )} +
+ ); +} diff --git a/components/SnippetVisualizer/CWVRenderer.jsx b/components/SnippetVisualizer/CWVRenderer.jsx new file mode 100644 index 0000000..90806fa --- /dev/null +++ b/components/SnippetVisualizer/CWVRenderer.jsx @@ -0,0 +1,81 @@ +const RATING_COLOR = { good: "#22c55e", "needs-improvement": "#f59e0b", poor: "#ef4444" }; +const RATING_ICON = { good: "🟢", "needs-improvement": "🟡", poor: "🔴" }; + +function formatValue(value, unit) { + if (unit === "ms") return value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${value}ms`; + if (unit === "score") return value.toFixed(4); + return String(value); +} + +const SUB_PARTS = [ + ["TTFB", "ttfb"], + ["Resource Load Delay", "resourceLoadDelay"], + ["Resource Load Time", "resourceLoadTime"], + ["Element Render Delay", "elementRenderDelay"], +]; + +export function CWVRenderer({ result }) { + const color = RATING_COLOR[result.rating] ?? "#6b7280"; + const sp = result.details?.subParts; + + return ( +
+
+ {RATING_ICON[result.rating] ?? "·"} +
+
+ {formatValue(result.value, result.unit)} +
+
+ {result.rating} · {result.script ?? result.metric} +
+
+
+ + {result.details?.element && ( +
+ Element: + {result.details.element} +
+ )} + + {sp && ( +
+
LCP Sub-Parts
+ + + + {["Phase", "Value", "%", "Status"].map((h) => ( + + ))} + + + + {SUB_PARTS.map(([label, key]) => { + const info = sp[key]; + if (!info) return null; + return ( + + + + + + + ); + })} + +
{h}
{label}{info.value}ms{info.percent}%{info.overTarget ? "🔴" : "✅"}
+ {result.details.slowestPhase && ( +
+ → Slowest: {result.details.slowestPhase} +
+ )} +
+ )} + + {result.reason && ( +
↳ {result.reason}
+ )} +
+ ); +} diff --git a/components/SnippetVisualizer/FontsRenderer.jsx b/components/SnippetVisualizer/FontsRenderer.jsx new file mode 100644 index 0000000..f3e901c --- /dev/null +++ b/components/SnippetVisualizer/FontsRenderer.jsx @@ -0,0 +1,92 @@ +const DISPLAY_WARN = new Set(["auto", "unknown"]); + +const TABLE_STYLE = { width: "100%", borderCollapse: "collapse", fontSize: "0.875rem" }; +const TH_STYLE = { textAlign: "left", padding: "6px 8px", color: "#6b7280", fontWeight: "normal", borderBottom: "1px solid rgba(128,128,128,0.2)" }; +const TD_STYLE = { padding: "6px 8px", borderBottom: "1px solid rgba(128,128,128,0.1)" }; + +function Section({ title, children }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +export function FontsRenderer({ result }) { + const d = result.details ?? {}; + const summary = [ + { label: "Preloaded", count: d.preloadedCount ?? 0, color: "#8b5cf6" }, + { label: "Loaded", count: d.loadedCount ?? 0, color: "#3b82f6" }, + { label: "Used above fold", count: d.usedAboveFoldCount ?? 0, color: "#22c55e" }, + ]; + + return ( +
+
+ {summary.map(({ label, count, color }) => ( +
+
{count}
+
{label}
+
+ ))} +
+ + {result.items?.length > 0 && ( +
+ + + {["Family", "Weight", "Style", "Display"].map((h) => )} + + + {result.items.map((f, i) => ( + + + + + + + ))} + +
{h}
{f.family}{f.weight}{f.style} + {DISPLAY_WARN.has(f.display) ? `⚠️ ${f.display}` : f.display} +
+
+ )} + + {result.usedFonts?.length > 0 && ( +
+ + + {["Family", "Weight", "Style", "Elements"].map((h) => )} + + + {[...result.usedFonts].sort((a, b) => b.elements - a.elements).map((f, i) => ( + + + + + + + ))} + +
{h}
{f.family}{f.weight}{f.style}{f.elements}
+
+ )} + +
+ {result.issues?.length > 0 ? ( +
    + {result.issues.map((issue, i) => ( +
  • + {issue.severity === "error" ? "✗" : "⚠"} {issue.message} +
  • + ))} +
+ ) : ( +

✅ Font loading looks optimized

+ )} +
+
+ ); +} diff --git a/components/SnippetVisualizer/exportMarkdown.js b/components/SnippetVisualizer/exportMarkdown.js new file mode 100644 index 0000000..1da0931 --- /dev/null +++ b/components/SnippetVisualizer/exportMarkdown.js @@ -0,0 +1,104 @@ +const RATING_ICON = { + good: "✅", + "needs-improvement": "⚠️", + poor: "❌", +}; + +function formatValue(value, unit) { + if (unit === "ms") return value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${value}ms`; + if (unit === "score") return value.toFixed(4); + return String(value); +} + +function fontsMarkdown(result) { + const d = result.details ?? {}; + const lines = [ + `## Fonts Analysis — ${result.script}`, + `> Preloaded: **${d.preloadedCount ?? 0}** · Loaded: **${d.loadedCount ?? 0}** · Used above fold: **${d.usedAboveFoldCount ?? 0}**`, + ]; + + if (result.items?.length) { + lines.push("", "### Loaded Fonts", "| Family | Weight | Style | Display |", "|--------|--------|-------|---------|"); + for (const f of result.items) { + lines.push(`| ${f.family} | ${f.weight} | ${f.style} | ${f.display} |`); + } + } + + if (result.usedFonts?.length) { + lines.push("", "### Used Above Fold", "| Family | Weight | Style | Elements |", "|--------|--------|-------|----------|"); + for (const f of [...result.usedFonts].sort((a, b) => b.elements - a.elements)) { + lines.push(`| ${f.family} | ${f.weight} | ${f.style} | ${f.elements} |`); + } + } + + if (result.issues?.length) { + lines.push("", "### Issues"); + for (const issue of result.issues) { + const icon = issue.severity === "error" ? "❌" : "⚠️"; + lines.push(`- ${icon} ${issue.message}`); + } + } else { + lines.push("", "✅ Font loading looks optimized"); + } + + return lines.join("\n"); +} + +function cwvMarkdown(result) { + const icon = RATING_ICON[result.rating] ?? "·"; + const value = formatValue(result.value, result.unit); + const lines = [`## ${result.script ?? result.metric} — ${value} ${icon} ${result.rating}`]; + + const sp = result.details?.subParts; + if (sp) { + lines.push("", "### LCP Sub-Parts", "| Phase | Value | % | Status |", "|-------|-------|---|--------|"); + for (const [phase, info] of [ + ["TTFB", sp.ttfb], + ["Resource Load Delay", sp.resourceLoadDelay], + ["Resource Load Time", sp.resourceLoadTime], + ["Element Render Delay", sp.elementRenderDelay], + ]) { + if (!info) continue; + lines.push(`| ${phase} | ${info.value}ms | ${info.percent}% | ${info.overTarget ? "🔴" : "✅"} |`); + } + if (result.details.slowestPhase) { + lines.push("", `→ Slowest phase: **${result.details.slowestPhase}**`); + } + } + + return lines.join("\n"); +} + +function auditMarkdown(result) { + const lines = [`## ${result.script}`]; + if (result.count != null) lines.push(`> ${result.count} item(s) found`); + + if (result.issues?.length) { + lines.push("", "### Issues"); + for (const issue of result.issues) { + const icon = issue.severity === "error" ? "❌" : issue.severity === "warning" ? "⚠️" : "ℹ️"; + lines.push(`- ${icon} ${issue.message}`); + } + } else { + lines.push("", "✅ No issues found"); + } + + const items = result.items ?? []; + if (items.length) { + const keys = Object.keys(items[0]).slice(0, 5); + lines.push("", `### Items (${items.length})`); + lines.push(`| ${keys.join(" | ")} |`); + lines.push(`| ${keys.map(() => "---").join(" | ")} |`); + for (const item of items) { + lines.push(`| ${keys.map((k) => String(item[k] ?? "")).join(" | ")} |`); + } + } + + return lines.join("\n"); +} + +export function exportMarkdown(result) { + if (result.script === "Fonts-Preloaded-Loaded-and-used-above-the-fold") return fontsMarkdown(result); + if (result.rating != null) return cwvMarkdown(result); + return auditMarkdown(result); +} diff --git a/components/SnippetVisualizer/index.jsx b/components/SnippetVisualizer/index.jsx new file mode 100644 index 0000000..4fb56ba --- /dev/null +++ b/components/SnippetVisualizer/index.jsx @@ -0,0 +1,190 @@ +import { useState, useCallback } from "react"; +import { CWVRenderer } from "./CWVRenderer"; +import { FontsRenderer } from "./FontsRenderer"; +import { AuditRenderer } from "./AuditRenderer"; +import { exportMarkdown } from "./exportMarkdown"; + +const PLACEHOLDER = `Paste the return value of any snippet here. + +In DevTools, after running a snippet, right-click the result object +and choose "Copy object", then paste it here. + +Or run: JSON.stringify(result) and paste the output.`; + +function detectRenderer(result) { + if (result.script === "Fonts-Preloaded-Loaded-and-used-above-the-fold") return "fonts"; + if (result.rating != null) return "cwv"; + if (Array.isArray(result.issues) || Array.isArray(result.items)) return "audit"; + return "raw"; +} + +function parseInput(text) { + if (!text.trim()) return null; + try { + return JSON.parse(text); + } catch { + throw new Error( + 'Invalid JSON. In DevTools: right-click the result → "Copy object", or run JSON.stringify(result).', + ); + } +} + +export function SnippetVisualizer() { + const [input, setInput] = useState(""); + const [copied, setCopied] = useState(false); + const [parseError, setParseError] = useState(null); + const [result, setResult] = useState(null); + + const handleReset = useCallback(() => { + setInput(""); + setResult(null); + setParseError(null); + setCopied(false); + }, []); + + const handleChange = useCallback((e) => { + const text = e.target.value; + setInput(text); + + if (!text.trim()) { + setResult(null); + setParseError(null); + return; + } + + try { + setResult(parseInput(text)); + setParseError(null); + } catch (err) { + setResult(null); + setParseError(err.message); + } + }, []); + + const handleCopyMarkdown = useCallback(async () => { + if (!result) return; + const md = exportMarkdown(result); + try { + await navigator.clipboard.writeText(md); + } catch { + const el = document.createElement("textarea"); + el.value = md; + el.style.cssText = "position:fixed;top:0;left:0;opacity:0"; + document.body.appendChild(el); + el.select(); + document.execCommand("copy"); + document.body.removeChild(el); + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [result]); + + const rendererType = result ? detectRenderer(result) : null; + + return ( +
+
+