A browser-based panel for formatting matplotlib figures that emits ordinary matplotlib code. Framework-free Web Component in TypeScript, with a small Python helper shipped inside the JS bundle. Built first for Trinket (the PICUP open-source fork, a Pyodide environment for physics students), but it depends on nothing Trinket-specific: a JupyterLab extension or a standalone Pyodide page adopts it by writing two small adapters.
Status: v0.3.0, released and working. The panel is complete for rcParams, and the first host adapter — a "Plot style" panel in the PICUP Trinket fork's Pyodide embed — is written and verified against a running Trinket on the main thread, the Web Worker path and the step-through recorder. That adapter is not merged upstream yet. See docs/trinket-integration.md.
Every release is built by a tagged GitHub Actions workflow that attaches
plotpolish.iife.js and its sha256; the published asset is byte-for-byte
reproducible from a local npm run build.
License: BSD-3-Clause. No monetization, no telemetry, no nag UI.
-
The tool never edits the user's own lines. It owns exactly one fenced, regenerated block and rewrites that block wholesale. There is no artist-to-source mapping and no AST round-trip. (pylustrator uses the same idea; it is GPL and Qt-only, so we read it for design and never copy code.)
-
The panel is over rcParams only. Per-artist editing is a later phase. The block looks like this and goes at the top of the file, after any
from __future__line, because rcParams are read when artists are created:# --- plot style (generated by plotpolish; edit above, not here) --- import matplotlib as mpl mpl.style.use("seaborn-v0_8-whitegrid") mpl.rcParams.update({ "font.size": 12, "axes.grid": True, "axes.spines.top": False, "axes.spines.right": False, }) # --- end plot style ---
The fenced text is the only persistence: it parses back into panel state on load. Regenerating replaces the existing fence in place. A second fence in the file is an error the panel shows, never a silent merge. The
mpl.style.useline appears only for a named style, and a fully default state removes the block rather than writing an empty one. -
Two tiny adapter interfaces. Hosts supply their own; the package ships the interfaces plus reference implementations.
FigureBackend—runPython(code: string): Promise<string>returning JSON. Used to listplt.style.available, introspect the live figure, and apply artist-level equivalents for live preview. The host owns the interpreter.CodeSink—getSource(): string | nullandsetSource(s: string). Reference sinks: in-memory (tests) and clipboard (copies the block and shows it in a selectable textarea, because the clipboard API can fail).
-
Live preview is split honestly. Of the thirty controls, twenty-four have artist-level equivalents and apply to the retained figure the moment they change. The style sheet is rcParams-only, needs a host re-run, and says so. The five under Save act on what leaves the tool — the PNG you save, the code you copy — not on what is on screen. The panel header says, in one line, that the user's own code always wins over these defaults.
That live preview shows what a re-run would draw is not a claim, it is a test.
python/tests/test_live_matches_rerun.pyruns the student's program with the settings applied the way the panel applies them, then runs the generated block plus the same program in a clean interpreter, and compares the two renders pixel for pixel. The reference is computed rather than stored, so there are no golden images to refresh and no cross-version tolerance to tune. The 58 cases are generated fromcontrols.json, so a new control arrives with a case; a case that changes no pixel is rejected as proving nothing. -
Framework-free. A custom element, no React/Vue. Scoped styles; light and dark theme via CSS custom properties the host can override. Nothing fetches remote assets at runtime — the Python helper ships as a string inside the bundle — so it works under a strict CSP.
-
Curated controls (thirty, not the whole of rcParams), in six categories. Look: the color cycle, with colorblind-safe presets (Okabe-Ito, Tol bright); style preset. Text: font size and family, title/label/tick/legend sizes, fit-labels. Lines: width, style, marker style and size, and a per-line table of color, width and style. Axes: grid with its opacity and line style, minor grid, box, axes line width, tick direction, minor ticks. Legend: position, frame, opacity. Save: save PNG, dpi, transparent background, crop to content, copy code. Plus a reset for each category and one for everything. Never reachable from this panel: axis labels, titles, limits, scale, annotations, and the color of a series the student named in their own code — the per-line table addresses cycle positions (the 1st line drawn, the 2nd), not series. Those are the user's code.
See docs/design.md for the reasoning behind the implementation choices (why the generator is in TypeScript, how live preview decides what to touch, the helper transport). docs/trinket-integration.md is the plan for the first host: what Trinket's adapter has to do, with the survey findings it rests on. docs/ux-design.md explains the panel's layout: six category tabs that sit in matplotlib's own figure toolbar and open a small draggable popover, with a "More" expander, so a student sees a handful of knobs at a time and the plot stays in view.
src/ TypeScript: <plotpolish-panel>, block generator/parser,
FigureBackend/CodeSink interfaces, reference adapters
src/schema/ controls.json — the single source of truth for controls
python/plotpolish/ Python helper (importable module; core.py is inlined
into the JS bundle verbatim)
python/tests/ pytest, run against matplotlib 3.8 and 3.10 in CI
demo/ Standalone page using the Pyodide reference adapter
docs/ Design notes
.github/ CI (pytest on both matplotlib versions, typecheck,
vitest, build) and the tagged release workflow
npm install # TypeScript side
npm test # Vitest
npm run build # library bundle to dist/
npm run demo # Vite dev server for demo/ (loads Pyodide from the CDN — large download)Python side, using uv:
uv venv .venv310 --python 3.13 && uv pip install --python .venv310/bin/python -e "python[test]" "matplotlib>=3.10,<3.11"
.venv310/bin/python -m pytest python/testsSwap in matplotlib==3.8.4 on a Python ≤ 3.12 for the other supported version.
import { MemorySink, PyodideBackend } from "plotpolish"; // registers <plotpolish-panel>
const panel = document.querySelector("plotpolish-panel")!;
panel.sink = new MemorySink(editor.getValue(), (s) => editor.setValue(s));
panel.backend = new PyodideBackend(pyodide); // host already loaded pyodide
panel.addEventListener("plotpolish-rerun-needed", () => showRerunHint());
panel.addEventListener("plotpolish-change", (e) => console.log(e.detail.block));
runButton.onclick = async () => {
const ran = panel.sink!.getSource(); // what this run will execute
await runUserCode();
await panel.refresh(ran);
};-
Sink.
getSource()/setSource()over your editor buffer. If your sink implementssubscribe(listener), the panel re-parses the fence when the user edits.getSource()may returnnullfor a write-only sink (ClipboardSink); thensetSource()receives just the block. -
Backend.
runPython(code)must runcodein a throwaway namespace and resolve with the value of the snippet's last expression as a string. If your host captures stdout instead, appendprint(__plotpolish_result__)first. Each snippet carries the whole helper module, so nothing has to be installed into the interpreter. -
After every run, call
panel.refresh()so the panel re-reads the style list, the effective rcParams and the live figure (and clears the "re-run to see" hint). Pass the source the run executed, read when the run started:panel.refresh(ranSource). Without it the panel assumes the run drew the current block, so a change the student made while the program was running is marked as applied when it was not. With it, those changes stay marked. On a host with live preview, rc changes are applied now; a style change still needs a run, as it always does, and stays marked. -
Live preview.
apply_livecallsfig.canvas.draw_idle(). Hosts whose figure transport needs pumping (a worker with Agg plus a hand-rolled webagg_core bridge, say) should trigger their redraw onplotpolish-change. -
Events. Seven, all bubbling and composed so a host can listen on an ancestor:
plotpolish-change(detail.blockis the fenced block,detail.sourcethe whole file after the write),plotpolish-rerun-needed,plotpolish-auto-update(the student switched the figure's auto-update on or off),plotpolish-error(whosedetail.stallis"busy"or"loading"when the host merely declined for now, so a host need not surface a transient refusal as an error;nullmeans only "not a transient refusal", which also covers failures that never reached the backend -- readdetail.contextto tell those apart), andplotpolish-saved.plotpolish-savedis cancelable: a host that cannot let a page trigger a download — a sandboxed iframe, which is where this actually runs — callspreventDefault()and deliversdetail.data(base64 PNG) its own way. The sixth isplotpolish-rerun-requested(detail.keysis the set of rcParams the student has changed since the last run) — seecanRerunbelow.The seventh is
plotpolish-save-requested(detail.format,detail.filename), fired when the student clicks Save PNG and the panel has no backend at all — the state a host puts it in by never settingpanel.backend, which is what a worker runtime does, since the program runs off the main thread and there is nothing on the page to call into. The panel cannot produce the file there, so it asks the host, which has the figure. It is cancelable, andpreventDefault()is the contract: call it and the panel reports "Saved"; leave it alone and the panel tells the student saving is not available here. UnlikecanRerun, this needs no feature flag — a cancelable event tells the panel by itself whether anyone was listening. -
panel.features:{ livePreview, showCode, groups, staleNotice, canRerun }. The last two are optional; all five can be set individually, since the setter merges aPartial. SetlivePreview: falseto disable backend calls on control changes. -
When the host can never preview. With
livePreview: falsethe panel shows a "Re-run to update plot." notice — a chip in the slot the auto-update switch vacates, and the full sentence above the controls in the popover — so a slider that cannot move the figure yet does not read as broken. It appears whenever there is a sink to write to and no fence error — including a write-only sink such asClipboardSink, where the student's workflow is paste-then-run and the advice is incomplete rather than wrong. It stays away with no sink at all, and while a malformed fence is stopping the panel from writing, because a re-run would then pick up nothing.Distinct from that, and gated more tightly: the "Your settings are saved in your code — run your program again to see them." clause appended to a stall or backend error. That is a factual claim about where the block is, not advice, so it appears only when the block has actually landed in a readable source — never for a write-only sink, never during a fence error, and never before the first write.
staleNotice:{ glyph, word, sentence }, any subset, to re-tune the wording or match your own Run button's glyph without waiting on a plotpolish release. All three are plain text — the panel lives in a shadow root, so a host's icon markup (<i class="fa fa-play">) renders as an empty element, and a Unicode glyph needs no stylesheet.canRerun: defaultfalse. Set ittrueonly if you are listening forplotpolish-rerun-requested, and the notice becomes a button that emits it; the host triggers its own run. The panel stays exactly as it is after the click — it cannot know whether you honored the request — and stands the button down when your nextpanel.refresh()lands. Leftfalse, the notice is an inert sentence, which is the safe default: a control labeled "Re-run" that does nothing is worse than no control.
-
Theme:
theme="light"|"dark"attribute, or leave unset to followprefers-color-scheme. Override--sf-bg,--sf-fg,--sf-accent,--sf-border,--sf-muted,--sf-fontand friends on the element.
When the user changes the style dropdown, the panel calls the helper's
set_style(), which resets the interpreter's rcParams to library defaults
and applies the new style between runs, so a previously applied style
cannot leak into the next run. List the rc keys your host owns in
panel.hostRcKeys: the reset preserves them, and so does the block. A style
sheet may set the very keys you set — 8 of matplotlib's 29 styles set
figure.figsize — and because the block runs after your per-run setup,
mpl.style.use would otherwise discard your value on every re-run. With
hostRcKeys set, a block with a named style saves those keys, applies the
style and puts them back, before its own rcParams.update so a key the
student set still wins.
Hosts with no bundler — Trinket, say, whose strict Content-Security-Policy only allows scripts from its own origin — can load a single self-contained IIFE build instead of the ES module above:
<script src="/vendor/plotpolish.iife.js"></script>
<script>
const { MemorySink, PyodideBackend } = window.plotpolish;
</script>Serve plotpolish.iife.js from the host's own origin. Nothing in it fetches
anything at runtime — the Python helper is inlined the same as in the ES
build — so it works unmodified under a CSP that allows only 'self'.
npm run demoOpen the printed URL with ?backend=mock for an offline mode that exercises
the panel and the fence without Python. Without the query string, the page
offers to load Pyodide 0.28.1 plus matplotlib from the jsDelivr CDN after you
click Run — that is a download of tens of MB, so it never starts by itself.
The real mode has been exercised end to end against Pyodide 0.28.1 with
matplotlib 3.8.4: introspection, live apply on the WebAgg canvas, style
changes via set_style, override detection, and reset.
Set PORT to serve the demo somewhere other than 5173, so two checkouts can
run it at once.
- Pyodide 0.28.1 with matplotlib 3.8.4 (Pyodide's WebAgg patch); 3.10.x imminent. Both are in CI.
mpl-data/stylelibships in Pyodide's wheel, soplt.style.availableworks there.- The host retains the live figure after a run, so introspection and live
apply target the most recently created open figure, via
plt.get_fignums()— neverplt.gcf(), which would fabricate an empty figure when the student's program drew none. The library does not assume which matplotlib integration it is talking to.
