
Tufte Report
- 155 installs
- 339 repo stars
- Updated August 4, 2026
- glebis/claude-skills
Author Edward Tufte-style reports with high data-ink ratio, small multiples, and clear narrative structure for analyses, memos, and stakeholder deliverables.
About
Builds publication-quality Tufte-inspired reports that prioritize information density, intentional typography, small multiples, and low chartjunk for communicating analyses and product metrics to technical and business readers.
- Tufte design principles
- High data-ink ratio
- Small multiples
- Minimal chartjunk
- Stakeholder-ready output
Tufte Report by the numbers
- 155 all-time installs (skills.sh)
- Ranked #284 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/glebis/claude-skills --skill tufte-reportAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 155 |
|---|---|
| repo stars | ★ 339 |
| Last updated | August 4, 2026 |
| Repository | glebis/claude-skills ↗ |
What it does
Author Edward Tufte-style reports with high data-ink ratio, small multiples, and clear narrative structure for analyses, memos, and stakeholder deliverables.
Files
Tufte Report — Data-Driven Infographic Skill
Create standalone HTML reports that combine editorial narrative with interactive data visualization in Edward Tufte's style: high information density, minimal chart junk, typography-first design.
Design Philosophy
Tufte's core principles drive every decision:
- Data-ink ratio: every pixel of ink should represent data, not decoration
- Small multiples: repeat a design to show comparison, not animation
- Sparklines: word-sized graphics that live inside prose
- Layering: overview first, then detail on demand
- Integration: text and graphics share the same visual space (sidenotes, not footnotes)
The report should feel like a well-edited magazine feature — you read it top to bottom, narrative carries you through the data, and every chart earns its space by answering a specific question.
Onboarding — Ask Before Building
Before writing ANY code, ask these questions. Do not proceed until all are answered:
1. What data sources do you have? (CSV, JSON, SQLite, API endpoint, or raw numbers) 2. What is the primary question this report should answer? (one sentence — this becomes the title and drives all design decisions) 3. How many sections do you need? (cap at 8 — push back if more are requested; each section should answer one sub-question) 4. What's the output format? (standalone HTML file, or embedded component) 5. Time budget? Provide an estimate:
- 1-2 sections with tables only: ~200 LOC, ~5 min bypass / ~10 min manual
- 3-4 sections with 2-3 charts: ~500 LOC, ~15 min bypass / ~25 min manual
- 5-8 sections with charts + health data + sparklines: ~1200 LOC, ~30 min bypass / ~50 min manual
Scope Protection
This skill enforces hard limits to prevent scope creep:
- Max 8 sections — if the user asks for more, suggest combining related topics
- Max 2 chart types per section — a section gets one primary chart and optionally one supporting chart or table. More than that means the section should split
- Max 3 colors per chart — beyond that, use small multiples instead of rainbow legends
- No 3D charts, no pie charts, no donut charts — these violate Tufte principles
- No gratuitous animation — scroll-reveal on enter is fine; spinning, bouncing, or pulsing is not
- Every chart must have a caption — if you can't write a one-sentence caption explaining what the chart shows, the chart shouldn't exist
When the user asks for something outside these limits, respond with: "That would take the report from [current LOC estimate] to [new estimate]. The extra complexity adds [X] but risks [Y]. Shall I proceed, or can we [simpler alternative]?"
Architecture
report.html (standalone, no build step)
├── Google Fonts CDN (EB Garamond)
├── jsDelivr CDN (Monaspace Argon woff2)
├── jsDelivr CDN (Chart.js 4.x UMD)
├── Inline <style> (design system CSS)
├── Inline HTML (semantic structure)
└── Inline <script> (data + Chart.js configs + sparklines + scroll-reveal)No build tools, no frameworks, no npm. One file, opens in any browser.
Design System
Read references/design-tokens.md for the complete CSS variables, typography scale, and color palette.
Read references/components.md for the HTML+CSS snippet of every reusable component.
Read references/charts.md for Chart.js configuration patterns and inline SVG sparkline code.
Report Structure Template
Every report follows this skeleton:
1. Title + subtitle + data source tags (monospace, subtle)
2. [Optional] Status dashboard (4-column KPI strip)
3. Overview narrative with inline sparklines + TOC sidebar
4. Summary cards (2-4 KPI tiles with sparklines)
5. Sections (each: state-line → chart+narrative aside → table+narrative aside)
6. [Optional] Decision register (threshold table with status colors)
7. Footer (generation date, sources)Section Pattern
Each section follows this rhythm:
<h2> with ↑ back-to-top link
<p class="state-line"> — one italic sentence, the takeaway
<div class="aside-container"> — chart on left, narrative on right
<div class="aside-container"> — table on left, interpretation on rightThe alternation of chart→narrative→table→narrative creates visual breathing room and prevents "wall of data" fatigue.
Rules for Narrative Text
- State-lines (the italic intro under each heading): one sentence, max 20 words, states the conclusion not the topic. "HRV down 13%, steps down 42%" not "This section covers health metrics"
- Aside narratives: 3-4 short paragraphs, each starting with a bold keyword. Written like a newspaper sidebar — facts first, interpretation second
- Flyouts: reserved for actionable insights or methodology notes. The ✦ symbol marks them as "pay attention"
- No "tells its own story" or similar filler. Every sentence should contain a number or a decision
Dual-Font Strategy
| Context | Font | Why |
|---|---|---|
| All body text, headers, captions | EB Garamond | Classical editorial feel, excellent readability |
| All numbers in tables | Monaspace Argon | Tabular figures align in columns, monospace scannability |
| Big numbers in cards/dashboards | Monaspace Argon | Visual weight, distinct from prose |
| Status indicators, trend percentages | Monaspace Argon | Precision signaling |
| Data source tags, code references | Monaspace Argon | Technical register |
| Ornament separators (:::) | Monaspace Argon with ligatures | Programming aesthetic, replaces floral Unicode |
Color Principles
Use --ink (near-black) for text, --bg (warm white) for background. Chart colors must be semantically meaningful — don't assign colors randomly:
- Orange (
--spark-claude, #c45a28): primary data stream, effort/work metrics - Green (
--spark-wispr, #2a7a5a): growth, positive health signals, English language - Purple (
--spark-social, #5a5aaa): social/communication metrics - Blue (rgba(42,80,140)): secondary overlay lines on charts
- Red (#a02a2a): alerts, negative trends, declining metrics
- Amber (#c89000): warnings, watch-level signals
- Green (#2a7a3a): healthy baselines, positive trends
Never use more than 3 colors in a single chart. If you need more, use opacity/saturation variations of the same hue.
Session Lessons (What Goes Wrong)
Based on building the reference report, these are the recurring problems:
1. Chart.js CDN version: Use @4 not a specific patch version — specific versions may not exist 2. Chart.js defaults: Set individual properties, never replace entire objects (Chart.defaults.scale.grid.color = '#eee' not Chart.defaults.scale.grid = {color: '#eee'}) 3. Legend circles: Use usePointStyle: false with boxWidth: 8, boxHeight: 8, borderRadius: 4 for true circles. usePointStyle: true creates ovals 4. file:// protocol: Charts won't load CDN scripts via file://. Always test via localhost 5. Back-to-back charts: Always separate consecutive charts with narrative, a table, or an ornament. Two charts in a row = "wall of data" 6. Table overflow on mobile: Wrap in .table-wrapper and add .hide-mobile to secondary columns 7. Dual-axis charts: Use sparingly — they invite false visual equivalence. Always label both axes clearly 8. Narrative overreach: Don't claim correlations without computing them. "r = 0.10" is more trustworthy than "strong relationship"
Universal Data Adapter
When the user provides data from any source (CSV, JSON, SQLite, API, raw numbers), normalize it into the standard ReportData intermediate format before generating HTML. This decouples data ingestion from report rendering.
Read references/data-adapter.md for the ReportData JSON schema, field reference, and adapter instructions for each source type.
Workflow: 1. User provides data → identify source type 2. Transform into ReportData JSON (ask user for meta.question and desired sections) 3. Confirm the normalized structure with the user 4. Generate HTML from the ReportData using the block library
Composable Block Library
Reports are assembled from typed blocks, each with a defined data contract. This replaces ad-hoc HTML generation with a systematic approach.
Read references/blocks.md for the complete block catalog: sparkline-row, kpi-card, trend-chart, data-table, correlation-matrix, narrative, heatmap, strip-chart.
Each block defines:
- Data contract (what JSON shape it expects)
- HTML template (copy-paste ready)
- Composition rules (how blocks pair and sequence)
Preview Server
For iterative development, use the built-in live-reload server:
python3 ~/.claude/skills/tufte-report/scripts/serve.py report.htmlServes on localhost:8042, auto-reloads on file change with scroll position preserved. Zero dependencies — Python stdlib only.
Read references/preview-server.md for details. After generating a report, offer to start the preview server for the user.
{
"name": "tufte-report",
"description": "Create Tufte-inspired data reports and infographic dashboards as standalone HTML files. Uses EB Garamond for text, Monas",
"author": {
"name": "Gleb Kalinin"
},
"repository": "https://github.com/glebis/claude-skills",
"license": "MIT"
}Block Library
Each block is a self-contained HTML+CSS+JS unit with a defined data contract. Blocks compose into report sections.
Block Contract
Every block receives:
{
type: "block-type", // determines which renderer to use
data: { ... }, // type-specific data shape
caption: "string", // optional, shown below the block
id: "unique-id" // for CSS targeting and anchoring
}Claude generates each block by looking up the type below and filling in the HTML template with the data.
---
1. sparkline-row
Inline sparkline with label and value. Used in tables, KPI strips, and inline text.
Data contract:
{
"label": "HRV",
"value": 42.3,
"unit": "ms",
"sparkline": [38, 41, 44, 39, 42, 45, 40, 43],
"color": "primary",
"trend": -0.13
}HTML output:
<div class="sparkline-row">
<span class="sr-label">HRV</span>
<span class="sr-value"><span class="mono">42.3</span> ms</span>
<svg class="spark-inline" id="spark-hrv" width="60" height="16"></svg>
<span class="sr-trend down">▼ 13%</span>
</div>Rendering JS:
drawSparkline('spark-hrv', [38,41,44,39,42,45,40,43], `var(--spark-primary)`);---
2. kpi-card
Summary card with big number, sparkline, and context.
Data contract:
{
"label": "Claude Code",
"value": "2,082",
"sparkline": [120, 180, 340, 520, 610, 780],
"detail": "sessions across 81 active days",
"trend_text": "+450% growth Jan–Apr",
"trend_direction": "up",
"color": "primary"
}HTML output:
<div class="summary-card" style="--card-color: var(--spark-primary)">
<div class="label">Claude Code</div>
<div class="big-number-row">
<div class="big-number">2,082</div>
<svg class="card-spark" id="cardSparkClaude" width="80" height="30"></svg>
</div>
<div class="detail">sessions across 81 active days</div>
<div class="trend up">+450% growth Jan–Apr</div>
</div>Wrap 2–4 cards in <div class="summary-row">.
---
3. trend-chart
Line or bar chart via Chart.js. The workhorse visualization.
Data contract:
{
"chart_type": "line",
"labels": ["Jan", "Feb", "Mar", "Apr"],
"datasets": [
{
"label": "Deep Sleep",
"values": [1.2, 1.0, 0.98, 1.1],
"color": "primary",
"fill": true
},
{
"label": "REM",
"values": [1.5, 1.4, 1.5, 1.6],
"color": "secondary",
"fill": false
}
],
"y_label": "hours",
"x_label": "Month"
}HTML output:
<div class="chart-container reveal">
<canvas id="chartDeepSleep" height="260"></canvas>
<div class="caption">Hours per night, 7-day rolling average</div>
</div>Color mapping: "primary" → --spark-primary, "secondary" → --spark-secondary, "tertiary" → --spark-tertiary. Direct hex also accepted.
Rules:
- Max 3 datasets per chart (use small multiples for more)
- Always include
y_label - Set
fill: truefor area charts,falsefor overlay lines - Chart.js config: see
references/charts.mdfor defaults
---
4. data-table
Structured table with optional inline sparklines and highlight rows.
Data contract:
{
"columns": [
{"key": "label", "header": "Metric", "align": "left", "font": "serif"},
{"key": "current", "header": "Current", "align": "right", "font": "mono"},
{"key": "previous", "header": "Previous", "align": "right", "font": "mono"},
{"key": "shape", "header": "Shape", "type": "sparkline"}
],
"rows": [
{"label": "HRV", "current": "42.3", "previous": "48.1", "shape": [38,41,44,39,42], "highlight": true},
{"label": "Steps", "current": "6,200", "previous": "9,100", "shape": [9100,8800,7200,6800,6200]}
],
"hide_on_mobile": ["previous"]
}HTML output: Standard <table> wrapped in .table-wrapper, numbers in Monaspace Argon, sparklines rendered via drawSparkline().
---
5. correlation-matrix
Heatmap grid showing pairwise correlations between variables.
Data contract:
{
"variables": ["Screen time", "Caffeine", "Exercise", "Deep sleep"],
"matrix": [
[1.0, 0.15, -0.22, -0.45],
[0.15, 1.0, -0.08, -0.38],
[-0.22, -0.08, 1.0, 0.52],
[-0.45, -0.38, 0.52, 1.0]
]
}HTML output:
<div class="correlation-matrix reveal">
<table class="corr-table">
<thead><tr><th></th><th>Screen</th><th>Caffeine</th><th>Exercise</th><th>Sleep</th></tr></thead>
<tbody>
<tr><th>Screen</th><td style="background:rgba(160,42,42,0.0)">1.00</td><td style="background:rgba(160,42,42,0.08)">0.15</td>...</tr>
</tbody>
</table>
<div class="caption">Pearson correlations, 90-day window</div>
</div>Color logic:
- Positive correlations: green channel intensity =
abs(r) * 0.4opacity of--status-green - Negative correlations: red channel intensity =
abs(r) * 0.4opacity of--status-red - Diagonal (1.0): neutral gray
- Display values in Monaspace Argon
---
6. narrative
Prose block with optional bold keywords. Pure text, no visualization.
Data contract:
{
"content": "**Deep sleep** declined steadily after the caffeine experiment in Feb. **REM** held steady, suggesting the issue is slow-wave, not total sleep.",
"style": "aside"
}Styles: "body" (full-width, normal size), "aside" (280px sidebar, smaller italic), "flyout" (callout box with diamond marker).
HTML output: Depends on style. "body" → <p>, "aside" → wraps in .aside div, "flyout" → wraps in .flyout div.
---
7. heatmap
Calendar or grid heatmap for daily/weekly data.
Data contract:
{
"period": "daily",
"labels": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"series": [
{"week": "W1", "values": [3, 5, 2, 7, 4, 1, 0]},
{"week": "W2", "values": [4, 6, 3, 5, 5, 2, 1]}
],
"color": "primary",
"max_value": 7
}HTML output: CSS Grid of cells, opacity proportional to value / max_value, using the semantic color.
---
8. strip-chart
Horizontal bar rows — good for ranked or periodic data.
Data contract:
{
"rows": [
{"label": "W1 Jan 6", "value": 42, "note": "started tracking"},
{"label": "W2 Jan 13", "value": 38},
{"label": "W3 Jan 20", "value": 55, "note": "peak week"}
],
"max_value": 55,
"color": "primary",
"value_label": "messages"
}HTML output: Each row is .tg-week + .tg-count + .tg-bar-area (proportional width) + .tg-note.
---
Composing Blocks into Sections
A section is a sequence of blocks. The layout engine follows these rules:
1. Never place two chart blocks back-to-back — insert a narrative or data-table between them 2. Pair charts with narratives using aside-container layout (chart left, narrative right) 3. KPI cards always go in summary-row groups of 2–4 4. Correlation matrices are standalone — they don't pair with asides 5. Strip charts and heatmaps span full width
Section Assembly Example
{
"id": "sleep",
"title": "Sleep Architecture",
"state_line": "Deep sleep down **18%**, REM stable",
"blocks": [
{"type": "trend-chart", "data": {...}, "caption": "..."},
{"type": "narrative", "data": {"content": "...", "style": "aside"}},
{"type": "data-table", "data": {...}},
{"type": "narrative", "data": {"content": "...", "style": "aside"}},
{"type": "correlation-matrix", "data": {...}, "caption": "..."}
]
}This renders as:
h2 + state-line
aside-container: [trend-chart | narrative]
aside-container: [data-table | narrative]
correlation-matrix (full width)
ornament separatorChart Library
Chart.js patterns for Tufte reports. All use Chart.js 4.x via CDN.
CDN and Defaults
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>// Tufte defaults — set BEFORE creating any charts
Chart.defaults.font.family = "'EB Garamond', Georgia, serif";
Chart.defaults.font.size = 13;
Chart.defaults.color = '#555';
Chart.defaults.plugins.legend.labels.usePointStyle = false;
Chart.defaults.plugins.legend.labels.boxWidth = 8;
Chart.defaults.plugins.legend.labels.boxHeight = 8;
Chart.defaults.plugins.legend.labels.borderRadius = 4;
Chart.defaults.plugins.legend.labels.font = { family: "'EB Garamond', serif", size: 11 };
Chart.defaults.plugins.legend.labels.padding = 14;
Chart.defaults.plugins.tooltip.backgroundColor = '#1a1a1a';
Chart.defaults.plugins.tooltip.cornerRadius = 2;
Chart.defaults.plugins.tooltip.padding = 10;
Chart.defaults.scale.grid.color = '#eee';
Chart.defaults.animation = {
duration: 800,
easing: 'easeOutQuart',
delay: (ctx) => ctx.type === 'data' ? ctx.dataIndex * 8 : 0
};
// Solid filled legend dots (override default hollow circles for line charts)
const defaultGen = Chart.defaults.plugins.legend.labels.generateLabels;
Chart.defaults.plugins.legend.labels.generateLabels = function(chart) {
const items = defaultGen.call(this, chart);
items.forEach(item => {
const ds = chart.data.datasets[item.datasetIndex];
const color = ds.borderColor || ds.backgroundColor;
item.fillStyle = Array.isArray(color) ? color[0] : color;
item.strokeStyle = 'transparent';
item.lineWidth = 0;
item.borderRadius = 4;
});
return items;
};Pattern 1: Bar + Line (Dual Axis)
Best for: comparing volume (bars) with a rate/trend (line) on different scales.
new Chart(document.getElementById('myChart'), {
type: 'bar',
data: {
labels: data.map(d => formatDate(d.date)),
datasets: [
{
label: 'Volume',
data: data.map(d => d.volume),
backgroundColor: 'rgba(196,90,40,0.25)',
borderWidth: 0,
borderRadius: 1,
order: 2
},
{
label: 'Rate',
data: data.map(d => d.rate),
type: 'line',
borderColor: 'rgba(42,80,140,0.7)',
backgroundColor: 'rgba(42,80,140,0.06)',
fill: true,
borderWidth: 1.5,
pointRadius: 0,
pointHitRadius: 8,
tension: 0.3,
yAxisID: 'y1',
order: 1
}
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 12, font: { size: 11 } } },
y: { position: 'left', title: { display: true, text: 'Volume' } },
y1: { position: 'right', title: { display: true, text: 'Rate' }, grid: { display: false } }
},
plugins: { legend: { position: 'top', align: 'end' } }
}
});Pattern 2: SPC Control Chart
Best for: monitoring a metric against statistical control limits.
new Chart(document.getElementById('spcChart'), {
type: 'line',
data: {
labels: weekLabels,
datasets: [
{
label: 'Metric',
data: weeklyValues,
borderColor: 'rgba(42,122,90,0.8)',
backgroundColor: weeklyValues.map(v => v < centerline ? 'rgba(160,42,42,0.6)' : 'rgba(42,122,90,0.6)'),
pointBackgroundColor: weeklyValues.map(v => v < centerline ? 'rgba(160,42,42,0.6)' : 'rgba(42,122,90,0.6)'),
borderWidth: 1.5,
pointRadius: 4,
tension: 0.2,
fill: false
},
{
label: 'Centerline',
data: Array(weekLabels.length).fill(centerline),
borderColor: 'rgba(0,0,0,0.2)',
borderDash: [6, 4],
borderWidth: 1,
pointRadius: 0,
fill: false
},
{
label: 'Lower limit',
data: Array(weekLabels.length).fill(lowerLimit),
borderColor: 'rgba(160,42,42,0.3)',
borderDash: [4, 4],
borderWidth: 1,
pointRadius: 0,
fill: false
}
]
}
});Pattern 3: Multi-line Comparison (No Fill)
Best for: comparing 2-3 trends on the same scale.
// Use distinct colors, no area fill, solid dots
// Use borderDash for the least important series
{
label: 'Primary',
borderColor: 'rgba(196,90,40,0.8)',
pointBackgroundColor: 'rgba(196,90,40,0.8)',
fill: false, borderWidth: 2, pointRadius: 3, tension: 0.3
},
{
label: 'Secondary',
borderColor: 'rgba(42,122,90,0.8)',
pointBackgroundColor: 'rgba(42,122,90,0.8)',
fill: false, borderWidth: 2, pointRadius: 3, tension: 0.3
},
{
label: 'Tertiary',
borderColor: 'rgba(90,90,170,0.7)',
pointBackgroundColor: 'rgba(90,90,170,0.7)',
fill: false, borderWidth: 1.5, pointRadius: 3, borderDash: [4, 3], tension: 0.3
}Pattern 4: Inline SVG Sparkline
Best for: word-sized trend indicators inside text or table cells.
<svg class="spark-inline" id="sparkId" width="50" height="14"></svg>function drawSparkline(svgId, values, color) {
const svg = document.getElementById(svgId);
if (!svg) return;
const w = parseInt(svg.getAttribute('width'));
const h = parseInt(svg.getAttribute('height'));
const pad = 2;
const max = Math.max(...values);
const min = Math.min(...values);
const range = max - min || 1;
const step = (w - pad * 2) / (values.length - 1);
const points = values.map((v, i) => {
const x = pad + i * step;
const y = h - pad - ((v - min) / range) * (h - pad * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const areaPoints = [`${pad},${h-pad}`, ...points, `${(pad+(values.length-1)*step).toFixed(1)},${h-pad}`].join(' ');
const lastX = pad + (values.length - 1) * step;
const lastY = h - pad - ((values[values.length-1] - min) / range) * (h - pad * 2);
svg.innerHTML = `
<polygon points="${areaPoints}" fill="${color}" opacity="0.12" />
<polyline points="${points.join(' ')}" fill="none" stroke="${color}" stroke-width="1.2" stroke-linejoin="round" />
<circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="2" fill="${color}" />
`;
}Pattern 5: Strip Chart (Horizontal Bar Rows)
Best for: weekly/periodic data with sparse annotations. Tufte-style alternative to bar charts.
Generated via JS — creates rows with .tg-week (label) + .tg-count (number) + .tg-bar-area (proportional bar) + .tg-note (annotation).
See components.md for the full CSS.
Pattern 6: Stacked Bar (Language/Category Split)
{
label: 'Category A',
data: data.map(d => d.catA),
backgroundColor: 'rgba(42,122,90,0.55)',
borderColor: 'rgba(42,122,90,0.7)',
borderWidth: 0.5,
borderRadius: 1
},
{
label: 'Category B',
data: data.map(d => d.catB),
backgroundColor: 'rgba(204,140,0,0.65)',
borderColor: 'rgba(180,120,0,0.8)',
borderWidth: 0.5,
borderRadius: 1
}
// scales: { x: { stacked: true }, y: { stacked: true } }Anti-Patterns (Don't Do This)
- Don't use
Chart.defaults.scale.grid = { ... }— it replaces the entire object. Set.colorindividually - Don't use
usePointStyle: truefor circles — it creates ovals. UseboxWidth/boxHeightwithborderRadius - Don't use
@4.4.7or specific patch versions in CDN — they may not exist. Use@4 - Don't put two charts back-to-back without narrative/table separation
- Don't use more than 2 y-axes on a single chart
- Don't use area fills on multi-line comparison charts (creates visual mud)
Component Catalog
Copy-paste ready HTML+CSS for each Tufte report component.
1. Aside Container (2-column narrative+data)
The fundamental layout unit. Data on the left, narrative on the right.
<div class="aside-container">
<div>
<!-- chart, table, or data content -->
</div>
<div class="aside" style="border-left:none;padding-top:0;">
<div style="font-variant:small-caps;text-transform:lowercase;letter-spacing:0.08em;font-size:0.82rem;color:var(--ink-light);margin-bottom:0.5rem;">sidebar title</div>
<p style="font-size:0.85rem;line-height:1.6;"><strong>Key point</strong> — explanation here.</p>
</div>
</div>CSS: display:grid; grid-template-columns:1fr 280px; gap:2rem; align-items:start; Mobile: collapses to single column.
2. State Line (italic section summary)
One sentence under each h2 — states the conclusion, not the topic.
<p class="state-line">HRV down <strong>13%</strong>, steps down <strong>42%</strong>, two 34-day streaks without rest.</p>CSS: font-size:1.5rem; font-style:italic; line-height:1.45; color:var(--ink-light); margin:1.5rem 0 2rem; max-width:750px; Strong tags render in normal weight, dark color — they pop out of the italic flow.
3. Summary Card (KPI tile with sparkline)
<div class="summary-card claude">
<div class="label">Claude Code</div>
<div class="big-number-row">
<div class="big-number">2,082</div>
<svg class="card-spark" id="cardSparkClaude"></svg>
</div>
<div class="detail">sessions across 81 active days</div>
<div class="trend up">+450% growth Jan-Apr</div>
</div>Wrap 2-4 cards in <div class="summary-row"> (3-column grid). Left border color set via ::before pseudo-element with var(--spark-*).
4. Status Strip (4-column dashboard)
<div class="status-strip" id="status">
<div class="status-cell status-red">
<div class="status-label">HRV Status</div>
<div class="status-value">26.1 ms</div>
<div class="status-note note-red">below 40 ms baseline</div>
</div>
<!-- repeat for each KPI -->
</div>Colors: .status-red (border-left #a02a2a), .status-amber (#c89000), .status-green (#2a7a3a). Numbers use Monaspace Argon. Mobile: 2x2 grid.
5. Flyout (callout box)
<div class="flyout">
<div class="flyout-title">key finding</div>
<p>Content here. Use for actionable insights or methodology notes.</p>
</div>The ::before pseudo-element adds a red diamond marker above the box. Use sparingly — max 2 per section.
6. Data Table (with Monaspace numbers)
<div class="table-wrapper">
<table>
<thead>
<tr><th>Label</th><th>Metric A</th><th>Metric B</th><th style="text-align:left;padding-left:0.5rem;">Shape</th></tr>
</thead>
<tbody>
<tr><td>Row 1</td><td>1,234</td><td>56.7</td><td style="text-align:left;padding-left:0.5rem;"><svg class="spark-inline" id="sparkId" width="50" height="14"></svg></td></tr>
<tr class="highlight-row"><td>Row 2</td><td>5,678</td><td>89.0</td><td>...</td></tr>
</tbody>
</table>
</div>Key CSS: tbody td uses Monaspace Argon with font-variant-numeric:tabular-nums. First column reverts to EB Garamond. Wrap in .table-wrapper for mobile scroll. Add .hide-mobile to secondary columns.
Row hover (active-row highlight — always include with data tables):
td { transition: background 0.2s ease; }
tbody tr:hover td { background: #f1ecdc; } /* warm tint, one step past --bg-aside */
.highlight-row td { background: #f4efe0; } /* persistent ★ row */
/* declare the hover rule AFTER .highlight-row so ★ rows also darken on hover */
@media (prefers-reduced-motion: reduce) { td { transition: none; } }7. Ornament Separator
<div class="ornament">:::</div>Uses Monaspace Argon with full ligature support. The ::: renders as a single ligature glyph. Place between sections. One per break, never three symbols.
8. TOC Sidebar
<nav class="toc" id="toc">
<div class="toc-title">contents</div>
<a href="#section-id">Section Name</a>
<a href="#section-id">Section Name</a>
</nav>Sticky positioned (top:2rem). Each link gets a ::before content '$' prefix. Wrap in .toc-layout grid alongside the overview: grid-template-columns:1fr 180px.
9. Section Header (with back-to-top)
<h2 id="section-id">section title <a href="#toc" class="back-to-top" title="Back to contents">+</a></h2>The arrow is near-invisible (uses --rule color), darkens on hover, floats up 2px.
10. Inline Sparkline
<svg class="spark-inline" id="sparkId" width="50" height="14"></svg>Rendered via JS drawSparkline(id, valuesArray, color) function. Creates a filled area + line + end dot. Use inside table cells or inline with text.
11. Strip Chart (horizontal bar rows)
<div class="telegram-strip" id="stripId">
<!-- rows generated by JS -->
</div>Each row: .tg-week (label) + .tg-count (number) + .tg-bar-area (proportional bar) + .tg-note (annotation). Good for weekly/periodic data with sparse annotations.
12. Scroll Reveal
Applied via JS IntersectionObserver. Add .reveal class to any element:
document.querySelectorAll('.chart-container, .flyout, .aside-container')
.forEach(el => el.classList.add('reveal'));
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.15 });
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));Respects prefers-reduced-motion.
Universal Data Adapter
Normalize any data source into a standard intermediate format before building the report.
Intermediate Schema: ReportData
Every report starts from this JSON structure. Claude transforms user-provided data (CSV, JSON, SQLite query results, API responses, raw numbers) into this format before generating HTML.
{
"meta": {
"title": "Q1 Health Report",
"subtitle": "Jan–Mar 2026",
"question": "Is my sleep quality improving?",
"generated": "2026-04-22T15:00:00Z",
"sources": ["Apple Health", "Oura Ring API"]
},
"kpis": [
{
"id": "hrv",
"label": "Avg HRV",
"value": 42.3,
"unit": "ms",
"trend": -0.13,
"status": "red",
"sparkline": [38, 41, 44, 39, 42, 45, 40, 43],
"context": "below 50ms baseline"
}
],
"sections": [
{
"id": "sleep",
"title": "Sleep Architecture",
"state_line": "Deep sleep down **18%**, REM stable at 22%",
"blocks": [
{
"type": "trend-chart",
"data": {
"labels": ["Jan", "Feb", "Mar"],
"datasets": [
{"label": "Deep Sleep", "values": [1.2, 1.0, 0.98], "color": "primary"},
{"label": "REM", "values": [1.5, 1.4, 1.5], "color": "secondary"}
]
},
"caption": "Hours per night, 7-day rolling average"
},
{
"type": "narrative",
"content": "**Deep sleep** declined steadily after the caffeine experiment in Feb. **REM** held steady, suggesting the issue is slow-wave, not total sleep."
},
{
"type": "correlation-matrix",
"data": {
"variables": ["Screen time", "Caffeine", "Exercise", "Deep sleep"],
"matrix": [
[1.0, 0.15, -0.22, -0.45],
[0.15, 1.0, -0.08, -0.38],
[-0.22, -0.08, 1.0, 0.52],
[-0.45, -0.38, 0.52, 1.0]
]
},
"caption": "Pearson correlations, 90-day window"
}
]
}
]
}Field Reference
meta
| Field | Type | Required | Notes |
|---|---|---|---|
| title | string | yes | Becomes h1 |
| subtitle | string | no | Date range or scope |
| question | string | yes | The one question the report answers — drives design |
| generated | ISO datetime | yes | Auto-set at generation time |
| sources | string[] | yes | Shown in footer tags |
kpis[]
| Field | Type | Required | Notes |
|---|---|---|---|
| id | string | yes | CSS id and anchor |
| label | string | yes | Human name |
| value | number | yes | Current value, displayed in Monaspace Argon |
| unit | string | no | "ms", "%", "hrs", etc. |
| trend | number | no | Fractional change (-0.13 = -13%). Sign determines arrow |
| status | "red" / "amber" / "green" | no | Maps to status-strip color |
| sparkline | number[] | no | Last 7-14 data points for inline sparkline |
| context | string | no | One-line note below the number |
sections[].blocks[]
Each block has a type and a data shape. See references/blocks.md for the full block catalog.
Adapter Instructions
When the user provides raw data, follow this process:
1. Identify the source type: CSV → parse headers as labels; JSON → map keys; SQLite → run query, use column names; API → extract from response body; raw numbers → ask for labels 2. Ask the user for the primary question (becomes meta.question) and desired sections 3. Normalize numbers: strip currency symbols, convert percentages to decimals for trend, keep display values as-is for value 4. Compute derived fields: sparklines from time-series slices, trends from first/last comparison, status from user-defined thresholds (ask if not provided) 5. Emit the ReportData JSON and confirm with the user before generating HTML
Example: CSV → ReportData
Input CSV:
date,hrv_ms,deep_sleep_hrs,steps
2026-01-01,45,1.3,8200
2026-01-02,42,1.1,7800
...Transformation:
- Each numeric column becomes a potential KPI (latest value, trend from first→last)
- Time-series columns become sparkline arrays
- Group related columns into sections
- Compute correlations between columns for correlation-matrix blocks
Example: Raw numbers → ReportData
User says: "HRV is 42ms (was 48), sleep score 72 (was 81), steps 6200 (was 9100)"
Transformation:
- Three KPIs with computed trends: -12.5%, -11.1%, -31.9%
- Status inferred: all declining → amber/red
- Single section with a narrative summary block
Design Tokens
CSS Variables
:root {
--ink: #1a1a1a; /* Primary text */
--ink-light: #555; /* Secondary text, aside narratives */
--ink-muted: #888; /* Tertiary text, captions, labels */
--bg: #fffff8; /* Background (warm white, not pure white) */
--bg-aside: #f9f6ee; /* Flyout/callout background */
--accent: #a00; /* Accent markers (aside-marker, flyout diamond) */
--rule: #ccc; /* Borders, rules, separator color */
/* Semantic chart colors */
--spark-primary: #c45a28; /* Primary data stream (orange) */
--spark-secondary: #2a7a5a; /* Secondary/growth (green) */
--spark-tertiary: #5a5aaa; /* Social/communication (purple) */
/* Status colors */
--status-red: #a02a2a;
--status-amber: #c89000;
--status-green: #2a7a3a;
--status-blue: rgba(42,80,140,0.7);
}Typography Scale
| Element | Font | Size | Weight | Style |
|---|---|---|---|---|
| h1 | EB Garamond | 2.2rem | 400 | small-caps |
| h2 | EB Garamond | 1.5rem | 400 | small-caps |
| h3 | EB Garamond | 1.15rem | 400 | small-caps, --ink-light |
| body | EB Garamond | 18px / 1.6 | 400 | normal |
| state-line | EB Garamond | 1.5rem / 1.45 | 400 | italic, --ink-light |
| overview lede | EB Garamond | 1.25rem / 1.6 | 400 | drop cap first letter |
| aside | EB Garamond | 0.85rem / 1.5 | 400 | italic, --ink-light |
| caption | EB Garamond | 0.82rem | 400 | italic, --ink-muted, centered |
| table header | EB Garamond | 0.92rem | 400 | small-caps, --ink-muted |
| table numbers | Monaspace Argon | 0.85rem | 400 | tabular-nums |
| big number | Monaspace Argon | 2.6rem | 400 | letter-spacing: -0.02em |
| status value | Monaspace Argon | 1.5rem | 400 | tabular-nums |
| source tags | Monaspace Argon | 0.65rem | 400 | --rule color |
| ornament | Monaspace Argon | 0.9rem | 400 | ligatures enabled |
Font Loading
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&display=swap" rel="stylesheet">
<style>
@font-face {
font-family: 'Monaspace Argon';
src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.101/fonts/webfonts/MonaspaceArgon-Regular.woff2') format('woff2');
font-weight: 400; font-display: swap;
}
@font-face {
font-family: 'Monaspace Argon';
src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.101/fonts/webfonts/MonaspaceArgon-Bold.woff2') format('woff2');
font-weight: 700; font-display: swap;
}
</style>Layout Grid
- Max width: 1200px, centered
- Padding: 2rem 1.5rem 4rem
- Aside-container:
1fr 280pxwith 2rem gap - TOC layout:
1fr 180pxwith 2rem gap - Summary cards:
repeat(3, 1fr)with 1.5rem gap - Status strip:
repeat(4, 1fr)with no gap - Mobile breakpoint: 800px (collapses all grids to single column)
Spacing
- Between sections (ornament): 2rem
- Chart container margin: 2rem 0
- Chart side padding: 5% left/right
- Table margin: 1.5rem 0
- State-line margin: 1.5rem 0 2rem
- Flyout margin: 1.5rem 0
Transitions
- Hover on cards/flyouts:
border-color 0.3s ease, box-shadow 0.3s ease - Table row hover:
background 0.2s ease→tbody tr:hover td { background: #f1ecdc; }(active-row highlight; place after.highlight-rowrule so persistent ★ rows darken on hover too) - Back-to-top arrow:
color 0.3s ease, transform 0.3s ease(translateY -2px) - Scroll reveal:
opacity 0.6s cubic-bezier(0.25,0.1,0.25,1), transform 0.6s(translateY 16px) - Reduced motion: all transitions disabled via
prefers-reduced-motion
Preview Server
Zero-dependency Python script for live-reloading Tufte reports during development.
Usage
python3 ~/.claude/skills/tufte-report/scripts/serve.py report.html
# → Serving report.html on http://localhost:8042
# → Watching for changes...Opens automatically in default browser. Reloads when the HTML file changes.
How It Works
1. Injects a tiny WebSocket client <script> before </body> in the served HTML 2. Watches the file's mtime every 500ms 3. Sends reload message over WebSocket when the file changes 4. Browser refreshes without full page navigation (preserves scroll position by default)
The Script
Claude should create this script at ~/.claude/skills/tufte-report/scripts/serve.py if it doesn't exist:
#!/usr/bin/env python3
"""Zero-dependency live-reload server for Tufte reports."""
import http.server, hashlib, json, os, sys, threading, time, struct, webbrowser
from pathlib import Path
PORT = int(os.environ.get("TUFTE_PORT", 8042))
WS_PORT = PORT + 1
INJECT = f'''<script>
(function(){{
var ws = new WebSocket("ws://localhost:{WS_PORT}");
ws.onmessage = function(e) {{
if (e.data === "reload") {{
var y = window.scrollY;
sessionStorage.setItem("_tufte_scroll", y);
location.reload();
}}
}};
ws.onclose = function() {{ setTimeout(function(){{ location.reload(); }}, 2000); }};
window.addEventListener("load", function() {{
var y = sessionStorage.getItem("_tufte_scroll");
if (y) {{ window.scrollTo(0, parseInt(y)); sessionStorage.removeItem("_tufte_scroll"); }}
}});
}})();
</script>'''
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *a, html_path=None, **kw):
self._html = html_path
super().__init__(*a, **kw)
def do_GET(self):
if self.path in ("/", f"/{self._html.name}"):
content = self._html.read_text()
content = content.replace("</body>", INJECT + "</body>")
data = content.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", len(data))
self.end_headers()
self.wfile.write(data)
else:
super().do_GET()
def log_message(self, fmt, *args): pass
def ws_server(html_path):
"""Minimal WebSocket server — just enough for reload signals."""
import socket, hashlib, base64
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("localhost", WS_PORT))
srv.listen(1)
clients = []
def accept_loop():
while True:
conn, _ = srv.accept()
data = conn.recv(4096).decode()
key = ""
for line in data.split("\r\n"):
if line.startswith("Sec-WebSocket-Key:"):
key = line.split(": ", 1)[1].strip()
accept = base64.b64encode(
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-5AB5DC11650A").encode()).digest()
).decode()
conn.send(
f"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n".encode()
)
clients.append(conn)
threading.Thread(target=accept_loop, daemon=True).start()
last_hash = hashlib.md5(html_path.read_bytes()).hexdigest()
while True:
time.sleep(0.5)
try:
cur = hashlib.md5(html_path.read_bytes()).hexdigest()
except FileNotFoundError:
continue
if cur != last_hash:
last_hash = cur
frame = b"\x81" + bytes([len(b"reload")]) + b"reload"
dead = []
for c in clients:
try:
c.send(frame)
except Exception:
dead.append(c)
for c in dead:
clients.remove(c)
print(f" ↻ reloaded ({time.strftime('%H:%M:%S')})")
def main():
if len(sys.argv) < 2:
print("Usage: serve.py <report.html>")
sys.exit(1)
html_path = Path(sys.argv[1]).resolve()
if not html_path.exists():
print(f"File not found: {html_path}")
sys.exit(1)
os.chdir(html_path.parent)
handler = lambda *a, **kw: Handler(*a, html_path=html_path, **kw)
server = http.server.HTTPServer(("localhost", PORT), handler)
threading.Thread(target=ws_server, args=(html_path,), daemon=True).start()
url = f"http://localhost:{PORT}/"
print(f" Serving {html_path.name} on {url}")
print(f" Watching for changes... (Ctrl+C to stop)")
webbrowser.open(url)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n Stopped.")
if __name__ == "__main__":
main()Integration with Skill
After generating a report, Claude should offer:
"Want me to start the preview server? I'll watch for changes and auto-reload."
Then run:
python3 ~/.claude/skills/tufte-report/scripts/serve.py /path/to/report.htmlKeep it running in the background. Each time Claude updates the HTML file, the browser reloads automatically with scroll position preserved.
#!/usr/bin/env python3
"""Zero-dependency live-reload server for Tufte reports."""
import http.server, hashlib, os, sys, threading, time, webbrowser, socket, base64
from pathlib import Path
PORT = int(os.environ.get("TUFTE_PORT", 8042))
WS_PORT = PORT + 1
INJECT = f'''<script>
(function(){{
var ws=new WebSocket("ws://localhost:{WS_PORT}");
ws.onmessage=function(e){{if(e.data==="reload"){{var y=window.scrollY;sessionStorage.setItem("_ts",y);location.reload()}}}};
ws.onclose=function(){{setTimeout(function(){{location.reload()}},2000)}};
window.addEventListener("load",function(){{var y=sessionStorage.getItem("_ts");if(y){{window.scrollTo(0,parseInt(y));sessionStorage.removeItem("_ts")}}}});
}})();
</script>'''
_html_path = None
class Handler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
if self.path in ("/", f"/{_html_path.name}"):
content = _html_path.read_text()
content = content.replace("</body>", INJECT + "</body>")
data = content.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", len(data))
self.end_headers()
self.wfile.write(data)
else:
super().do_GET()
def log_message(self, fmt, *args): pass
def ws_server():
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("localhost", WS_PORT))
srv.listen(5)
clients = []
def accept_loop():
while True:
conn, _ = srv.accept()
data = conn.recv(4096).decode()
key = ""
for line in data.split("\r\n"):
if line.startswith("Sec-WebSocket-Key:"):
key = line.split(": ", 1)[1].strip()
accept = base64.b64encode(
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-5AB5DC11650A").encode()).digest()
).decode()
conn.send(f"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n".encode())
clients.append(conn)
threading.Thread(target=accept_loop, daemon=True).start()
last = hashlib.md5(_html_path.read_bytes()).hexdigest()
while True:
time.sleep(0.5)
try:
cur = hashlib.md5(_html_path.read_bytes()).hexdigest()
except FileNotFoundError:
continue
if cur != last:
last = cur
frame = b"\x81" + bytes([len(b"reload")]) + b"reload"
dead = []
for c in clients:
try: c.send(frame)
except Exception: dead.append(c)
for c in dead: clients.remove(c)
print(f" ↻ reloaded ({time.strftime('%H:%M:%S')})")
def main():
global _html_path
if len(sys.argv) < 2:
print("Usage: serve.py <report.html>"); sys.exit(1)
_html_path = Path(sys.argv[1]).resolve()
if not _html_path.exists():
print(f"Not found: {_html_path}"); sys.exit(1)
os.chdir(_html_path.parent)
threading.Thread(target=ws_server, daemon=True).start()
server = http.server.HTTPServer(("localhost", PORT), Handler)
url = f"http://localhost:{PORT}/"
print(f" Serving {_html_path.name} on {url}")
print(f" Watching for changes... (Ctrl+C to stop)")
webbrowser.open(url)
try: server.serve_forever()
except KeyboardInterrupt: print("\n Stopped.")
if __name__ == "__main__":
main()