diff --git a/js/src/20_theme.ts b/js/src/20_theme.ts
index 7691bb64..a85c1f5b 100644
--- a/js/src/20_theme.ts
+++ b/js/src/20_theme.ts
@@ -82,15 +82,26 @@ export function cssColor([r, g, b, a]: any) {
// convention every host we target uses (Reflex/next-themes, Radix Themes,
// Tailwind). These remain zero-specificity :where() rules, so public
// --chart-badge-* / --chart-modebar-* tokens or utility classes override them.
+//
+// Every chrome text slot below either declares `font-weight:400` or declares
+// no weight at all and inherits 400. Matplotlib's `axes.titleweight`,
+// `axes.labelweight` and `font.weight` all default to "normal", and its legend
+// title and colorbar label are normal too, so 400 is the parity default for
+// title, axis titles, annotations, legend titles and colorbar titles alike.
+// The SVG (`python/xy/_svg.py`) and native raster (`python/xy/_raster.py`)
+// exporters carry the same 400 default — the three renderers must not
+// disagree; tests/test_text_weight_defaults.py guards all three. A heavier
+// weight is opt-in, via `styles[slot]`, a mark/axis text style, or the pyplot
+// `axes.titleweight`/`axes.labelweight` rcParams.
export const XY_CHROME_CSS = `
@layer base{
-:where(.xy [data-xy-slot="title"]){text-align:center;font-size:14px;font-weight:600;color:var(--chart-text,inherit)}
+:where(.xy [data-xy-slot="title"]){text-align:center;font-size:14px;font-weight:400;color:var(--chart-text,inherit)}
:where(.xy [data-xy-slot="tooltip"]){max-width:calc(100% - 8px);max-height:calc(100% - 8px);box-sizing:border-box;white-space:normal;overflow-wrap:anywhere;overflow:auto;background:var(--chart-tooltip-bg,rgba(20,24,33,.92));color:var(--chart-tooltip-text,#fff);padding:5px 8px;border-radius:4px;font-size:11px;line-height:1.35;box-shadow:0 2px 8px rgba(0,0,0,.3)}
:where(.xy [data-xy-slot="legend"]){left:var(--xy-legend-left,auto);right:var(--xy-legend-right,auto);top:var(--xy-legend-top,auto);bottom:var(--xy-legend-bottom,auto);transform:var(--xy-legend-transform,none);max-width:var(--xy-legend-max-width);max-height:var(--xy-legend-max-height);gap:2px;font-size:11px;background:var(--chart-legend-bg,rgba(128,128,128,.08));border-radius:4px;padding:4px 8px;color:var(--chart-text,inherit)}
:where(.xy [data-xy-slot="legend_swatch"]){width:12px;height:10px;border-radius:2px;margin-right:5px}
:where(.xy [data-xy-slot="colorbar"]){color:var(--chart-text,inherit);font-size:10px}
:where(.xy [data-xy-slot="colorbar_bar"]){background:var(--xy-colorbar-gradient);border:1px solid currentColor;box-sizing:border-box}
-:where(.xy [data-xy-slot="colorbar_title"]){font-weight:500}
+:where(.xy [data-xy-slot="colorbar_title"]){font-weight:400}
:where(.xy [data-xy-slot="badge"]){gap:3px;font-size:11px;line-height:1.2}
:where(.xy [data-xy-slot="badge_item"]){padding:3px 6px;border-radius:4px;color:var(--chart-badge-text,var(--xy-badge-text));background:var(--chart-badge-bg,var(--xy-badge-bg));box-shadow:var(--xy-badge-shadow)}
:where(.xy){--xy-badge-text:#0f172a;--xy-badge-bg:rgba(255,255,255,.82);--xy-badge-shadow:0 1px 4px rgba(15,23,42,.14);--xy-modebar-bg:#fff;--xy-modebar-menu-bg:#fff;--xy-modebar-hover:#edf1f6;--xy-modebar-text:#5c6573;--xy-modebar-text-strong:#1b212a;--xy-modebar-text-soft:#798495;--xy-modebar-text-subtle:#9aa4b2;--xy-modebar-border:rgba(27,33,42,.12);--xy-modebar-separator:rgba(27,33,42,.08);--xy-modebar-active:#edf1f6;--xy-modebar-shadow:0 8px 24px rgba(28,32,36,.1),0 2px 6px rgba(28,32,36,.06);--xy-modebar-menu-shadow:0 8px 24px rgba(28,32,36,.12);--xy-modebar-button-shadow:0 1px 2px rgba(28,32,36,.06)}
@@ -141,7 +152,7 @@ export const XY_CHROME_CSS = `
:where(.xy [data-xy-slot="crosshair_x"],.xy [data-xy-slot="crosshair_y"]){background:var(--chart-crosshair,rgba(15,23,42,.42))}
:where(.xy [data-xy-slot="tick_label"]){color:var(--chart-text,inherit)}
:where(.xy [data-xy-slot="axis_title"]){color:var(--chart-text,inherit);font-size:12px}
-:where(.xy [data-xy-slot="annotation_label"]){font-size:11px;line-height:1.2;font-weight:500;color:var(--chart-annotation-text,var(--chart-text,inherit))}
+:where(.xy [data-xy-slot="annotation_label"]){font-size:11px;line-height:1.2;font-weight:400;color:var(--chart-annotation-text,var(--chart-text,inherit))}
:where(.xy [data-xy-slot="canvas"]){cursor:var(--chart-cursor,crosshair)}
:where(.xy [data-xy-slot="canvas"][data-xy-dragmode="pan"]){cursor:var(--chart-cursor-pan,grab)}
:where(.xy [data-xy-slot="canvas"][data-xy-dragmode="none"]){cursor:default}
diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts
index a5472402..846c4850 100644
--- a/js/src/50_chartview.ts
+++ b/js/src/50_chartview.ts
@@ -1769,7 +1769,10 @@ export class ChartView {
if (options.title) {
const title = document.createElement("div");
title.textContent = String(options.title);
- title.style.fontWeight = "600";
+ // Matplotlib renders a legend title at normal weight, and the native
+ // raster exporter never emphasized it, so 400 is what both the SVG
+ // exporter and this path emit.
+ title.style.fontWeight = "400";
title.style.gridColumn = `1 / span ${horizontal ? ncols : 1}`;
lg.appendChild(title);
}
@@ -4173,7 +4176,7 @@ export class ChartView {
const hasAngle = axis && Number.isFinite(Number(axis.label_angle));
if (!hasPosition && !hasOffset && !hasAngle) return { css: fallbackCss, style: null };
if (rawPosition && typeof rawPosition === "object" && !Array.isArray(rawPosition)) {
- return { css: "font-weight:500;white-space:nowrap;", style: rawPosition };
+ return { css: "font-weight:400;white-space:nowrap;", style: rawPosition };
}
const p = this.plot;
@@ -4196,7 +4199,7 @@ export class ChartView {
css:
`left:${x}px;top:${y}px;` +
`transform:translateX(${translateX}%) rotate(${angle}deg);` +
- "transform-origin:center;font-weight:500;white-space:nowrap;",
+ "transform-origin:center;font-weight:400;white-space:nowrap;",
style: null,
};
}
@@ -4211,7 +4214,7 @@ export class ChartView {
css:
`left:${x}px;top:${y}px;` +
`transform:translate(-50%,-50%) rotate(${angle}deg);` +
- "transform-origin:center;font-weight:500;white-space:nowrap;",
+ "transform-origin:center;font-weight:400;white-space:nowrap;",
style: null,
};
}
@@ -4536,7 +4539,7 @@ export class ChartView {
if (axis.label && this._axisTickLabelStrategy(axis) !== "none") {
const top = axis.side === "top" ? p.y - 34 : p.y + p.h + 24;
const fallbackCss =
- `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:500;`;
+ `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:400;`;
const placement = this._axisLabelCss(axis, "x", fallbackCss);
label(axis.label, placement.css, axis, "label", placement.style);
}
@@ -4586,22 +4589,22 @@ export class ChartView {
}
if (axis.label && this._axisTickLabelStrategy(axis) !== "none") {
const fallbackCss = axis.side === "left"
- ? `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:500;`
- : `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:500;`;
+ ? `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:400;`
+ : `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:400;`;
const placement = this._axisLabelCss(axis, "y", fallbackCss);
label(axis.label, placement.css, axis, "label", placement.style);
}
}
if (s.x_axis.label && !hideX) {
const top = xAxis.side === "top" ? p.y - 34 : p.y + p.h + 24;
- const fallbackCss = `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:500;`;
+ const fallbackCss = `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:400;`;
const placement = this._axisLabelCss(xAxis, "x", fallbackCss);
label(s.x_axis.label, placement.css, xAxis, "label", placement.style);
}
if (s.y_axis.label && !hideY) {
const fallbackCss = yAxis.side === "right"
- ? `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:500;`
- : `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:500;`;
+ ? `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:400;`
+ : `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:400;`;
const placement = this._axisLabelCss(yAxis, "y", fallbackCss);
label(s.y_axis.label, placement.css, yAxis, "label", placement.style);
}
diff --git a/pr-assets/text-weight/core-chart-text-weight.png b/pr-assets/text-weight/core-chart-text-weight.png
new file mode 100644
index 00000000..aaf0e381
Binary files /dev/null and b/pr-assets/text-weight/core-chart-text-weight.png differ
diff --git a/pr-assets/text-weight/pdsh-cell23-text-weight.png b/pr-assets/text-weight/pdsh-cell23-text-weight.png
new file mode 100644
index 00000000..89840ea6
Binary files /dev/null and b/pr-assets/text-weight/pdsh-cell23-text-weight.png differ
diff --git a/python/xy/_raster.py b/python/xy/_raster.py
index b026e446..0048f605 100644
--- a/python/xy/_raster.py
+++ b/python/xy/_raster.py
@@ -1001,7 +1001,10 @@ def emit_tick_labels(
title_italic, title_bold = _native_font_emphasis(
{
"font_style": title_style.get("font-style"),
- "font_weight": title_style.get("font-weight", 600),
+ # 400 = Matplotlib's `axes.titleweight: normal`; the baked
+ # atlas only has a bold face, so anything >= 600 rounds up to
+ # it. Mirrors the SVG/browser title default.
+ "font_weight": title_style.get("font-weight", 400),
}
)
cmd.text(
@@ -1024,7 +1027,7 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
italic, bold = _native_font_emphasis(
{
"font_style": axis_style.get("label_font_style"),
- "font_weight": axis_style.get("label_font_weight", 500),
+ "font_weight": axis_style.get("label_font_weight", 400),
}
)
args = (
diff --git a/python/xy/_svg.py b/python/xy/_svg.py
index fe9d6ae5..0c107c1a 100644
--- a/python/xy/_svg.py
+++ b/python/xy/_svg.py
@@ -1625,7 +1625,11 @@ def line_attrs(style: dict[str, Any], color: str) -> str:
if spec.get("title"):
title_style = ((spec.get("dom") or {}).get("styles") or {}).get("title") or {}
title_size = _px_size(title_style.get("font-size"), 14.0)
- title_weight = title_style.get("font-weight", 600)
+ # Matplotlib's `axes.titleweight`/`axes.labelweight` both default to
+ # "normal", so chrome text stays at 400 unless a style or rcParam asks
+ # for more. Keep this in step with the `title`/`axis_title` slot rules
+ # in js/src/20_theme.ts and the raster defaults in _raster.py.
+ title_weight = title_style.get("font-weight", 400)
title_family = title_style.get("font-family")
title_font_style = title_style.get("font-style")
title_font_attrs = (
@@ -1658,7 +1662,7 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
chrome.append(
f''
f"{escape(str(axis['label']))}"
)
@@ -2906,7 +2910,7 @@ def _legend(
if title:
rows.append(
f'{escape(str(title))}'
+ f'font-weight="400" fill="{escape(text_color)}">{escape(str(title))}'
)
for i, t in enumerate(named[: legend["visible_count"]]):
style = t.get("style") or {}
diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py
index 549fe429..a81cc941 100644
--- a/python/xy/pyplot/_axes.py
+++ b/python/xy/pyplot/_axes.py
@@ -102,12 +102,14 @@ def _rc_chrome_snapshot(dpi: float) -> dict[str, Any]:
f"{_font_size(rcParams['axes.titlesize'], rcParams['font.size'], dpi):g}px"
),
"color": theme_tokens["text_color"],
+ **_rc_font_weight(rcParams["axes.titleweight"]),
},
"axis_title": {
"font-size": (
f"{_font_size(rcParams['axes.labelsize'], rcParams['font.size'], dpi):g}px"
),
"color": resolve_color(rcParams["axes.labelcolor"]),
+ **_rc_font_weight(rcParams["axes.labelweight"]),
},
"tick_label": {
"font-size": (
@@ -5081,6 +5083,19 @@ def _font_size(value: Any, base: Any, dpi: float = 96.0) -> float:
return _font_size_points(value, base) * float(dpi) / 72.0
+def _rc_font_weight(value: Any, key: str = "font-weight") -> dict[str, Any]:
+ """Carry a weight rcParam only when it asks for something other than normal.
+
+ Matplotlib's ``axes.titleweight``/``axes.labelweight`` default to
+ ``"normal"``, which is also every xy renderer's own default, so the
+ default case needs nothing on the wire — the same rule the neighbouring
+ ``axes.labelcolor`` check uses. An explicit weight (``"bold"``, ``"light"``,
+ a numeric string) travels through to the browser, SVG, and raster paths.
+ """
+ weight = str(value)
+ return {} if weight == "normal" else {key: weight}
+
+
def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]:
prefix = "xtick" if axis == "x" else "ytick"
point_scale = float(dpi) / 72.0
@@ -5105,6 +5120,10 @@ def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]:
if rcParams["axes.labelcolor"] != "black":
result["label_color"] = resolve_color(rcParams["axes.labelcolor"])
result["label_size"] = _font_size(rcParams["axes.labelsize"], rcParams["font.size"], dpi)
+ # The browser reads the weight off `chrome_styles["axis_title"]`; the SVG
+ # and native raster exporters read it off the axis style, so `axes.labelweight`
+ # has to be published in both places to reach all three renderers.
+ result.update(_rc_font_weight(rcParams["axes.labelweight"], "label_font_weight"))
return result
diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py
index 19537e39..1d676d3e 100644
--- a/python/xy/pyplot/_rc.py
+++ b/python/xy/pyplot/_rc.py
@@ -44,8 +44,10 @@ def by_key(self) -> dict[str, list[str]]:
"axes.edgecolor": "black",
"axes.labelcolor": "black",
"axes.labelsize": "medium",
+ "axes.labelweight": "normal",
"axes.titlesize": "large",
"axes.titlecolor": "auto",
+ "axes.titleweight": "normal",
"axes.linewidth": 0.8,
"axes.xmargin": 0.05,
"axes.ymargin": 0.05,
diff --git a/spec/api/styling.md b/spec/api/styling.md
index 3504641f..66e48a73 100644
--- a/spec/api/styling.md
+++ b/spec/api/styling.md
@@ -127,6 +127,7 @@ or a CSS `px` value such as `"3px"`.
| `grid_opacity` | Number from `0` to `1` |
| `tick_length` | Non-negative pixel length |
| `tick_size` / `tick_label_size`, `label_size` | Positive pixel font size |
+| `label_font_weight`, `label_font_family`, `label_font_style` | Axis-label font overrides, passed through to the browser, SVG, and native PNG paths. `label_font_weight` defaults to `400` — see [Chrome text weight](#chrome-text-weight). |
| `tick_direction` | `"in"`, `"out"`, or `"inout"` |
| `tick_label_anchor` | `"start"`, `"center"`, or `"end"` (mpl `ha` aliases `"left"`/`"right"`/`"middle"` normalize) — which label edge pins to the tick; rotated labels pivot about the pinned edge. Also a first-class `x_axis`/`y_axis` option. X defaults to `"center"`; y defaults to the tick-side edge (`"end"` left of the plot, `"start"` right of it). Honored by static SVG/PNG exports. |
@@ -237,6 +238,51 @@ raises before it reaches the client.
…
```
+### Chrome text weight
+
+**Every chrome text element defaults to `font-weight: 400`** — chart title, axis
+titles, tick labels, legend entries, legend titles, colorbar titles, and text/
+label/callout annotations alike. This is Matplotlib's default and it is
+deliberate: `axes.titleweight`, `axes.labelweight`, and `font.weight` are all
+`normal` in Matplotlib 3.11, and its legend titles and colorbar labels are
+normal too, so a chart exported from `xy.pyplot` carries the same text weight as
+the same script run under Matplotlib.
+
+The default is a **cross-renderer contract**, not a per-renderer choice. All
+three renderers must agree:
+
+| Renderer | Where the default lives |
+| --- | --- |
+| Browser render client | `font-weight:400` on the text slot rules in `js/src/20_theme.ts`, plus the inline axis-label/legend-title weights in `js/src/50_chartview.ts` (inline styles beat the slot stylesheet, so both have to say 400) |
+| SVG export | `python/xy/_svg.py` — the `font-weight` attribute on the title, axis-title, and legend-title `` elements |
+| Native PNG export | `python/xy/_raster.py` — `_native_font_emphasis` maps a weight `>= 600` onto the baked atlas's bold face, so 400 emits a plain, unemphasized text record |
+
+A renderer that drifts heavier is a bug; `tests/test_text_weight_defaults.py`
+asserts the emitted weight per element in the SVG output and in the native
+raster command stream, and holds a source-level guard on the TypeScript
+defaults (the client bundles are a generated, git-ignored artifact, so a
+bundle-reading test could not run from a fresh checkout).
+
+Heavier text is always opt-in, never a default:
+
+```python
+xy.chart(..., styles={"title": {"font_weight": 600}}) # per-slot
+xy.x_axis(label="time", style={"label_font_weight": "bold"}) # per-axis
+```
+
+Under the pyplot shim, Matplotlib's own knobs work too —
+`rcParams["axes.titleweight"]`, `rcParams["axes.labelweight"]`, and the explicit
+`ax.set_title(..., fontweight=)` / `ax.set_xlabel(..., fontweight=)` arguments.
+Because `normal` is already every renderer's default, the shim only puts a
+weight on the wire when it differs from `normal`.
+
+The native PNG exporter's font atlas is bounded and carries one regular and one
+bold face, so it approximates: any weight `>= 600` (or a name in
+`bold`/`semibold`/`demibold`/`heavy`/`black`) renders with the bold face, and
+everything lighter renders regular. Intermediate weights are therefore not
+distinguishable in native PNG output, while the browser and SVG paths pass the
+requested weight through verbatim.
+
## Why your styles always win
The client injects one stylesheet of *visual* defaults (background, color,
diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md
index 75bb35d8..210018a6 100644
--- a/spec/matplotlib/compat.md
+++ b/spec/matplotlib/compat.md
@@ -86,7 +86,8 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent
| `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, turbo, coolwarm, Blues, Purples, PuBu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal (RdGy/jet render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) |
| `LinearSegmentedColormap.from_list` / `ListedColormap` | Python-side callables (`cmap(np.arange(cmap.N))` → RGBA) for scripts that colormap values themselves; they cannot be passed as `cmap=` to plotting calls (no engine table), which fails loudly |
| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`); with no mappable it uses the current image the way pyplot does. `ticks=`/`extend=` render in PNG and SVG (the HTML colorbar stays a minimal gradient without tick text); `clim` retargets the mappable's color window and any colorbar derived from it |
-| `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore them. Unknown keys warn once |
+| `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore most of them. `axes.titleweight` and `axes.labelweight` are supported and verified to reach all three renderers (browser, single-chart SVG, single-chart native PNG); both default to `normal`, matching Matplotlib. Unknown keys warn once |
+| Text weight | Title, axis-label, tick-label, legend, legend-title, colorbar-title, and annotation text all default to normal (400) weight in every renderer, matching Matplotlib's `axes.titleweight`/`axes.labelweight`/`font.weight` defaults. Heavier text needs an explicit `fontweight=`, `label_font_weight`, `styles[slot]`, or rcParam. Native PNG approximates: the bounded font atlas holds one regular and one bold face, so weights `>= 600` render bold and everything lighter renders regular — intermediate weights are not distinguishable in native PNG, while browser and SVG output pass the requested weight through verbatim. See [styling § Chrome text weight](../api/styling.md#chrome-text-weight) |
| `plt.style.use(...)` / `plt.style.context(...)` | `"default"`, `"xy"`, bounded rcParam dictionaries, ordered lists, and the stock sheets fivethirtyeight, ggplot, bmh, dark_background, grayscale, seaborn-v0_8-white(grid), seaborn-v0_8-darkgrid, and seaborn-v0_8-deep — reduced to the supported rcParams subset (colors, grid, cycle, line width, font size; per-sheet keys outside that subset are not carried). The darkgrid sheet mirrors seaborn's `axes_style` (including `patch.edgecolor: white` + `patch.force_edgecolor`, which give hist/bar patches their white separators); `-deep` installs seaborn's classic color cycle. `context()` snapshots and restores. Unknown sheet names fail precisely |
| `plt.GridSpec(r, c, wspace=, hspace=, width_ratios=)` + slice specs | Spans (`grid[0, 1:]`, `grid[:-1, 0]`) and custom spacing resolve to explicit figure rectangles using Matplotlib's SubplotParams frame; default-geometry single cells keep the uniform grid. Spanning layouts position exactly in HTML, PNG, and SVG: free-form panels (including `add_axes` rects and insets) render absolutely at their figure rectangles in every exporter, with later axes stacked above earlier ones |
| `add_subplot(spec, sharex=, sharey=, xticklabels=[], ...)` | per-axes sharing aliases the axis-property store (static domains, as `twiny` does), not Matplotlib's live Grouper; `get_shared_x_axes()` reflects it |
diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md
index dcdf2c03..ee0eadcd 100644
--- a/spec/matplotlib/shim-todo.md
+++ b/spec/matplotlib/shim-todo.md
@@ -661,7 +661,10 @@ streamplot
### Known-inconsistent, still open
- Exporter chrome beyond backgrounds (fonts, tick/legend styling) stays fixed
- in single-chart PNG/SVG regardless of rcParams.
+ in single-chart PNG/SVG regardless of rcParams — except `axes.titleweight`
+ and `axes.labelweight`, which reach the browser, single-chart SVG, and
+ single-chart native PNG paths and are covered by
+ `tests/pyplot/test_rc_chrome_contracts.py`.
- `ax.set_facecolor()` mutates the per-Axes plot background after creation,
but `set_facecolor(None)` is a silent no-op rather than a reset to the rc
default; restoring it requires passing `rcParams["axes.facecolor"]` back
diff --git a/tests/pyplot/test_rc_chrome_contracts.py b/tests/pyplot/test_rc_chrome_contracts.py
index b8dbcf78..d4bf965b 100644
--- a/tests/pyplot/test_rc_chrome_contracts.py
+++ b/tests/pyplot/test_rc_chrome_contracts.py
@@ -96,3 +96,50 @@ def test_spine_controls_and_invalid_cycle_boundaries() -> None:
ax.spines[["top", "right"]].set_visible(False)
spec, _ = ax._build_chart(640, 480).figure().build_payload()
assert spec["frame_sides"] == ["bottom"]
+
+
+def test_title_and_label_weight_rc_defaults_match_matplotlib() -> None:
+ # Matplotlib 3.11: axes.titleweight and axes.labelweight are both "normal".
+ assert plt.rcParams["axes.titleweight"] == "normal"
+ assert plt.rcParams["axes.labelweight"] == "normal"
+
+ _fig, ax = plt.subplots()
+ ax.set_title("title")
+ ax.set_xlabel("x")
+ built = ax._build_chart(640, 480).figure()
+
+ # "normal" is every renderer's own default, so it needs nothing on the
+ # wire — the browser, SVG, and raster paths all land on 400 unaided.
+ assert "font-weight" not in built.chrome_styles["title"]
+ assert "font-weight" not in built.chrome_styles["axis_title"]
+ assert "label_font_weight" not in built.axis_options["x"]["style"]
+
+
+def test_title_and_label_weight_rc_overrides_reach_every_renderer() -> None:
+ with plt.rc_context({"axes.titleweight": "bold", "axes.labelweight": 700}):
+ _fig, ax = plt.subplots()
+ ax.set_title("title")
+ ax.set_xlabel("x")
+ ax.set_ylabel("y")
+ built = ax._build_chart(640, 480).figure()
+ svg = built.to_svg()
+
+ # browser: the chrome slot styles the render client applies
+ assert built.chrome_styles["title"]["font-weight"] == "bold"
+ assert built.chrome_styles["axis_title"]["font-weight"] == "700"
+ # SVG + native raster read the weight off the axis style, not the chrome
+ # slots, so axes.labelweight has to be published in both places.
+ assert built.axis_options["x"]["style"]["label_font_weight"] == "700"
+ assert built.axis_options["y"]["style"]["label_font_weight"] == "700"
+ assert 'font-weight="bold"' in svg
+ assert 'font-weight="700"' in svg
+
+
+def test_explicit_set_title_weight_still_beats_the_rc_default() -> None:
+ _fig, ax = plt.subplots()
+ ax.set_title("title", fontweight="bold")
+ ax.set_xlabel("x", fontweight="light")
+ built = ax._build_chart(640, 480).figure()
+
+ assert built.chrome_styles["title"]["font-weight"] == "bold"
+ assert built.axis_options["x"]["style"]["label_font_weight"] == "light"
diff --git a/tests/test_css_mark_styles.py b/tests/test_css_mark_styles.py
index 156d1e35..097a34fa 100644
--- a/tests/test_css_mark_styles.py
+++ b/tests/test_css_mark_styles.py
@@ -187,7 +187,9 @@ def test_axis_style_reaches_svg_and_native_renderers() -> None:
assert 'stroke="#0000ff" stroke-width="2"' in svg
assert 'stroke="#00aa00" stroke-width="2"' in svg
assert 'fill="#cc5500" font-size="13" text-anchor="middle"' in svg
- assert 'font-size="15" font-weight="500" fill="#aa00aa"' in svg
+ # 400 is the matplotlib-parity axis-label default; this style sets no
+ # `label_font_weight`, so the default is what lands in the SVG.
+ assert 'font-size="15" font-weight="400" fill="#aa00aa"' in svg
assert _raster.render_raster(*fig.build_payload(), scale=1).shape[-1] == 4
diff --git a/tests/test_text_weight_defaults.py b/tests/test_text_weight_defaults.py
new file mode 100644
index 00000000..615a0779
--- /dev/null
+++ b/tests/test_text_weight_defaults.py
@@ -0,0 +1,201 @@
+"""Chrome text weight defaults must match Matplotlib, in every renderer.
+
+Matplotlib 3.11's `axes.titleweight`, `axes.labelweight` and `font.weight` all
+default to `normal`, and its legend titles and colorbar labels are normal too.
+So every xy chrome text default is 400, and the three renderers — the browser
+render client (`js/src/`), the SVG exporter (`python/xy/_svg.py`) and the native
+raster exporter (`python/xy/_raster.py`) — must agree on it. A renderer that
+quietly drifts heavier is the bug these tests exist to catch.
+
+The TypeScript source guard is a source-level assertion on purpose: the render
+client's bundles are a generated, git-ignored artifact, so a test that can only
+read the bundle cannot run from a fresh checkout.
+"""
+
+from __future__ import annotations
+
+import re
+import struct
+from pathlib import Path
+
+import pytest
+
+import xy
+from xy import _raster
+
+_ROOT = Path(__file__).resolve().parents[1]
+_JS = _ROOT / "js" / "src"
+
+# Native text-record header sizes, from `_Cmd.text` in python/xy/_raster.py.
+# Both records end with `u32 byte_length` + UTF-8 payload, so a unique payload
+# is an unambiguous anchor to walk back from.
+# _TEXT_OP : op(1) x(4) y(4) anchor(1) size(4) rgba(4) len(4) = 22
+# _STYLED_TEXT : op(1) x(4) y(4) anchor(1) size(4) angle(4) flags(1)
+# range_count(4) [range(8) * n] rgba(4) len(4) = 31 + 8n
+_TEXT_OP_HEADER = 22
+_STYLED_HEADER = 31
+
+
+def _title_axis_legend_figure(**axis_style):
+ """A chart exercising title, both axis labels, legend title and annotation."""
+ fig = xy.chart(
+ xy.line(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.5], name="series-a"),
+ xy.x_axis(label="XLABEL", style=axis_style or None),
+ xy.y_axis(label="YLABEL", style=axis_style or None),
+ title="TITLE",
+ ).figure()
+ fig.legend_options = {"title": "LEGENDTITLE"}
+ fig.text(1.0, 2.0, "ANNOTATION")
+ return fig
+
+
+def _svg_text_weights(svg: str) -> dict[str, str]:
+ """Map each `` body to the `font-weight` the SVG exporter emitted."""
+ weights: dict[str, str] = {}
+ for attrs, body in re.findall(r"]*)>(?:]*>)?([^<]*)", svg):
+ match = re.search(r'font-weight="([^"]+)"', attrs)
+ weights[body.strip()] = match.group(1) if match else "unset"
+ return weights
+
+
+def _raster_stream(fig) -> bytes:
+ """The native command stream `to_png` hands to the Rust rasterizer."""
+ captured: dict[str, object] = {}
+ original = _raster._Cmd.__init__
+
+ def spy(self, *args, **kwargs):
+ original(self, *args, **kwargs)
+ captured.setdefault("cmd", self)
+
+ _raster._Cmd.__init__ = spy
+ try:
+ fig.to_png(scale=1)
+ finally:
+ _raster._Cmd.__init__ = original
+ return bytes(captured["cmd"].buf) # type: ignore[union-attr]
+
+
+def _raster_text_bold(stream: bytes, text: str) -> bool:
+ """Whether the native record carrying `text` sets the bold emphasis flag."""
+ payload = text.encode("utf-8")
+ at = stream.find(struct.pack("= 0, f"no native text record carries {text!r}"
+ body = at + 4
+ styled = body - _STYLED_HEADER
+ if styled >= 0 and stream[styled] == _raster._STYLED_TEXT:
+ (ranges,) = struct.unpack("= 0 and stream[plain] == _raster._TEXT_OP, (
+ f"record for {text!r} is neither a plain nor a styled text record"
+ )
+ return False # the plain opcode carries no emphasis byte at all
+
+
+# --- SVG exporter ----------------------------------------------------------
+
+
+def test_svg_chrome_text_defaults_to_normal_weight() -> None:
+ weights = _svg_text_weights(_title_axis_legend_figure().to_svg())
+
+ assert weights["TITLE"] == "400"
+ assert weights["XLABEL"] == "400"
+ assert weights["YLABEL"] == "400"
+ assert weights["LEGENDTITLE"] == "400"
+ # The annotation and legend-entry paths emit no weight at all, so they
+ # inherit the document's 400. Asserted so a later default cannot sneak in.
+ assert weights["ANNOTATION"] == "unset"
+ assert weights["series-a"] == "unset"
+
+
+def test_svg_honors_an_explicit_heavier_axis_label_weight() -> None:
+ fig = _title_axis_legend_figure(label_font_weight="bold")
+ assert _svg_text_weights(fig.to_svg())["XLABEL"] == "bold"
+
+
+def test_svg_honors_an_explicit_heavier_title_weight() -> None:
+ fig = _title_axis_legend_figure()
+ fig.chrome_styles["title"] = {"font-weight": "700"}
+ assert _svg_text_weights(fig.to_svg())["TITLE"] == "700"
+
+
+# --- native raster exporter ------------------------------------------------
+
+
+def test_native_raster_chrome_text_carries_no_bold_by_default() -> None:
+ stream = _raster_stream(_title_axis_legend_figure())
+
+ for label in ("TITLE", "XLABEL", "YLABEL", "LEGENDTITLE", "ANNOTATION"):
+ assert not _raster_text_bold(stream, label), f"{label} is bold in the raster stream"
+
+
+@pytest.mark.parametrize("weight", ["bold", "700", 800])
+def test_native_raster_emits_bold_only_when_asked(weight) -> None:
+ fig = _title_axis_legend_figure()
+ fig.chrome_styles["title"] = {"font-weight": weight}
+ stream = _raster_stream(fig)
+
+ assert _raster_text_bold(stream, "TITLE")
+ # Opting the title in must not drag the axis labels along with it.
+ assert not _raster_text_bold(stream, "XLABEL")
+
+
+def test_native_raster_and_svg_agree_on_the_axis_label_default() -> None:
+ fig = _title_axis_legend_figure()
+ svg_weight = _svg_text_weights(fig.to_svg())["XLABEL"]
+ raster_bold = _raster_text_bold(_raster_stream(fig), "XLABEL")
+
+ assert svg_weight == "400"
+ assert raster_bold is False
+
+
+# --- browser render client (source-level guard) -----------------------------
+
+# Slot rules in js/src/20_theme.ts that carry chrome *text*. Each must declare
+# 400, or declare nothing and inherit it.
+_TEXT_SLOTS = (
+ "title",
+ "axis_title",
+ "tick_label",
+ "legend",
+ "colorbar",
+ "colorbar_title",
+ "annotation_label",
+)
+
+
+def test_theme_css_chrome_text_slots_declare_normal_weight() -> None:
+ css = (_JS / "20_theme.ts").read_text(encoding="utf-8")
+ rules = dict(re.findall(r'data-xy-slot="(\w+)"\]\)\{([^}]*)\}', css))
+
+ for slot in _TEXT_SLOTS:
+ assert slot in rules, f"no `{slot}` slot rule in 20_theme.ts"
+ weight = re.search(r"font-weight:\s*([^;}]+)", rules[slot])
+ assert weight is None or weight.group(1).strip() in {"400", "normal"}, (
+ f'slot "{slot}" declares font-weight:{weight.group(1).strip()}; '
+ "Matplotlib renders every chrome text element at normal weight"
+ )
+
+
+def test_chartview_inline_text_weights_are_all_normal() -> None:
+ """The inline axis-label/legend-title weights beat the slot stylesheet.
+
+ They are written out at ten sites in `_axisLabelCss`/`_drawChrome`/
+ `_legendBox`, so a guard on the stylesheet alone would not catch a drift
+ here.
+ """
+ source = (_JS / "50_chartview.ts").read_text(encoding="utf-8")
+
+ inline = set(re.findall(r"font-weight:\s*(\d+|normal|bold)", source))
+ assert inline, "expected inline font-weight declarations in 50_chartview.ts"
+ assert inline <= {"400", "normal"}, (
+ f"50_chartview.ts declares inline chrome weights {sorted(inline)}; "
+ "only normal/400 matches Matplotlib"
+ )
+
+ assigned = set(re.findall(r"\.style\.fontWeight\s*=\s*\"(\d+|normal|bold)\"", source))
+ assert assigned <= {"400", "normal"}, (
+ f"50_chartview.ts assigns chrome fontWeight {sorted(assigned)}; "
+ "only normal/400 matches Matplotlib"
+ )