mirror of
https://github.com/olehomelchenko/chart-sins.git
synced 2026-08-08 02:22:43 +00:00
Scaffold Chart Sins site with Astro + build-time Vega-Lite
Set up the static-site tech stack: Astro 5 with Markdown content collections, Vega-Lite specs rendered to static SVG at build time (zero client JS), plain scoped CSS, and a GitHub Pages deploy workflow. Includes three sample sins (truncated y-axis, dual-axis deception, pie chart overload), each with a bad and fixed chart spec, plus a gallery index and per-sin detail pages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPEvmMbac2fCvKXpQcovj8
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"title": "Ice Cream Sales vs. Shark Attacks",
|
||||
"width": 360,
|
||||
"height": 240,
|
||||
"data": {
|
||||
"values": [
|
||||
{ "month": "Jan", "icecream": 20, "sharks": 1 },
|
||||
{ "month": "Feb", "icecream": 24, "sharks": 1 },
|
||||
{ "month": "Mar", "icecream": 38, "sharks": 2 },
|
||||
{ "month": "Apr", "icecream": 55, "sharks": 3 },
|
||||
{ "month": "May", "icecream": 78, "sharks": 5 },
|
||||
{ "month": "Jun", "icecream": 95, "sharks": 6 },
|
||||
{ "month": "Jul", "icecream": 100, "sharks": 7 },
|
||||
{ "month": "Aug", "icecream": 96, "sharks": 6 }
|
||||
]
|
||||
},
|
||||
"encoding": {
|
||||
"x": { "field": "month", "type": "ordinal", "sort": null, "axis": { "title": null, "labelAngle": 0 } }
|
||||
},
|
||||
"layer": [
|
||||
{
|
||||
"mark": { "type": "line", "color": "#c0392b", "point": true },
|
||||
"encoding": {
|
||||
"y": { "field": "icecream", "type": "quantitative", "title": "Ice cream sales", "axis": { "titleColor": "#c0392b" } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"mark": { "type": "line", "color": "#2980b9", "point": true },
|
||||
"encoding": {
|
||||
"y": { "field": "sharks", "type": "quantitative", "title": "Shark attacks", "axis": { "titleColor": "#2980b9" } }
|
||||
}
|
||||
}
|
||||
],
|
||||
"resolve": { "scale": { "y": "independent" } }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"title": "Two Series, Shown Separately",
|
||||
"data": {
|
||||
"values": [
|
||||
{ "month": "Jan", "icecream": 20, "sharks": 1 },
|
||||
{ "month": "Feb", "icecream": 24, "sharks": 1 },
|
||||
{ "month": "Mar", "icecream": 38, "sharks": 2 },
|
||||
{ "month": "Apr", "icecream": 55, "sharks": 3 },
|
||||
{ "month": "May", "icecream": 78, "sharks": 5 },
|
||||
{ "month": "Jun", "icecream": 95, "sharks": 6 },
|
||||
{ "month": "Jul", "icecream": 100, "sharks": 7 },
|
||||
{ "month": "Aug", "icecream": 96, "sharks": 6 }
|
||||
]
|
||||
},
|
||||
"vconcat": [
|
||||
{
|
||||
"width": 360,
|
||||
"height": 110,
|
||||
"mark": { "type": "line", "color": "#c0392b", "point": true },
|
||||
"encoding": {
|
||||
"x": { "field": "month", "type": "ordinal", "sort": null, "axis": { "title": null, "labels": false } },
|
||||
"y": { "field": "icecream", "type": "quantitative", "scale": { "zero": true }, "title": "Ice cream" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"width": 360,
|
||||
"height": 110,
|
||||
"mark": { "type": "line", "color": "#2980b9", "point": true },
|
||||
"encoding": {
|
||||
"x": { "field": "month", "type": "ordinal", "sort": null, "axis": { "title": null, "labelAngle": 0 } },
|
||||
"y": { "field": "sharks", "type": "quantitative", "scale": { "zero": true }, "title": "Shark attacks" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"title": "Market Share by Browser (%)",
|
||||
"width": 300,
|
||||
"height": 260,
|
||||
"data": {
|
||||
"values": [
|
||||
{ "browser": "Chrome", "share": 63 },
|
||||
{ "browser": "Safari", "share": 20 },
|
||||
{ "browser": "Edge", "share": 5 },
|
||||
{ "browser": "Firefox", "share": 3 },
|
||||
{ "browser": "Samsung", "share": 3 },
|
||||
{ "browser": "Opera", "share": 2 },
|
||||
{ "browser": "UC", "share": 2 },
|
||||
{ "browser": "Other", "share": 2 }
|
||||
]
|
||||
},
|
||||
"mark": { "type": "arc" },
|
||||
"encoding": {
|
||||
"theta": { "field": "share", "type": "quantitative" },
|
||||
"color": { "field": "browser", "type": "nominal", "legend": { "title": "Browser" } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"title": "Market Share by Browser (%)",
|
||||
"width": 320,
|
||||
"height": 260,
|
||||
"data": {
|
||||
"values": [
|
||||
{ "browser": "Chrome", "share": 63 },
|
||||
{ "browser": "Safari", "share": 20 },
|
||||
{ "browser": "Edge", "share": 5 },
|
||||
{ "browser": "Firefox", "share": 3 },
|
||||
{ "browser": "Samsung", "share": 3 },
|
||||
{ "browser": "Opera", "share": 2 },
|
||||
{ "browser": "UC", "share": 2 },
|
||||
{ "browser": "Other", "share": 2 }
|
||||
]
|
||||
},
|
||||
"mark": { "type": "bar", "color": "#1e8b5b" },
|
||||
"encoding": {
|
||||
"y": { "field": "browser", "type": "nominal", "sort": "-x", "axis": { "title": null } },
|
||||
"x": { "field": "share", "type": "quantitative", "title": "Share (%)" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"title": "Monthly Revenue ($M)",
|
||||
"width": 340,
|
||||
"height": 240,
|
||||
"data": {
|
||||
"values": [
|
||||
{ "month": "Jan", "revenue": 92 },
|
||||
{ "month": "Feb", "revenue": 94 },
|
||||
{ "month": "Mar", "revenue": 93 },
|
||||
{ "month": "Apr", "revenue": 96 },
|
||||
{ "month": "May", "revenue": 95 },
|
||||
{ "month": "Jun", "revenue": 98 }
|
||||
]
|
||||
},
|
||||
"mark": { "type": "bar", "color": "#c0392b" },
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": "month",
|
||||
"type": "nominal",
|
||||
"sort": null,
|
||||
"axis": { "labelAngle": 0, "title": null }
|
||||
},
|
||||
"y": {
|
||||
"field": "revenue",
|
||||
"type": "quantitative",
|
||||
"scale": { "domain": [90, 100] },
|
||||
"title": "Revenue ($M)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"title": "Monthly Revenue ($M)",
|
||||
"width": 340,
|
||||
"height": 240,
|
||||
"data": {
|
||||
"values": [
|
||||
{ "month": "Jan", "revenue": 92 },
|
||||
{ "month": "Feb", "revenue": 94 },
|
||||
{ "month": "Mar", "revenue": 93 },
|
||||
{ "month": "Apr", "revenue": 96 },
|
||||
{ "month": "May", "revenue": 95 },
|
||||
{ "month": "Jun", "revenue": 98 }
|
||||
]
|
||||
},
|
||||
"mark": { "type": "bar", "color": "#1e8b5b" },
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": "month",
|
||||
"type": "nominal",
|
||||
"sort": null,
|
||||
"axis": { "labelAngle": 0, "title": null }
|
||||
},
|
||||
"y": {
|
||||
"field": "revenue",
|
||||
"type": "quantitative",
|
||||
"scale": { "zero": true },
|
||||
"title": "Revenue ($M)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
// Build-time Vega-Lite renderer.
|
||||
//
|
||||
// This component runs only during `astro build` (and dev SSR), never in the
|
||||
// browser. It compiles a Vega-Lite spec to Vega, renders it to a static SVG
|
||||
// string with Vega's headless view, and inlines that SVG. The result ships as
|
||||
// plain markup — zero client-side JavaScript, no Vega runtime downloaded.
|
||||
//
|
||||
// To make a specific chart interactive later, you would swap this component
|
||||
// out for a client-side vega-embed island on that page only.
|
||||
import * as vega from 'vega';
|
||||
import { compile, type TopLevelSpec } from 'vega-lite';
|
||||
|
||||
interface Props {
|
||||
/** A Vega-Lite spec (object). Do not include $schema/width/height/config. */
|
||||
spec: TopLevelSpec;
|
||||
/** Accessible description of what the chart shows. */
|
||||
title: string;
|
||||
/** Optional caption rendered under the chart. */
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
const { spec, title, caption } = Astro.props;
|
||||
|
||||
let svg = '';
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
// Compile Vega-Lite -> Vega, then render headlessly to SVG.
|
||||
const vgSpec = compile(spec as TopLevelSpec).spec;
|
||||
const view = new vega.View(vega.parse(vgSpec), { renderer: 'none' }).initialize();
|
||||
svg = await view.toSVG();
|
||||
view.finalize();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
// Fail loudly in the build log, but don't crash the whole build.
|
||||
console.error(`[VegaChart] failed to render "${title}": ${error}`);
|
||||
}
|
||||
---
|
||||
|
||||
<figure class="vega-chart" role="group" aria-label={title}>
|
||||
{error ? (
|
||||
<div class="vega-chart__error">
|
||||
<strong>Chart failed to render.</strong>
|
||||
<code>{error}</code>
|
||||
</div>
|
||||
) : (
|
||||
<div class="vega-chart__svg" set:html={svg} />
|
||||
)}
|
||||
{caption && <figcaption>{caption}</figcaption>}
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.vega-chart {
|
||||
margin: 0;
|
||||
}
|
||||
.vega-chart__svg :global(svg) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.vega-chart figcaption {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
.vega-chart__error {
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--sin-red);
|
||||
border-radius: 8px;
|
||||
color: var(--sin-red);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.vega-chart__error code {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
|
||||
// Each "sin" is one Markdown/MDX file in src/content/sins/.
|
||||
// Charts are authored as Vega-Lite JSON specs in src/charts/ and referenced
|
||||
// here by their basename (without the .json extension). The page template
|
||||
// renders `badChart` and `fixedChart` side by side automatically.
|
||||
const sins = defineCollection({
|
||||
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/sins' }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
// Short one-line summary shown on the index gallery.
|
||||
summary: z.string(),
|
||||
// Grouping, e.g. "Misleading Scales", "Chart-Type Abuse".
|
||||
category: z.string(),
|
||||
// 1 (venial) … 5 (mortal). Drives the flame rating in the UI.
|
||||
severity: z.number().int().min(1).max(5),
|
||||
tags: z.array(z.string()).default([]),
|
||||
// Basenames of Vega-Lite specs in src/charts/ (without .json).
|
||||
badChart: z.string(),
|
||||
fixedChart: z.string(),
|
||||
// Optional publish date; unset entries sort last.
|
||||
date: z.coerce.date().optional(),
|
||||
draft: z.boolean().default(false),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { sins };
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: "The Dual-Axis Deception"
|
||||
summary: "Two unrelated series on two independent axes, engineered to look correlated."
|
||||
category: "False Relationships"
|
||||
severity: 5
|
||||
tags: ["dual axis", "correlation", "line chart"]
|
||||
badChart: "dual-axis-bad"
|
||||
fixedChart: "dual-axis-fixed"
|
||||
date: 2026-08-05
|
||||
---
|
||||
|
||||
## The sin
|
||||
|
||||
Put two series on the same plot with two independent y-axes and you can make
|
||||
*anything* look correlated. Slide the scales until the lines overlap and the
|
||||
reader's brain does the rest: "these move together, so one must cause the other."
|
||||
|
||||
## Why it deceives
|
||||
|
||||
With independent axes, the vertical position of each line is arbitrary — you
|
||||
chose it. Overlapping lines imply a relationship that exists only because you
|
||||
tuned two scales to coincide. Ice cream sales and shark attacks both rise in
|
||||
summer; neither causes the other. The shared season is the hidden variable.
|
||||
|
||||
## The repentance
|
||||
|
||||
Don't force two series into one coordinate space just to imply a link. Show them
|
||||
in **separate, stacked panels** with honest zero-based scales. If you truly want
|
||||
to argue they move together, plot one **against** the other (a scatter plot) and
|
||||
let the reader judge the relationship — don't manufacture it with axis scaling.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: "Pie Chart Overload"
|
||||
summary: "Eight near-identical slices no human eye can rank. A bar chart was right there."
|
||||
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
|
||||
---
|
||||
|
||||
## The sin
|
||||
|
||||
The pie chart asks the reader to compare **angles** — something people are
|
||||
genuinely bad at. One or two slices, fine. But eight slices, several of them
|
||||
within a percentage point of each other? Nobody can tell whether Firefox beats
|
||||
Samsung by eyeballing two thin wedges on opposite sides of the circle.
|
||||
|
||||
## Why it deceives
|
||||
|
||||
Angle and area are hard to judge precisely, and slices scattered around the
|
||||
circle can't be lined up against a common baseline. The small categories blur
|
||||
into an indistinguishable fringe, and the ranking — usually the whole point —
|
||||
becomes guesswork.
|
||||
|
||||
## The repentance
|
||||
|
||||
Use a **sorted horizontal bar chart**. Every value shares one baseline, the
|
||||
ordering is explicit, and long labels sit comfortably beside their bars. Reserve
|
||||
the pie for the rare case of two or three parts of an obvious whole — and even
|
||||
then, a bar chart rarely does worse.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: "The Truncated Y-Axis"
|
||||
summary: "Chopping the baseline off a bar chart to make small differences look enormous."
|
||||
category: "Misleading Scales"
|
||||
severity: 4
|
||||
tags: ["bar chart", "axis", "exaggeration"]
|
||||
badChart: "truncated-axis-bad"
|
||||
fixedChart: "truncated-axis-fixed"
|
||||
date: 2026-08-05
|
||||
---
|
||||
|
||||
## The sin
|
||||
|
||||
Bar charts encode value with **length**. When you start the y-axis somewhere
|
||||
above zero, the bars no longer represent their true magnitudes — a jump from 92
|
||||
to 98 (a ~6% change) can be drawn to look like a chart-topping surge. The reader
|
||||
compares bar heights and walks away with a wildly inflated sense of the trend.
|
||||
|
||||
## Why it deceives
|
||||
|
||||
The eye reads the ratio of bar lengths, not the axis labels. Truncating the
|
||||
baseline breaks the contract between "twice as tall" and "twice as much." It is
|
||||
the single most common way an honest-looking bar chart tells a lie.
|
||||
|
||||
## The repentance
|
||||
|
||||
Start bar charts at zero. Always. If the interesting variation is genuinely
|
||||
small and a zero baseline flattens it, that is a signal to switch encodings —
|
||||
use a **line chart** or a **dot plot**, where position (not length) carries the
|
||||
meaning and a non-zero range is legitimate.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import { href } from '../lib/url';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, description = 'A catalogue of data-visualization sins.' } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="wrap">
|
||||
<a class="brand" href={href()}>⚡ Chart Sins</a>
|
||||
<span class="tagline">The bad chart, the repentance, the fixed version.</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">
|
||||
Chart Sins — an educational catalogue of data-visualization mistakes.
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { TopLevelSpec } from 'vega-lite';
|
||||
|
||||
// Eagerly load every Vega-Lite spec in src/charts/ at build time so pages can
|
||||
// look one up by its basename (e.g. "dual-axis-bad").
|
||||
const modules = import.meta.glob<{ default: TopLevelSpec }>('../charts/*.json', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
const specsByName = new Map<string, TopLevelSpec>();
|
||||
for (const [path, mod] of Object.entries(modules)) {
|
||||
const name = path.split('/').pop()!.replace(/\.json$/, '');
|
||||
specsByName.set(name, mod.default);
|
||||
}
|
||||
|
||||
/** Look up a Vega-Lite spec by basename; throws at build time if missing. */
|
||||
export function getChartSpec(name: string): TopLevelSpec {
|
||||
const spec = specsByName.get(name);
|
||||
if (!spec) {
|
||||
throw new Error(
|
||||
`Chart spec "${name}" not found in src/charts/. Available: ${[...specsByName.keys()].join(', ')}`,
|
||||
);
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** Render a 1–5 severity as flame glyphs for display. */
|
||||
export function severityFlames(severity: number): string {
|
||||
return '🔥'.repeat(severity) + '·'.repeat(Math.max(0, 5 - severity));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Join a path onto the configured base path with exactly one slash, so links
|
||||
// work whether `base` is "/chart-sins" (no trailing slash) or "/".
|
||||
const BASE = import.meta.env.BASE_URL;
|
||||
|
||||
export function href(path = ''): string {
|
||||
const base = BASE.endsWith('/') ? BASE.slice(0, -1) : BASE;
|
||||
if (path === '' || path === '/') return `${base}/`;
|
||||
const p = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${base}${p}`;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { severityFlames } from '../lib/charts';
|
||||
import { href } from '../lib/url';
|
||||
|
||||
const sins = (await getCollection('sins', ({ data }) => !data.draft)).sort((a, b) => {
|
||||
// Most severe first; ties broken by newest date.
|
||||
if (b.data.severity !== a.data.severity) return b.data.severity - a.data.severity;
|
||||
return (b.data.date?.getTime() ?? 0) - (a.data.date?.getTime() ?? 0);
|
||||
});
|
||||
---
|
||||
|
||||
<BaseLayout title="Chart Sins">
|
||||
<section class="wrap hero">
|
||||
<h1>A catalogue of chart sins.</h1>
|
||||
<p>
|
||||
Every entry is a data-visualization mistake in the wild: the chart that
|
||||
misleads, why it fools the eye, and the honest version that fixes it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="wrap">
|
||||
<div class="gallery">
|
||||
{
|
||||
sins.map((sin) => (
|
||||
<a class="card" href={href(`sins/${sin.id}/`)}>
|
||||
<span class="pill">{sin.data.category}</span>
|
||||
<h3>{sin.data.title}</h3>
|
||||
<p>{sin.data.summary}</p>
|
||||
<div class="meta">
|
||||
<span class="severity" title={`Severity ${sin.data.severity} of 5`}>
|
||||
{severityFlames(sin.data.severity)}
|
||||
</span>
|
||||
<span class="pill">Read →</span>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import VegaChart from '../../components/VegaChart.astro';
|
||||
import { getChartSpec, severityFlames } from '../../lib/charts';
|
||||
import { href } from '../../lib/url';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const sins = await getCollection('sins', ({ data }) => !data.draft);
|
||||
return sins.map((sin) => ({
|
||||
params: { slug: sin.id },
|
||||
props: { sin },
|
||||
}));
|
||||
}
|
||||
|
||||
const { sin } = Astro.props;
|
||||
const { Content } = await render(sin);
|
||||
|
||||
const badSpec = getChartSpec(sin.data.badChart);
|
||||
const fixedSpec = getChartSpec(sin.data.fixedChart);
|
||||
---
|
||||
|
||||
<BaseLayout title={`${sin.data.title} — Chart Sins`} description={sin.data.summary}>
|
||||
<article class="wrap sin">
|
||||
<header>
|
||||
<span class="pill">{sin.data.category}</span>
|
||||
<h1>{sin.data.title}</h1>
|
||||
<span class="severity" title={`Severity ${sin.data.severity} of 5`}>
|
||||
{severityFlames(sin.data.severity)}
|
||||
</span>
|
||||
<div class="tags">
|
||||
{sin.data.tags.map((t) => <span class="pill">{t}</span>)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="compare">
|
||||
<div class="panel bad">
|
||||
<h2>The Sin</h2>
|
||||
<VegaChart spec={badSpec} title={`Misleading chart: ${sin.data.title}`} />
|
||||
</div>
|
||||
<div class="panel good">
|
||||
<h2>The Fix</h2>
|
||||
<VegaChart spec={fixedSpec} title={`Honest chart: ${sin.data.title}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prose">
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<a class="back-link" href={href()}>← All sins</a>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,235 @@
|
||||
:root {
|
||||
--bg: #fbf9f6;
|
||||
--surface: #ffffff;
|
||||
--text: #1c1a17;
|
||||
--muted: #6b6660;
|
||||
--border: #e6e1d9;
|
||||
--accent: #7c3aed;
|
||||
--sin-red: #c0392b;
|
||||
--good-green: #1e8b5b;
|
||||
--maxw: 68rem;
|
||||
--radius: 12px;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #16140f;
|
||||
--surface: #201d17;
|
||||
--text: #f3efe8;
|
||||
--muted: #a8a196;
|
||||
--border: #35302a;
|
||||
--accent: #a78bfa;
|
||||
--sin-red: #e07668;
|
||||
--good-green: #4cc38a;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: var(--maxw);
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.site-header .wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
padding-block: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.site-header a.brand {
|
||||
font-weight: 800;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.site-header .tagline {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 4rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.site-footer .wrap {
|
||||
padding-block: 2rem;
|
||||
}
|
||||
|
||||
.severity {
|
||||
letter-spacing: 1px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--sin-red);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* --- index gallery --- */
|
||||
.hero {
|
||||
padding-block: 3rem 1.5rem;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||
line-height: 1.1;
|
||||
margin: 0 0 0.75rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.hero p {
|
||||
color: var(--muted);
|
||||
font-size: 1.1rem;
|
||||
max-width: 44rem;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
gap: 1.25rem;
|
||||
padding-block: 1rem 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: transform 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.card .meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* --- sin detail page --- */
|
||||
.sin {
|
||||
padding-block: 2.5rem;
|
||||
}
|
||||
.sin header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.sin h1 {
|
||||
font-size: clamp(1.8rem, 4vw, 2.6rem);
|
||||
margin: 0.4rem 0 0.5rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.sin .tags {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.compare {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
@media (min-width: 46rem) {
|
||||
.compare {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
.compare .panel {
|
||||
padding: 1.25rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.compare .panel.bad {
|
||||
border-top: 4px solid var(--sin-red);
|
||||
}
|
||||
.compare .panel.good {
|
||||
border-top: 4px solid var(--good-green);
|
||||
}
|
||||
.compare .panel h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.compare .panel.bad h2 {
|
||||
color: var(--sin-red);
|
||||
}
|
||||
.compare .panel.good h2 {
|
||||
color: var(--good-green);
|
||||
}
|
||||
|
||||
.prose {
|
||||
max-width: 42rem;
|
||||
}
|
||||
.prose h2 {
|
||||
margin-top: 2rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.prose code {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-top: 3rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
Reference in New Issue
Block a user