Reframe as a linkable, cited callout ("nohello for charts")

Make each sin a self-contained page you send instead of re-explaining:

- Per-sin "poke" one-liner (the shareable callout)
- Citations system: a canonical bibliography (src/lib/references.ts) of the
  field's giants — Munzner, Knaflic, Tufte, Cairo, Few, Cleveland & McGill,
  Wilke, Healy, Calling Bullshit, data-to-viz, Junk Charts, Spurious
  Correlations, FT Visual Vocabulary — cited per sin with a relevance note
- "Don't take our word for it" receipts block on each sin
- /canon page: the full annotated bibliography
- Share control: copy-link "send them the receipt" (progressive enhancement)
- Restructured sin page: poke -> proof -> why -> receipts -> share
- Header nav (Sins / Canon); homepage reframed to the "send the link" mission

Also fix the truncated-axis bad chart: clamp the [90,100] domain so the bar
baseline doesn't overflow thousands of pixels off-canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPEvmMbac2fCvKXpQcovj8
This commit is contained in:
Claude
2026-08-05 13:21:07 +00:00
parent a3ab9f8a79
commit e0d3f9f773
13 changed files with 578 additions and 11 deletions
+1 -1
View File
@@ -23,7 +23,7 @@
"y": {
"field": "revenue",
"type": "quantitative",
"scale": { "domain": [90, 100] },
"scale": { "domain": [90, 100], "zero": false, "clamp": true },
"title": "Revenue ($M)"
}
}
+36
View File
@@ -0,0 +1,36 @@
---
import { getReference, citeLabel } from '../lib/references';
interface Props {
citations: { key: string; note?: string }[];
}
const { citations } = Astro.props;
const resolved = citations.map((c) => ({ ...c, ref: getReference(c.key) }));
---
{
resolved.length > 0 && (
<section class="receipts" aria-labelledby="receipts-h">
<h2 id="receipts-h" class="receipts__title">Dont take our word for it</h2>
<p class="receipts__lede">The giants who say the same thing, so you dont have to argue:</p>
<ol class="receipts__list">
{resolved.map(({ ref, note }) => (
<li class="receipt">
<div class="receipt__cite">
{ref.url ? (
<a href={ref.url} rel="noopener" class="receipt__work">
{citeLabel(ref)}
</a>
) : (
<span class="receipt__work">{citeLabel(ref)}</span>
)}
<span class={`receipt__kind receipt__kind--${ref.kind}`}>{ref.kind}</span>
{ref.free && <span class="receipt__free">free online</span>}
</div>
{note && <p class="receipt__note">{note}</p>}
</li>
))}
</ol>
</section>
)
}
+43
View File
@@ -0,0 +1,43 @@
---
// The "send this to them" mechanic — the whole point of the site.
// Progressive enhancement: the URL is always visible and selectable; the
// button just makes copying a one-click affair when JS is available.
interface Props {
url: string;
}
const { url } = Astro.props;
---
<aside class="share" aria-label="Share this sin">
<p class="share__prompt">Caught someone mid-sin? Send them the receipt:</p>
<div class="share__row">
<input class="share__url" type="text" readonly value={url} aria-label="Link to this page" />
<button class="share__btn" type="button" data-url={url}>Copy link</button>
</div>
</aside>
<script>
for (const btn of document.querySelectorAll<HTMLButtonElement>('.share__btn')) {
btn.addEventListener('click', async () => {
const url = btn.dataset.url ?? '';
const done = () => {
const label = btn.textContent;
btn.textContent = 'Copied ✓';
btn.classList.add('is-copied');
setTimeout(() => {
btn.textContent = label;
btn.classList.remove('is-copied');
}, 1600);
};
try {
await navigator.clipboard.writeText(url);
done();
} catch {
const input = btn.previousElementSibling as HTMLInputElement | null;
input?.select();
document.execCommand('copy');
done();
}
});
}
</script>
+7
View File
@@ -11,6 +11,13 @@ const sins = defineCollection({
title: z.string(),
// Short one-line summary shown on the index gallery.
summary: z.string(),
// The cheeky one-line callout — the thing you send the sinner.
poke: z.string(),
// Citations into the canon (src/lib/references.ts) with a per-sin note on
// why that source is relevant. This is the "don't take our word for it".
citations: z
.array(z.object({ key: z.string(), note: z.string().optional() }))
.default([]),
// Grouping, e.g. "Misleading Scales", "Chart-Type Abuse".
category: z.string(),
// 1 (venial) … 5 (mortal). Drives the flame rating in the UI.
+10
View File
@@ -1,12 +1,22 @@
---
title: "The Dual-Axis Deception"
summary: "Two unrelated series on two independent axes, engineered to look correlated."
poke: "Two y-axes, tuned until the lines kiss. Congratulations — you can now make anything 'cause' anything."
category: "False Relationships"
severity: 5
tags: ["dual axis", "correlation", "line chart"]
badChart: "dual-axis-bad"
fixedChart: "dual-axis-fixed"
date: 2026-08-05
citations:
- key: datatoviz
note: "Lists “using two Y axes” as a top caveat — a way to manipulate the story."
- key: spurious
note: "Thousands of nonsense correlations: proof that “they move together” proves nothing."
- key: howchartslie
note: "Cairo on how aligned axes manufacture relationships that arent there."
- key: swd
note: "Knaflics standing advice: avoid the second axis; separate or index instead."
---
## The sin
+10
View File
@@ -1,12 +1,22 @@
---
title: "Pie Chart Overload"
summary: "Eight near-identical slices no human eye can rank. A bar chart was right there."
poke: "Eight slices, three of them the same size. Nobody can read this. A bar chart could."
category: "Chart-Type Abuse"
severity: 3
tags: ["pie chart", "part-to-whole", "comparison"]
badChart: "pie-overload-bad"
fixedChart: "pie-overload-fixed"
date: 2026-08-05
citations:
- key: fewpies
note: "“Save the Pies for Dessert” — the definitive, free takedown of the overloaded pie."
- key: clevelandmcgill
note: "Their ranking shows angle and area are judged far worse than position along a scale."
- key: junkcharts
note: "Kaiser Fungs running #onelesspie campaign exists for precisely this chart."
- key: datatoviz
note: "Pie charts are the first entry in its catalogue of caveats."
---
## The sin
+10
View File
@@ -1,12 +1,22 @@
---
title: "The Truncated Y-Axis"
summary: "Chopping the baseline off a bar chart to make small differences look enormous."
poke: "You started the y-axis at 90. That's not a bar chart — it's a magic trick."
category: "Misleading Scales"
severity: 4
tags: ["bar chart", "axis", "exaggeration"]
badChart: "truncated-axis-bad"
fixedChart: "truncated-axis-fixed"
date: 2026-08-05
citations:
- key: callingbullshit
note: "Their “Misleading Axes” module is the canonical explainer for exactly this move."
- key: clevelandmcgill
note: "The perception research: readers compare bar length, so length must map to value from zero."
- key: tufte
note: "The “Lie Factor” puts an actual number on how much the graphic exaggerates."
- key: howchartslie
note: "Cairo devotes a chapter to scale manipulation like this one."
---
## The sin
+4 -1
View File
@@ -32,7 +32,10 @@ const { title, description = 'A catalogue of data-visualization sins, styled on
<a class="cds-header__name" href={href()}>
Chart&nbsp;Sins <span class="mono">/ honest data-viz</span>
</a>
<span class="cds-header__tag">built on Carbon</span>
<nav class="cds-header__nav" aria-label="Primary">
<a href={href()}>Sins</a>
<a href={href('canon/')}>Canon</a>
</nav>
</div>
</header>
+183
View File
@@ -0,0 +1,183 @@
// The Canon — the shoulders of giants Chart Sins stands on.
//
// Each sin cites into this list by key, so a page can say "don't take our word
// for it" and point at the authority that actually condemns the practice.
// Add a reference once here; cite it from any number of sins.
export type RefKind = 'book' | 'site' | 'paper';
export interface Reference {
authors: string;
title: string;
/** Publisher / journal / site, for books and papers. */
work?: string;
year?: number;
url?: string;
kind: RefKind;
/** One line on why it belongs in the canon. */
blurb: string;
/** True for the free-to-read-online works, surfaced as a hint. */
free?: boolean;
}
export const REFERENCES = {
// --- books ---
munzner: {
authors: 'Tamara Munzner',
title: 'Visualization Analysis & Design',
work: 'CRC Press',
year: 2014,
url: 'https://www.cs.ubc.ca/~tmm/vadbook/',
kind: 'book',
blurb: 'The rigorous framework: what channels the eye reads accurately, and why position beats angle, length, and area.',
},
swd: {
authors: 'Cole Nussbaumer Knaflic',
title: 'Storytelling with Data',
work: 'Wiley',
year: 2015,
url: 'https://www.storytellingwithdata.com/books',
kind: 'book',
blurb: 'The practitioners bible on decluttering charts and directing attention honestly.',
},
tufte: {
authors: 'Edward R. Tufte',
title: 'The Visual Display of Quantitative Information',
work: 'Graphics Press (2nd ed.)',
year: 2001,
url: 'https://www.edwardtufte.com/book/the-visual-display-of-quantitative-information/',
kind: 'book',
blurb: 'Origin of the “Lie Factor,” chartjunk, and the data-ink ratio.',
},
howchartslie: {
authors: 'Alberto Cairo',
title: 'How Charts Lie',
work: 'W. W. Norton',
year: 2019,
url: 'http://www.thefunctionalart.com/',
kind: 'book',
blurb: 'A whole book on exactly this: the tricks charts use to mislead, and how to read past them.',
},
fewpies: {
authors: 'Stephen Few',
title: 'Save the Pies for Dessert',
work: 'Perceptual Edge',
year: 2007,
url: 'https://www.perceptualedge.com/articles/visual_business_intelligence/save_the_pies_for_dessert.pdf',
kind: 'paper',
blurb: 'The definitive takedown of the pie chart, from the fields most stubborn advocate for clarity.',
free: true,
},
wilke: {
authors: 'Claus O. Wilke',
title: 'Fundamentals of Data Visualization',
work: 'OReilly',
year: 2019,
url: 'https://clauswilke.com/dataviz/',
kind: 'book',
blurb: 'Free, thorough, and organized by problem — a per-topic reference for doing it right.',
free: true,
},
healy: {
authors: 'Kieran Healy',
title: 'Data Visualization: A Practical Introduction',
work: 'Princeton University Press',
year: 2018,
url: 'https://socviz.co/',
kind: 'book',
blurb: 'Free online; strong on why perception, not taste, decides what works.',
free: true,
},
callingbullshit: {
authors: 'Carl T. Bergstrom & Jevin D. West',
title: 'Calling Bullshit',
work: 'Random House',
year: 2020,
url: 'https://www.callingbullshit.org/tools/tools_misleading_axes.html',
kind: 'book',
blurb: 'Book and free course; the “Misleading Axes” module is required reading for axis crimes.',
free: true,
},
huff: {
authors: 'Darrell Huff',
title: 'How to Lie with Statistics',
work: 'W. W. Norton',
year: 1954,
kind: 'book',
blurb: 'The 1954 classic that named the game; the truncated axis is right there in it.',
},
clevelandmcgill: {
authors: 'William S. Cleveland & Robert McGill',
title: 'Graphical Perception: Theory, Experimentation, and Application',
work: 'Journal of the American Statistical Association',
year: 1984,
url: 'https://www.jstor.org/stable/2288400',
kind: 'paper',
blurb: 'The experiments proving people judge position accurately and angle/area poorly — the root of most of these sins.',
},
schwabish: {
authors: 'Jonathan Schwabish',
title: 'Better Data Visualizations',
work: 'Columbia University Press',
year: 2021,
url: 'https://policyviz.com/books/',
kind: 'book',
blurb: 'A modern, chart-by-chart guide to which form fits which job.',
},
// --- sites ---
datatoviz: {
authors: 'Yan Holtz & Conor Healy',
title: 'From Data to Viz — Caveats',
work: 'data-to-viz.com',
url: 'https://www.data-to-viz.com/caveats.html',
kind: 'site',
blurb: 'A browsable catalogue of common dataviz mistakes, each with a fix.',
free: true,
},
junkcharts: {
authors: 'Kaiser Fung',
title: 'Junk Charts',
work: 'junkcharts.com',
url: 'https://junkcharts.typepad.com/',
kind: 'site',
blurb: 'Fifteen years of chart critique-and-redesign, plus the #onelesspie campaign.',
free: true,
},
spurious: {
authors: 'Tyler Vigen',
title: 'Spurious Correlations',
work: 'tylervigen.com',
url: 'https://www.tylervigen.com/spurious-correlations',
kind: 'site',
blurb: 'Thousands of nonsense correlations — the canonical proof that “they move together” proves nothing.',
free: true,
},
ftvisvocab: {
authors: 'Financial Times Visual Journalism',
title: 'Visual Vocabulary',
work: 'FT',
url: 'https://github.com/Financial-Times/chart-doctor/tree/main/visual-vocabulary',
kind: 'site',
blurb: 'A cheat sheet mapping what you want to say to the chart that says it.',
free: true,
},
} as const satisfies Record<string, Reference>;
export type ReferenceKey = keyof typeof REFERENCES;
/** Resolve a citation key; throws at build time if it isn't in the canon. */
export function getReference(key: string): Reference {
const ref = (REFERENCES as Record<string, Reference>)[key];
if (!ref) {
throw new Error(
`Reference "${key}" is not in the canon (src/lib/references.ts). Known: ${Object.keys(REFERENCES).join(', ')}`,
);
}
return ref;
}
/** A short "Author, Title (Year)" label. */
export function citeLabel(ref: Reference): string {
return `${ref.authors}, ${ref.title}${ref.year ? ` (${ref.year})` : ''}`;
}
+51
View File
@@ -0,0 +1,51 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { REFERENCES, citeLabel, type RefKind } from '../lib/references';
const groups: { kind: RefKind; heading: string; blurb: string }[] = [
{ kind: 'book', heading: 'The books', blurb: 'The shelf every chart argument can lean on.' },
{ kind: 'paper', heading: 'The papers & essays', blurb: 'The primary sources — where the evidence actually lives.' },
{ kind: 'site', heading: 'The sites', blurb: 'Living references you can link straight into a thread.' },
];
const byKind = (kind: RefKind) => Object.values(REFERENCES).filter((r) => r.kind === kind);
---
<BaseLayout title="The Canon — Chart Sins" description="The books, papers, and sites Chart Sins stands on.">
<section class="wrap hero">
<p class="hero__eyebrow">// the shoulders we stand on</p>
<h1 class="expressive-display">The Canon</h1>
<p class="hero__lede">
Every sin here cites its sources. This is the whole shelf in one place —
the giants of data visualization who already made the case, so you can
link the receipt instead of relitigating it.
</p>
</section>
<div class="wrap canon">
{
groups.map((g) => (
<section class="canon__group">
<h2 class="canon__heading productive-heading-03">{g.heading}</h2>
<p class="helper">{g.blurb}</p>
<ul class="canon__list">
{byKind(g.kind).map((ref) => (
<li class="canon__item">
<div class="canon__cite">
{ref.url ? (
<a href={ref.url} rel="noopener" class="canon__work">{citeLabel(ref)}</a>
) : (
<span class="canon__work">{citeLabel(ref)}</span>
)}
{ref.work && <span class="canon__pub">{ref.work}</span>}
{ref.free && <span class="receipt__free">free online</span>}
</div>
<p class="canon__blurb">{ref.blurb}</p>
</li>
))}
</ul>
</section>
))
}
</div>
</BaseLayout>
+6 -4
View File
@@ -13,11 +13,13 @@ const sins = (await getCollection('sins', ({ data }) => !data.draft)).sort((a, b
<BaseLayout title="Chart Sins">
<section class="wrap hero">
<p class="hero__eyebrow">// a catalogue of data-visualization sins</p>
<h1 class="expressive-display">Charts that lie,<br />and the honest fix.</h1>
<p class="hero__eyebrow">// stop re-explaining the same chart mistake</p>
<h1 class="expressive-display">Someone made a<br />misleading chart again.</h1>
<p class="hero__lede">
Each entry is a data-visualization mistake in the wild: the chart that
misleads, why it fools the eye, and the honest version that repents.
Dont type the whole explanation for the hundredth time — send the link.
Each sin shows the deceptive chart beside its honest twin, names the tell,
and cites the giants who back you up. Its <a href="https://nohello.net" rel="noopener">nohello.net</a>,
for bad charts.
</p>
</section>
+16 -1
View File
@@ -2,6 +2,8 @@
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import VegaChart from '../../components/VegaChart.astro';
import Citations from '../../components/Citations.astro';
import ShareLink from '../../components/ShareLink.astro';
import { getChartSpec, severityUnits, tagModifier } from '../../lib/charts';
import { href } from '../../lib/url';
@@ -18,9 +20,12 @@ const { Content } = await render(sin);
const badSpec = getChartSpec(sin.data.badChart);
const fixedSpec = getChartSpec(sin.data.fixedChart);
// Canonical, shareable URL for this sin — the thing you paste into a thread.
const pageUrl = new URL(href(`sins/${sin.id}/`), Astro.site).toString();
---
<BaseLayout title={`${sin.data.title} — Chart Sins`} description={sin.data.summary}>
<BaseLayout title={`${sin.data.title} — Chart Sins`} description={sin.data.poke}>
<article class="wrap sin">
<nav class="breadcrumb"><a href={href()}>All sins</a> / {sin.data.title}</nav>
@@ -37,6 +42,7 @@ const fixedSpec = getChartSpec(sin.data.fixedChart);
</span>
</div>
<h1 class="sin__title expressive-heading">{sin.data.title}</h1>
<p class="sin__poke">{sin.data.poke}</p>
<div class="sin__tags">
{sin.data.tags.map((t) => <span class="tag">{t}</span>)}
</div>
@@ -52,9 +58,18 @@ const fixedSpec = getChartSpec(sin.data.fixedChart);
<VegaChart spec={fixedSpec} title={`Honest chart: ${sin.data.title}`} />
</div>
</div>
<p class="compare__note">Same numbers, both charts. Only the design changed.</p>
<div class="prose">
<Content />
</div>
<Citations citations={sin.data.citations} />
<ShareLink url={pageUrl} />
<p class="more-canon">
Building a case? See the <a href={href('canon/')}>full canon →</a>
</p>
</article>
</BaseLayout>
+200 -3
View File
@@ -235,10 +235,20 @@ code {
font-family: var(--font-mono);
letter-spacing: 0;
}
@media (max-width: 40rem) {
.cds-header__tag {
display: none;
.cds-header__nav {
margin-left: auto;
display: flex;
gap: var(--spacing-06);
}
.cds-header__nav a {
color: var(--shell-text-secondary);
font-size: 0.875rem;
text-decoration: none;
border: 0;
padding-block: var(--spacing-02);
}
.cds-header__nav a:hover {
color: var(--shell-text);
}
/* ------------------------------------------------------------------ *\
@@ -527,6 +537,193 @@ code {
color: var(--text-primary);
}
/* the poke — the shareable one-liner under the title */
.sin__poke {
font-size: 1.375rem;
line-height: 1.35;
font-weight: 300;
color: var(--text-primary);
max-width: 42rem;
margin: var(--spacing-05) 0 0;
}
.compare__note {
margin: calc(-1 * var(--spacing-05)) 0 0;
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--text-helper);
}
/* the receipts — citations block */
.receipts {
margin-top: var(--spacing-09);
padding-top: var(--spacing-07);
border-top: 1px solid var(--border-subtle);
}
.receipts__title {
font-size: 1.5rem;
font-weight: 400;
margin: 0;
letter-spacing: -0.01em;
}
.receipts__lede {
color: var(--text-secondary);
margin: var(--spacing-03) 0 var(--spacing-06);
}
.receipts__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 1px;
background: var(--border-subtle);
border: 1px solid var(--border-subtle);
}
.receipt {
background: var(--layer-01);
padding: var(--spacing-05) var(--spacing-06);
}
.receipt__cite {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--spacing-03);
}
.receipt__work {
font-weight: 600;
color: var(--text-primary);
}
a.receipt__work {
color: var(--link-primary);
}
.receipt__kind {
font-family: var(--font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-helper);
border: 1px solid var(--border-subtle);
padding: 0 var(--spacing-02);
}
.receipt__free {
font-family: var(--font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--support-success);
}
.receipt__note {
margin: var(--spacing-03) 0 0;
color: var(--text-secondary);
font-size: 0.9375rem;
line-height: 1.5;
}
/* the share box */
.share {
margin-top: var(--spacing-08);
padding: var(--spacing-06);
background: var(--layer-01);
border: 1px solid var(--border-subtle);
border-left: 3px solid var(--interactive);
}
.share__prompt {
margin: 0 0 var(--spacing-04);
font-weight: 600;
}
.share__row {
display: flex;
gap: 0;
max-width: 34rem;
}
.share__url {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: 0.8125rem;
padding: var(--spacing-04) var(--spacing-05);
background: var(--background);
color: var(--text-secondary);
border: 1px solid var(--border-strong);
border-right: 0;
}
.share__url:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -2px;
}
.share__btn {
flex: 0 0 auto;
font-family: var(--font-sans);
font-size: 0.875rem;
font-weight: 600;
color: var(--text-on-color);
background: var(--interactive);
border: 1px solid var(--interactive);
padding-inline: var(--spacing-05);
cursor: pointer;
transition: background var(--dur-fast) var(--ease-productive);
}
.share__btn:hover {
background: var(--link-hover);
}
.share__btn.is-copied {
background: var(--support-success);
border-color: var(--support-success);
}
.more-canon {
margin-top: var(--spacing-07);
font-family: var(--font-mono);
font-size: 0.875rem;
}
/* canon page */
.canon {
padding-block: var(--spacing-08) var(--spacing-10);
}
.canon__group {
margin-bottom: var(--spacing-09);
}
.canon__heading {
margin: 0;
}
.canon__list {
list-style: none;
margin: var(--spacing-05) 0 0;
padding: 0;
display: grid;
gap: 1px;
background: var(--border-subtle);
border: 1px solid var(--border-subtle);
}
.canon__item {
background: var(--layer-01);
padding: var(--spacing-05) var(--spacing-06);
}
.canon__cite {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--spacing-03);
}
.canon__work {
font-weight: 600;
font-size: 1.0625rem;
color: var(--text-primary);
}
a.canon__work {
color: var(--link-primary);
}
.canon__pub {
font-size: 0.8125rem;
color: var(--text-helper);
}
.canon__blurb {
margin: var(--spacing-03) 0 0;
color: var(--text-secondary);
max-width: 44rem;
line-height: 1.5;
}
/* ------------------------------------------------------------------ *\
Footer + colophon
\* ------------------------------------------------------------------ */