plotlet v0.6.6

Themes

Two functions style a chart, one mechanism underneath — a deep-merge over spec.json, the locked default look:

c.spec(font=dict(title_size=15))   # set individual spec values
c.theme("dark")                    # apply a named preset

They compose in either order: spec values always apply on top of the preset. spec calls accumulate — repeated calls deep-merge, later wins per key — while theme is last-call-wins.

Each spec kwarg is a spec section (figure, font, frame, grid, legend, …) holding a partial dict of that section's keys — the same shape as a theme JSON. Unknown sections or keys raise at record time with a did-you-mean hint. The overrides are recorded on the chart, so a saved journal stays self-contained — nothing to re-register before replay.

What you can set

Any spec.json key is overridable — the sections you'll typically touch:

{
  "figure":   { "background": "..." },
  "font":     { "family": "...", "color": "...",
                "tick_size": ..., "label_size": ..., "title_size": ... },
  "frame":    { "color": "...", "linewidth": ..., "tick_length": ...,
                "tick_direction": "...", "tick_marks": true, "x_side": "...",
                "spine_top": true, /* …one flag per side */ },
  "grid":     { "color": "...", "linewidth": ..., "dasharray": "2,3",
                "default_on": false },
  "linewidth": ...,  "marker_size": ...,  "cmap": "...",  /* shared ink */
  "errorbar": { "color": "...", "capsize": ... },
  "scatter":  { "size": ..., "alpha": ... },
  /* …one block per artist, beside the chrome sections */
  "legend":   { "label_size": ..., "header_size": ...,
                "background": "...", "swatch_width": ..., /* … */ },
  "linestyles": { "--": "6,3", /* …dash specs */ }
}

The remaining sections (size, pad, sectors, scale, raster, layout) merge the same way — see the size.data_width warning below before touching geometry. "dasharray": null drops the dash (solid gridlines — how minimal gets its look). Unspecified keys fall through to classic — which is just spec.json with no overrides. The authoritative key set and the default values live in src/plotlet/spec.json.

Text sizes: font.*_size covers axis chrome (ticks, labels, title, subtitle, caption); a component that draws its own text carries its own size key — legend.label_size / legend.header_size, sectors.label_size, the text artist's text.size, and the heatmap family's heatmap.annot_size. There is no single scale-everything knob. (Artist kwargs spell text size fontsize= / annot_fontsize=; spec keys always say size — two vocabularies by design, and a spec() typo in either direction gets a did-you-mean hint.)

Artist ink sits at the spec root beside the chrome sections: bare scalar keys (linewidth, marker_size, cmap, …) are shared vocabulary any artist may read — c.spec(linewidth=1.5) — and each plot-type block (scatter, errorbar, …) is read only by its artist(s). Chrome keeps its own widths: frame.linewidth and grid.linewidth are separate from the root linewidth. A block with several readers (errorbar by both errorbar and bar's whiskers, heatmap by image_cmap) is a deliberate consistency decision, declared in tests/test_spec_ownership.py — extend that map to share a block, or give a new artist its own.

font.family doubles as the face selector: its first comma-separated segment takes a bundled name or a .ttf/.otf path (see API.md → Fonts) — c.spec(font=dict(family="Arimo")) picks the face, under any preset.

Convention: the data palette stays orthogonal to theme. TAB10 and the named-color shortcuts live in src/plotlet/draw/colors.py as plain constants — not in spec.json, not theme-overridable. Themes change frame chrome; the data palette is for users to override at the chart / call level. Frame-chrome and data-color knobs stay separate so swapping a theme never changes the data colors.

Built-in presets

A preset is a named bundle of the same overrides. plotlet ships four:

theme look
classic white background, black spines on all four sides, no grid (default)
minimal white background, no spines, light solid gridlines on by default
dark dark gray background, light spines, soft grid on by default
void white background, no spines, no tick marks (tick labels remain — pass c.xticks([]) / yticks([]) to drop those too) — for sparklines/insets
import plotlet as pt
from plotlet import aes

c = pt.chart(df, aes(x="t"), title="hits per minute",
             xlabel="t", ylabel="hits")
c.theme("dark")
c.add_line(aes(y="A"), label="A")
c.add_line(aes(y="B"), label="B")

Chaining also works — theme and spec are frame methods like title / xlabel:

c = pt.chart().theme("minimal").title("residuals").add_line(df, aes(x="x", y="resid"))

Whole-figure themes

A theme only affects the chart it's set on; multi-panel layouts may mix themes per leaf:

a = pt.chart(df, aes(x="t", y="raw"), title="raw")
a.theme("minimal")
a.add_line()
b = pt.chart(df, aes(x="t", y="fit"), title="model")
b.theme("dark")
b.add_line()
fig = a | b

To theme a whole figure in one call, set it on the layout:

fig = pt.grid([[a, b], [c, d]])
fig.theme("dark")

The layout theme is the default for every leaf under it — a leaf's own c.theme(...) wins — and it styles the outer canvas: figure background, figure title, the space between panels. Nested layouts cascade the same way (nearest themed ancestor provides a leaf's default). layout.spec(...) sets figure-wide spec values the same way: every leaf inherits them, and a leaf's own c.spec(...) deep-merges on top (leaf wins per key). The two channels cascade independently, so a leaf that only tweaks spec values still inherits the layout's preset.

Without a layout theme, the outer canvas comes from whatever theme is active at the root render: a root that funnels to a single themed chart takes that chart's theme; a multi-leaf parent like a | b stays classic. The background is painted as a real first-child <rect> (not CSS on the root element), so PNG/PDF exports and non-browser SVG consumers carry it too.

Naming your own theme

When the same overrides recur across charts, register them under a name. As a dict at runtime:

import plotlet as pt
pt.register_theme("paper", dict(
    figure=dict(background="#fafafa"),
    frame=dict(color="#222222", spine_top=False, spine_right=False),
    grid=dict(color="#dddddd", default_on=True),
    font=dict(color="#222222"),
))
c = pt.chart(df, aes(x="x", y="y"))
c.theme("paper")
c.add_line()

Or as a JSON file you ship in your project:

pt.register_theme("paper", "themes/paper.json")

pt.list_themes() lists everything registered, including the built-ins. One caveat versus inline overrides: a journal that names a registered theme needs that registration re-run before replay.

⚠️ A theme that overrides size.data_width / size.data_height changes the geometry of every chart that doesn't set its own — most baselines assume the classic dimensions, so use sparingly.

How theme application works

c.theme(...) and c.spec(...) record onto the chart's call list. At render time, the accumulated spec overrides are deep-merged over the preset, and the spec dicts (_FRAME, _D, …) are mutated in place with the result for the duration of one render, then restored. In layout rendering each leaf's style is applied independently — themes don't leak between panels.