Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/bin/sh
npm run check:consistency
158 changes: 158 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
@@ -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 |
78 changes: 78 additions & 0 deletions components/SnippetVisualizer/AuditRenderer.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<div style={{ display: "flex", alignItems: "center", gap: "12px", paddingBottom: "16px" }}>
<span style={{ fontSize: "1.5rem" }}>{statusIcon}</span>
<div>
<div style={{ fontWeight: "600" }}>{result.script}</div>
{result.count != null && (
<div style={{ color: "#6b7280", fontSize: "0.875rem" }}>{result.count} item(s)</div>
)}
</div>
</div>

{result.issues?.length > 0 ? (
<section style={{ marginBottom: "24px" }}>
<div style={{ fontSize: "0.875rem", fontWeight: "600", marginBottom: "8px" }}>Issues</div>
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{result.issues.map((issue, i) => (
<li key={i} style={{ color: SEVERITY_COLOR[issue.severity] ?? "#6b7280", padding: "4px 0", fontSize: "0.875rem" }}>
{SEVERITY_ICON[issue.severity] ?? "·"} {issue.message}
</li>
))}
</ul>
</section>
) : (
<p style={{ color: "#22c55e", marginBottom: "24px" }}>✅ No issues found</p>
)}

{items.length > 0 && columns.length > 0 && (
<section>
<div style={{ fontSize: "0.875rem", fontWeight: "600", marginBottom: "8px" }}>
Items ({items.length})
</div>
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.8rem" }}>
<thead>
<tr style={{ borderBottom: "1px solid rgba(128,128,128,0.2)" }}>
{columns.map((col) => (
<th key={col} style={{ textAlign: "left", padding: "6px 8px", color: "#6b7280", fontWeight: "normal" }}>{col}</th>
))}
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i} style={{ borderBottom: "1px solid rgba(128,128,128,0.1)" }}>
{columns.map((col) => (
<td key={col} style={{ padding: "6px 8px", maxWidth: "280px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{String(item[col] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
)}

{result.reason && (
<div style={{ color: "#6b7280", fontSize: "0.8rem", marginTop: "12px" }}>↳ {result.reason}</div>
)}
</div>
);
}
81 changes: 81 additions & 0 deletions components/SnippetVisualizer/CWVRenderer.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<div style={{ display: "flex", alignItems: "center", gap: "12px", paddingBottom: "16px" }}>
<span style={{ fontSize: "2rem" }}>{RATING_ICON[result.rating] ?? "·"}</span>
<div>
<div style={{ fontSize: "1.5rem", fontWeight: "bold", color }}>
{formatValue(result.value, result.unit)}
</div>
<div style={{ color: "#6b7280", fontSize: "0.875rem" }}>
{result.rating} · {result.script ?? result.metric}
</div>
</div>
</div>

{result.details?.element && (
<div style={{ background: "rgba(128,128,128,0.08)", borderRadius: "6px", padding: "8px 12px", fontSize: "0.8rem", marginBottom: "16px" }}>
<span style={{ color: "#6b7280" }}>Element: </span>
<code style={{ wordBreak: "break-all" }}>{result.details.element}</code>
</div>
)}

{sp && (
<div>
<div style={{ fontSize: "0.875rem", fontWeight: "600", marginBottom: "8px" }}>LCP Sub-Parts</div>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.875rem" }}>
<thead>
<tr style={{ borderBottom: "1px solid rgba(128,128,128,0.2)" }}>
{["Phase", "Value", "%", "Status"].map((h) => (
<th key={h} style={{ textAlign: h === "Phase" ? "left" : "right", padding: "6px 8px", color: "#6b7280", fontWeight: "normal" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{SUB_PARTS.map(([label, key]) => {
const info = sp[key];
if (!info) return null;
return (
<tr key={key} style={{ borderBottom: "1px solid rgba(128,128,128,0.1)" }}>
<td style={{ padding: "6px 8px" }}>{label}</td>
<td style={{ textAlign: "right", padding: "6px 8px" }}>{info.value}ms</td>
<td style={{ textAlign: "right", padding: "6px 8px" }}>{info.percent}%</td>
<td style={{ textAlign: "right", padding: "6px 8px" }}>{info.overTarget ? "🔴" : "✅"}</td>
</tr>
);
})}
</tbody>
</table>
{result.details.slowestPhase && (
<div style={{ color: "#6b7280", fontSize: "0.8rem", marginTop: "8px" }}>
→ Slowest: <strong>{result.details.slowestPhase}</strong>
</div>
)}
</div>
)}

{result.reason && (
<div style={{ color: "#6b7280", fontSize: "0.8rem", marginTop: "12px" }}>↳ {result.reason}</div>
)}
</div>
);
}
Loading
Loading