Releases: evompmi/plottr
First public release
Full Changelog: v1.6.0...v1.6.1
Ascension
Full Changelog: v1.5.3...v1.6.0
Baptism
Full Changelog: v1.5.2...v1.5.3
Legwork
Full Changelog: v1.5.1...v1.5.2
v1.5.1 Comments
Full Changelog: v1.5.0...v1.5.1
1.5.0 Twix
Full Changelog: v1.4.2...v1.5.0
v1.4.2 — Paste
Plöttr v1.4.2 "Paste" — release notes
Released 2026-05-11.
A wet-lab-flow release: every plot tool's upload step is rebuilt around three
ideas that travel together. (a) Paste data from clipboard — the new
"Paste data" card sits side-by-side with "Drop a file" so users working out
of Excel or Sheets don't have to Save-As-CSV before they can chart anything.
(b) Auto-detected separator — the upfront Column-Separator picker, which
used to gate the drop zone behind a 🚫 icon until the user picked one,
collapses behind a small "Override ▾" disclosure; parseRaw already routed
through autoDetectSep so this was UI friction with no parser cost.
(c) Prominent sample dataset — the "Try sample data" affordance promotes
from a tiny secondary button buried beneath the upload zone into a green
banner at the top of the step with a hand-drawn <DatasheetIcon />,
dataset title + subtitle, and a primary CTA. UpSet drops its upfront
Wide/Long picker too (auto-detected from column shape now). Group Plot's
Y-axis label defaults to the value column's name instead of "Value". A
handful of polish passes round out the visual hierarchy on the new banner.
✨ Added
<DetectedSeparatorBadge sep={detectedSep} /> surfaces the auto-detected
delimiter on every plot tool's post-upload step. Inline trailing fragment
(· detected: tab-separated) on each tool's file-info line — Configure step
for tools that have one (Aequorin, Group Plot, Heatmap, Line Plot, UpSet,
Venn, Volcano) and the top of the Plot step for Scatter (which skips
Configure). The badge renders nothing when the detector hits the empty-string
whitespace fallback, so it stays out of the way on genuinely ambiguous
input. Maps "," → "comma", ";" → "semicolon", "\t" → "tab", " " →
"space", anything else → "whitespace". Component + describeSeparator()
helper live in tools/_shell/DetectedSeparatorBadge.tsx.
<DatasheetIcon /> — single shared hand-drawn notepad icon for the new
sample-dataset banner. Dog-eared page outline with four hand-drawn rows
(the last deliberately short, so the sketch reads as "spreadsheet in
progress" rather than printed table). stroke="currentColor" so the icon
inherits the banner's --success-text colour and themes itself in dark
mode automatically. Rendered at 36 px / 70 % opacity so it supports the
title without competing for visual centre. Lives in
tools/_shell/DatasheetIcon.tsx; the previous per-tool emoji / TOOL_ICONS
treatments are both retired (emoji read as playful; the per-tool art
duplicated the topbar badge).
Paste-from-clipboard ingest path on every plot tool. The "Paste data"
card sits next to "Drop a file" as a side-by-side card — no toggle to
discover, both are first-class. Behind the scenes a 160-px-min <textarea>
feeds the same doParse pipeline as a dropped file (forces sepOverride=""
so auto-detect resolves the delimiter from the pasted bytes), gated on the
same FILE_LIMIT_BYTES (2 MB) policy via new Blob([text]).size. "Parse
pasted data" and "Clear" buttons inline below the textarea; the same red /
warning banners surface for over-limit or empty input. Closes the
"Save-As-CSV-first" tax for users working in Excel or Sheets, which is the
default flow on benchtops the tool was built for.
Prominent sample-dataset banner at the top of every upload step. A
green --success-bg panel with the new datasheet icon, a "NEW HERE? QUICK
START" tag, dataset title + subtitle, and a primary "Plot this example →"
CTA. Replaces the legacy Try sample data: [tiny secondary button] line
that became invisible once Drop + Paste landed side-by-side. Per-tool
copy: plant biomass under drought × salt (Group Plot), aequorin Ca²⁺
time-course mutant vs WT (Aequorin), gene-expression matrix 500 × 6
(Heatmap), bacterial growth curves 3 × 5 × 3 (Line Plot), Fisher's Iris
(Scatter), Arabidopsis stress-response DEGs in 3 sets (Venn) and 5 sets
(UpSet), mock DESeq2 results 200 features (Volcano). Powered by a new
typed ExampleSummary payload on UploadPanel — { title, subtitle?, icon?, buttonLabel? }.
🔧 Changed
The separator picker is no longer required upfront. The drop zone is
enabled immediately; the dropdown collapses behind an "Override ▾"
disclosure in the auto-detect info banner. Plöttr's parser already routed
every parse through autoDetectSep (which scores comma / tab / semicolon
by per-line consistency, not raw count) — the legacy gate was UI friction
with no functional rationale. Override remains available for the rare
case where the detector picks the wrong delimiter; the dropdown's
"Auto-detect" option is now the default. Behind a new opt-in
autoDetect prop on UploadPanel so the 7-tools-at-once rollout could
be reviewed on Group Plot first; every plot tool now sets it.
UpSet retired its upfront Wide/Long data-format picker. The toggle was
vestigial: 3+ column input is unambiguously wide (one column per set), and
2-column input runs through the same detectLongFormat heuristic Venn has
used since the audit-M2 pass (≥ 3 col-2 rows, 2–20 distinct values, ≥ 50 %
col-2 repetition, ≥ 70 % col-1 uniqueness). The helper moved from
tools/venn/long-format-detect.ts to tools/_shell/long-format-detect.ts
so both tools share the same source of truth; Venn keeps the re-export so
its test loader and existing 13 detection tests stay unchanged. UpSet's
doParse runs the detector on 2-column input and flips format to
"long" automatically; the cross-tool handoff from Venn still respects
an explicit format field on the incoming payload.
Group Plot Y-axis label defaults to the selected value column's name.
Was: hard-coded "Value" placeholder until the user manually edited the
Label control tile. Now: a small useEffect watches the selected value
column and syncs vis.yLabel to the column's current name. Picking
Biomass_mg in Configure makes the Y axis read Biomass_mg without a
trip to the Label tile; renames in the Configure step's "Display as"
field flow through too. "Auto mode" is detected by comparing the current
yLabel against three sentinels (empty string, VIS_INIT_BOXPLOT.yLabel
placeholder, the previously-auto-applied name kept in a ref) — once the
user types anything custom, the value diverges from all three and the
effect stops touching it, so customisations survive subsequent column
picks and renames. Clearing the input back to empty re-enters auto mode.
How-to card now leads with the expand/collapse chevron instead of the
tool icon. The topbar + the sample-dataset banner already carry the
tool icon on the upload step; the third repetition in the How-to header
was redundant. Moving the chevron from the trailing slot to the leading
slot also reads more directly as "click to expand" — a single affordance
instead of icon + chevron. toolName stays on HowToCard props because
it still drives the open-state localStorage key (dv-howto-<toolName>).
Topbar tool icon a touch bigger (22 → 28 px). The per-tool art next to
the H1 title now slightly exceeds the 22-pt cap-height, reinforcing
"which tool am I in" without inflating the header block. Title font-size
stays at 22.
Benchmark page reorders the two reference panels so each summary sits
next to its own collapsible. Was: R summary → SciPy summary → SciPy
collapsible → R per-category tables. Now: SciPy summary → SciPy
collapsible → R summary → R per-category tables, so each reference's
headline number reads alongside its own breakdown instead of
interleaving. Edit lives in benchmark/run.js; benchmark.html
regenerated.
🐛 Fixed
Override disclosure no longer pops open after loading the sample
dataset and navigating back to the upload step. Each tool's
loadExample (and Group Plot's cross-tool handoff effect) was carrying
a vestige from the pre-autoDetect era: setSepOverride(",") /
setSepOverride("\t") was needed back when the separator picker had to
be unlocked manually to enable the drop zone. Auto-detect makes that
unnecessary — but AutoDetectUploadPanel's mount-time
useState(sepOverride !== "") initializer read any non-empty value as
"user explicitly picked an override" and popped the disclosure open with
that separator forced. Every loader now passes "" and relies on
autoDetectSep to resolve the delimiter from the data; only the UI-state
leak got closed. Two regression tests pin the closed-vs-open semantics
("disclosure closed when sepOverride is empty", "disclosure open only
when user-set").
Landing-page "How it works" step 1 now reads "Upload CSV". The pill
said "Paste CSV" but no plot tool exposed a paste textarea at the time —
the only ingest surface was FileDropZone (drag-drop or file picker).
The copy was misleading from day one. Now that paste actually exists,
the landing copy is honest either way; left as "Upload CSV" so the pill
covers both file and paste paths under one verb.
Scatter regression r² now stays inside [0, 1]. The two-pass formula
(n·Σxy − Σx·Σy)² / (denomX · denomY) suffers catastrophic cancellation
when either denominator collapses to FP-noise scale (x-values 1e-160
apart, y-values at subnormal magnitudes), which lifted the raw ratio to
~1.04 or pushed it slightly negative on pathological inputs. Clamping
the FP overshoot in tools/scatter/helpers.ts restores the mathematical
invariant the property test pins. Surfaced by a fast-check seed on CI;
real-data regressions stay well inside the interval and are unaffected.
Boxplot quartile property tolerates 1-ULP rounding near the edges of
its arbitrary's range. The quartiles satisfies q1 ≤ med ≤ q3 property
failed on a fast-check seed that generated
[999.9999999999993, 999.9999999999994] — two doubles 1 ULP apart at
the upper end of the [-1000, 1000] range. Linear interpolation between
them at 25 / 50 / 75 percentiles produces deltas below the ULP of the
operands themselves; rounding directions can disagree and break
monotonicity by an amount that is numerically...
v1.4.1 — Lazy Loading
A patch release built around three concerns:
- Export attribution mark — every SVG (and the PNG rasterised from it) now carries a small italic
Plöttr v1.4.1stamp in a reserved 14 px bottom band, wrapped in<g id="plottr-attribution" data-plottr-version="…">so journals that require unbranded figures can strip it with one selector. Plot area, axes and existing margins stay pixel-identical — only the canvas grows downward. - Lazy SPA bundles — first-visit download shrinks ~60–95 % depending on route. Each tool's
Appis now wrapped inReact.lazy(() => import("..."))and esbuild's--splittingflag emits one chunk per tool. A calculator-only first visit drops from ~1042 KB to ~403 KB. Two failure-mode fixes shipped alongside: dynamic-import retry with backoff, and a 6 s Suspense escape hatch with a Reload-page button. - Accessibility sweep — chart-level
role="img"+aria-label+<title>+<desc>on heatmap and volcano (the two that were missing them), per-elementaria-labels on lineplot / aequorin / upset / scatter, site-wideprefers-reduced-motionhonouring, volcano NS default bumped to#737373(3.27:1 vs the previous 2.85:1), and a newnpm run audit:contrastscript that locks the palette catalogues against the same bar.
Plus two Power Analysis chrome fixes (full-width question banner, gap-10 alignment with the rest of the app) and a latent test-loader bug.
Long-form release notes: docs/release-notes/v1.4.1.md.
Heavy Refactor
Full Changelog: v1.3.0...v1.4.0
v1.3.0 — SPA shell, Welch by default, SciPy cross-check
Plöttr v1.3.0 — release notes
Released 2026-05-05.
This is the first release after a deliberate "look-back-and-fix" cycle driven
by an internal harsh review of v1.2.0. The headline is a full architectural
rewrite of the page shell (iframe → SPA), but the cycle also lands two
substantive methodological wins (Welch by default in selectTest, SciPy
cross-check for the noncentral distributions and qtukey), a complete test
infrastructure swap (Vitest + real React 18 + happy-dom), and a long tail of
UX, theming, and refactor work.
The prose below is the long-form context — what changed, why, and how — that
the in-cycle CHANGELOG entries grew to hold. The CHANGELOG itself now keeps
one bullet per item with a link back here so the file stays scannable.
🏗 iframe shell replaced by a single-page app
Pre-SPA, index.html (1303 lines) preloaded ten hidden iframes — one per
tool, each booting its own copy of vendored React + ReactDOM +
tools/shared.bundle.js + a per-tool bundle. Theme syncs went through three
overlapping paths (storage event + BroadcastChannel + postMessage),
and a 150-line frame-buster snippet was kept byte-identical across 11 HTML
files by a dedicated scripts/anti-clickjack-sync.js. The whole shell is
now gone:
- Ten
tools/<tool>.htmlfiles deleted. URL break for any external
bookmark to those paths is the explicit trade-off. index.htmlshrunk 1303 → 1095 lines: ten iframe blocks removed,
prefetch IIFE + XHR removed,openTool/goBack/
cross-iframe-postMessage listener / topbar builder / per-iframe theme
propagation all removed. New body: one<div id="root">, the SPA
bundle, and a 12-line "landing ↔ tool view toggle" IIFE that flips
data-spa-route="active"on<html>based onlocation.hash.tools/_app/(new) holds the SPA shell: hand-rolled hash router
(useSyncExternalStoreoverlocation.hash), top-levelApp.tsx
with shared topbar, single ReactDOM mount. Every tool route is
/#/<tool>(e.g./#/boxplot).- Each tool's old
index.tsx(App component + standalone mount) was
split intoapp.tsx(exportsApp, imported by the SPA registry) +
a tinyindex.tsxmount entry, then the mount entry was deleted
alongside the iframe shell. Now every plot tool ships only its
app.tsxplus chart / controls / steps / helpers / reports / howto /
plot-area / stats-panel siblings. Calculators (tools/molarity.tsx,
tools/power.tsx) becametools/molarity-app.tsx+
tools/power-app.tsxfor the same reason. tools/theme.jslost themessageevent listener that accepted
iframe-parent theme pushes. The cross-tab paths (BroadcastChannel +
storage event + matchMedia) stay.tools/shared-handoff.jsgains anavigateToTool(toolKey)helper.
When the SPA shell has registeredwindow.__plottrSpaNavigate, it
switches in place; otherwise falls back to a top-level reload. The
Aequorin → Boxplot button and the Venn → UpSet "Open in UpSet"
nudge both use it now; the pre-existing sibling-iframe postMessage
routing is gone.scripts/anti-clickjack-sync.jsdeleted — only one HTML needs
the frame-buster now, and that snippet lives inline inindex.html.
tests/anti-clickjack.test.jsdeleted with it.scripts/vendor-sri.jssimplified from "walk everytools/*.html"
to "walk a one-element list of HTML pages that load vendored React".package.jsonesbuild entry list collapsed from ten entries to a
single--outfile-based entry producingtools/_app/index.js
(~609 KB minified, every tool inlined).lint:anti-clickjack
script removed.scripts/watch.jsupdated to single entry.- Test infrastructure:
tests/helpers/render-loader.js'sloadTool
no longer reads precompiled per-tool bundles (those don't exist
anymore); it bundles each tool'sapp.tsxin-memory via
esbuild.buildSyncwithformat=iifeand caches the result.
tests/power.test.jsdoes the same fortools/power-app.tsx.
tests/vendor-sri.test.jsfixtures updated to match the new SRI
script path scope. - All ten e2e specs gained a single mechanical edit:
page.goto("/tools/<tool>.html")→
page.goto("/index.html#/<tool>"). Selectors unchanged. - CLAUDE.md "Tool structure" + "Theming" sections rewritten to
reflect the new shape; ~80 lines simplified or deleted.
Net effect: ~500–800 lines of architectural plumbing gone (iframe
propagation, theme sync glue, anti-clickjack-sync.js in full, prefetch
bar, openTool postMessage, per-iframe React copies). React loads once
per session instead of up to eleven times. Tool switching is instant
(route swap, not a fresh React boot inside an iframe).
🪟 Tab-style state preservation across tool switches
The first shipped SPA dropped every piece of in-memory state on every
route change — parsed CSV, computed charts, configured panels — because
the router unmounted the previous tool's React tree on each hop. The
shell now keeps every visited tool mounted (hidden via display:none),
so navigating aequorin → boxplot → aequorin returns to the original
aequorin state instead of remounting fresh. Mount-on-demand is preserved
— a tool you never visit never boots, so the cold-start cost is paid
only when needed.
Same-tab cross-tool hand-off (RLU's Σ → Group Plot, Venn → UpSet) needed
a small companion change: with the destination tool already mounted under
keep-alive, its mount-time consumeHandoff() has long since returned,
and the browser storage event only fires for writes from other
tabs/iframes. setHandoff() and the Venn → UpSet path now both dispatch
a synchronous plottr-handoff CustomEvent right after the storage write;
boxplot and upset listen for it alongside their existing mount-time and
storage listeners.
🧪 Test runner migrated to Vitest 3.x with real React 18 + happy-dom
Two changes that landed together:
Vitest migration. The 24 tests/*.test.js files were unchanged —
tests/harness.js shrank from a 56-line bespoke suite() / test() / assert() / eq() / approx() / throws() / summary() runner into a ~50-line
compat shim that delegates each test() call to globalThis.test
(Vitest's injected global, made available by globals: true in the new
vitest.config.js). The project keeps its house vocabulary; Vitest gets
to schedule, time, diff, and report.
New scripts: npm run test:watch (Vitest watch mode for local
development), npm run test:coverage. New wrapper scripts/run-vitest.js
tees stdout/stderr to .test-output.log so the posttest badge-bumper
still picks up the canonical count (parses Tests N passed (N) from
Vitest's verbose reporter; legacy per-suite X/X passed format kept as
a fallback). Wall-clock runtime: ~13 s under Vitest's parallel-by-file
scheduling vs. ~3 min under the prior sequential node tests/x.test.js
chain. Per-test timeout set to 30 s in the config to accommodate the
slow stats cross-validations (deep-tail cpsets, qtukey at small df).
Real React 18 + happy-dom for component render-smoke tests. The
354-line bespoke functional-React mock under tests/helpers/render-loader.js
(custom createElement returning {type, props, children} element-tree
objects, hand-rolled useState / useReducer / useMemo / useEffect /
useContext simulators with effect flushing) is retired. The new helper
is ~140 lines that delegate to the real react@^18.3.1,
react-dom/server, and react-dom/client packages. Component testing
API: renderHtml(Component, props) for synchronous static HTML via
renderToStaticMarkup (the right tool for 90% of smoke-render
assertions), renderWithEffects(Component, props) for the small block
of tests that need useEffect / useLayoutEffect to actually fire
(mounts through react-dom/client.createRoot + happy-dom + act).
tests/components.test.js rewritten to assert on DOM / HTML directly —
no more el.type === "div", JSON.stringify(el).indexOf("X"), or
countElements(el) > N.
react@^18.3.1 (matching the vendored runtime), react-dom@^18.3.1,
happy-dom@^20.9.0, and vitest@^3.2.4 added as devDependencies.
📐 Statistical methodology — Welch by default
selectTest in tools/stats.js now recommends Welch's t (k = 2) or
Welch's ANOVA + Games-Howell (k ≥ 3) regardless of the Shapiro-Wilk /
Levene outcomes. The previous gated rule tree (SW p < α →
Mann-Whitney / Kruskal-Wallis; Levene p < α → Welch; else Student /
one-way ANOVA) is replaced because pre-screening for normality with SW
and routing on the result is a known Type I-error-inflating anti-pattern
(Schucany & Ng 2006; Zimmerman 2004; Rasch, Kubinger & Moder 2011;
Delacre et al. 2019).
SW + Levene are still computed and shown in the decision trace; when SW
flags non-normal data the trace adds a suggestion field pointing at
Mann-Whitney / Kruskal-Wallis, but the recommendation itself stays Welch.
The user picks any of {Student t, Welch t, Mann-Whitney} or {one-way
ANOVA, Welch ANOVA, Kruskal-Wallis} from the stats panel's per-test
dropdown if they want a different test.
The reason text on every recommendation now spells out (a) what was
picked, (b) what the diagnostics found, (c) why Welch is the default,
and (d) where to override. Equal-variance / normal data still get a
Welch result that matches Student / one-way ANOVA closely (Welch is
conservative, not different); unequal-variance data are now correct by
default instead of routed away from the issue. R-script export tracks
whatever test was actually run, so reproducibility is unchanged.
The recommendation.suggestion field (when SW flags non-normality) is
now rendered as a themed --info-bg info banner in every stats panel
(boxplot, RLU timecourse, lineplot, the legacy shared StatsTile):
"Suggested alternative — Shapiro-Wilk flagged non-normal data, consider
Mann-Whitney U. [Use suggestion]". Clicking Use suggestion flips
the per-set test override the same way the dropdown would; the banner
disappears...