# 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 `<plotname>.yaml` next to
     `<plotname>.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.
