# Dataplane Visuals Lab

A foundation for building **tiny, dependency-free, beautiful web visuals** from
first principles — the kind that make a page feel expensive without a WebGL
engine, a chart library, or a single raster image.

Open **`index.html`** for the live gallery — **105** hand-built demos. This file is
the field guide: how Stripe does it, every technique worth knowing, why the files
are so small, and how to lift a demo into the Astro site.

Open **`editor.html`** (served) to *retune any demo* — font scale, the house
colours, line weight, point size, and drag the actual inflection points — then
download the edited copy. It's the "back end" of every page; see **§11**.

> Design tokens match the live site (`website/src/styles/global.css`): navy ground
> `#081420`, sky accent `#38bdf8`, Inter. Everything here drops onto the real
> brand unchanged.

---

## 1. What Stripe actually does (the honest version)

Two different things, and people conflate them:

**a) The famous animated hero gradient is *not* SVG.** It's a WebGL shader on a
`<canvas>`, ~10 KB of code (their "MiniGL" + a `Gradient` class). The motion is
**fractal Brownian motion**: several octaves of Simplex noise layered at rising
frequency / falling amplitude, with the UV coordinate grid warped by `sin()/cos()`
over time so it reads like liquid or stretched fabric. It runs on the GPU, so it's
smooth and cheap on CPU/RAM. You cannot reproduce that exact fluid warp without a
shader — but you can get ~90% of the *feeling* with blurred gradient blobs + grain
(see demo 01), for a fraction of the complexity and no canvas.

**b) Almost everything else on the page is inline SVG + CSS.** Icons, the
"connected nodes" diagrams, money-movement lines, section dividers. This is the
part you *should* copy wholesale, because it's the part that stays a few KB of
text, scales crisply, themes with `currentColor`, and animates on the compositor.

**Takeaway for us:** reach for SVG + CSS first (demos 02–05). Keep a canvas shader
in your back pocket for one signature hero moment only, if ever.

---

## 2. The techniques worth owning

Grouped by primitive. Each is tiny because it's *text or math*, not pixels.

### SVG path animation
| Technique | What it does | Notes |
|---|---|---|
| `stroke-dasharray` + `stroke-dashoffset` | "Self-drawing" lines | Set `pathLength="100"` on the path so dash maths is just 0–100, independent of real length. |
| `clip-path: inset()` / `mask` wipe | Reveal an area fill or image | Animate `inset(0 100% 0 0)` → `inset(0 0 0 0)` for a left-to-right wipe. |
| `<animateMotion>` + `<mpath>` (SMIL) | Move an element *along a path* | `rotate="auto"` banks it into curves. Rock-solid, declarative, loops forever with `repeatCount="indefinite"`. |
| CSS `offset-path` / `offset-distance` | Same idea, in pure CSS | The modern equivalent of animateMotion; nicer to theme, slightly less universal than SMIL for path-following. |

### Procedural texture & colour (SVG filters — zero image bytes)
| Filter | Effect |
|---|---|
| `feTurbulence` (`fractalNoise`) | Film grain, clouds, paper, organic noise. The single biggest "why does this look expensive" trick. |
| `feGaussianBlur` | Soft glows, blob light (or just CSS `filter: blur()`). |
| `feColorMatrix` | Duotone, desaturate, tint, glow saturation. |
| `feDisplacementMap` (+ turbulence) | Warp/liquid distortion — the poor man's shader. |

### CSS-native
| Technique | Use |
|---|---|
| `@property` typed custom props | Makes otherwise **non-animatable** things animate: conic-gradient sweeps, gradient angles. |
| `conic-gradient` | Rings, gauges, pie/donut KPIs — no SVG needed. |
| `transform` + `opacity` keyframes | The only two properties that animate without touching layout/paint — always prefer them. `scaleX` bars, drifts, pulses. |
| `mix-blend-mode` / `background-blend-mode` | Layer grain and light convincingly (`screen`, `overlay`, `soft-light`). |
| `clamp()` + `vw/vmax` units | Fluid, responsive visuals with no JS resize handlers. |

### When you genuinely need JS
| Tool | Use only when |
|---|---|
| Count-up (`requestAnimationFrame`, ~12 lines) | Animating a *number's value* (CSS can't tween text content). |
| `IntersectionObserver` | Trigger the animation when it scrolls into view (add for below-the-fold visuals). |
| Canvas 2D / WebGL | Particle fields, thousands of points, or the one shader hero. |

### Deliberately avoided (and why)
- **Lottie** — powerful, but JSON exports balloon and pull in a runtime; overkill here.
- **GSAP / anime.js / D3** — great tools, but a foundation you *own* shouldn't need a
  40–90 KB dependency to move a rectangle. Everything above is a browser primitive.
- **GIF / video / raster** — 100×–1000× the bytes of the SVG equivalent, and blurry when scaled.

---

## 3. Why these files are so small

Measured, gzipped, **comments and all** (production minification shaves more):

| File | Technique headline | Raw | gzip | JS |
|---|---|--:|--:|:--:|
| `01-mesh-gradient.html` | Aurora blobs + `feTurbulence` grain | 3.9 KB | **1.9 KB** | none |
| `02-drawing-line.html` | `stroke-dashoffset` + clip wipe + `mpath` | 5.0 KB | **2.1 KB** | none |
| `03-pipeline-flow.html` | `animateMotion` packets on wires | 4.8 KB | **1.8 KB** | none |
| `04-gauge-ring.html` | `@property` conic gauge + count-up | 3.0 KB | **1.6 KB** | 12 lines |
| `05-delta-bars.html` | `scaleX` staggered bars + count-up | 4.1 KB | **1.8 KB** | 15 lines |
| `06-diff-in-diff.html` | line reveal + counterfactual + effect polygon | 4.3 KB | **1.9 KB** | none |
| `07-commission-slope.html` | slope chart, direction-coloured | 4.9 KB | **1.8 KB** | none |
| `08-reconciliation-tieout.html` | connector reveal + exception pulse | 5.9 KB | **2.0 KB** | none |
| `09-migration-estate.html` | grid transforms (shed + migrate) | 4.6 KB | **1.8 KB** | none |
| `10-catalogue-monitor.html` | status grid + scan sweep + toast | 4.8 KB | **1.8 KB** | none |
| `11-human-in-the-loop.html` | `animateMotion` gate-hold via keyTimes | 5.1 KB | **2.1 KB** | none |
| `12-prospect-radar.html` | rotating conic beam + staggered blips | 3.3 KB | **1.5 KB** | none |
| `13-dashboards-as-code.html` | template stamp-out, per-brand var | 5.4 KB | **1.9 KB** | none |
| `14-stat-counters.html` | count-up + growing accent rule | 3.1 KB | **1.5 KB** | 15 lines |
| `15-cohort-heatmap.html` | alpha heatmap, diagonal reveal | 5.5 KB | **1.6 KB** | none |
| `16-funnel-dropoff.html` | centre-scaleX bands + drop callouts | 4.3 KB | **1.8 KB** | none |
| `17-waterfall-bridge.html` | scaleY steps + running-level connectors | 4.7 KB | **1.8 KB** | none |
| `18-slopegraph.html` | per-workload line draw, gutter labels | 5.0 KB | **1.8 KB** | none |
| `19-bullet-kpis.html` | layered-track bullet rows + target tick | 4.3 KB | **1.7 KB** | none |
| `20-sparkline-grid.html` | small-multiples spark draw, shared scale | 5.0 KB | **1.9 KB** | none |
| `21-bump-ranks.html` | rank-lane polylines, accent climber | 3.8 KB | **1.6 KB** | none |
| `22-dumbbell-distance.html` | connector grow + paired dot pop | 5.1 KB | **1.9 KB** | none |
| `23-histogram-threshold.html` | scaleY bins + amber tail recolour | 4.0 KB | **1.7 KB** | none |
| `24-jackknife-range.html` | centre-out CI whiskers + pooled diamond | 4.5 KB | **1.8 KB** | none |
| `25-forecast-fan.html` | history draw + nested 50/80% bands | 3.8 KB | **1.7 KB** | none |
| `26-anomaly-timeline.html` | expected band + breach pulse + toast | 3.9 KB | **1.8 KB** | none |
| `27-control-chart.html` | staggered dots, 3σ limits, breach blink | 4.1 KB | **1.6 KB** | none |
| `28-sankey-attribution.html` | bézier ribbons, width = volume | 4.2 KB | **1.7 KB** | none |
| `29-waffle-per-100.html` | 10×10 person-grid, group stagger | 3.5 KB | **1.7 KB** | 12 lines |
| `30-calendar-heatmap.html` | weekday×week alpha cells, diagonal wash | 4.2 KB | **1.9 KB** | 18 lines |
| `31-stacked-area-mix.html` | clip-wipe stack, shared boundary vertices | 3.3 KB | **1.7 KB** | none |
| `32-diverging-bars.html` | scaleX from zero, dual transform-origin | 3.8 KB | **1.6 KB** | none |
| `33-pareto-8020.html` | ranked bars + cumulative line + 80% drop | 4.3 KB | **1.9 KB** | none |
| `34-quadrant-scatter.html` | quadrant guides + dot pop + ring pulse | 4.3 KB | **1.8 KB** | none |
| `35-regression-fit.html` | dots → fit line → residual sticks | 4.9 KB | **1.9 KB** | none |
| `36-catchment-map.html` | abstract dot map, road-assignment flips | 5.5 KB | **2.0 KB** | none |
| `37-effect-error-bars.html` | CI whiskers + detectability bound | 4.4 KB | **1.8 KB** | none |
| `38-migration-gantt.html` | overlapping phase bars + milestone pop | 4.3 KB | **1.8 KB** | none |
| `39-decision-tree.html` | branch draw in tree order + EV badges | 5.9 KB | **2.1 KB** | none |
| `40-placebo-test.html` | bell-path draw + observed-line drop-in | 3.5 KB | **1.6 KB** | none |
| `41-likert-diverging.html` | outward-growing diverging stacks | 6.1 KB | **2.0 KB** | none |
| `42-step-cumulative.html` | H/V step path draw + riser dots | 4.1 KB | **1.7 KB** | none |
| `43-radar-compare.html` | pentagon rings + two polygon draws | 4.0 KB | **1.7 KB** | none |
| `44-treemap-mix.html` | hand-squarified rect pop-in | 3.8 KB | **1.6 KB** | none |
| `45-breakeven-lines.html` | two-line draw + shared-vertex wedge | 4.0 KB | **1.9 KB** | none |
| `46-box-whisker.html` | box grow + whisker draw + outlier pulse | 6.1 KB | **2.2 KB** | none |
| `47-violin-distribution.html` | mirrored density paths + clip wipe | 4.5 KB | **2.1 KB** | none |
| `48-ridgeline-hours.html` | overlapping area ridges, stagger rise | 4.2 KB | **1.8 KB** | none |
| `49-beeswarm-effects.html` | stacked-dot swarm + median diamond | 6.0 KB | **2.0 KB** | none |
| `50-butterfly-pyramid.html` | mirrored scaleX bars, dual origins | 4.9 KB | **1.9 KB** | none |
| `51-marimekko-mix.html` | width-true columns + stacked pop | 4.3 KB | **1.8 KB** | none |
| `52-sunburst-hierarchy.html` | dasharray ring segments (no path math) | 5.2 KB | **1.8 KB** | none |
| `53-chord-crossshop.html` | dasharray node arcs + bézier ribbons | 4.5 KB | **1.7 KB** | none |
| `54-streamgraph-topics.html` | centred stack, shared boundary vertices | 3.9 KB | **1.9 KB** | none |
| `55-horizon-strips.html` | fold-overlay polygons + amber negatives | 4.6 KB | **2.0 KB** | none |
| `56-hexbin-density.html` | one hex `<use>`-stamped, alpha bins | 6.2 KB | **1.8 KB** | none |
| `57-packed-bubbles.html` | hand-packed circles, √-area radii | 4.6 KB | **1.8 KB** | none |
| `58-parallel-coordinates.html` | shared axes, per-entity polyline draw | 3.9 KB | **1.7 KB** | none |
| `59-network-map.html` | dim retired mesh + marching-dash spokes | 5.1 KB | **2.0 KB** | none |
| `60-dendrogram-clusters.html` | elbow paths in merge order + cut line | 4.2 KB | **1.8 KB** | none |
| `61-splom-matrix.html` | dot clouds mirrored via transpose matrix | 5.6 KB | **1.9 KB** | none |
| `62-correlation-matrix.html` | diverging alpha cells, values printed | 8.0 KB | **2.1 KB** | none |
| `63-connected-scatter.html` | dated path draw + amber break segment | 4.6 KB | **1.9 KB** | none |
| `64-candlestick-cash.html` | OHLC wick+body candles + facility line | 4.8 KB | **1.9 KB** | none |
| `65-cycle-plot.html` | weekday panels, within-panel trend | 4.3 KB | **1.8 KB** | none |
| `66-bar-race.html` | synced keyframe lane-hops + scaleX, holds | 5.4 KB | **1.9 KB** | none |
| `67-tile-cartogram.html` | equal geo tiles + alpha encoding | 4.8 KB | **1.7 KB** | none |
| `68-dual-axis-combo.html` | bars then line, zero-anchored dual axes | 4.5 KB | **1.9 KB** | none |
| `69-table-lens.html` | CSS-grid table + in-cell bars + chips | 4.2 KB | **1.7 KB** | none |
| `70-kpi-cards.html` | count-up BANs + semantic delta chips | 4.9 KB | **2.2 KB** | 14 lines |
| `71-lift-curve.html` | baseline-first gains curve + 20% guide | 4.4 KB | **1.9 KB** | none |
| `72-survival-retention.html` | Kaplan–Meier steps + censor ticks | 4.1 KB | **1.9 KB** | none |
| `73-event-study.html` | pre/post CI whiskers around the event | 6.0 KB | **2.1 KB** | none |
| `74-tornado-sensitivity.html` | centre-anchored swing bars, sorted | 4.7 KB | **1.8 KB** | none |
| `75-rose-months.html` | coxcomb sector paths, √-honest radii | 4.4 KB | **1.9 KB** | none |

**A key gotcha, learned the hard way (it's in the code comments too):** the
`stroke-dashoffset` draw-on reveal (06, 07, 08 and the line in 02) only works if the
shape carries `pathLength="100"`. Without it, `stroke-dasharray:100` is read as *100
user-units on, 100 off* — a dashed line — instead of "the whole length". Set
`pathLength="100"` and the dash maths is always 0–100 regardless of the real geometry.

Three reasons they stay tiny:
1. **They're text.** SVG/CSS/HTML are markup; gzip/brotli crush repetitive markup by
   ~60–75%. A raster of the same visual is 50–500 KB and doesn't scale.
2. **Texture is procedural.** `feTurbulence` *computes* grain from a seed — the grain
   costs a dozen bytes of filter definition, not a noise PNG.
3. **Motion is declarative + GPU-composited.** `transform`/`opacity` keyframes and SMIL
   are description, not frames. No per-frame image data, no animation runtime.

For comparison: a single hero background JPG+GIF combo the site would otherwise use is
easily 150–600 KB. All **105** demos together gzip to **~190 KB** — still less
than two mid-size product photos, for the entire visual library. The live data layer
(runtime + five pages + sample datasets) adds ~15 KB more; the narrative layer
(dp-story.js + two story pages) another ~14 KB.

---

## 4. The first-principles mental model

Every visual here is the same four-layer recipe. Learn the recipe, not the demos:

```
GROUND   a flat brand-colour base (navy)
LIGHT    soft gradients / blurred blobs for depth        (radial-gradient, blur)
FORM     the actual shape or data                        (SVG path, conic, bars)
LIFE     motion + texture that never fully repeats        (transform keyframes,
                                                            animateMotion, feTurbulence)
```

Then three non-negotiable finishing rules:
- **Only animate `transform` and `opacity`** in hot paths. Animating `width`, `top`,
  `box-shadow`, etc. forces layout/paint and drops frames.
- **Gate every animation** behind `@media (prefers-reduced-motion: reduce)` with a
  static end-state fallback. All 109 demos already do — and the base opacity of every
  fade-in element is set to its *end* value with `animation-fill-mode: both`, so the
  reduced-motion state is the finished state, never a stuck `opacity:0`.
- **Red-team the composition, not just the technique.** "It renders" is *not* "it
  reads." After a visual renders, hunt for: labels crossing lines, clipped/overflowing
  text, overlapping elements, and low contrast — at both wide and narrow widths.
  (Learned the hard way: 06 shipped with labels sitting on the data lines, and 08 with
  text overflowing its pill. Park series labels in a right-hand gutter at each line's
  end so they *can't* collide; size any badge to its text; render and actually look.)

---

## 5. Make it *say something* (the Dataplane angle)

Decoration is cheap; a decorative animation on a data consultancy's site is a missed
tell. The insight set is each wired to a real case study — numbers illustrative, but the
*shape of the argument* is the actual work:

| Demo | Case study it argues for |
|---|---|
| **02** self-drawing line | the "after the fix, revenue did *this*" moment |
| **05** delta bars | reconciliation root-cause breakdown of a recovered gap |
| **06** difference-in-differences | *What does opening a store do to online sales?* (the causal-lift money-shot) |
| **07** commission slope | *Redesigning a sales commission scheme* — winners & losers named |
| **08** reconciliation tie-out | *Getting every system to agree about every sale* |
| **09** migration estate | *Migrating a retail BI estate to Snowflake* — audit, shed, migrate |
| **10** catalogue monitor | *Catching product-feed problems before they cost a month* |
| **11** human-in-the-loop | *An ad-management platform with a human in the loop* |
| **12** prospect radar | the growth engine's UK/US Shopify prospect radar |
| **13** dashboards-as-code | *Merchant analytics for a multi-brand loyalty platform* |
| **15** cohort heatmap | cohort analysis behind store-impact & edtech |
| **14** stat counters | the live site's `<StatsBar>` |
| **03** pipeline flow | the brand motif: messy sources → clean model → decision |
| **04** gauge ring | a single hard KPI (reconciliation match rate) |
| **01** mesh gradient | atmosphere — the hero background |

The 2026-07-06 expansion (16–45) adds thirty more idioms, grouped by the job they do:

| Group | Demos | Case studies they argue for |
|---|---|---|
| Comparison & change | **18** slopegraph · **19** bullet KPIs · **21** bump ranks · **22** dumbbell gap · **32** diverging bars · **43** radar compare | Snowflake per-workload cost; exec dashboards; category reviews; geo routing (road vs crow); per-store lift; maturity assessments |
| Composition & flow | **16** funnel leak · **17** waterfall bridge · **28** attribution flow · **29** waffle per-100 · **31** stacked area · **33** Pareto 80/20 · **44** treemap | checkout diagnostics; cashflow memos; dp_attr attribution; plain-language exec reporting; channel mix; SKU concentration; revenue composition |
| Uncertainty & rigour | **23** histogram+threshold · **24** jackknife · **25** forecast fan · **35** fit+residuals · **37** effects+detectability · **40** placebo distribution | postcode uncertainty; small-n event studies; cashflow scenarios; price elasticity; "effect ≈ 0" power bounds; "test, don't debate" |
| Time, monitoring & ops | **20** sparkline grid · **26** anomaly band · **27** control chart · **30** calendar heatmap · **38** delivery gantt · **42** step cumulative | per-store trends; GMC monitor; data-quality ops; order seasonality; migration plan; recon recovery |
| Decisions & geography | **34** effort×impact quadrants · **36** catchment dot map · **39** decision tree EV · **41** Likert diverging · **45** break-even | audit→roadmap; nearest-store by road; pricing EV; edtech surveys; automation ROI |

The uncertainty group is deliberate: it draws the standing red-team methodology
(jackknife ranges, placebo tests, detectability bounds) so the rigour is *visible
on the website*, not just claimed in a report.

The 2026-07-06 second expansion (46–75) adds the Tableau/Observable canon — the
idioms clients recognise from BI tools, rebuilt in the house style:

| Group | Demos | Case studies they argue for |
|---|---|---|
| Distributions & shape | **46** box-whisker · **47** violin · **48** ridgeline · **49** beeswarm · **50** butterfly pyramid · **65** cycle plot | delivery SLAs (tails, not means); channel bimodality; staffing by hour; per-rep commission fairness; channel crossover by age; the weekday trend inside the seasonality |
| Composition, hierarchy & maps | **51** marimekko · **52** sunburst · **57** packed bubbles · **67** tile cartogram · **75** rose | province × channel mix; category→subcategory budgets; share-at-a-glance slides; provincial reviews without projection lies; the year as a cycle |
| Relationships & structure | **53** chord · **56** hexbin · **58** parallel coords · **59** network · **60** dendrogram · **61** SPLOM · **62** correlation matrix · **63** connected scatter | cross-shopping bundles; catchment density (599K→1,798 points); store archetypes ×3 views; integration spaghetti → hub; hypothesis nomination (with the causation caveat printed); the elasticity cliff |
| Time, finance & motion | **54** streamgraph · **55** horizon · **64** candlestick · **66** bar race | ticket-mix drift; six-metric ops walls; intraweek cash risk the close hides; the overtake story (CSS-only) |
| Boardroom & evaluation | **68** dual-axis combo · **69** table lens · **70** KPI cards · **71** lift curve · **72** survival · **73** event study · **74** tornado | promo margin cost; exec tables that stay auditable; honest BANs; **trivial-baseline benchmarking drawn first**; censoring done right; **parallel trends shown, not assumed**; sensitivity-ordered de-risking |

Again the methodology rows are deliberate: 71 and 73 put two more standing
red-team defaults (trivial baselines, pre-trend checks) on the website itself.

Rule of thumb: **if the visual would work with lorem-ipsum numbers, it's decoration.**
Wire it to a claim you'd defend.

---

## 6. Porting into the Astro site

The demos are standalone HTML so they open with a double-click, but they're written to
lift cleanly:

1. **Component:** copy the `<svg>`/markup into a new `src/components/*.astro`; move the
   `<style>` block into the component's scoped `<style>` (Astro scopes it for free).
2. **Tokens:** delete the local `:root` vars — the site already exposes `--color-accent-500`
   etc. via the `@theme` block, or use the Tailwind classes (`text-accent-400`, `bg-navy-900`).
3. **Trigger on scroll:** wrap the animation start in an `IntersectionObserver` so it
   fires when visible, not on load (matters for anything below the fold).
4. **Reduced motion:** the `@media (prefers-reduced-motion)` blocks come along for free —
   keep them.
5. First natural homes: **01** behind the `<Hero>` (replacing the static radial blob),
   **03/11** in a "how we work" section, **14** as the live StatsBar, and **06/07/08/
   09/10/13/15** inside the matching case-study pages next to the real numbers.

### Responsive status (verified at desktop; mobile notes)

The **SVG-viewBox** demos (01–04, 06–08, 11, 12) are responsive *by construction* — the
`viewBox` scales the whole drawing to any width, so nothing can collide or overflow;
they just get smaller. Verified at desktop width.

The **CSS grid/flex** demos need real breakpoints. Fixed: **13** stacks (master → arrow →
2-col fan) and **15** shrinks its cells on narrow; **05/14** use `minmax(0,1fr)` so a
track can never blow out the grid. **Caveat — 09** carries a fixed `translateX(304px)`
migrate distance tuned for a 640px stage; parameterise that (e.g. a CSS var driven off
container width) before shipping it narrow. Headless Chrome proved an *unreliable oracle
for mobile viewport* in this build (contradictory results run-to-run), so these mobile
fixes are correct-by-construction but pixel-confirmed only in a real browser's responsive
mode — do that once as part of porting.

---

## 7. Red-teaming visual breaks (deterministic)

Screenshots *lie* about overflow, and headless screenshotting proved flaky. So the
red-team check reads the truth from the DOM instead: a scrollbar appears **iff**
`scrollWidth > clientWidth` (horizontal) or `scrollHeight > clientHeight` (vertical).

`red-team/overflow-check.html` loads every demo in an iframe at several viewport presets
(gallery-thumbnail, mobile, desktop), measures both axes, and prints `PASS`/`FAIL` per
demo — **naming the elements that stick out** and by how many px.

`red-team/legibility-check.html` is the gate for the bugs a scrollbar *can't* reveal.
The outer `<svg>` clips overflow, so a label that runs past the viewBox is silently
truncated — no scrollbar, still broken. And a label can sit *on* a data line and be
unreadable while every box measures fine. This probe, over the 34 new demos (76–109),
flags any `<text>` whose `getBBox` leaves the viewBox by > 2.5 units, and any label
whose centre lands within 6 units of a data-bearing curve (sampled with
`getPointAtLength`). It caught three real breaks on the first run — two clipped
callouts and a percentile label lying on the ECDF step — now fixed. It earned its keep
again on the polynomial cluster (106–109): a degree-3 legend label overran the viewBox
by 26u and an `x →` axis label sat 3u from the extrapolation tail — both caught pre-ship,
not by eye. (It's scoped to 76–109 on purpose: several of the original 75 label line
*endpoints* by design, which this heuristic would false-flag.)

`red-team/story-check.html` is the second gate, for the narrative layer (§9): it
*drives every scene* of every story page (the overflow gate only ever sees scene 0)
and asserts from the DOM that (a) all visible text stays inside the SVG box in every
scene, (b) no caption or annotation contains `undefined`/`NaN` (the computed-annotation
regression trap), (c) **no label sits on a data line** — the standing legibility rule,
implemented as segment-distance maths in screen space via `getScreenCTM`, not eyeballs —
(d) the reveal scene confesses every declared lie, and (e) the **disclosure gate**
actually blocks a story that lies without confessing. Live pages run through the same
measurements on their end state. Two hard-won harness rules live in its comments: kill
CSS transitions inside the probed page and measure *end states* (headless virtual time
freezes transitions at their start value and cries wolf), and compute *effective*
opacity up the ancestor chain (a text inside a faded `<g>` reports opacity 1).

```
bash red-team/check.sh          # all three gates → full report, exit 0 (pass) / 1 (fail)
# …or open red-team/overflow-check.html / legibility-check.html / story-check.html
```

The overflow gate caught the real bug behind the "clunky scroll": 7 demos overflowed
*vertically* inside the fixed-height gallery cards. The fix wasn't the demos — it was
the gallery, which now renders each demo at a 900×600 viewport and scales it to a
thumbnail (zero scrollbars). It now reports **0 FAIL across 122 pages (109 demos + 5
live + 8 story) × 3 presets**, the legibility gate **0 FAIL across the 34 new demos**,
and the story gate **0 FAIL**. The story gate also asserts the dead-end ledger
prints on any page that declares `deadEnd` scenes. It earned its keep on day one: it caught
five label-on-line collisions across the two story pages *and* cleared `live/01`,
which the author had wrongly suspected of the same bug — measure against the evidence,
don't fix by intuition. Add axes/checks (contrast, tap-target size) to the same
harnesses as the library grows — they're the reusable "does it actually read?" gate
the [visual red-team rule](#4-the-first-principles-mental-model) asks for.

## 8. The data layer — tying real data in

Everything in `demos/` is hand-authored geometry: perfect for design, useless for a
Tuesday number. **`lib/dp-bind.js`** (~2 KB gzipped, zero dependencies) is the bridge:

- **Loaders** — `DP.load(src)` fetches `.csv` (tiny parser, numerics auto-coerced) or
  `.json`, or reads an inline `<script type="application/json">` by `#id`.
- **Scales & stats** — `DP.linear`, `DP.band`, `DP.extent`, `DP.quartiles` (real
  Tukey boxes: 1.5×IQR whiskers, outliers surfaced).
- **Emitters** — `DP.bars / line / area / dots / cells / box / text` create SVG with
  the **same classes the demos use**, so the house animations (scaleY grow,
  `pathLength` draw-on, pop, fade) apply unchanged. `line()` always sets
  `pathLength="100"` — the README's own gotcha, baked in.
- **Formatters** — `DP.fmt.R` (R1.2m / R340k), `.pct`, `.n` for the ZA house style.

The five pages in `live/` are working proof, each the live twin of a static demo:

| Live page | Static twin | What the layer computes |
|---|---|---|
| `01-live-pareto` | 33 | cumulative share; the 80% guide finds its own bar |
| `02-live-revenue-line` | 02 | axis labels from the data's extent; peak annotation |
| `03-live-heatmap` | 30/48 | 168 cells; busiest cell found and ringed |
| `04-live-boxplot` | 46 | quartiles/whiskers/outliers from 161 **raw** rows |
| `05-live-kpis` | 70 | cards, chips, per-series-scaled sparklines from JSON |

Two rules carried over from the standing methodology: **derived artifacts stay
derived** — everything in `data/` is regenerated by `data/make_data.py` (seeded, so
reruns are byte-identical) and never hand-edited; and annotations are **computed, not
asserted** — if the CSV changes, the 80% guide, the peak label and the outlier note
re-derive themselves. Live pages need a server for `fetch`: `python3 serve.py`, then
open `/live/…`. If fetch fails they degrade to an instruction message, never a
half-rendered chart.

## 9. The narrative layer — dp-story.js

dp-bind answers *"how does data become the house SVG?"*; **`lib/dp-story.js`**
(~4.8 KB gzipped, zero dependencies) answers *"who is holding the laser pointer?"*.
It turns a chart into a **told** story — the difference between a slide and a
presenter.

**Scenes.** `DP.story(figure, { scenes: […] })` walks named scenes with prev/next
buttons, clickable dots, ←/→ keys, and optional autoplay (`dur` seconds per scene,
space to pause). Each scene's `enter(ctx)` sets the FULL state for that scene —
that's the contract that makes stepping *backwards* exactly as correct as forwards.
A caption bar (title + note + optional HTML) is injected and re-animates per scene;
put **computed numbers** in the notes, so the story retells itself when the data
changes.

**Emphasis grammar** — colour, size, shading and motion, aimed on demand:
- `DP.focus(svg, '.head')` — dims everything carrying `data-dim` that doesn't match,
  glows what does. `DP.clearFocus(svg)` resets.
- `DP.replay(el|selector)` — re-fires any house CSS animation (draw-on, grow, pop)
  when a scene needs it, not just on page load.
- `DP.count(el, value, {fmt})` — tweens the headline number a caption stakes its
  claim on (`DP.fmt.R` for rands).
- `DP.pinpoint(svg, target, {kind, dim, scrim, callout, magnify})` — **marks the
  finding, and drives it home.** A composable attention layer laid *over* the
  settled scene, on the element(s) the caption is about:
  - **marker** — `kind:'ping'` (radar rings), `'halo'` (breathing), `'ring'`
    (static); a single point gets concentric rings, a group gets a bracket.
  - **focus** — `dim:true` fades the rest, or `scrim:true` drops a cinematic
    *spotlight* (a full-frame dim through a luminance mask with a soft radial hole).
  - **callout** — `callout:'42% = 80% of revenue'` (or `{text,count:{to,fmt}}`)
    puts the number **on the chart**: an amber pill + thin leader, placed toward
    the roomier side and clamped in-box.
  - **magnify** — `magnify:true` (or `{x,y,w,h}`) pulls a framed, enlarged inset
    of the region into the emptiest corner, with a dashed connector.

  A scene declares `pinpoint:'.head'` or the full object; the runtime fires it
  once any transition lands — emphasis *inferred by the narrative*, added on top
  of the initial render, cleared on scene change. Everything is decorative
  (circle/rect/thin lines) or a single safely-placed pill, and the inset clones
  geometry with its text stripped and its strokes forced thin, so the legibility
  gate is never tripped; reduced-motion falls back to a static ring.
- Class toggles + CSS transitions do the rest (stroke-width, recolour, hero glow) —
  the runtime stamps `scene-<id>` classes on the figure for pure-CSS scene styling.

**Client themes.** Every page colours itself through the same eight custom
properties, so a client brand is one var-pack: `DP.applyTheme('homeware')` (or an
object) re-tints ground, accents, semantics and even gradient `<defs>` — story
pages write stops as `style="stop-color:var(--accent)"`. Four packs ship
(`dataplane`, `homeware`, `fintech`, `boutique`); `?theme=homeware` on any URL that
loads the file re-brands the page. Dashboards-as-code, applied to the lab.

**The disclosure gate — lying, honestly.** Scenes may be marked
`{deceptive:true, trick:'…'}` to *demonstrate* how presentation choices mislead:
`story/02-story-three-lies.html` shows one dataset as a truncated-axis rocket, a
cherry-picked recovery, and a salience-rigged "we beat the market" — each scene
factually true, each a lie. The mechanics make the point: an axis lie is just an
affine transform, so the reveal literally *morphs the frame back* to honest over
1.4 s while the data never moves. The runtime enforces the ethics: **a story with
deceptive scenes and no `{reveal:true}` scene after the last lie refuses to run**
(⛔ banner instead), and the reveal auto-prints every `trick` used, with the lie
dots turned amber. This is the standing red-team methodology as theatre — the same
reason 40 (placebo) and 71 (baseline-first) exist, aimed at the audience's defences.

**Multi-panel stories.** `DP.panel(fig, 'p2')` crossfades between stacked
`.dp-panel` blocks inside a `.dp-stage` — one chart per act (an attack per
panel, a suspect board, a waterfall finale) without one over-loaded SVG.

**Scene-to-scene transitions — an adaptive magic move.** dp-story re-aims
emphasis inside *one* picture; **`lib/dp-transition.js`** (~3 KB gzipped, zero
dependencies) handles the case where the picture itself changes but the *ink*
is reused. Give any element that should persist a `data-key="…"`; declare
`transition:'auto'` on a scene, and the runtime snapshots the outgoing keyed
ink **before** `enter()` rebuilds, then a **planner** inspects both scenes and
choreographs — because "24 bars fold into one column" and "three points bloom
into cards while twenty-one bow out" are different stories and shouldn't move
identically:
- It **counts** matched / entering / leaving and *phases* from the balance —
  leavers clear the stage, survivors then shift, arrivals fill last; with
  nothing leaving the move starts at once, with nothing moving arrivals come
  sooner.
- **Movement is chosen by the pair of shapes**, not one global effect: *same
  tag* (rect→rect, circle→circle) morphs the geometry attributes themselves
  (x/y/width/height, cx/cy/r) so a bar never balloons; *polyline→polyline* gets
  a **point-morph** — both vertex lists are resampled to a common count and
  interpolated so one curve *bends* into the other (a tiny rAF tween with a
  setTimeout backstop, since `points` isn't a CSS-transitionable property);
  *different simple shapes* (rect↔circle) get a FLIP transform fly; *into a
  composite* (→`<g>`/card/label) gets a **dissolve** — the old shape drifts to
  the new spot and fades while the new one rises in, so text never scales.
- **Arrivals/departures match the shape:** bars **grow from / collapse to** the
  baseline, dots **pop / shrink** from centre, lines **draw / erase**, cards
  **rise / sink** — each with easing suited to its direction (overshoot in,
  accelerate out, smooth for moves). Duration scales a little with travel and
  elements stagger by x-position, so a transition reads as a wave, not a jump.

Forced modes stay for contrast/experiments (`'magic'` = uniform FLIP + fade;
`'fade'`/`'rise'`/`'scale'`/`'slide'`/`'draw'`), and an object tunes the manual
path (`{dur, ease, stagger, color}`). Standalone use:
`DP.transition(svgRoot, () => rebuild(), 'auto')`; `DP.transition._last` reports
the chosen plan (counts + job kinds) for debugging. `prefers-reduced-motion`
and the red-team probe (which kills transitions) both fall straight through to
the clean end state `enter()` already built — leaving clones are **stripped of
their `data-key`** (so a later snapshot can't mistake a fading ghost for live
ink) and removed on short timers, so a probe measuring soon after never sees
stray ink. `story/09-story-transitions.html` is the working proof, with a live
picker (Auto vs the uniform/manual modes) and ↻ Replay.

**Verdict chips.** A scene may carry `verdict:'SURVIVES — sharpened'` and the
caption gets a coloured chip (tone from the first word: SURVIVES teal,
DOWNGRADED amber, OVERTURNED red, UNTESTABLE grey) — the red-team vocabulary
as a first-class scene property, so robustness stories stamp their outcomes.

**The dead-end ledger.** Scenes may carry `deadEnd:'…'` (string or array):
hypotheses that were *examined and cleared*. Any scene marked `{ledger:true}`
auto-prints the accumulated bank under the heading "Examined, and not the
answer". Negative results are findings — they die in chat threads unless the
artifact carries them; this makes "what we looked at that didn't answer it" a
standard section of a told analysis. The story gate asserts the ledger prints
whenever a page declares dead ends.

The data layer grew two emitters for these stories: `DP.waffle` (per-100
person grid, returns its rects for scene re-classing) and `DP.whiskers`
(lo/value/hi CI glyphs with house classes).

The pages in `story/` are the working proof — including one analysis told
**three different ways** (04/05/06: the same store-impact evidence as a board
story, an econometric walk, and an adversarial cross-examination — pick the
audience, keep the evidence):

| Story page | What it demonstrates |
|---|---|
| `01-story-pareto` | scene control + focus/dim + count-ups + live CSV + the four theme packs |
| `02-story-three-lies` | the disclosure gate: 3 lies → animated honest reveal → auto-confession → defence checklist |
| `03-story-lead-scorer` | a real backtest with devil's-advocate scenes and computed captions |
| `04-story-cannibal-per100` | board framing: twins → per-100 waffle → honest BANs (median beside mean) → neighbourhood check |
| `05-story-cannibal-eventstudy` | econometric framing: real 48-month series, anticipation flagged amber, +R599 → +R893 under attack |
| `06-story-cannibal-redteam` | adversarial framing: attack → falsification test → verdict chip; the losing claims shown too |
| `07-story-online-downturn` | the detective structure + dead-end ledger: six suspects stamped ✗, geography cracks it, waterfall with drawn uncertainty, open threads listed |
| `08-story-tm2-finance` | the finance twin: the reported number → two engines funding a leak → where EBITDA went → per-unit truth → open questions |
| `09-story-transitions` | the adaptive magic move: 24 SKU bars → revenue stack → cumulative curve → **straighten to the equality line** (polyline point-morph) → KPI cards → back to bars, same keyed ink; a **pinpoint** on each scene's finding; live transition picker |
| `10-story-scatter-to-pie` | the **drill as narrative** (dp-camera): a market of SKU dots → dive into the best-seller → it **blooms into its channel pie** → dive again into the dominant wedge → that channel by month → pull back to the market; overview → detail → deeper → overview, each scene a rest state |

All are gated by `red-team/story-check.html` (§7), which drives every scene.

## The interaction layer — dp-camera.js + dp-reveal.js

dp-story re-aims emphasis inside one picture, dp-transition changes the picture
while reusing the ink — this layer changes **where the reader stands**, and how
meaning is **built up**.

**`lib/dp-camera.js`** (~3 KB gzipped, zero deps) is a **semantic** zoom, not a
magnifying glass. `DP.camera(svg, {xRange, yRange, xDomain, yDomain, render})`
manages a *data window*: wheel to zoom around the cursor, drag to pan,
double-click to fly home — and on every change it narrows the domain and calls
`render(view)` so the chart re-plots at **constant point/label size** with the
**axes re-ticked** for the visible range (`DP.niceTicks`, 1–2–5 only). So
R0–R620 becomes R280–R360, *relabelled* — the axis keeps meaning instead of
stretching pixels. `view.xDomain`/`yDomain` expose the true window for honest
captions; `flyTo(rect)` animates the domain (not a matrix). `demos/110-zoomable-axes`
is the proof.

**`DP.zoomThrough(svg, {from, plot, build, center})`** is the **clever link** —
zoom into a dot until it becomes the next chart. The field scatters outward
(`DP.scatterOut`), the camera dives into `from`, and the destination — painted
by `build(dest)` as an **ordinary rest state** — blooms out of it. Because the
destination is a real chart, not a special frame, a killed transition /
reduced-motion / a hidden tab lands straight on it (a `motionOff()` probe
enforces this under the story gate). `demos/112-drill-scatter-pie` (click a dot)
and `story/10` (the drill narrated) are the proofs.

**`lib/dp-reveal.js`** (~2.5 KB gzipped, zero deps) adds meaning **a piece at a
time**. `DP.reveal(root, steps, opts)` is a timeline where each step can
`{ show, colorize, highlight, focus, pinpoint, note }` — the frame lands, then
the bars in neutral grey, then **colour washes in** to encode the verdict
(`DP.colorize`, hue spent last and deliberately), then a **band highlights the
section** that matters (`DP.highlightBand` — an x- or y-range), then the finding
is named. `.end()` paints the complete figure with transitions off, so the
build-up is always an enhancement, never a prerequisite. `demos/111-progressive-build`
is the proof.

Design notes, best-practice basis, and the forward plan (scrollytelling,
focus+context lens, data-driven camera tours, uncertainty-that-survives-zoom)
are in **`ROADMAP.md`**.

## The chart/story advisor (`advisor.html`)

*Which* visual should tell a given story? `advisor.html` reads
**`data/atlas.json`**, a curated map of **job → ranked picks** that covers **all
109 demos + the story decks** across **16 jobs** (brand & atmosphere, rank,
composition, hierarchy, change-over-time, compare, distribution, relationship,
flow, uncertainty, geography, headline number, the decision, monitoring/ops,
money & finance, and *tell it as a story*). Pick a job — or search across all of
them — and each option shows:

- **Best** — when this visual argues the point well;
- **Watch** — the red-team caveat: *how it misleads* (truncated axis, area
  perception, bin width, parallel-trends assumption, spurious correlation…).
  This is the differentiator: the advisor recommends the chart that argues
  **honestly**, not the flashiest, and it links straight to the live example;
- **Alternatives** — one or two other idioms for the same job.

`data/atlas.json` is the source of truth (a derived artifact — regenerate it
with `scratchpad/gen_atlas.py`, don't hand-edit the page); `?goal=<jobId>` and
`?q=<term>` deep-link. Gated for horizontal overflow alongside the gallery
(`red-team/overflow-check.html`, `scrolls:true` pages).

**The LLM front door (optional).** Beyond the deterministic job pills + keyword
search, the advisor can route a *freeform intent* ("show that opening stores
lifted online orders") to the right job. `serve.py` exposes `POST /api/advise`,
which calls **`claude-opus-4-8`** (official Anthropic SDK, structured output
constrained to the 16 job IDs) with the atlas as context and returns
`{jobId, why, tips, highlight, concern}` — the model *routes and frames*, the
manifest still supplies the visuals and their honest-use caveats. The key stays
server-side; the browser never sees it.

**It refuses to help you mislead.** The honesty stance is enforced at the front
door, not just noted in a caveat: if an intent asks the model to deceive — "make
our flat revenue look like a rocket", "hide the segment that's down", "prove the
campaign caused the lift" — it does **not** comply. It sets `concern` naming the
specific deception, **reframes** to the job that answers the honest underlying
question, and the page shows an amber *Honesty check* banner linking to the
`story/02` three-lies deck ("see how this misleads"). Honest intents route
normally with no banner. It's **opt-in and degrades cleanly**:
the static site and deterministic advisor run with zero dependencies, and the
"✦ Ask" box only appears when the server reports the SDK is installed and a key
is set —

```
pip install anthropic
export ANTHROPIC_API_KEY=sk-ant-...
python3 serve.py        # startup prints "advisor LLM front door: ON"
```

Without those, `GET /api/advise` reports `llm:false` and the page falls back to
keyword search (footer says so). The SDK import is lazy, so `serve.py` still
runs the static lab with no extra packages.

## 10. Files

```
visuals-lab/
├── index.html                       gallery (Stripe-dark, scaled thumbnails, all 112 + editor + live)
├── editor.html                      the visual editor — retune any demo, drag inflection points (§11)
├── advisor.html                     the chart/story advisor — job → ranked visuals + honest-use caveats
├── data/atlas.json                  the advisor's manifest (job → picks, best/watch/alternatives)
├── README.md                        this field guide
├── ROADMAP.md                       motion & interaction — best-practice basis + forward plan
├── serve.py                         no-cache dev server (:8777) + optional LLM advisor route (/api/advise)
├── lib/
│   ├── dp-bind.js                   the data layer (loaders, scales, emitters)
│   ├── dp-story.js                  the narrative layer (scenes, focus, pinpoint, themes, disclosure gate)
│   ├── dp-transition.js             scene-to-scene magic move (adaptive planner: geometry/flip/dissolve/point-morph)
│   ├── dp-camera.js                 the viewport layer — semantic zoom/pan, niceTicks, zoomThrough drill
│   └── dp-reveal.js                 progressive disclosure — reveal/colorize/highlightBand (meaning a piece at a time)
├── data/
│   ├── make_data.py                 regenerates every dataset below (seeded)
│   ├── sku_revenue.csv              24 SKUs, power-law
│   ├── monthly_revenue.csv          24 months, Nov/Dec season
│   ├── orders_by_hour.csv           7×24 grid
│   ├── delivery_days.csv            161 raw order rows
│   └── kpis.json                    header-row KPIs + spark series
├── live/
│   ├── 01-live-pareto.html          Pareto from CSV, computed 80% guide
│   ├── 02-live-revenue-line.html    line+area from CSV, computed peak
│   ├── 03-live-heatmap.html         weekday×hour heat from CSV
│   ├── 04-live-boxplot.html         quartiles from raw rows, in-browser
│   └── 05-live-kpis.html            KPI cards from JSON
├── story/
│   ├── 01-story-pareto.html         narrated Pareto: scenes, focus, count-ups, theme packs
│   ├── 02-story-three-lies.html     3 chart lies → animated honest reveal → auto-confession
│   ├── 03-story-lead-scorer.html    lead-scorer backtest with devil's-advocate scenes
│   ├── 04-story-cannibal-per100.html    store-impact, board cut: twins + waffle + honest BANs
│   ├── 05-story-cannibal-eventstudy.html store-impact, technical cut: the real 48-month walk
│   ├── 06-story-cannibal-redteam.html   store-impact, adversarial cut: attack → test → verdict
│   ├── 07-story-online-downturn.html    detective cut + dead-end ledger: R3.5m decline solved
│   ├── 08-story-tm2-finance.html        monthly finance report as a told story (engine JSON)
│   ├── 09-story-transitions.html        magic move: one dataset repurposed across five forms + picker
│   └── 10-story-scatter-to-pie.html     the drill as narrative: market → dot → pie → wedge → month → back
├── red-team/
│   ├── overflow-check.html          deterministic scroll/overflow probe (all presets)
│   ├── legibility-check.html        clipped-label + label-on-line probe (demos 76–112)
│   ├── story-check.html             drives every story scene: bounds, label×line, confession
│   └── check.sh                     one-command runner → PASS/FAIL, gating exit code
└── demos/
    ├── 01-mesh-gradient.html         Stripe-style aurora, no WebGL
    ├── 02-drawing-line.html          self-drawing insight line
    ├── 03-pipeline-flow.html         data packets on a pipeline
    ├── 04-gauge-ring.html            animated conic KPI ring
    ├── 05-delta-bars.html            staggered reconciliation bars
    ├── 06-diff-in-diff.html          store-opening causal lift
    ├── 07-commission-slope.html      commission winners & losers
    ├── 08-reconciliation-tieout.html systems tie-out + exception
    ├── 09-migration-estate.html      audit → shed → migrate (Snowflake)
    ├── 10-catalogue-monitor.html     daily feed-health scan + alert
    ├── 11-human-in-the-loop.html     propose → approve → apply → verify
    ├── 12-prospect-radar.html        prospect radar sweep
    ├── 13-dashboards-as-code.html    one template → every merchant brand
    ├── 14-stat-counters.html         StatsBar count-up
    ├── 15-cohort-heatmap.html        retention by cohort
    ├── 16-funnel-dropoff.html        funnel with the leak named
    ├── 17-waterfall-bridge.html      opening → closing cash, steps named
    ├── 18-slopegraph.html            cost per workload, before → after
    ├── 19-bullet-kpis.html           measure · target · last year rows
    ├── 20-sparkline-grid.html        small multiples, shared scale
    ├── 21-bump-ranks.html            category ranks over time
    ├── 22-dumbbell-distance.html     crow-flies vs road distance
    ├── 23-histogram-threshold.html   centroid error + ±20 km line
    ├── 24-jackknife-range.html       leave-one-out robustness
    ├── 25-forecast-fan.html          cash runway, 50/80% bands
    ├── 26-anomaly-timeline.html      metric vs its expected band
    ├── 27-control-chart.html         3σ weather-vs-climate
    ├── 28-sankey-attribution.html    channels → outcomes ribbons
    ├── 29-waffle-per-100.html        per-100-customers person grid
    ├── 30-calendar-heatmap.html      weekday × week order heat
    ├── 31-stacked-area-mix.html      channel-mix shift, calm total
    ├── 32-diverging-bars.html        lift vs give-back from zero
    ├── 33-pareto-8020.html           SKU concentration + 80% guide
    ├── 34-quadrant-scatter.html      effort × impact roadmap
    ├── 35-regression-fit.html        elasticity fit + residuals
    ├── 36-catchment-map.html         abstract dot map, road flips
    ├── 37-effect-error-bars.html     effects + detectability bound
    ├── 38-migration-gantt.html       overlapping delivery phases
    ├── 39-decision-tree.html         pricing EV, chosen path lit
    ├── 40-placebo-test.html          800 nothing-windows vs observed
    ├── 41-likert-diverging.html      survey rows, shared boundary
    ├── 42-step-cumulative.html       recovery in lumps, not curves
    ├── 43-radar-compare.html         maturity month 0 vs month 6
    ├── 44-treemap-mix.html           revenue composition, area-true
    ├── 45-breakeven-lines.html       when the build pays for itself
    ├── 46-box-whisker.html           delivery tails set the SLA
    ├── 47-violin-distribution.html   bimodal channel, mirrored density
    ├── 48-ridgeline-hours.html       the week has two shapes
    ├── 49-beeswarm-effects.html      every rep a dot, nobody averaged
    ├── 50-butterfly-pyramid.html     channel crossover at 45
    ├── 51-marimekko-mix.html         province width × channel split
    ├── 52-sunburst-hierarchy.html    two rings of dash arithmetic
    ├── 53-chord-crossshop.html       bundling rides the fat ribbon
    ├── 54-streamgraph-topics.html    ticket-mix drift, calm total
    ├── 55-horizon-strips.html        six metrics folded into one screen
    ├── 56-hexbin-density.html        599K customers, 37 honest bins
    ├── 57-packed-bubbles.html        area-true share bubbles
    ├── 58-parallel-coordinates.html  store profiles as shapes
    ├── 59-network-map.html           spaghetti → hub and spoke
    ├── 60-dendrogram-clusters.html   8 stores, 3 archetypes, 1 cut
    ├── 61-splom-matrix.html          pairwise panels via transpose
    ├── 62-correlation-matrix.html    diverging heat, values printed
    ├── 63-connected-scatter.html     the elasticity cliff has an address
    ├── 64-candlestick-cash.html      the wick is the risk
    ├── 65-cycle-plot.html            the trend inside the weekday
    ├── 66-bar-race.html              rank 5 → 1, pure CSS
    ├── 67-tile-cartogram.html        SA provinces, equal tiles
    ├── 68-dual-axis-combo.html       revenue bars vs margin line
    ├── 69-table-lens.html            the exec table that stays auditable
    ├── 70-kpi-cards.html             honest BANs with sparklines
    ├── 71-lift-curve.html            baseline drawn first, always
    ├── 72-survival-retention.html    censoring done right
    ├── 73-event-study.html           parallel trends shown, not assumed
    ├── 74-tornado-sensitivity.html   de-risk the top row first
    ├── 75-rose-months.html           the year is a circle
    ├── 76–105 …                      thirty more idioms — fresh spins + new charts (see §11)
    ├── 106–109 …                     the polynomial-fit cluster — the overfit trap, drawn (see §12)
    └── 110–112 …                     the interaction layer — zoomable axes, progressive build, scatter→pie drill
```

---

## 11. The visual editor (`editor.html`) — retune any page

The "back end" of every visual, in one file. Open `editor.html?demo=35-regression-fit`
(or pick from the rail) with the folder **served** — it reads the demo's live DOM through
a same-origin iframe, so `python3 serve.py` first.

It works across **all** demos with *zero per-demo wiring*, because the whole lab shares one
vocabulary (§ tokens): the CSS vars `--accent / --teal / --sky / --amber / --ink-100 /
--navy-950` and the class names `.t / .bar / .pt / .fit / .axis …`. So the editor can retune
any of them generically:

| Channel | What it does | How (so it needs no per-demo code) |
|---|---|---|
| **Type** | scales every label / number / title in place | snapshots each text node's *computed* font-size, writes `base × k` inline (px literals mean a root font-size wouldn't cascade) |
| **Colour** | retints anything using the house tokens | overrides the CSS custom properties on the demo's `<html>` — everything referencing them recolours at once |
| **Line** | data-line weight (axes/grid left alone) | snapshot-multiply on `stroke-width`, excluding `axis/grid/tick/track` classes |
| **Points** | dot / marker size | snapshot-multiply on circle `r` |
| **Shape** | **drag the actual inflection points** — a line's vertices, a scatter's dots, a fit segment's ends | draggable handles in the SVG's own user space (`getScreenCTM().inverse()`); a derived area fill sharing a moved vertex is moved with it, so **fills stay honest** |
| **Export** | *Copy theme* → paste-ready `:root{}`; *Download edited copy* → a self-contained HTML with every edit baked in | serialises the live iframe DOM (inline styles + moved geometry + overridden vars) |

A live **Changes** readout and a *Replay* button keep it honest and quick. Limits: colour
retinting only reaches values that go through the house tokens (the new demos are authored
that way); line/point/shape are SVG-only and degrade gracefully for the HTML/CSS demos
(colour + type still apply).

## The thirty new idioms (76–105) — and when each is *most* useful

Fresh spins on familiar charts, plus idioms the first pass missed. Each maps to a real
Dataplane case; the gallery card carries the same "best for" line. All ≤ **2.3 KB** gzip.

| # | Demo | When it's the right pick |
|---|---|---|
| 76 | Annotated line | Exec narratives where every turning point has a cause to act on |
| 77 | Lollipop ranking | Long ranked lists where full bars feel heavy — the endpoint is the point |
| 78 | Cleveland dot plot | Two states across many categories, where the *change* is the message |
| 79 | Radial bars | Cyclical categories (hours, months, compass) or a punchy hero |
| 80 | Range bars | Categories where *spread* matters as much as the centre — SLAs, price ranges |
| 81 | Mix-shift ribbons | How a composition redistributed between two moments |
| 82 | Variance bridge | Explaining a gap between two totals — budget vs actual, plan vs forecast |
| 83 | Icicle hierarchy | A strict part-of-whole read as levels, not a sunburst's wedges |
| 84 | Density strip | One variable's shape, compactly, on a tight dashboard (mean-vs-median skew) |
| 85 | Q–Q plot | Checking a normality assumption before trusting a model or t-test |
| 86 | ECDF + percentiles | SLA / percentile questions — P50 / P90, "what share is below X" |
| 87 | Bland–Altman | Agreement between two systems measuring the same thing (SAP vs bank) |
| 88 | Burndown | Tracking delivery against a plan — sprints, migrations, closes |
| 89 | Cumulative flow | Flow systems — a widening band is the bottleneck (pipelines, queues) |
| 90 | Punchcard | Patterns across two cyclical keys where magnitude varies a lot |
| 91 | Uptime ribbon | A compact history of a mostly-binary daily state — uptime, SLA, backups |
| 92 | Deviation area | Emphasising departures from a reference — vs budget, vs seasonal norm |
| 93 | Arc diagram | Pairwise relationships over an ordered / small set of nodes |
| 94 | Adjacency matrix | Dense pairwise data where a node-link view becomes a hairball |
| 95 | Marginal scatter | When the relationship *and* each variable's own shape both matter |
| 96 | Proportional symbols | Mapping a count across regions where area would mislead (vs choropleth) |
| 97 | Decision matrix | A defensible multi-criteria choice — vendor, tool, prioritisation |
| 98 | Cost curve | Prioritising a portfolio of actions by cost *and* size at once |
| 99 | Forest plot | Comparing an effect across subgroups without cherry-picking |
| 100 | Uplift + CI | Experiment / incrementality results — "is it beyond chance?" |
| 101 | Cash runway | Any "how long have we got?" — cash, inventory cover, headroom |
| 102 | FX exposure | A split-by-attribute that carries a risk reading — currency, fixed/variable |
| 103 | Tax ladder | Any tiered / threshold schedule people confuse marginal with average |
| 104 | Completeness grid | Data-quality review before building on a source (the "profile first" habit) |
| 105 | Freshness dots | Monitoring recency across many feeds — pipelines, dashboards, backups |

Three of these (76, 92, 101) are line + area sharing exact vertices, so they double as the
editor's *drag-the-curve* demos — move a point and the fill follows.

## 12. The polynomial-fit cluster (106–109) — the overfit trap, drawn honestly

Four idioms built around one lesson from the standing methodology: *a curve that hugs
every dot is not a better model — benchmark it, band it, diagnose it, and never read it
past its data.* The geometry is real: dots and fitted curves come from `numpy.polyfit`
(build script `geom.py`, seeded), not hand-drawn — the degree-9 fit genuinely has **7
turning points** to degree-3's **1**, and the confidence band shares the fit's *exact*
x-vertices (upper offset forward, lower back), so the fill can't drift off the curve.

| # | Demo | When it's the right pick |
|---|---|---|
| 106 | Degree ladder | Showing why a higher-degree fit that hugs the data isn't better — the overfit made visible (deg 1 / 3 / 9 on one scatter) |
| 107 | Fit with confidence band | Showing a fitted curve *with* how sure you are of it — the band fans out where the dots thin |
| 108 | Residuals vs fitted | The diagnostic a fit must survive — a ∪ in the residuals is the curvature a straight line missed |
| 109 | Extrapolation cliff | The warning against reading a fit past its data — same cubic, sane inside its support, absurd one step out |

All four gzip to 2.1–2.8 KB, ship a correct reduced-motion end-state, and pass all three
red-team gates (overflow, legibility, story). The legibility gate caught two real breaks
on them pre-ship — a clipped legend label and an axis label lying on the extrapolation
tail — both fixed before they shipped.

**Red-team verdicts** (assertions tested, not asserted — per the standing methodology):

- *106 "the fit that hugs every dot predicts the next one worst"* — **SURVIVES.**
  Leave-one-out CV RMSE: deg 1 = 0.076, deg 3 = 0.079, deg 9 = **0.946** (12× worse than
  the line), while *in-sample* RMSE falls monotonically (0.068 → 0.057 → 0.040). Best
  in-sample = worst out-of-sample: the overfit signature, confirmed on this exact data.
- *107 "95% band"* — was **DOWNGRADED, then fixed.** The first draft used a hand-shaped
  envelope labelled "95%", which reached 0.145 at the edges — ~2× wider than the *real*
  95% confidence band (0.036 mid → 0.069 edge, 1.91× fan). Now computed properly as
  `t·σ·√(v'(XᵀX)⁻¹v)` and the label is precise: it's the CI for the fitted line (13/18
  points fall outside it, as a mean-band should), not a point-coverage band. False
  precision withdrawn and reworded in the demo, its subtitle, and the gallery card.

---

## Sources

- [How To: Create the Stripe Website Gradient Effect — Kevin Hufnagl](https://kevinhufnagl.com/how-to-stripe-website-gradient-effect/) (MiniGL / shader breakdown)
- [How to create the Stripe Website Gradient Effect — Bram.us](https://www.bram.us/2021/10/13/how-to-create-the-stripe-website-gradient-effect/)
- [How SVG Line Animation Works — CSS-Tricks](https://css-tricks.com/svg-line-animation-works/)
- [`pathLength` makes SVG path animations easier — Stefan Judis](https://www.stefanjudis.com/today-i-learned/pathlength-makes-makes-svg-path-animations-easier-to-manage/)
- MDN: [`offset-path`](https://developer.mozilla.org/en-US/docs/Web/CSS/offset-path), [`@property`](https://developer.mozilla.org/en-US/docs/Web/CSS/@property), [`feTurbulence`](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/feTurbulence)
