# CLAUDE.md — `plotstyle` Guidance for Claude Code (or any coding agent) writing or editing scripts that use `plotstyle`. This package is a **standalone matplotlib styling toolkit**, decoupled from the `gallery/` package in this repo — `gallery/` never imports it. The connection between the two is a file on disk: `plotstyle` produces PDF figures, and `gallery` (elsewhere in this repo) turns a directory of PDFs into an HTML gallery. See the bottom of this file for that handoff. ## What it is A KIT (Karlsruhe Institute of Technology) corporate-design matplotlib theme plus a handful of building-block functions, so every figure produced for a thesis chapter or a talk slide looks consistent — validated color palette, consistent spines/ticks/grid, LaTeX text in a modern sans font, figure titles with a parameters subtitle, and legend/panel-label helpers. **Read `examples/plotstyle_showcase.ipynb` (repo root) for a fully rendered, end-to-end tour** — it's the fastest way to see what every function actually produces. Everything below is the reference; the notebook is the demo. ## Install `plotstyle` is its own project (`plotstyle/pyproject.toml`, `uv_build` backend, code under `plotstyle/src/plotstyle/`) and a member of this repo's uv workspace (`[tool.uv.workspace]` in the root `pyproject.toml`) — not a `gallery` extra. ```bash uv sync --all-packages # this repo's uv workflow — installs gallery + plotstyle together ``` `matplotlib>=3.7` is `plotstyle`'s own direct dependency — don't add it to `gallery`'s dependencies to support this package; `gallery` should stay installable without ever pulling in matplotlib. **Hard requirement: a working local LaTeX toolchain (`latex` + `dvipng`).** `plotstyle.use()` sets `text.usetex = True` unconditionally — there is no mathtext fallback. If a script using `plotstyle` needs to run somewhere LaTeX isn't installed, that's a real environment gap to flag, not something to silently work around in `plotstyle` itself (that decision was made deliberately across several iterations of this package — don't reintroduce a fallback without being asked). ## Quick start ```python import numpy as np import plotstyle as ps ps.use() # once, before creating any figure fig, ax = ps.new_figure( "thesis-single", title="Measured signal", params={"N": 512, "sigma": 1.2, "seed": 42}, ) ax.plot(np.linspace(0, 10, 200), np.sin(np.linspace(0, 10, 200)), label="signal") ax.set_xlabel("Time (s)") ax.set_ylabel(r"Amplitude $A(t)$") ps.style_legend(ax, title="Series") ps.savefig(fig, "plots/measured_signal", formats=("pdf",)) ``` ## API reference Everything is re-exported at the top level (`import plotstyle as ps`); the submodule layout (`style.py`, `colors.py`, `figures.py`, `annotations.py`) is an implementation detail, not part of the interface agents should reach into. ### `ps.use(cycle_linestyles: bool = False)` Applies the theme to matplotlib's global `rcParams`. **Call this once, near the top of the script, before creating any figure.** Sets: - Only the bottom spine visible, colored black and heavier than default (`axes.edgecolor`/`axes.linewidth`) — left/top/right spines off. - Ticks on left + bottom only; x-axis gets shorter minor ticks between the major ones (y-axis doesn't — its horizontal gridlines already mark position). Major tick labels read as dark ink, minor tick labels lighter grey. - Horizontal-only, light-grey gridlines. - Left-aligned axes titles (`axes.titlelocation: left`). - `axes.prop_cycle` = the 9 KIT categorical colors, in fixed order. Color only by default — pass `cycle_linestyles=True` to also cycle linestyle (solid/dashed/dash-dot/dotted), which matters if a figure might be printed in grayscale or viewed by someone with color-vision deficiency. - `text.usetex = True` with Latin Modern Sans (`lmodern` + `sfmath` so *math-mode* text is sans too, not just body text; `fontenc` T1 so plain ASCII like `|` doesn't render as the wrong glyph under LaTeX's OT1 default). `ps.reset()` restores matplotlib defaults (`plt.rcdefaults()`) — useful between notebook cells or in tests, not normally needed in a script. ### `ps.new_figure(preset="thesis-single", *, title=None, params=None, **subplots_kwargs)` Thin wrapper over `plt.subplots()`. Returns `(fig, ax)` or `(fig, axes)` exactly like `plt.subplots()` — `**subplots_kwargs` (`nrows`, `ncols`, `sharex`, ...) pass straight through. - `preset`: one of `ps.FIGSIZES` — `"thesis-single"` (6×4"), `"thesis-wide"` (8×4.5"), `"slide-16x9"` (10×5.625"), `"square"` (5×5"). Or pass an explicit `(w, h)` tuple in inches to bypass the presets. - `title`: sets a **left-aligned, bold figure-level title** via `fig.suptitle`. **Prefer this over `ax.set_title()` for a single-axes figure** — it's the recommended, consistent way to title a plot in this codebase. Reserve `ax.set_title()` for multi-axes figures, where each panel needs its own title and no single figure title could cover all of them (see the multi-panel example in the notebook). - `params`: an optional dict rendered as a smaller subtitle line under the title: `key1: value1 | key2: value2 | ...`. Good for recording the run parameters that produced a plot (`params={"N": 512, "seed": 42}`). Note it is *not* colored differently from the title (see "Known limitation" below) — only smaller. ### `ps.colorbar(mappable, ax, size="5%", pad=0.05, **kwargs)` Use this **instead of** `fig.colorbar(im, ax=ax)` whenever `ax` has `set_aspect("equal")` (or anything else that visually shrinks it) — plain `fig.colorbar` sizes to the axes' nominal bounding box and ends up taller than what's actually drawn. This appends a matching-size axes via `mpl_toolkits.axes_grid1.make_axes_locatable` and also turns off the colorbar's own border (`cb.outline.set_visible(False)`), which otherwise independently picks up the bold black spine styling as a stray box around the colorbar. ### `ps.no_spines(ax)` Hides all four spines on `ax` (or every Axes in an array, e.g. from `new_figure(nrows=..., ncols=...)`). `use()` keeps only the bottom spine visible by default, since most plots have a meaningful x baseline — but pixel/bin-indexed plots (`imshow`, `pcolormesh`, 2D histograms) don't have one, so the themed bottom spine implies an axis origin that doesn't mean anything there. Call this on the Axes for that kind of plot instead of leaving the bottom spine on or hand-rolling `ax.spines[...].set_visible(False)`: ```python im = ax.imshow(image_data) ps.no_spines(ax) ps.colorbar(im, ax, label="Intensity") ``` ### `ps.style_legend(ax, loc="outside right upper", frameon=False, title=None, **kwargs)` Builds a legend from `ax`'s handles/labels but attaches it to the **figure** (`fig.legend(...)`), so it always sits outside the axes rather than overlapping data. Pass `title=` — strongly encouraged; omitting it prints a `UserWarning` (the legend still renders, so this won't break a script, but an agent generating new plots should always pass one). ### `ps.panel_label(ax, label, loc="lower right", fontweight="bold", box=True, **kwargs)` Adds a `(a)`/`(b)`/… label for multi-panel figures. Defaults to the bottom-right corner (nudged up from the very edge so it clears the x-axis), colored to match `ax`'s xlabel, on a light-grey semi-transparent rounded box with a slim solid border. Pass `box=False` for bare text. Don't hand-roll this with `ax.text(...)` — use the helper so every panel label in a figure (and across figures) looks the same. ### `ps.savefig(fig, path, formats=("pdf",), dpi=300)` Writes one file per format (`path` has no extension; each format is appended). **Default to `formats=("pdf",)`** — see "Combining with `gallery`" below for why PDF is what you almost always want here. Creates parent directories automatically. ### `ps.get_color(i)` / `ps.colors` `ps.get_color(i)` indexes the 9-color categorical palette (0-based) and raises `ValueError` past the last slot — **never** wrap/cycle back to 0 yourself past index 8; fold extra series into an "Other" bucket or facet instead. Prefer relying on the default `prop_cycle` (i.e. just call `ax.plot(...)` repeatedly without specifying `color=`) over calling `get_color()` explicitly, unless you need a specific slot out of order (e.g. matching a color used elsewhere in the same figure). `ps.colors` also exposes, if you need direct access: - `CATEGORICAL` — the 9 hex strings, in order. - `sequential_cmap()` — continuous KIT-blue colormap (light tint → brand blue) for magnitude/heatmap data. - `diverging_cmap()` — KIT blue ↔ KIT red through a neutral grey midpoint, for signed data. Always pass symmetric `vmin`/`vmax` around the data's true zero when using it. - `STATUS` — fixed `good`/`warning`/`serious`/`critical` colors. **Never** put these in a categorical series cycle; only use them for actual good/bad-style status encoding, always paired with a label. - `INK` — the grey/text roles (`primary`, `secondary`, `muted`, `gridline`, `baseline`, `surface`) the theme itself is built from. ## Best practices (for agents writing or reviewing plot scripts) 1. **Call `ps.use()` once, before any figure is created.** Don't call it again mid-script unless deliberately toggling `cycle_linestyles` back and forth (rare — only useful when a notebook wants to show both modes). 2. **Prefer `new_figure(title=..., params=...)` over `ax.set_title()`** for any single-axes figure. Use `ax.set_title()` only per-panel in multi-axes figures. 3. **Never use literal `#` or `%` in any text passed to matplotlib** (titles, labels, legend entries, annotations) while `plotstyle` is active — usetex is always on, and those are LaTeX special characters that will break rendering with a `RuntimeError` from `latex`. Rephrase instead of escaping where possible (e.g. a hex color used as a *label* should be spelled without its `#`; a percentage should read "42 percent" or use an escaped `\%` if you specifically need the glyph). 4. **Don't hand-style spines/ticks/grid/legend/panel-labels manually** — that's what `use()`, `style_legend()`, and `panel_label()` are for. If an agent finds itself writing `ax.spines[...].set_visible(...)` or similar in a script that already calls `ps.use()`, that's very likely fighting the theme rather than working with it — stop and reconsider. The one sanctioned exception is `ps.no_spines(ax)` on `imshow`/`pcolormesh`/2D histogram Axes, where the themed bottom spine implies a baseline that doesn't exist for pixel/bin data. 5. **Default `savefig(..., formats=("pdf",))`.** Only add `"png"`/`"svg"` if there's a concrete reason (e.g. a quick raster preview outside the gallery pipeline) — seeing `formats=("pdf", "png")` in a new script is a signal to ask why, since the gallery already produces its own PNG thumbnails from the PDF. 6. **This package has no test/CI dependency on a real LaTeX install being absent** — the test suite (`tests/test_plotstyle.py`) assumes LaTeX *is* present (this repo's dev machine has it), and exercises real rendering rather than mocking it out. Don't add a mathtext-fallback code path to make tests pass in a hypothetical no-LaTeX CI without being asked; that would silently reintroduce the fallback behavior that was deliberately removed. 7. **Known limitation, don't try to route around it:** a figure title and its `params` subtitle can't have different colors (matplotlib's usetex rendering tints an entire Text artist with one color; any in-source `\color`/`\textcolor` is ignored). They're differentiated by size (`\small`) only. If asked to make the subtitle a different color, the real fix requires a second, independently-positioned Text artist with its own color — flag the added complexity rather than quietly reaching for `\textcolor` again. ## Combining with `gallery`: producing plots the gallery will display `plotstyle` and `gallery` never share code or imports — the only connection is that `gallery` recursively scans **source directories** (configured in `gallery`'s `config.yaml`, see repo-root `CLAUDE.md`) for PDF/HTML files plus `metadata.yaml`/`.yml`/`.json` files, and turns them into a static site. `gallery` does its own PDF→PNG conversion at a configured DPI — so a `plotstyle` script only needs to produce the PDF; **don't** also generate a PNG "for the gallery" (that's `gallery`'s job, and a hand-made PNG would just be redundant/inconsistent with the thumbnail `gallery` generates itself). ### End-to-end workflow 1. **Pick or create a source directory** for the project's plots — this can be anywhere on disk, it does not need to live inside this repo (e.g. `~/experiments/run42/plots/`). Subdirectories inside it become the gallery's folder hierarchy. 2. **Write the plotting script using `plotstyle`**, saving into that directory: ```python import plotstyle as ps ps.use() fig, ax = ps.new_figure("thesis-single", title="Beam profile", params={"run": 42}) # ... plot ... ps.savefig(fig, "/home/user/experiments/run42/plots/beam_profile/x_projection", formats=("pdf",)) ``` 3. **Add a `metadata.yaml`** in any folder of that source tree to annotate every plot within it (and its subfolders — metadata inherits downward, child keys override parent keys). Fields are freeform YAML — there's no fixed schema — but a few keys get special, prominent placement in the per-plot popup UI: `title`, `description`, `plot_type`, `experiment`. Everything else still displays, just under "Additional Information". ```yaml # metadata.yaml title: "Run 42 — Beam Profile Measurements" description: "Transverse beam profiles at IP1, measured with the wire scanner." experiment: "Run 42" plot_type: "beam-profile" parameters: beam_energy: "6.5 TeV" bunch_intensity: "1.1e11" tags: - "beam-diagnostics" - "ip1" ``` - Also accepts `.yml`/`.json` instead of `.yaml`. - **Per-plot override**: create `.yaml` next to `.pdf` (matching the PDF's stem) with just the keys to override for that one plot — it's merged on top of the inherited folder metadata. - Long text values (>100 chars), lists, and nested mappings all render sensibly in the UI automatically (truncated-with-"show more", tags, nested key/value blocks respectively) — no special formatting needed on the Python/YAML side. - Metadata text values support inline LaTeX, rendered client-side via MathJax, e.g. `formula: "$$E = mc^2$$"` or `luminosity: "35.9 fb^{-1}"`. 4. **Register the source** in `gallery`'s `config.yaml` if it isn't already there: ```yaml sources: - name: "run42" path: "/home/user/experiments/run42/plots" ``` 5. **Generate (or update) the gallery**: ```bash gallery generate --source /home/user/experiments/run42/plots --verbose ``` Incremental: `gallery` only reconverts a PDF to PNG if the PDF is newer than the cached PNG (with a 30s buffer — see repo-root `CLAUDE.md`), so re-running a `plotstyle` script that overwrites the same PDF path is exactly the intended update flow.