Skip to content

Commit a12789a

Browse files
authored
Merge pull request #85 from nucliweb/feat/snippet-visualizer
feat(visualizer): add snippet result visualizer page
2 parents f286087 + bd80ad7 commit a12789a

13 files changed

Lines changed: 758 additions & 31 deletions

File tree

.githooks/pre-commit

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/sh
2+
npm run check:consistency

SPEC.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# Spec: Snippet Result Visualizer
2+
3+
## Objective
4+
5+
A client-side page on webperf-snippets that accepts the JSON return value of any snippet (copied
6+
from the browser console), detects the snippet type, and renders a formatted performance report.
7+
8+
**Problem solved**: Sites protected by Akamai or strict CSP block extended DevTools console output,
9+
but the IIFE return value remains accessible as the last evaluated expression. The visualizer turns
10+
that object into a readable report without requiring any setup or CLI.
11+
12+
**Target users**: Web performance engineers doing reviews on sites with restrictive security
13+
policies (e.g., FedEx, enterprise sites with Akamai).
14+
15+
---
16+
17+
## Core Features
18+
19+
1. **Paste area** — textarea accepting a raw JSON object (single result, not array)
20+
2. **Auto-parse** — live parse on input change (debounced 300ms); no submit button
21+
3. **Auto-detect** — identify snippet type from the `script` field (or structural heuristics)
22+
4. **Renderers** — three display modes:
23+
- **CWV** — metric + rating + value + optional LCP subparts
24+
- **Fonts** — loaded fonts table, used-above-fold table, issues
25+
- **Audit** — issues list (severity-colored) + items table (generic)
26+
5. **Export as Markdown** — copy the rendered report as Markdown to clipboard
27+
6. **Error state** — clear message for invalid JSON or unrecognized format
28+
29+
---
30+
31+
## Acceptance Criteria
32+
33+
- [ ] Pasting a Fonts snippet result renders three sections: Loaded, Used above fold, Issues
34+
- [ ] Pasting an LCP result shows rating badge + value + subParts breakdown when present
35+
- [ ] Pasting any audit snippet result shows issues list + items table (up to 10 rows)
36+
- [ ] Pasting invalid JSON shows an inline error, not a crash
37+
- [ ] "Copy as Markdown" copies formatted Markdown to clipboard and shows confirmation
38+
- [ ] Page appears in the sidebar nav as "Visualizer"
39+
- [ ] No new npm dependencies introduced
40+
41+
---
42+
43+
## Detection Logic
44+
45+
```
46+
result.script === "Fonts-Preloaded-Loaded-and-used-above-the-fold" → FontsRenderer
47+
result.rating != null → CWVRenderer
48+
Array.isArray(result.issues) → AuditRenderer
49+
otherwise → RawRenderer (formatted JSON)
50+
```
51+
52+
---
53+
54+
## Data Shapes (input contracts)
55+
56+
**CWV metric** (LCP, CLS, INP, FCP, etc.):
57+
```json
58+
{
59+
"script": "LCP",
60+
"metric": "LCP",
61+
"rating": "good | needs-improvement | poor",
62+
"value": 1234,
63+
"unit": "ms | score",
64+
"details": { "element": "...", "subParts": { "ttfb": {}, ... } }
65+
}
66+
```
67+
68+
**Fonts**:
69+
```json
70+
{
71+
"script": "Fonts-Preloaded-Loaded-and-used-above-the-fold",
72+
"status": "ok",
73+
"details": { "preloadedCount": 2, "loadedCount": 3, "usedAboveFoldCount": 2, ... },
74+
"items": [{ "family": "...", "weight": "400", "style": "normal", "display": "swap" }],
75+
"usedFonts": [{ "family": "...", "weight": "400", "style": "normal", "elements": 12 }],
76+
"issues": [{ "severity": "warning | error", "message": "..." }]
77+
}
78+
```
79+
80+
**Audit** (all other snippets):
81+
```json
82+
{
83+
"script": "Find-render-blocking-resources",
84+
"status": "ok",
85+
"count": 3,
86+
"items": [{ "url": "...", "type": "script", "durationMs": 120 }],
87+
"issues": [{ "severity": "error | warning | info", "message": "..." }]
88+
}
89+
```
90+
91+
---
92+
93+
## Project Structure
94+
95+
```
96+
pages/
97+
visualizer.mdx ← Nextra page (imports SnippetVisualizer)
98+
components/
99+
SnippetVisualizer.jsx ← Main component (textarea + renderer dispatch)
100+
SnippetVisualizer/
101+
CWVRenderer.jsx
102+
FontsRenderer.jsx
103+
AuditRenderer.jsx
104+
exportMarkdown.js ← Pure function: result → markdown string
105+
```
106+
107+
`pages/_meta.json` gets a new entry:
108+
```json
109+
"visualizer": { "title": "Visualizer" }
110+
```
111+
112+
---
113+
114+
## Code Style
115+
116+
- **No new dependencies** — React hooks only (`useState`, `useMemo`, `useCallback`)
117+
- **CSS classes** — Nextra `nx-` utility classes for visual consistency; inline styles only for
118+
dynamic values (rating colors)
119+
- **No TypeScript** — plain `.jsx` / `.js`, matching the rest of the project
120+
- **No comments** unless the why is non-obvious
121+
122+
---
123+
124+
## Markdown Export Format
125+
126+
Single result exported as:
127+
```markdown
128+
## Fonts — Fonts-Preloaded-Loaded-and-used-above-the-fold
129+
130+
### Loaded Fonts
131+
| Family | Weight | Style | Display |
132+
|--------|--------|-------|---------|
133+
| ... | 400 | normal| swap |
134+
135+
### Used Above Fold
136+
| Family | Weight | Style | Elements |
137+
...
138+
139+
### Issues
140+
- ⚠️ warning: Font preloaded without crossorigin...
141+
```
142+
143+
For CWV metrics:
144+
```markdown
145+
## LCP — 1.2s ✅ good
146+
...
147+
```
148+
149+
---
150+
151+
## Boundaries
152+
153+
| Always | Ask First | Never |
154+
|--------|-----------|-------|
155+
| Handle invalid input gracefully | Adding a new npm dependency | Server-side code / API routes |
156+
| Keep all logic client-side | Changing next.config.js | TypeScript migration |
157+
| Use `nx-` classes for styling | Adding a new page category | Storing paste data anywhere |
158+
| Clear error feedback | | Sending data to any external service |
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
const SEVERITY_COLOR = { error: "#ef4444", warning: "#f59e0b", info: "#3b82f6" };
2+
const SEVERITY_ICON = { error: "✗", warning: "⚠", info: "ℹ" };
3+
4+
const IGNORED_COLS = new Set(["raw", "html", "element", "selector"]);
5+
6+
export function AuditRenderer({ result }) {
7+
const errors = (result.issues ?? []).filter((i) => i.severity === "error");
8+
const warnings = (result.issues ?? []).filter((i) => i.severity === "warning");
9+
const statusIcon = errors.length ? "🔴" : warnings.length ? "🟡" : "🟢";
10+
11+
const items = result.items ?? [];
12+
const columns = items.length > 0
13+
? Object.keys(items[0]).filter((k) => !IGNORED_COLS.has(k)).slice(0, 5)
14+
: [];
15+
16+
return (
17+
<div>
18+
<div style={{ display: "flex", alignItems: "center", gap: "12px", paddingBottom: "16px" }}>
19+
<span style={{ fontSize: "1.5rem" }}>{statusIcon}</span>
20+
<div>
21+
<div style={{ fontWeight: "600" }}>{result.script}</div>
22+
{result.count != null && (
23+
<div style={{ color: "#6b7280", fontSize: "0.875rem" }}>{result.count} item(s)</div>
24+
)}
25+
</div>
26+
</div>
27+
28+
{result.issues?.length > 0 ? (
29+
<section style={{ marginBottom: "24px" }}>
30+
<div style={{ fontSize: "0.875rem", fontWeight: "600", marginBottom: "8px" }}>Issues</div>
31+
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
32+
{result.issues.map((issue, i) => (
33+
<li key={i} style={{ color: SEVERITY_COLOR[issue.severity] ?? "#6b7280", padding: "4px 0", fontSize: "0.875rem" }}>
34+
{SEVERITY_ICON[issue.severity] ?? "·"} {issue.message}
35+
</li>
36+
))}
37+
</ul>
38+
</section>
39+
) : (
40+
<p style={{ color: "#22c55e", marginBottom: "24px" }}>✅ No issues found</p>
41+
)}
42+
43+
{items.length > 0 && columns.length > 0 && (
44+
<section>
45+
<div style={{ fontSize: "0.875rem", fontWeight: "600", marginBottom: "8px" }}>
46+
Items ({items.length})
47+
</div>
48+
<div style={{ overflowX: "auto" }}>
49+
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.8rem" }}>
50+
<thead>
51+
<tr style={{ borderBottom: "1px solid rgba(128,128,128,0.2)" }}>
52+
{columns.map((col) => (
53+
<th key={col} style={{ textAlign: "left", padding: "6px 8px", color: "#6b7280", fontWeight: "normal" }}>{col}</th>
54+
))}
55+
</tr>
56+
</thead>
57+
<tbody>
58+
{items.map((item, i) => (
59+
<tr key={i} style={{ borderBottom: "1px solid rgba(128,128,128,0.1)" }}>
60+
{columns.map((col) => (
61+
<td key={col} style={{ padding: "6px 8px", maxWidth: "280px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
62+
{String(item[col] ?? "")}
63+
</td>
64+
))}
65+
</tr>
66+
))}
67+
</tbody>
68+
</table>
69+
</div>
70+
</section>
71+
)}
72+
73+
{result.reason && (
74+
<div style={{ color: "#6b7280", fontSize: "0.8rem", marginTop: "12px" }}>{result.reason}</div>
75+
)}
76+
</div>
77+
);
78+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
const RATING_COLOR = { good: "#22c55e", "needs-improvement": "#f59e0b", poor: "#ef4444" };
2+
const RATING_ICON = { good: "🟢", "needs-improvement": "🟡", poor: "🔴" };
3+
4+
function formatValue(value, unit) {
5+
if (unit === "ms") return value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${value}ms`;
6+
if (unit === "score") return value.toFixed(4);
7+
return String(value);
8+
}
9+
10+
const SUB_PARTS = [
11+
["TTFB", "ttfb"],
12+
["Resource Load Delay", "resourceLoadDelay"],
13+
["Resource Load Time", "resourceLoadTime"],
14+
["Element Render Delay", "elementRenderDelay"],
15+
];
16+
17+
export function CWVRenderer({ result }) {
18+
const color = RATING_COLOR[result.rating] ?? "#6b7280";
19+
const sp = result.details?.subParts;
20+
21+
return (
22+
<div>
23+
<div style={{ display: "flex", alignItems: "center", gap: "12px", paddingBottom: "16px" }}>
24+
<span style={{ fontSize: "2rem" }}>{RATING_ICON[result.rating] ?? "·"}</span>
25+
<div>
26+
<div style={{ fontSize: "1.5rem", fontWeight: "bold", color }}>
27+
{formatValue(result.value, result.unit)}
28+
</div>
29+
<div style={{ color: "#6b7280", fontSize: "0.875rem" }}>
30+
{result.rating} · {result.script ?? result.metric}
31+
</div>
32+
</div>
33+
</div>
34+
35+
{result.details?.element && (
36+
<div style={{ background: "rgba(128,128,128,0.08)", borderRadius: "6px", padding: "8px 12px", fontSize: "0.8rem", marginBottom: "16px" }}>
37+
<span style={{ color: "#6b7280" }}>Element: </span>
38+
<code style={{ wordBreak: "break-all" }}>{result.details.element}</code>
39+
</div>
40+
)}
41+
42+
{sp && (
43+
<div>
44+
<div style={{ fontSize: "0.875rem", fontWeight: "600", marginBottom: "8px" }}>LCP Sub-Parts</div>
45+
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.875rem" }}>
46+
<thead>
47+
<tr style={{ borderBottom: "1px solid rgba(128,128,128,0.2)" }}>
48+
{["Phase", "Value", "%", "Status"].map((h) => (
49+
<th key={h} style={{ textAlign: h === "Phase" ? "left" : "right", padding: "6px 8px", color: "#6b7280", fontWeight: "normal" }}>{h}</th>
50+
))}
51+
</tr>
52+
</thead>
53+
<tbody>
54+
{SUB_PARTS.map(([label, key]) => {
55+
const info = sp[key];
56+
if (!info) return null;
57+
return (
58+
<tr key={key} style={{ borderBottom: "1px solid rgba(128,128,128,0.1)" }}>
59+
<td style={{ padding: "6px 8px" }}>{label}</td>
60+
<td style={{ textAlign: "right", padding: "6px 8px" }}>{info.value}ms</td>
61+
<td style={{ textAlign: "right", padding: "6px 8px" }}>{info.percent}%</td>
62+
<td style={{ textAlign: "right", padding: "6px 8px" }}>{info.overTarget ? "🔴" : "✅"}</td>
63+
</tr>
64+
);
65+
})}
66+
</tbody>
67+
</table>
68+
{result.details.slowestPhase && (
69+
<div style={{ color: "#6b7280", fontSize: "0.8rem", marginTop: "8px" }}>
70+
→ Slowest: <strong>{result.details.slowestPhase}</strong>
71+
</div>
72+
)}
73+
</div>
74+
)}
75+
76+
{result.reason && (
77+
<div style={{ color: "#6b7280", fontSize: "0.8rem", marginTop: "12px" }}>{result.reason}</div>
78+
)}
79+
</div>
80+
);
81+
}

0 commit comments

Comments
 (0)