Skip to content

API

Signatures and docstrings are read out of skill/scripts/ when this page builds, so what is shown is what the code has.

Installed, the three modules are a package:

from figure_gate import check_figure as cf     # needs matplotlib
from figure_gate import check_palette as cp    # standard library only
from figure_gate import suggest_fixes as sf

Vendoring, the default route, copies the files into your own project. They are then whatever you named them, imported flat:

import check_figure as cf
import check_palette as cp
import suggest_fixes as sf

Same modules, same signatures; only the import line differs. Everything below applies to both.

Not on this page: the thresholds. They are module-level constants, one table per module on the gates, with the measurement behind each on the style guide. Read them as cf.TYPE_FLOOR_PT.

Everything else is here, including the 21 gate functions, at the bottom. audit is what most callers want: it runs all 21 and computes the renderer, canvas and scale arguments they take. Call one directly and that is yours to reproduce, which the how-to covers.

check_figure

Composition. Takes a built figure, measures what it renders at print size.

check_figure.audit

audit(fig: Figure, scale: float | None = None, placed_frac: float = 1.0, *, context_axes: Sequence[Axes] | None = None, venue: str | None = None) -> tuple[bool, list[tuple[str, bool | str, str]]]

Run every gate over a figure. Returns (ok, rows).

check_palette.check returns the same shape. It returned (rows, ok) until 0.4.0, and unpacking either one the wrong way binds a bool to the rows and raises nothing at the call site, which is why they were made to agree rather than documented as differing.

rows are (label, status, detail), one per gate, in the order the report prints them. status is True, False, or the string "warn"; only a hard False sets ok to False, so an advisory row reports without gating a build.

placed_frac is the fraction of the content width the figure is placed at, and venue names a row of VENUE_WIDTH_PT instead of setting CONTENT_WIDTH_PT by hand; between them they decide the page scale every type and stroke measurement runs through. scale overrides that calculation outright. context_axes names axes whose fill is a context surface rather than data ink, which is what stops a filled contourf panel reading as saturated.

context_axes and venue are keyword-only. They were positional until 0.9.0, and a venue passed in the context_axes slot was iterated into a frozenset of axes ids rather than raising, because a string is iterable. The venue was discarded and the figure was measured at the wrong width: a wrong verdict, reported green, from an argument order. Keyword-only is the only shape in which that call cannot be written.

Parameters:

Name Type Description Default
fig Figure

The built figure. Measured through an Agg canvas at MEASURE_DPI and handed back on its authored dpi, so the verdict depends on neither the backend it was made on nor the resolution it was set to.

required
scale float | None

Points per authored inch, overriding page_scale outright.

None
placed_frac float

Fraction of the content width the figure is placed at.

1.0
venue str | None

A key of VENUE_WIDTH_PT, overriding CONTENT_WIDTH_PT.

None
context_axes Sequence[Axes] | None

Axes whose fill is a context surface, not data ink.

None

Returns:

Type Description
bool

(ok, rows). rows are (label, status, detail), one per gate, in

list[tuple[str, bool | str, str]]

report order; ok is False only when a row is a hard False.

Source code in skill/scripts/check_figure.py
def audit(fig: Figure, scale: float | None = None, placed_frac: float = 1.0,
          *,
          context_axes: Sequence[Axes] | None = None,
          venue: str | None = None,
          ) -> tuple[bool, list[tuple[str, bool | str, str]]]:
    """Run every gate over a figure. Returns `(ok, rows)`.

    `check_palette.check` returns the same shape. It returned `(rows, ok)`
    until 0.4.0, and unpacking either one the wrong way binds a bool to the
    rows and raises nothing at the call site, which is why they were made to
    agree rather than documented as differing.

    `rows` are `(label, status, detail)`, one per gate, in the order the report
    prints them. `status` is True, False, or the string "warn"; only a hard
    False sets `ok` to False, so an advisory row reports without gating a
    build.

    `placed_frac` is the fraction of the content width the figure is placed at,
    and `venue` names a row of `VENUE_WIDTH_PT` instead of setting
    `CONTENT_WIDTH_PT` by hand; between them they decide the page scale every
    type and stroke measurement runs through. `scale` overrides that
    calculation outright. `context_axes` names axes whose fill is a context
    surface rather than data ink, which is what stops a filled contourf panel
    reading as saturated.

    `context_axes` and `venue` are keyword-only. They were positional until
    0.9.0, and a `venue` passed in the `context_axes` slot was iterated into a
    frozenset of axes ids rather than raising, because a string is iterable.
    The venue was discarded and the figure was measured at the wrong width: a
    wrong verdict, reported green, from an argument order. Keyword-only is the
    only shape in which that call cannot be written.

    Args:
        fig: The built figure. Measured through an Agg canvas at `MEASURE_DPI`
            and handed back on its authored dpi, so the verdict depends on
            neither the backend it was made on nor the resolution it was set to.
        scale: Points per authored inch, overriding `page_scale` outright.
        placed_frac: Fraction of the content width the figure is placed at.
        venue: A key of `VENUE_WIDTH_PT`, overriding `CONTENT_WIDTH_PT`.
        context_axes: Axes whose fill is a context surface, not data ink.

    Returns:
        `(ok, rows)`. `rows` are `(label, status, detail)`, one per gate, in
        report order; `ok` is False only when a row is a hard False.
    """
    with _at_draw_rc(fig), _at_measure_dpi(fig):
        rows = _rows(fig, scale, placed_frac, venue, context_axes)
    # "warn" rows are advisory: they report something worth a look without
    # failing the build. Only a hard False gates.
    return all(s is not False for _, s, _ in rows), rows

check_figure.report

report(fig: Figure, name: str = '', scale: float | None = None, placed_frac: float = 1.0, *, context_axes: Sequence[Axes] | None = None, venue: str | None = None, suggest: bool = False) -> bool

audit(), printed. Returns the same ok bool and nothing else.

The arguments are audit's, plus name for the heading. Advisory rows print as WARN and do not change the verdict, so a figure can be COMPOSED with advisories; only a hard FAIL makes this return False.

suggest=True prints what suggest_fixes.py has to offer for the marked rows, under the table. Off by default and kept in that file rather than this one: a gate measures, and what to do about it is a separate claim that can be wrong on its own. Several rows get more than one suggestion, because the choice between them is the author's.

This is what the examples and the CLI call. Use audit() when the rows themselves are wanted rather than a printed table.

context_axes, venue and suggest are keyword-only, matching audit. They were positional until 0.9.0, and a venue passed in the context_axes slot was iterated into a frozenset of axes ids rather than raising, because a string is iterable. The venue was discarded and the figure was measured at the wrong width: a wrong verdict, reported green, from an argument order. Keyword-only is the only shape in which that call cannot be written.

Parameters:

Name Type Description Default
fig Figure

The built figure.

required
name str

A heading for the table.

''
scale float | None

Points per authored inch, overriding page_scale outright.

None
placed_frac float

Fraction of the content width the figure is placed at.

1.0
venue str | None

A key of VENUE_WIDTH_PT, overriding CONTENT_WIDTH_PT.

None
context_axes Sequence[Axes] | None

Axes whose fill is a context surface, not data ink.

None
suggest bool

Print suggest_fixes remedies under the table.

False

Returns:

Type Description
bool

audit's ok bool. Advisory rows print as WARN without changing it.

Source code in skill/scripts/check_figure.py
def report(fig: Figure, name: str = "", scale: float | None = None,
           placed_frac: float = 1.0,
           *,
           context_axes: Sequence[Axes] | None = None,
           venue: str | None = None, suggest: bool = False) -> bool:
    """`audit()`, printed. Returns the same `ok` bool and nothing else.

    The arguments are `audit`'s, plus `name` for the heading. Advisory rows
    print as WARN and do not change the verdict, so a figure can be COMPOSED
    with advisories; only a hard FAIL makes this return False.

    `suggest=True` prints what `suggest_fixes.py` has to offer for the marked
    rows, under the table. Off by default and kept in that file rather than
    this one: a gate measures, and what to do about it is a separate claim that
    can be wrong on its own. Several rows get more than one suggestion, because
    the choice between them is the author's.

    This is what the examples and the CLI call. Use `audit()` when the rows
    themselves are wanted rather than a printed table.

    `context_axes`, `venue` and `suggest` are keyword-only, matching `audit`.
    They were positional until 0.9.0, and a `venue` passed in the
    `context_axes` slot was iterated into a frozenset of axes ids rather than
    raising, because a string is iterable. The venue was discarded and the
    figure was measured at the wrong width: a wrong verdict, reported green,
    from an argument order. Keyword-only is the only shape in which that call
    cannot be written.

    Args:
        fig: The built figure.
        name: A heading for the table.
        scale: Points per authored inch, overriding `page_scale` outright.
        placed_frac: Fraction of the content width the figure is placed at.
        venue: A key of `VENUE_WIDTH_PT`, overriding `CONTENT_WIDTH_PT`.
        context_axes: Axes whose fill is a context surface, not data ink.
        suggest: Print `suggest_fixes` remedies under the table.

    Returns:
        `audit`'s `ok` bool. Advisory rows print as WARN without changing it.
    """
    ok, rows = audit(fig, scale, placed_frac,
                     context_axes=context_axes, venue=venue)
    print(f"\nComposition audit{': ' + name if name else ''}")
    warned = False
    for label, status, detail in rows:
        tag = "WARN" if status == "warn" else ("PASS" if status else "FAIL")
        warned = warned or status == "warn"
        print(f"  [{tag}] {label:<18} {detail}")
    verdict = "COMPOSED" if ok else "FIX THE MARKED CHECKS"
    if ok and warned:
        verdict += " (with advisories)"
    print(f"\n  -> {verdict}\n")
    if suggest:
        _print_suggestions(rows)
    return ok

check_figure.describe

describe(fig: Figure, text: str) -> None

Attach a text description to a figure, for readers who cannot see it.

Across 100,000 public Jupyter notebooks, 99.81% of programmatically generated images shipped with no alt text at all, and the overwhelming majority of them were matplotlib. Matplotlib has no field for this, so the description is stashed on the figure and handed to savefig:

describe(fig, "Validation loss against training epoch for three "
              "optimisers. All three fall; the Bayesian run reaches "
              "0.05 by epoch 6, the baseline is still at 0.25 at 12.")
fig.savefig(path, metadata=alt_metadata(fig, path))

Say what the reader would have taken from looking, not what the figure is made of. "A line chart with three lines" describes the file; the numbers and the direction describe the finding.

Parameters:

Name Type Description Default
fig Figure

The figure to attach the description to.

required
text str

The description. ALT_TEXT_MIN_CHARS is the gate's floor.

required

Returns:

Type Description
None

None. The description is stashed on fig for alt_metadata to read.

Source code in skill/scripts/check_figure.py
def describe(fig: Figure, text: str) -> None:
    """Attach a text description to a figure, for readers who cannot see it.

    Across 100,000 public Jupyter notebooks, 99.81% of programmatically
    generated images shipped with no alt text at all, and the overwhelming
    majority of them were matplotlib. Matplotlib has no field for this, so the
    description is stashed on the figure and handed to `savefig`:

        describe(fig, "Validation loss against training epoch for three "
                      "optimisers. All three fall; the Bayesian run reaches "
                      "0.05 by epoch 6, the baseline is still at 0.25 at 12.")
        fig.savefig(path, metadata=alt_metadata(fig, path))

    Say what the reader would have taken from looking, not what the figure is
    made of. "A line chart with three lines" describes the file; the numbers
    and the direction describe the finding.

    Args:
        fig: The figure to attach the description to.
        text: The description. `ALT_TEXT_MIN_CHARS` is the gate's floor.

    Returns:
        `None`. The description is stashed on `fig` for `alt_metadata` to read.
    """
    setattr(fig, ALT_TEXT_ATTR, str(text))
    return fig

check_figure.alt_metadata

alt_metadata(fig: Figure, path: Any = None) -> dict[str, str] | None

The metadata= dict for savefig, carrying whatever describe set.

Pass the same path you are about to save to, so the description lands in a field the target format actually has:

fig.savefig(path, metadata=alt_metadata(fig, path))

PNG, PDF and SVG all keep a description and none of them agrees with the others about what to call it, so the key is chosen from the suffix.

For a format that carries no description -- ps, or any of the rasters -- this returns None rather than an empty dict, and the difference is not cosmetic. matplotlib's guard is elif metadata is not None: raise, so an empty dict is rejected exactly as hard as a full one; savefig(path, metadata={}) on a jpeg is a traceback. None is savefig's own default for the argument and the only value those formats accept.

Called without a path, or with a buffer whose format cannot be read, it returns Description. That is right for PNG and SVG and is what every earlier version returned unconditionally.

Parameters:

Name Type Description Default
fig Figure

The figure describe was called on.

required
path Any

The path about to be saved to. The suffix picks the key.

None

Returns:

Type Description
dict[str, str] | None

A dict for savefig(metadata=), or None for a format that carries

dict[str, str] | None

no description. Empty when describe was never called.

Source code in skill/scripts/check_figure.py
def alt_metadata(fig: Figure, path: Any = None) -> dict[str, str] | None:
    """The `metadata=` dict for `savefig`, carrying whatever `describe` set.

    Pass the same `path` you are about to save to, so the description lands in
    a field the target format actually has:

        fig.savefig(path, metadata=alt_metadata(fig, path))

    PNG, PDF and SVG all keep a description and none of them agrees with the
    others about what to call it, so the key is chosen from the suffix.

    For a format that carries no description -- ps, or any of the rasters --
    this returns `None` rather than an empty dict, and the difference is not
    cosmetic. matplotlib's guard is `elif metadata is not None: raise`, so an
    empty dict is rejected exactly as hard as a full one; `savefig(path,
    metadata={})` on a jpeg is a traceback. `None` is `savefig`'s own default
    for the argument and the only value those formats accept.

    Called without a path, or with a buffer whose format cannot be read, it
    returns `Description`. That is right for PNG and SVG and is what every
    earlier version returned unconditionally.

    Args:
        fig: The figure `describe` was called on.
        path: The path about to be saved to. The suffix picks the key.

    Returns:
        A dict for `savefig(metadata=)`, or `None` for a format that carries
        no description. Empty when `describe` was never called.
    """
    # Before the empty-description check, because a format that rejects the
    # kwarg rejects `{}` too -- the figure having nothing to say does not make
    # the jpeg save survive.
    suffix = _savefig_suffix(path) if path is not None else None
    if suffix in ALT_TEXT_UNSUPPORTED_SUFFIXES:
        return None
    text = getattr(fig, ALT_TEXT_ATTR, None)
    if not text:
        return {}
    if suffix is None:
        return {ALT_TEXT_KEY_DEFAULT: text}
    return {ALT_TEXT_KEY_BY_SUFFIX.get(suffix, ALT_TEXT_KEY_DEFAULT): text}

check_figure.page_scale

page_scale(fig: Figure, placed_frac: float = 1.0, venue: str | None = None) -> float

Scale from authored inches to points on the page.

placed_frac is the fraction of the content width the figure is placed at, so it reads like the call site: \includegraphics[width=0.48\textwidth] is placed_frac=0.48. Without it every figure is measured as if it were full width, and a half-width figure is certified at twice the type size it actually ships at - which is the wrong direction for a legibility gate to be wrong in.

venue names a row of VENUE_WIDTH_PT and overrides CONTENT_WIDTH_PT for this call, which is the usual way in: the width is a property of the document, not of the checkout.

Parameters:

Name Type Description Default
fig Figure

The figure, read for its authored width in inches.

required
placed_frac float

Fraction of the content width the figure is placed at.

1.0
venue str | None

A key of VENUE_WIDTH_PT, overriding CONTENT_WIDTH_PT.

None

Returns:

Type Description
float

Points on the page per authored inch. 1.0 when no content width is

float

set, which measures the figure at the size it was authored.

Raises:

Type Description
ValueError

placed_frac is not 1.0 and no content width is set.

Source code in skill/scripts/check_figure.py
def page_scale(fig: Figure, placed_frac: float = 1.0,
               venue: str | None = None) -> float:
    """Scale from authored inches to points on the page.

    `placed_frac` is the fraction of the content width the figure is placed at,
    so it reads like the call site: `\\includegraphics[width=0.48\\textwidth]`
    is `placed_frac=0.48`. Without it every figure is measured as if it were
    full width, and a half-width figure is certified at twice the type size it
    actually ships at - which is the wrong direction for a legibility gate to
    be wrong in.

    `venue` names a row of `VENUE_WIDTH_PT` and overrides `CONTENT_WIDTH_PT` for
    this call, which is the usual way in: the width is a property of the
    document, not of the checkout.

    Args:
        fig: The figure, read for its authored width in inches.
        placed_frac: Fraction of the content width the figure is placed at.
        venue: A key of `VENUE_WIDTH_PT`, overriding `CONTENT_WIDTH_PT`.

    Returns:
        Points on the page per authored inch. `1.0` when no content width is
        set, which measures the figure at the size it was authored.

    Raises:
        ValueError: `placed_frac` is not 1.0 and no content width is set.
    """
    width = content_width_pt(venue)
    if width is None:
        if placed_frac != 1.0:
            raise ValueError(
                "placed_frac requires a content width. With CONTENT_WIDTH_PT "
                "None and no venue= the checker assumes you authored the "
                "figure at the width it is placed at, which already makes the "
                "scale 1.0; a fractional placement contradicts that. Pass "
                "venue=, set CONTENT_WIDTH_PT, or author at the placed width "
                "and drop placed_frac.")
        return 1.0
    return width * placed_frac / (fig.get_size_inches()[0] * 72)

check_figure.content_width_pt

content_width_pt(venue: str | None = None) -> float | None

The usable page width to measure against, in points.

Parameters:

Name Type Description Default
venue str | None

A key of VENUE_WIDTH_PT. None reads CONTENT_WIDTH_PT.

None

Returns:

Type Description
float | None

The width in points, or None when neither is set.

Raises:

Type Description
KeyError

The venue is not in VENUE_WIDTH_PT.

Source code in skill/scripts/check_figure.py
def content_width_pt(venue: str | None = None) -> float | None:
    """The usable page width to measure against, in points.

    Args:
        venue: A key of `VENUE_WIDTH_PT`. `None` reads `CONTENT_WIDTH_PT`.

    Returns:
        The width in points, or `None` when neither is set.

    Raises:
        KeyError: The venue is not in `VENUE_WIDTH_PT`.
    """
    if venue is None:
        return CONTENT_WIDTH_PT
    try:
        return VENUE_WIDTH_PT[venue]
    except KeyError:
        raise KeyError(
            f"unknown venue {venue!r}. Known: "
            f"{', '.join(sorted(VENUE_WIDTH_PT))}. For anything else, put "
            "\\the\\textwidth in the document, read the log, and set "
            "CONTENT_WIDTH_PT to what it says.") from None

check_figure.scatter_diameter_pt

scatter_diameter_pt(size: float | ndarray) -> float | ndarray

The diameter in points that scatter(s=size) actually draws.

matplotlib documents s as "the marker size in points**2", which reads as an area and is not one. The unit marker path for 'o' is a circle of radius 0.5, and scatter scales it by sqrt(s): the drawn diameter is sqrt(s) points and the drawn area is pi * s / 4, a factor 4/pi below the nominal number.

Measured rather than inferred. At 200 dpi, scatter(s=100) and plot(markersize=10) each lay down 741 pixels of ink, which is what makes markersize and sqrt(s) the same quantity and s an area only up to that constant. test_scatter_size_is_a_squared_diameter pins it against matplotlib rather than against this docstring.

Parameters:

Name Type Description Default
size float | ndarray

The value passed to scatter(s=). A float or a numpy array.

required

Returns:

Type Description
float | ndarray

The drawn diameter in points, sqrt(size), in the same shape as

float | ndarray

size was given.

Public because both check_mark_ratio and check_overplotting decide on it.

Source code in skill/scripts/check_figure.py
def scatter_diameter_pt(size: float | np.ndarray) -> float | np.ndarray:
    """The diameter in points that `scatter(s=size)` actually draws.

    matplotlib documents `s` as "the marker size in points**2", which reads as
    an area and is not one. The unit marker path for 'o' is a circle of radius
    0.5, and scatter scales it by `sqrt(s)`: the drawn diameter is `sqrt(s)`
    points and the drawn area is `pi * s / 4`, a factor 4/pi below the nominal
    number.

    Measured rather than inferred. At 200 dpi, `scatter(s=100)` and
    `plot(markersize=10)` each lay down 741 pixels of ink, which is what makes
    `markersize` and `sqrt(s)` the same quantity and `s` an area only up to
    that constant. `test_scatter_size_is_a_squared_diameter` pins it against
    matplotlib rather than against this docstring.

    Args:
        size: The value passed to `scatter(s=)`. A float or a numpy array.

    Returns:
        The drawn diameter in points, `sqrt(size)`, in the same shape as
        `size` was given.

    Public because both `check_mark_ratio` and `check_overplotting` decide on
    it.
    """
    return size ** 0.5

check_palette

Colour. Standard library only, so these are also the functions to port when the checks are reimplemented elsewhere.

check_palette.check

check(colors: Sequence[str], surface: str = '#ffffff', all_pairs: bool = False, ordinal: bool = False, ink: Collection[str] = frozenset()) -> tuple[bool, list[tuple[str, bool | str, str]]]

Gate a palette. Returns (ok, rows).

The order matches check_figure.audit. It did not until 0.4.0: this returned (rows, ok) and the README carried a paragraph warning about the difference, which is documentation standing in for a fix. Unpacking either one the wrong way binds a bool to the rows and raises nothing, so the two were made the same rather than described.

rows are (name, status, detail), one per gate. status is True, False, or the string "warn" for the advisory contrast row, and only a hard False sets ok to False.

colors are hex strings. surface is the page they are drawn on and sets what the contrast row measures against. all_pairs gates every pair rather than adjacent ones, which is what a scatter needs and a line chart does not. ordinal swaps the categorical separation rows for the ramp rows: monotone lightness, even steps, a light end that still holds contrast. ink names colours to treat as furniture rather than data.

Parameters:

Name Type Description Default
colors Sequence[str]

Hex strings, the palette to gate.

required
surface str

The page colour they are drawn on.

'#ffffff'
all_pairs bool

Gate every pair rather than adjacent ones.

False
ordinal bool

Swap the categorical rows for the ramp rows.

False
ink Collection[str]

Colours to treat as furniture rather than data.

frozenset()

Returns:

Type Description
bool

(ok, rows). rows are (name, status, detail), one per gate;

list[tuple[str, bool | str, str]]

status is True, False or "warn", and ok is False only when a

tuple[bool, list[tuple[str, bool | str, str]]]

row is a hard False.

Source code in skill/scripts/check_palette.py
def check(colors: Sequence[str], surface: str = "#ffffff",
          all_pairs: bool = False, ordinal: bool = False,
          ink: Collection[str] = frozenset(),
          ) -> tuple[bool, list[tuple[str, bool | str, str]]]:
    """Gate a palette. Returns `(ok, rows)`.

    The order matches `check_figure.audit`. It did not until 0.4.0: this
    returned `(rows, ok)` and the README carried a paragraph warning about the
    difference, which is documentation standing in for a fix. Unpacking either
    one the wrong way binds a bool to the rows and raises nothing, so the two
    were made the same rather than described.

    `rows` are `(name, status, detail)`, one per gate. `status` is True,
    False, or the string "warn" for the advisory contrast row, and only a
    hard False sets `ok` to False.

    `colors` are hex strings. `surface` is the page they are drawn on and sets
    what the contrast row measures against. `all_pairs` gates every pair rather
    than adjacent ones, which is what a scatter needs and a line chart does
    not. `ordinal` swaps the categorical separation rows for the ramp rows:
    monotone lightness, even steps, a light end that still holds contrast.
    `ink` names colours to treat as furniture rather than data.

    Args:
        colors: Hex strings, the palette to gate.
        surface: The page colour they are drawn on.
        all_pairs: Gate every pair rather than adjacent ones.
        ordinal: Swap the categorical rows for the ramp rows.
        ink: Colours to treat as furniture rather than data.

    Returns:
        `(ok, rows)`. `rows` are `(name, status, detail)`, one per gate;
        `status` is True, False or "warn", and `ok` is False only when a
        row is a hard False.
    """
    lin = [hex_to_linear(c) for c in colors]
    lab = [linear_to_oklab(v) for v in lin]
    rows: list[tuple[str, bool | str, str]] = []
    ok = True

    if ordinal:
        ls = [v[0] for v in lab]
        mono = all(x > y for x, y in zip(ls, ls[1:])) or all(x < y for x, y in zip(ls, ls[1:]))
        rows.append(("Lightness monotone", mono, "steps read light->dark" if mono
                     else "steps are not monotone in lightness"))
        gaps = [abs(x - y) for x, y in zip(ls, ls[1:])]
        gap_ok = all(g >= ORDINAL_DL_MIN for g in gaps)
        rows.append(("Adjacent dL", gap_ok,
                     f"min gap {min(gaps):.3f}" if gaps else "single step"))
        light_end = max(colors, key=lambda c: linear_to_oklab(hex_to_linear(c))[0])
        cr = contrast(light_end, surface)
        rows.append(("Light-end contrast", cr >= ORDINAL_LIGHT_END_CONTRAST_MIN,
                     f"{light_end} at {cr:.2f}:1 vs surface"))
        # Step uniformity replaced an earlier "Single hue" gate (hue spread <= 20
        # degrees). That gate was a proxy for the property actually wanted, and it
        # rejected perceptually uniform multi-hue ramps such as viridis while
        # accepting a single-hue ramp with wildly uneven steps. What makes a ramp
        # readable is monotone lightness in even increments, which the three rows
        # above plus this one measure directly. The rainbow failure mode the old
        # gate was aimed at (jet) is caught by "Lightness monotone".
        ratio = max(gaps) / min(gaps) if gaps and min(gaps) > 0 else float("inf")
        rows.append(("Step uniformity", ratio <= ORDINAL_STEP_RATIO_MAX,
                     f"largest/smallest dL {ratio:.2f}" if gaps else "single step"))
        return all(r[1] for r in rows), rows

    ink_set = set(ink) if isinstance(ink, frozenset) else set(ink)
    band = [c for c, v in zip(colors, lab)
            if c not in ink_set and not (L_MIN <= v[0] <= L_MAX)]
    n_exempt_band = sum(1 for c in colors if c in ink_set and
                        not (L_MIN <= linear_to_oklab(hex_to_linear(c))[0] <= L_MAX))
    rows.append(("Lightness band", not band,
                 f"all {len(colors)} inside L {L_MIN}-{L_MAX}" if not band
                 else f"outside: {band}"
                 + (f" ({n_exempt_band} ink tokens exempted)" if n_exempt_band else "")))

    chroma = [c for c, v in zip(colors, lab)
              if c not in ink_set and math.hypot(v[1], v[2]) < CHROMA_MIN]
    n_exempt_chroma = sum(1 for c in colors if c in ink_set and
                          math.hypot(*linear_to_oklab(hex_to_linear(c))[1:]) < CHROMA_MIN)
    rows.append(("Chroma floor", not chroma,
                 f"all {len(colors)} >= {CHROMA_MIN}" if not chroma
                 else f"too gray: {chroma}"
                 + (f" ({n_exempt_chroma} ink tokens exempted)" if n_exempt_chroma else "")))

    pairs = (list(itertools.combinations(range(len(colors)), 2)) if all_pairs
             else [(i, i + 1) for i in range(len(colors) - 1)])
    label = "all-pairs" if all_pairs else "adjacent"

    # Gate on protanopia and deuteranopia (~8% of males between them). Tritan is
    # reported but not gated: it is ~0.01% prevalence, and the Vienot matrix used
    # here is only validated for the red-green forms, so a tritan number is
    # indicative rather than decisive.
    #
    # Swept over severity, not read at the endpoint. Dichromacy is not the worst
    # case: measured over 240000 pairs of hues this file would accept as series
    # slots, 0.87% clear CVD_TARGET at dichromacy and miss it at some lower
    # severity. See MACHADO.
    worst_cvd, worst_cvd_at = float("inf"), None
    for i, j in pairs:
        for kind in ("protan", "deutan"):
            views = [(delta_e(simulate(lin[i], kind), simulate(lin[j], kind)),
                      1.0)]
            views += [(delta_e(simulate_anomalous(lin[i], kind, s),
                               simulate_anomalous(lin[j], kind, s)), s)
                      for s in ANOMALOUS_SEVERITIES]
            d, at = min(views)
            if d < worst_cvd:
                worst_cvd, worst_cvd_at = d, (colors[i], colors[j], kind, at)
    worst_tri = min((delta_e(simulate(lin[i], "tritan"), simulate(lin[j], "tritan"))
                     for i, j in pairs), default=float("nan"))
    if worst_cvd_at:
        a, b, kind, at = worst_cvd_at
        good = worst_cvd >= CVD_TARGET
        rows.append((f"CVD separation ({label})", good,
                     f"worst {a} vs {b} dE {worst_cvd:.1f} ({kind} at severity "
                     f"{at:.1f}) - tritan {worst_tri:.1f}"
                     + ("" if good else "  [FIX] needs direct labels/gaps/texture, or re-step")))

    worst_n, worst_n_at = float("inf"), None
    for i, j in pairs:
        d = delta_e(lin[i], lin[j])
        if d < worst_n:
            worst_n, worst_n_at = d, (colors[i], colors[j])
    if worst_n_at:
        a, b = worst_n_at
        good = worst_n >= NORMAL_FLOOR
        rows.append((f"Normal-vision floor ({label})", good,
                     f"worst {a} vs {b} dE {worst_n:.1f}"
                     + ("" if good else
                        "  [FIX] move one of the pair, or re-step the ramp"
                        f"  [WHY] below {NORMAL_FLOOR}, hard to tell apart in "
                        "full color")))

    # Advisory, not a gate: a sub-3:1 hue is legal, it just obligates a visible
    # direct label. Reporting it as FAIL while the run still passes reads as a
    # contradiction, so it carries its own status.
    low = [(c, round(contrast(c, surface), 2)) for c in colors if contrast(c, surface) < CONTRAST_MIN]
    rows.append(("Contrast vs surface", "warn" if low else True,
                 f"all >= {CONTRAST_MIN}:1" if not low
                 else f"under {CONTRAST_MIN}:1, each needs a visible direct label: {low}"))

    ok = all(r[1] is True for r in rows if r[1] != "warn")
    return ok, rows

check_palette.cmap_kind

cmap_kind(samples: Sequence[str]) -> str

Classify hex samples as qualitative, sequential, diverging, cyclic or misc.

misc is the failure: the lightness reverses, or its span is flat, or its halves are monotone and its ends match neither cyclic nor diverging. A colormap in that state encodes nothing the reader can order.

Fewer than CMAP_QUALITATIVE_N = 40 samples is read as a set of category colours rather than a ramp, and gated on separation instead.

Parameters:

Name Type Description Default
samples Sequence[str]

Hex strings sampled along the colormap, in order.

required

Returns:

Type Description
str

One of "qualitative", "sequential", "diverging", "cyclic" or

str

"misc".

Source code in skill/scripts/check_palette.py
def cmap_kind(samples: Sequence[str]) -> str:
    """Classify hex samples as `qualitative`, `sequential`, `diverging`,
    `cyclic` or `misc`.

    `misc` is the failure: the lightness reverses, or its span is flat, or its
    halves are monotone and its ends match neither cyclic nor diverging. A
    colormap in that state encodes nothing the reader can order.

    Fewer than `CMAP_QUALITATIVE_N = 40` samples is read as a set of category
    colours rather than a ramp, and gated on separation instead.

    Args:
        samples: Hex strings sampled along the colormap, in order.

    Returns:
        One of `"qualitative"`, `"sequential"`, `"diverging"`, `"cyclic"` or
        `"misc"`.
    """
    if len(samples) < CMAP_QUALITATIVE_N:
        return "qualitative"

    ls = [linear_to_oklab(hex_to_linear(h))[0] for h in samples]

    if max(ls) - min(ls) < CMAP_SPAN_MIN:
        return "misc"

    if _back_travel(ls) < CMAP_BACKTRAVEL_MAX:
        return "sequential"

    half = len(ls) // 2
    if (_back_travel(ls[:half + 1]) < CMAP_BACKTRAVEL_MAX
            and _back_travel(ls[half:]) < CMAP_BACKTRAVEL_MAX):
        wrap = delta_e(hex_to_linear(samples[0]), hex_to_linear(samples[-1]))
        return "cyclic" if wrap < CMAP_WRAP_DE_MAX else "diverging"

    return "misc"

check_palette.cmap_kind_rgb

cmap_kind_rgb(samples: Sequence[tuple[float, float, float]]) -> str

cmap_kind on float sRGB. See cmap_back_travel_rgb for why.

Source code in skill/scripts/check_palette.py
def cmap_kind_rgb(samples: Sequence[tuple[float, float, float]]) -> str:
    """`cmap_kind` on float sRGB. See `cmap_back_travel_rgb` for why."""
    if len(samples) < CMAP_QUALITATIVE_N:
        return "qualitative"
    ls = [_lightness_rgb(s) for s in samples]
    if max(ls) - min(ls) < CMAP_SPAN_MIN:
        return "misc"
    if _back_travel(ls) < CMAP_BACKTRAVEL_MAX:
        return "sequential"
    half = len(ls) // 2
    if (_back_travel(ls[:half + 1]) < CMAP_BACKTRAVEL_MAX
            and _back_travel(ls[half:]) < CMAP_BACKTRAVEL_MAX):
        first = tuple(_srgb_to_linear(float(c)) for c in samples[0])
        last = tuple(_srgb_to_linear(float(c)) for c in samples[-1])
        wrap = delta_e(first, last)
        return "cyclic" if wrap < CMAP_WRAP_DE_MAX else "diverging"
    return "misc"

check_palette.cmap_back_travel

cmap_back_travel(samples: Sequence[str]) -> float

How much of a ramp's lightness runs backwards, as a fraction of its span.

0.0 is monotone. CMAP_BACKTRAVEL_MAX = 0.02 is where a ramp stops counting as ordered, because lightness that reverses makes two different values render at the same lightness.

Parameters:

Name Type Description Default
samples Sequence[str]

Hex strings sampled along the ramp, in ramp order.

required

Returns:

Type Description
float

Backward lightness travel as a fraction of the ramp's span. 0.0 is

float

monotone.

Source code in skill/scripts/check_palette.py
def cmap_back_travel(samples: Sequence[str]) -> float:
    """How much of a ramp's lightness runs backwards, as a fraction of its span.

    0.0 is monotone. `CMAP_BACKTRAVEL_MAX = 0.02` is where a ramp stops
    counting as ordered, because lightness that reverses makes two different
    values render at the same lightness.

    Args:
        samples: Hex strings sampled along the ramp, in ramp order.

    Returns:
        Backward lightness travel as a fraction of the ramp's span. 0.0 is
        monotone.
    """
    return _back_travel([linear_to_oklab(hex_to_linear(h))[0] for h in samples])

check_palette.cmap_back_travel_rgb

cmap_back_travel_rgb(samples: Sequence[tuple[float, float, float]]) -> float

cmap_back_travel on float sRGB, without the 8-bit round trip.

The hex API rounds every channel to 1/255 before lightness is computed. On a smooth ramp those roundings are oscillations of about 0.001 OKLab that accumulate into the measure: winter reads 0.0343 through hex against 0.0139 in float, which straddles the 0.02 floor and made a sequential map classify misc. Maps that genuinely reverse are unaffected, because their reversals are orders of magnitude larger than the rounding.

Parameters:

Name Type Description Default
samples Sequence[tuple[float, float, float]]

(r, g, b) floats in 0..1, in ramp order.

required

Returns:

Type Description
float

Backward lightness travel as a fraction of the ramp's span.

Source code in skill/scripts/check_palette.py
def cmap_back_travel_rgb(samples: Sequence[tuple[float, float, float]]) -> float:
    """`cmap_back_travel` on float sRGB, without the 8-bit round trip.

    The hex API rounds every channel to 1/255 before lightness is computed. On
    a smooth ramp those roundings are oscillations of about 0.001 OKLab that
    accumulate into the measure: `winter` reads 0.0343 through hex against
    0.0139 in float, which straddles the 0.02 floor and made a sequential map
    classify `misc`. Maps that genuinely reverse are unaffected, because their
    reversals are orders of magnitude larger than the rounding.

    Args:
        samples: `(r, g, b)` floats in 0..1, in ramp order.

    Returns:
        Backward lightness travel as a fraction of the ramp's span.
    """
    return _back_travel([_lightness_rgb(s) for s in samples])

check_palette.contrast

contrast(hex_a: str, hex_b: str) -> float

WCAG contrast ratio between two hex colours, from 1.0 to 21.0.

The floors it is compared against: 4.5 for text, 3.0 for a hue against the surface it sits on.

Parameters:

Name Type Description Default
hex_a str

A #rrggbb string.

required
hex_b str

The colour to measure it against.

required

Returns:

Type Description
float

The ratio, 1.0 (identical) to 21.0 (black on white).

Source code in skill/scripts/check_palette.py
def contrast(hex_a: str, hex_b: str) -> float:
    """WCAG contrast ratio between two hex colours, from 1.0 to 21.0.

    The floors it is compared against: 4.5 for text, 3.0 for a hue against the
    surface it sits on.

    Args:
        hex_a: A `#rrggbb` string.
        hex_b: The colour to measure it against.

    Returns:
        The ratio, 1.0 (identical) to 21.0 (black on white).
    """
    la, lb = relative_luminance(hex_to_linear(hex_a)), relative_luminance(hex_to_linear(hex_b))
    hi, lo = max(la, lb), min(la, lb)
    return (hi + 0.05) / (lo + 0.05)

check_palette.delta_e

delta_e(rgb_a: Sequence[float], rgb_b: Sequence[float]) -> float

CAM02-UCS colour difference between two linear-light colours.

Euclidean distance in CAM02-UCS, which is the space's intended use: Luo, Cui & Li (2006) fitted it so that this distance predicts perceived difference, so the number carries a unit somebody measured. CVD_TARGET and NORMAL_FLOOR are quoted in it.

Not a CIELAB dEab and not a CIEDE2000. Over the gamut this file gates as series slots, one CAM02-UCS unit is a median 1.99 CIELAB dEab, but the ratio runs 1.72-2.24 across the interquartile range, so the two are not interconvertible at a fixed rate and a threshold quoted in one does not transfer to the other.

Before 0.8.0 this returned OKLab distance x100. See the CIECAM02 section above for why that number could not support a threshold.

Parameters:

Name Type Description Default
rgb_a Sequence[float]

Linear-light (r, g, b).

required
rgb_b Sequence[float]

The colour to measure it against.

required

Returns:

Type Description
float

CAM02-UCS euclidean distance.

Source code in skill/scripts/check_palette.py
def delta_e(rgb_a: Sequence[float], rgb_b: Sequence[float]) -> float:
    """CAM02-UCS colour difference between two linear-light colours.

    Euclidean distance in CAM02-UCS, which is the space's intended use: Luo,
    Cui & Li (2006) fitted it so that this distance predicts perceived
    difference, so the number carries a unit somebody measured. `CVD_TARGET`
    and `NORMAL_FLOOR` are quoted in it.

    Not a CIELAB dE*ab and not a CIEDE2000. Over the gamut this file gates as
    series slots, one CAM02-UCS unit is a median 1.99 CIELAB dE*ab, but the
    ratio runs 1.72-2.24 across the interquartile range, so the two are not
    interconvertible at a fixed rate and a threshold quoted in one does not
    transfer to the other.

    Before 0.8.0 this returned OKLab distance x100. See the CIECAM02 section
    above for why that number could not support a threshold.

    Args:
        rgb_a: Linear-light `(r, g, b)`.
        rgb_b: The colour to measure it against.

    Returns:
        CAM02-UCS euclidean distance.
    """
    a, b = linear_to_cam02ucs(rgb_a), linear_to_cam02ucs(rgb_b)
    return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))

check_palette.simulate

simulate(rgb: Sequence[float], kind: str) -> tuple[float, float, float]

Linear-light RGB as a "protan", "deutan" or "tritan" viewer sees it.

Vienot, Brettel & Mollon (1999), applied on linear light. Protan and deutan are what the gates decide on; the tritan matrix is validated only for the red-green forms, so its distances are printed and never gated.

Parameters:

Name Type Description Default
rgb Sequence[float]

Linear-light (r, g, b), each channel in 0..1.

required
kind str

"protan", "deutan" or "tritan".

required

Returns:

Type Description
tuple[float, float, float]

Linear-light (r, g, b) as that viewer sees it.

Source code in skill/scripts/check_palette.py
def simulate(rgb: Sequence[float], kind: str) -> tuple[float, float, float]:
    """Linear-light RGB as a `"protan"`, `"deutan"` or `"tritan"` viewer sees it.

    Vienot, Brettel & Mollon (1999), applied on linear light. Protan and deutan
    are what the gates decide on; the tritan matrix is validated only for the
    red-green forms, so its distances are printed and never gated.

    Args:
        rgb: Linear-light `(r, g, b)`, each channel in 0..1.
        kind: `"protan"`, `"deutan"` or `"tritan"`.

    Returns:
        Linear-light `(r, g, b)` as that viewer sees it.
    """
    m = CVD[kind]
    r, g, b = (max(0.0, min(1.0, sum(m[i][j] * rgb[j] for j in range(3))))
               for i in range(3))
    return r, g, b

check_palette.simulate_anomalous

simulate_anomalous(rgb: Sequence[float], kind: str, severity: float) -> tuple[float, float, float]

Linear-light RGB as an anomalous trichromat of this severity sees it.

kind is "protan" or "deutan". severity runs 0.0 (normal vision) to 1.0 (dichromacy) and is read at the nearest tenth, which is where Machado, Oliveira & Fernandes publish the table. No interpolation between two published matrices, because that is a modelling claim the paper does not make and this file would then be asserting.

simulate remains the dichromacy model and the anchor for every number the style guide quotes. This is the range between the two ends, which is where most colour vision deficiency actually sits.

Parameters:

Name Type Description Default
rgb Sequence[float]

Linear-light (r, g, b), each channel in 0..1.

required
kind str

"protan" or "deutan".

required
severity float

0.0 (normal vision) to 1.0 (dichromacy), read at the nearest tenth.

required

Returns:

Type Description
tuple[float, float, float]

Linear-light (r, g, b) as that viewer sees it.

Source code in skill/scripts/check_palette.py
def simulate_anomalous(rgb: Sequence[float], kind: str,
                       severity: float) -> tuple[float, float, float]:
    """Linear-light RGB as an anomalous trichromat of this severity sees it.

    `kind` is `"protan"` or `"deutan"`. `severity` runs 0.0 (normal vision) to
    1.0 (dichromacy) and is read at the nearest tenth, which is where Machado,
    Oliveira & Fernandes publish the table. No interpolation between two
    published matrices, because that is a modelling claim the paper does not
    make and this file would then be asserting.

    `simulate` remains the dichromacy model and the anchor for every number the
    style guide quotes. This is the range between the two ends, which is where
    most colour vision deficiency actually sits.

    Args:
        rgb: Linear-light `(r, g, b)`, each channel in 0..1.
        kind: `"protan"` or `"deutan"`.
        severity: 0.0 (normal vision) to 1.0 (dichromacy), read at the
            nearest tenth.

    Returns:
        Linear-light `(r, g, b)` as that viewer sees it.
    """
    if kind not in MACHADO:
        raise ValueError(f"severity is modelled for protan and deutan only, "
                         f"not {kind!r} - see the note above MACHADO")
    if not 0.0 <= severity <= 1.0:
        raise ValueError(f"severity runs 0.0 to 1.0, got {severity!r}")
    tenths = int(round(severity * 10))
    if tenths == 0:
        r, g, b = (max(0.0, min(1.0, c)) for c in rgb)
        return r, g, b
    m = MACHADO[kind][tenths]
    r, g, b = (max(0.0, min(1.0, sum(m[i][j] * rgb[j] for j in range(3))))
               for i in range(3))
    return r, g, b

check_palette.hex_to_linear

hex_to_linear(h: str) -> tuple[float, float, float]

A #rrggbb string as linear-light RGB, each channel in 0..1.

Linear light, not the 0..255 the hex digits carry: every distance and luminance below is defined on it, and averaging or mixing gamma-encoded values is the usual source of a wrong answer that looks plausible.

Parameters:

Name Type Description Default
h str

A #rrggbb string. The leading # is optional.

required

Returns:

Type Description
tuple[float, float, float]

(r, g, b), each channel linear-light in 0..1.

Source code in skill/scripts/check_palette.py
def hex_to_linear(h: str) -> tuple[float, float, float]:
    """A `#rrggbb` string as linear-light RGB, each channel in 0..1.

    Linear light, not the 0..255 the hex digits carry: every distance and
    luminance below is defined on it, and averaging or mixing gamma-encoded
    values is the usual source of a wrong answer that looks plausible.

    Args:
        h: A `#rrggbb` string. The leading `#` is optional.

    Returns:
        `(r, g, b)`, each channel linear-light in 0..1.
    """
    h = h.lstrip("#")
    if len(h) != 6:
        raise ValueError(f"expected 6-digit hex, got {h!r}")
    r, g, b = (_srgb_to_linear(int(h[i:i + 2], 16) / 255) for i in (0, 2, 4))
    return r, g, b

check_palette.oklab_distance

oklab_distance(rgb_a: Sequence[float], rgb_b: Sequence[float]) -> float

OKLab distance between two linear-light colours, x100.

What delta_e measured before 0.8.0, kept because the lightness rows still reason in OKLab and because a caller comparing against a number this project published earlier needs the old scale to do it.

It is not a colour difference in any calibrated sense: OKLab was fitted for hue uniformity, not for discrimination, so distances in it have no published referent. Gate on delta_e.

Parameters:

Name Type Description Default
rgb_a Sequence[float]

Linear-light (r, g, b).

required
rgb_b Sequence[float]

The colour to measure it against.

required

Returns:

Type Description
float

OKLab euclidean distance, x100.

Source code in skill/scripts/check_palette.py
def oklab_distance(rgb_a: Sequence[float], rgb_b: Sequence[float]) -> float:
    """OKLab distance between two linear-light colours, x100.

    What `delta_e` measured before 0.8.0, kept because the lightness rows still
    reason in OKLab and because a caller comparing against a number this project
    published earlier needs the old scale to do it.

    It is not a colour difference in any calibrated sense: OKLab was fitted for
    hue uniformity, not for discrimination, so distances in it have no published
    referent. Gate on `delta_e`.

    Args:
        rgb_a: Linear-light `(r, g, b)`.
        rgb_b: The colour to measure it against.

    Returns:
        OKLab euclidean distance, x100.
    """
    a, b = linear_to_oklab(rgb_a), linear_to_oklab(rgb_b)
    return 100 * math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))

check_palette.linear_to_oklab

linear_to_oklab(rgb: Sequence[float]) -> tuple[float, float, float]

Linear-light RGB as OKLab (L, a, b).

OKLab rather than CIELAB because its lightness tracks perceived lightness across hues, which is what every gate here asks about. l, m, s below are the cone responses the published matrix names, not a lint slip.

Parameters:

Name Type Description Default
rgb Sequence[float]

Linear-light (r, g, b), each channel in 0..1.

required

Returns:

Type Description
tuple[float, float, float]

(L, a, b) in OKLab. L is 0..1.

Source code in skill/scripts/check_palette.py
def linear_to_oklab(rgb: Sequence[float]) -> tuple[float, float, float]:
    """Linear-light RGB as OKLab `(L, a, b)`.

    OKLab rather than CIELAB because its lightness tracks perceived lightness
    across hues, which is what every gate here asks about. `l, m, s` below are
    the cone responses the published matrix names, not a lint slip.

    Args:
        rgb: Linear-light `(r, g, b)`, each channel in 0..1.

    Returns:
        `(L, a, b)` in OKLab. `L` is 0..1.
    """
    r, g, b = rgb
    l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
    m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
    s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
    l_, m_, s_ = (math.copysign(abs(v) ** (1 / 3), v) for v in (l, m, s))
    return (
        0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
        1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
        0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
    )

check_palette.linear_to_cam02ucs

linear_to_cam02ucs(rgb: Sequence[float]) -> tuple[float, float, float]

Linear-light RGB as CAM02-UCS (J', a', b').

The space delta_e measures in. Euclidean distance here is a perceived colour difference in the units Luo, Cui & Li (2006) fitted, which is what lets the floors in this file cite a measurement instead of a preference.

Parameters:

Name Type Description Default
rgb Sequence[float]

Linear-light (r, g, b), each channel in 0..1.

required

Returns:

Type Description
tuple[float, float, float]

(J', a', b') in CAM02-UCS. J' runs 0..100 for in-gamut colours.

Source code in skill/scripts/check_palette.py
def linear_to_cam02ucs(rgb: Sequence[float]) -> tuple[float, float, float]:
    """Linear-light RGB as CAM02-UCS `(J', a', b')`.

    The space `delta_e` measures in. Euclidean distance here is a perceived
    colour difference in the units Luo, Cui & Li (2006) fitted, which is what
    lets the floors in this file cite a measurement instead of a preference.

    Args:
        rgb: Linear-light `(r, g, b)`, each channel in 0..1.

    Returns:
        `(J', a', b')` in CAM02-UCS. `J'` runs 0..100 for in-gamut colours.
    """
    xyz = tuple(100.0 * v for v in _mul3(_M_XYZ, rgb))
    rgb_c = tuple(dr * v for dr, v in zip(_D_RGB, _mul3(_M_CAT02, xyz)))
    return _cam02ucs_from_post_adapted(
        _post_adapt(_mul3(_M_HPE, _mul3(_M_CAT02_INV, rgb_c)), _F_L))

check_palette.relative_luminance

relative_luminance(rgb: Sequence[float]) -> float

WCAG relative luminance of linear-light RGB.

The WCAG coefficients, and deliberately not OKLab's L: the contrast ratios this project cites are defined against this number, so computing them from a perceptual lightness would report figures no standard backs.

Parameters:

Name Type Description Default
rgb Sequence[float]

Linear-light (r, g, b), each channel in 0..1.

required

Returns:

Type Description
float

Relative luminance in 0..1, by the WCAG coefficients.

Source code in skill/scripts/check_palette.py
def relative_luminance(rgb: Sequence[float]) -> float:
    """WCAG relative luminance of linear-light RGB.

    The WCAG coefficients, and deliberately not OKLab's `L`: the contrast
    ratios this project cites are defined against this number, so computing
    them from a perceptual lightness would report figures no standard backs.

    Args:
        rgb: Linear-light `(r, g, b)`, each channel in 0..1.

    Returns:
        Relative luminance in 0..1, by the WCAG coefficients.
    """
    r, g, b = rgb
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

suggest_fixes

Remedies. Both take the rows audit returned, not the figure.

suggest_fixes.suggest

suggest(rows: Sequence[tuple[str, bool | str, str]]) -> list[tuple[str, list[Remedy]]]

Remedies for the rows of an audit that are not passing.

Takes rows rather than the figure: what is on offer depends on which gates fired, and nothing here needs to look at the figure again. Returns [(gate_name, [Remedy, ...]), ...] in the order the gates reported, and skips gates with nothing to offer rather than padding them with a restatement of the failure.

Parameters:

Name Type Description Default
rows Sequence[tuple[str, bool | str, str]]

(label, status, detail) triples, as audit or check returned them. Passing rows are ignored.

required

Returns:

Type Description
list[tuple[str, list[Remedy]]]

[(gate_name, [Remedy, ...]), ...], in the order the gates reported.

list[tuple[str, list[Remedy]]]

Empty when nothing fired that this file has an answer for.

Source code in skill/scripts/suggest_fixes.py
def suggest(rows: Sequence[tuple[str, bool | str, str]],
            ) -> list[tuple[str, list[Remedy]]]:
    """Remedies for the rows of an `audit` that are not passing.

    Takes `rows` rather than the figure: what is on offer depends on which
    gates fired, and nothing here needs to look at the figure again. Returns
    `[(gate_name, [Remedy, ...]), ...]` in the order the gates reported, and
    skips gates with nothing to offer rather than padding them with a
    restatement of the failure.

    Args:
        rows: `(label, status, detail)` triples, as `audit` or `check`
            returned them. Passing rows are ignored.

    Returns:
        `[(gate_name, [Remedy, ...]), ...]`, in the order the gates reported.
        Empty when nothing fired that this file has an answer for.
    """
    marked = [name for name, status, _ in rows if status is not True]
    out = []
    for name in marked:
        found = [r for r in REMEDIES if r.gate == name]
        if found:
            out.append((name, found))
    return out

suggest_fixes.format_suggestions

format_suggestions(rows: Sequence[tuple[str, bool | str, str]], indent: str = '      ') -> list[str]

suggest, as lines ready to print under a report.

Parameters:

Name Type Description Default
rows Sequence[tuple[str, bool | str, str]]

(label, status, detail) triples, as audit returned them.

required
indent str

Leading whitespace for each line.

' '

Returns:

Type Description
list[str]

A list of lines. Empty when nothing fired that this file has an

list[str]

answer for.

Source code in skill/scripts/suggest_fixes.py
def format_suggestions(rows: Sequence[tuple[str, bool | str, str]],
                       indent: str = "      ") -> list[str]:
    """`suggest`, as lines ready to print under a report.

    Args:
        rows: `(label, status, detail)` triples, as `audit` returned them.
        indent: Leading whitespace for each line.

    Returns:
        A list of lines. Empty when nothing fired that this file has an
        answer for.
    """
    lines = []
    for name, remedies in suggest(rows):
        lines.append(f"{indent}{name}:")
        for remedy in remedies:
            lines.append(f"{indent}  - {remedy.suggestion}")
            for code_line in remedy.code.splitlines():
                lines.append(f"{indent}      {code_line}")
    return lines

The gate functions

One row each, in the order audit runs them, which is the order the gates tables them in and the order a report prints. Every one returns (status, detail), where status is True, False or "warn".

The signature says what the gate needs beyond the figure. A r parameter is a renderer, canvas an already-drawn canvas, and scale/placed_frac/venue the page arithmetic. audit supplies all of them.

check_figure.check_clipping

check_clipping(fig: Figure, r: Any) -> tuple[bool | str, str]

Text that runs off the canvas.

Reads the axis-aligned box and is correct to: an AABB is the bounding box of the oriented box's own corners, so its extremes are attained by real corners of the label and a min/max test against the canvas gives the same answer either way. Rotation costs this gate nothing, and only check_collisions had to change, because two AABBs can overlap where the boxes inside them do not.

Source code in skill/scripts/check_figure.py
def check_clipping(fig: Figure, r: Any) -> tuple[bool | str, str]:
    """Text that runs off the canvas.

    Reads the axis-aligned box and is correct to: an AABB is the bounding box
    of the oriented box's own corners, so its extremes are attained by real
    corners of the label and a min/max test against the canvas gives the same
    answer either way. Rotation costs this gate nothing, and only
    `check_collisions` had to change, because two AABBs can overlap where the
    boxes inside them do not.
    """
    w, h = fig.canvas.get_width_height()
    ghosts = _ghost_ticks(fig)
    bad = []
    for t, bb in _texts(fig, r):
        if id(t) in ghosts:
            continue
        if bb.x0 < -1 or bb.y0 < -1 or bb.x1 > w + 1 or bb.y1 > h + 1:
            bad.append(str(t.get_text())[:32])
    return (not bad,
            "no text past the canvas" if not bad
            else f"clipped: {bad}  [FIX] add constrained_layout or widen the figure")

check_figure.check_collisions

check_collisions(fig: Figure, r: Any) -> tuple[bool | str, str]

Text-on-text overlap. Tick labels on a shared axis are exempt: matplotlib lays those out itself and a 1px touch there is not a defect.

Compared as oriented boxes, because the axis-aligned one is 5x the ink at 45 degrees and the extra area is two empty triangles. Two parallel oblique labels with clear page between them collided on that box, which is the shape of false positive that teaches people to skim the row.

Source code in skill/scripts/check_figure.py
def check_collisions(fig: Figure, r: Any) -> tuple[bool | str, str]:
    """Text-on-text overlap. Tick labels on a shared axis are exempt: matplotlib
    lays those out itself and a 1px touch there is not a defect.

    Compared as oriented boxes, because the axis-aligned one is 5x the ink at
    45 degrees and the extra area is two empty triangles. Two parallel oblique
    labels with clear page between them collided on that box, which is the shape
    of false positive that teaches people to skim the row."""
    ticks = _tick_texts(fig)
    items = [(t, _corners(t, bb, r))
             for t, bb in _texts(fig, r) if id(t) not in ticks]
    hits = []
    for (ta, ba), (tb, bb) in itertools.combinations(items, 2):
        if _overlap(ba, bb):
            hits.append((str(ta.get_text())[:22], str(tb.get_text())[:22]))
    return (not hits,
            f"{len(items)} text objects, none overlapping" if not hits
            else f"overlapping: {hits[:4]}")

check_figure.check_text_readability

check_text_readability(fig: Figure, r: Any, canvas: Any = None, scale: float | None = None, placed_frac: float = 1.0, venue: str | None = None) -> tuple[bool | str, str]

Whether each string can be read where it sits.

check_label_attribution asks which curve a label belongs to. This asks the prior question — whether the label is legible at all — and the two come apart hard: a label printed on its own curve is attributed perfectly, and is read through the line crossing its letterforms.

Both clauses are measured off rendered pixels, because both depend on what happened to land behind the glyphs and no artist knows that about itself. The figure is drawn a second time with every string hidden; that render is the backdrop each label was placed onto.

Clutter. Inside a label's box the backdrop should be one surface. Pixels that no blend of {that surface, the grid, the axis rule} explains are data ink passing through the text — a curve, a marker, a spike between the strokes. Measuring the backdrop rather than the finished render is the whole trick: casing hides the evidence, because a white halo over an orange curve renders as clean white while punching a visible gap through the data. Both halves of that are defects and this sees them as one number.

Uniform data ink is not clutter. A label on a heatmap cell has the cell as its surface, so it is the contrast clause that governs there, which is the correct division: a flat fill is a background, a curve is not.

Contrast. The text against the backdrop it actually got, at the WCAG text threshold (4.5:1, or 3:1 for large text) rather than the 3:1 mark threshold, because a glyph stem is thinner than a mark. Casing counts: a black label with a white halo on a dark field is read against the halo.

Both clauses are measured over the pixels the label covers, not over the box that contains it. A 45-degree string reports an axis-aligned extent five times its own ink, and the extra area is two triangles nothing is drawn in; strokes laid in one of them read as ink on the label, and a dark field in one of them sets the contrast verdict for a string sitting on light ground. See _oriented_mask.

Tick labels are included. They sit outside the axes on most figures and cost nothing to check there, and on the figures where they do not — an inset, a twinned frame, a label moved inside — that is exactly where they get crossed.

Source code in skill/scripts/check_figure.py
def check_text_readability(fig: Figure, r: Any, canvas: Any = None,
                           scale: float | None = None,
                           placed_frac: float = 1.0,
                           venue: str | None = None) -> tuple[bool | str, str]:
    """Whether each string can be read where it sits.

    `check_label_attribution` asks which curve a label belongs to. This asks the
    prior question — whether the label is legible at all — and the two come
    apart hard: a label printed *on* its own curve is attributed perfectly, and
    is read through the line crossing its letterforms.

    Both clauses are measured off rendered pixels, because both depend on what
    happened to land behind the glyphs and no artist knows that about itself.
    The figure is drawn a second time with every string hidden; that render is
    the backdrop each label was placed onto.

    *Clutter.* Inside a label's box the backdrop should be one surface. Pixels
    that no blend of {that surface, the grid, the axis rule} explains are data
    ink passing through the text — a curve, a marker, a spike between the
    strokes. Measuring the backdrop rather than the finished render is the whole
    trick: casing hides the evidence, because a white halo over an orange curve
    renders as clean white while punching a visible gap through the data. Both
    halves of that are defects and this sees them as one number.

    Uniform data ink is not clutter. A label on a heatmap cell has the cell as
    its surface, so it is the contrast clause that governs there, which is the
    correct division: a flat fill is a background, a curve is not.

    *Contrast.* The text against the backdrop it actually got, at the WCAG text
    threshold (4.5:1, or 3:1 for large text) rather than the 3:1 mark threshold,
    because a glyph stem is thinner than a mark. Casing counts: a black label
    with a white halo on a dark field is read against the halo.

    Both clauses are measured over the pixels the label covers, not over the box
    that contains it. A 45-degree string reports an axis-aligned extent five
    times its own ink, and the extra area is two triangles nothing is drawn in;
    strokes laid in one of them read as ink on the label, and a dark field in one
    of them sets the contrast verdict for a string sitting on light ground. See
    `_oriented_mask`.

    Tick labels are included. They sit outside the axes on most figures and cost
    nothing to check there, and on the figures where they do not — an inset, a
    twinned frame, a label moved inside — that is exactly where they get
    crossed.
    """
    import numpy as np
    from matplotlib.colors import to_rgb

    items = _texts(fig, r)
    if not items:
        return True, "no text to read"
    if canvas is None:
        # Called on its own rather than through `audit`, so nobody has put the
        # figure on `MEASURE_DPI` yet and the pixel fractions below would be
        # read against whatever resolution the author set. Do it here and
        # re-enter, so the whole body runs at the one resolution and `r` is
        # remeasured against it.
        #
        # `_at_draw_rc` for the same reason and in the same order `audit` enters
        # them. A figure whose builder reported on it has a record, and reading
        # this gate under the caller's font list instead would put the two
        # routes on two different faces: measured on `gallery-density` under
        # matplotlib 3.8.4, ax0's ink fraction came back 0.07 through `audit`
        # and 0.08 here.
        with _at_draw_rc(fig), _at_measure_dpi(fig):
            r, canvas = _renderer(fig)
            return check_text_readability(fig, r, canvas, scale, placed_frac,
                                          venue)

    # Hiding text changes what constrained_layout has to fit, so the second
    # render would come back with every artist in a slightly different place and
    # the backdrop would not line up with the boxes measured against the first.
    # Pin the layout for the duration; the engine is put back before returning.
    engine = fig.get_layout_engine()
    fig.set_layout_engine("none")
    visible = [t.get_visible() for t, _ in items]
    try:
        for t, _ in items:
            t.set_visible(False)
        canvas.draw()
        backdrop = np.asarray(
            canvas.buffer_rgba())[:, :, :3].astype(np.int16).copy()
    finally:
        for (t, _), v in zip(items, visible):
            t.set_visible(v)
        canvas.draw()
        if engine is not None:
            fig.set_layout_engine(engine)

    if scale is None:
        scale = page_scale(fig, placed_frac, venue)
    H, W = backdrop.shape[:2]
    furniture = _furniture(fig)
    # Ticks that exist on the axes but never reach the page — a hidden axes, a
    # location outside the view. `check_clipping` learned about these the same
    # way this did: by reporting a defect on a schematic that draws no axes and
    # still carries the tick Text objects matplotlib made for it.
    ghosts = _ghost_ticks(fig)
    radial = _polar_radial_ticks(fig)
    cluttered, faint, checked, unjudged = [], [], 0, 0

    for t, bb in items:
        if id(t) in ghosts:
            continue
        if id(t) in radial:
            unjudged += 1
            continue
        xa, xb = max(int(bb.x0) - 1, 0), min(int(bb.x1) + 2, W)
        ya, yb = max(int(bb.y0) - 1, 0), min(int(bb.y1) + 2, H)
        # Agg's origin is top-left, the figure's is bottom-left
        block = backdrop[slice(H - yb, H - ya), slice(xa, xb)]
        # An oblique label covers a band across that block and nothing in the
        # two triangles either side of it. At 45 degrees those triangles are
        # four fifths of what was sliced, and strokes laid in one of them were
        # reported as ink on the label. Upright labels get no mask: `_corners`
        # hands back the axis-aligned box for them, so a mask would only shave
        # off the 1px ring the slice adds, and that is a number nobody measured.
        angle = float(t.get_rotation()) % 180.0
        mask = (None if angle in (0.0, 90.0)
                else _oriented_mask(_corners(t, bb, r), xa, yb,
                                    block.shape[:2]))
        # Below this the fractions are counting antialiasing, not measuring.
        covered = block.size // 3 if mask is None else int(mask.sum())
        if covered < TEXT_FOOTPRINT_MIN_PX:
            continue
        checked += 1

        fg = np.array(to_rgb(t.get_color())) * 255.0
        halo_color, _ = _halo(t)
        name = str(t.get_text())[:22]

        frac = _foreign_ink(block, furniture, TEXT_BLEND_TOL, mask)
        if frac > TEXT_CLUTTER_MAX:
            cluttered.append(
                f"{name!r} sits on data ink over {frac:.0%} of its box"
                + (" — the casing hides it by erasing the data underneath"
                   if halo_color else " and wears no casing"))

        pt = t.get_fontsize() * scale
        weight = t.get_fontweight()
        bold = (weight in ("bold", "heavy", "black", "extra bold", "semibold")
                or (isinstance(weight, (int, float))
                    and weight >= BOLD_WEIGHT_MIN))
        floor = (TEXT_CONTRAST_MIN_LARGE
                 if pt >= LARGE_TEXT_PT or (pt >= LARGE_TEXT_BOLD_PT and bold)
                 else TEXT_CONTRAST_MIN)
        if halo_color:
            # Casing replaces the backdrop under the strokes, so that is what
            # the reader reads against.
            ratio = _contrast_255(fg, np.array(to_rgb(halo_color)) * 255.0)
        else:
            _, ratio = _worst_backdrop(block, fg, TEXT_BACKDROP_MIN_SHARE,
                                       mask)
        if ratio < floor:
            faint.append(f"{name!r} at {ratio:.1f}:1 on its backdrop "
                         f"(text needs {floor}:1)")

    skipped = (f"; {unjudged} polar radial labels not judged (matplotlib "
               "places them on the data and offers nowhere else to put them)"
               if unjudged else "")
    if not checked:
        return True, "no text large enough to measure" + skipped
    bad = cluttered + faint
    if not bad:
        return True, (f"{checked} strings read clean against their backdrop"
                      + skipped)
    return False, ("; ".join(bad[:3])
                   + "  [FIX] move the label to clear ground; casing rescues a "
                     "gridline, not a curve")

check_figure.check_contrast_stack

check_contrast_stack(fig: Figure) -> tuple[bool | str, str]

A figure where nothing is at full opacity has no focal point, and a long tail of alpha values reads as haze rather than hierarchy.

Source code in skill/scripts/check_figure.py
def check_contrast_stack(fig: Figure) -> tuple[bool | str, str]:
    """A figure where nothing is at full opacity has no focal point, and a long
    tail of alpha values reads as haze rather than hierarchy."""
    import numpy as np

    alphas = []
    for ax in fig.axes:
        for a in list(ax.collections) + list(ax.lines) + list(ax.patches):
            if not a.get_visible():
                continue
            al = a.get_alpha()
            # unset alpha means opaque, and that is exactly what this check
            # wants to know about, so it counts as 1.0 rather than being skipped
            if al is None:
                alphas.append(1.0)
            elif np.ndim(al) == 0:
                alphas.append(round(float(al), 2))
            else:
                # matplotlib has taken a per-point alpha array since 3.4, and
                # `float()` raises on one. A raising non-advisory gate is turned
                # into a hard `False` by `_rows`, so a legal figure failed on a
                # defect in the checker rather than on anything in the figure.
                #
                # A ramp across one artist is ONE level, not one per value: the
                # question this row asks is how many separate alpha decisions
                # the reader has to resolve, and a continuous encoding is a
                # single decision. Counting each value instead made an ordinary
                # `pcolormesh` report sixteen levels of haze.
                #
                # Opacity is the exception and is read per value, because
                # "is anything solid" is a fact about pixels rather than about
                # how many choices were made.
                values = np.atleast_1d(al).ravel()
                if values.size:
                    solid_here = float(values.max())
                    alphas.append(round(solid_here if solid_here >= OPAQUE_ALPHA_MIN
                                        else float(values.min()), 2))
    if not alphas:
        return True, "no data artists"
    levels = sorted(set(alphas))
    solid = any(x >= OPAQUE_ALPHA_MIN for x in alphas)
    ok = len(levels) <= ALPHA_LEVELS_MAX and solid
    note = ""
    if not solid:
        note = ("  [FIX] raise the artist that carries the point to alpha 1"
                "  [WHY] nothing is opaque, so the figure has no focal point")
    elif len(levels) > ALPHA_LEVELS_MAX:
        note = f"  [FIX] {len(levels)} levels reads as haze; keep to {ALPHA_LEVELS_MAX}"
    return ok, f"alpha levels {levels}{note}"

check_figure.check_mark_ratio

check_mark_ratio(fig: Figure) -> tuple[bool | str, str]

One mark far larger than the rest stops reading as a mark and starts reading as an ornament stuck on top of the plot.

Reads scatter sizes and line markers, both converted to the area of the disc actually drawn. Bars and other patches are deliberately NOT counted: a bar thirty times another bar is the encoding working, not a defect. This gate is about marks whose size is not carrying the value.

Both operands go through one conversion because the two APIs take different quantities and neither is an area. markersize is a diameter in points. scatter(s=...) is a squared diameter, not the area its own documentation calls it - see scatter_diameter_pt. Converting one side and not the other leaves a standing 4/pi = 1.27x error on any figure mixing scatter with plot(marker=...), which is enough against a 5.0 threshold to fail a legal figure at a true 3.9x and pass a bad one at 6.4x. That error was fixed on the markersize side first and survived on the s side until this change, where two marks of measurably identical drawn area - 741 pixels each - still reported 1.3x.

Source code in skill/scripts/check_figure.py
def check_mark_ratio(fig: Figure) -> tuple[bool | str, str]:
    """One mark far larger than the rest stops reading as a mark and starts
    reading as an ornament stuck on top of the plot.

    Reads scatter sizes and line markers, both converted to the area of the
    disc actually drawn. Bars and other patches are deliberately NOT counted: a
    bar thirty times another bar is the encoding working, not a defect. This
    gate is about marks whose size is not carrying the value.

    Both operands go through one conversion because the two APIs take different
    quantities and neither is an area. `markersize` is a diameter in points.
    `scatter(s=...)` is a *squared* diameter, not the area its own
    documentation calls it - see `scatter_diameter_pt`. Converting one side and
    not the other leaves a standing 4/pi = 1.27x error on any figure mixing
    `scatter` with `plot(marker=...)`, which is enough against a 5.0 threshold
    to fail a legal figure at a true 3.9x and pass a bad one at 6.4x. That
    error was fixed on the `markersize` side first and survived on the `s` side
    until this change, where two marks of measurably identical drawn area -
    741 pixels each - still reported 1.3x.
    """
    worst = None
    for ax in fig.axes:
        sizes: list[float] = []
        for c in ax.collections:
            s: Any = getattr(c, "get_sizes", lambda: [])()
            sizes.extend(math.pi * (scatter_diameter_pt(float(v)) / 2.0) ** 2
                         for v in s if v > 0)
        for ln in ax.lines:
            if not ln.get_visible() or ln.get_marker() in ("", "None", None):
                continue
            ms = float(ln.get_markersize())
            if ms > 0:
                sizes.append(math.pi * (ms / 2.0) ** 2)
        if len(sizes) < 2:
            continue
        ratio = max(sizes) / min(sizes)
        if worst is None or ratio > worst[0]:
            worst = (ratio, min(sizes), max(sizes))
    if worst is None:
        return True, "fewer than two mark sizes"
    ratio, lo, hi = worst
    return (ratio <= MARK_RATIO_MAX,
            f"largest/smallest mark area {ratio:.1f}x  "
            f"(drawn area {lo:.0f} to {hi:.0f} pt^2)"
            + ("" if ratio <= MARK_RATIO_MAX
               else f"  [FIX] cap at {MARK_RATIO_MAX}x"))

check_figure.check_overplotting

check_overplotting(fig: Figure) -> tuple[bool | str, str]

Warn when scatter points overlap into an unreadable mass.

For each PathCollection with offsets (a scatter), the fraction of points whose nearest neighbour in display pixels sits closer than the two marks' radii summed - which is exactly when the two discs intersect on the page. Above the threshold the marks merge into a blob — thin the count, use hollow markers, add transparency, or switch to hexbin.

Two separate errors used to make this roughly 1.8x too lenient, and a scatter of 64 discs each overlapping its neighbours by a quarter of their diameter rendered as one solid square while the gate returned clean. The radius came from sqrt(s / pi), treating s as an area it is not (see scatter_diameter_pt), which is 12.8% too large; and the comparison was against one radius rather than two, which is the condition for a mark's centre to be swallowed rather than for the two marks to touch.

Nearest is the wrong neighbour to ask about once radii vary. Contact is d < r_i + r_j, and the j that minimises d need not be the j that maximises r_j, so a mark can clear its nearest neighbour and be swallowed whole by a larger one further off. Measured on a fixture of 60 marks - 40 small ones in pairs 8px apart, each pair under a 120px disc centred 20px away - every mark is in contact and 40 of them are not visible in the render at all, while the 1-NN test reported 33% and the gate returned "no scatter overplotting".

Where every mark is the same size, nearest is the right neighbour: r_i + r_j is then a constant, so some mark is within it exactly when the nearest one is, and the 1-NN query answers the question outright. That is the common case and it keeps the cheap path. Where radii differ the marks are grouped into radius octaves and each group is asked separately; see _contact_fraction for why the bound has to be the group's largest radius rather than the scatter's, and what enumerating candidate pairs at 2 * r_max cost on a figure with one oversized mark in it.

Source code in skill/scripts/check_figure.py
def check_overplotting(fig: Figure) -> tuple[bool | str, str]:
    """Warn when scatter points overlap into an unreadable mass.

    For each PathCollection with offsets (a scatter), the fraction of points
    whose nearest neighbour in display pixels sits closer than the two marks'
    radii summed - which is exactly when the two discs intersect on the page.
    Above the threshold the marks merge into a blob — thin the count, use
    hollow markers, add transparency, or switch to hexbin.

    Two separate errors used to make this roughly 1.8x too lenient, and a
    scatter of 64 discs each overlapping its neighbours by a quarter of their
    diameter rendered as one solid square while the gate returned clean. The
    radius came from `sqrt(s / pi)`, treating `s` as an area it is not (see
    `scatter_diameter_pt`), which is 12.8% too large; and the comparison was
    against one radius rather than two, which is the condition for a mark's
    *centre* to be swallowed rather than for the two marks to touch.

    Nearest is the wrong neighbour to ask about once radii vary. Contact is
    `d < r_i + r_j`, and the `j` that minimises `d` need not be the `j` that
    maximises `r_j`, so a mark can clear its nearest neighbour and be swallowed
    whole by a larger one further off. Measured on a fixture of 60 marks - 40
    small ones in pairs 8px apart, each pair under a 120px disc centred 20px
    away - every mark is in contact and 40 of them are not visible in the render
    at all, while the 1-NN test reported 33% and the gate returned "no scatter
    overplotting".

    Where every mark is the same size, nearest *is* the right neighbour: `r_i +
    r_j` is then a constant, so some mark is within it exactly when the nearest
    one is, and the 1-NN query answers the question outright. That is the common
    case and it keeps the cheap path. Where radii differ the marks are grouped
    into radius octaves and each group is asked separately; see
    `_contact_fraction` for why the bound has to be the group's largest radius
    rather than the scatter's, and what enumerating candidate pairs at
    `2 * r_max` cost on a figure with one oversized mark in it.
    """
    import numpy as np
    try:
        from scipy.spatial import cKDTree
    except ImportError:                      # optional, see `_box_blur`
        cKDTree = None

    dpi = fig.dpi

    bad = []
    for i, ax in enumerate(fig.axes):
        for j, coll in enumerate(ax.collections):
            try:
                offsets = coll.get_offsets()
            except Exception:
                continue
            if offsets.size < 2:
                continue
            try:
                xy = ax.transData.transform(offsets)
            except Exception:
                continue
            sizes: Any = getattr(coll, "get_sizes", lambda: [])()
            if len(sizes) == 0:
                continue
            sizes = np.asarray(sizes, dtype=float)
            n = len(xy)
            if n < 2:
                continue
            # A Collection cycles a short size list over its offsets, so the
            # sizes it was handed are not the sizes it drew unless the lengths
            # already match. `np.resize` tiles, which is that same cycle.
            if sizes.size != n:
                sizes = np.resize(sizes, n)
            radius_px = scatter_diameter_pt(sizes) / 2.0 * dpi / 72.0

            frac = _contact_fraction(xy, radius_px, cKDTree)
            if frac > OVERPLOT_THRESHOLD:
                bad.append((i, j, frac))

    if not bad:
        return True, "no scatter overplotting"
    detail = "; ".join(f"ax{i}.col{j} {f:.0%}" for i, j, f in bad)
    return "warn", (f"overplotting: {detail} — marks merge into blob"
                    "  [FIX] thin counts or switch to hexbin. This measures how "
                    "close the marks are, so transparency and hollow markers "
                    "do not move it")

check_figure.check_redundancy

check_redundancy(fig: Figure, r: Any) -> tuple[bool | str, str]

Side-by-side panels on the same scale should share their axis furniture. Two identical tick columns and two identical axis labels is duplicated ink.

Source code in skill/scripts/check_figure.py
def check_redundancy(fig: Figure, r: Any) -> tuple[bool | str, str]:
    """Side-by-side panels on the same scale should share their axis furniture.
    Two identical tick columns and two identical axis labels is duplicated ink."""
    # Only same-row panels can share a y axis, and only same-column panels can
    # share an x axis. Two panels side by side each legitimately need their own
    # x label; repeating the y label between them is the duplication.
    rows: dict[Any, list[Any]] = {}
    cols: dict[Any, list[Any]] = {}
    for ax in fig.axes:
        ss = ax.get_subplotspec()
        if ss is None:
            continue
        # A panel at `axis("off")` shows no furniture to duplicate. Its tick
        # Text objects still exist and still carry their strings, which is how
        # three image panels with no visible axis at all came to be told to
        # "use sharex/sharey" — advice with nothing to act on.
        if not ax.axison:
            continue
        r_, c_ = ss.rowspan.start, ss.colspan.start
        rows.setdefault(r_, []).append(ax)
        cols.setdefault(c_, []).append(ax)

    dupes = []
    for group, getter, axis in ((rows, "get_ylabel", "y"),
                                (cols, "get_xlabel", "x")):
        for _, axes in group.items():
            vals = [getattr(a, getter)().strip() for a in axes]
            vals = [v for v in vals if v]
            for label, n in Counter(vals).items():
                if n > 1:
                    dupes.append(f"{axis}label {label!r} x{n}")

    dup_ticks = 0
    for _, axes in rows.items():
        # Grouped by the scale as well as the tick strings. `docs/gates.md`
        # promises this row fires on "panels on a shared scale", and comparing
        # tick text alone broke that promise: two panels carrying different
        # quantities in different units, whose tick strings happen to coincide,
        # were told to use `sharey`. Taking that advice would put unrelated
        # data on one axis, so the row was not merely noisy, it was wrong.
        #
        # The axis label is part of the key because limits and scale type alone
        # do not settle it: two panels can carry 0 to 2 kilometres and 0 to 2
        # seconds and agree on every number while sharing no scale at all. What
        # a reader reads as one scale is one quantity, and the label is where
        # the figure says which quantity that is. Panels that name the same
        # quantity, or name none, still group together, which is the
        # small-multiples case this row exists for.
        cols_seen = Counter(
            (a.get_ylim(), a.get_yscale(), a.get_ylabel().strip(),
             tuple(t.get_text() for t in a.get_yticklabels()
                   if t.get_text() and t.get_visible()))
            for a in axes)
        dup_ticks += sum(n - 1 for (_lim, _scale, _label, v), n in cols_seen.items()
                         if v and n > 1)

    ok = not dupes and not dup_ticks
    if ok:
        return True, "axis furniture not duplicated"
    bits = dupes + ([f"repeated y tick column x{dup_ticks}"] if dup_ticks else [])
    return False, "; ".join(bits) + "  [FIX] use sharex/sharey"

check_figure.check_type_size

check_type_size(fig: Figure, r: Any, scale: float | None = None, placed_frac: float = 1.0, venue: str | None = None) -> tuple[bool | str, str]

Every rendered string clears the legibility floor once the figure is scaled into the document.

This used to be a regex over the source file hunting for fontsize=, which missed anything set through rcParams, anything computed, and anything set by a helper. Reading get_fontsize() off the artists that actually rendered reports what is on the page instead of what is in the source.

Source code in skill/scripts/check_figure.py
def check_type_size(fig: Figure, r: Any, scale: float | None = None,
                    placed_frac: float = 1.0,
                    venue: str | None = None) -> tuple[bool | str, str]:
    """Every rendered string clears the legibility floor once the figure is
    scaled into the document.

    This used to be a regex over the source file hunting for `fontsize=`, which
    missed anything set through rcParams, anything computed, and anything set by
    a helper. Reading `get_fontsize()` off the artists that actually rendered
    reports what is on the page instead of what is in the source.
    """
    scale = page_scale(fig, placed_frac, venue) if scale is None else scale
    ghosts = _ghost_ticks(fig)
    sizes = [(round(float(t.get_fontsize()) * scale, 1), str(t.get_text())[:22])
             for t, _ in _texts(fig, r) if id(t) not in ghosts]
    if not sizes:
        return True, "no text"
    small = sorted({(pt, s) for pt, s in sizes if pt < TYPE_FLOOR_PT})
    mn = min(pt for pt, _ in sizes)
    if not small:
        detail = f"smallest {mn:.1f}pt on page (floor {TYPE_FLOOR_PT})"
        if placed_frac < PLACED_FRAC_WARN:
            return "warn", (f"{detail}; placed at {placed_frac:.0%} of content width"
                           " — labels may be too small to read; author at the"
                           " width it ships at")
        return True, detail
    return False, (f"under {TYPE_FLOOR_PT}pt on page at scale {scale}: {small[:4]}"
                   "  [FIX] cut words, do not shrink type")

check_figure.check_line_weight

check_line_weight(fig: Figure, scale: float | None = None, placed_frac: float = 1.0, venue: str | None = None) -> tuple[bool | str, str]

Every drawn stroke against the printer's floor, measured ON THE PAGE.

SIAM states it plainly in its instructions for authors: illustrations must use lines one point or thicker, because thinner lines break up or disappear. It is the same failure as the type floor and it has the same cause — a stroke authored at 0.8pt in a 9-inch figure placed at 5.5 inches prints at 0.49pt — so it is measured the same way, through page_scale.

Furniture is held to a lower floor than data. A gridline that drops out at the printer costs the reader a reference; a data curve that drops out costs them the finding. The sheet ships the grid at 0.7pt deliberately, and failing it against the data floor would be failing the sheet's own design.

Source code in skill/scripts/check_figure.py
def check_line_weight(fig: Figure, scale: float | None = None,
                      placed_frac: float = 1.0,
                      venue: str | None = None) -> tuple[bool | str, str]:
    """Every drawn stroke against the printer's floor, measured ON THE PAGE.

    SIAM states it plainly in its instructions for authors: illustrations must
    use lines one point or thicker, because thinner lines break up or disappear.
    It is the same failure as the type floor and it has the same cause — a
    stroke authored at 0.8pt in a 9-inch figure placed at 5.5 inches prints at
    0.49pt — so it is measured the same way, through `page_scale`.

    Furniture is held to a lower floor than data. A gridline that drops out at
    the printer costs the reader a reference; a data curve that drops out costs
    them the finding. The sheet ships the grid at 0.7pt deliberately, and
    failing it against the data floor would be failing the sheet's own design.
    """
    from matplotlib.lines import Line2D
    from matplotlib.collections import LineCollection

    if scale is None:
        scale = page_scale(fig, placed_frac, venue)

    thin, widths = [], []
    for ax in fig.axes:
        # A colorbar's dividers ship at 0.4pt and are matplotlib's, not
        # anybody's design decision — the same reason `check_ink` skips this
        # axes entirely.
        if ax.get_label() == "<colorbar>":
            continue
        gridlines = {id(g) for axis in (ax.xaxis, ax.yaxis)
                     for g in axis.get_gridlines()}
        for artist in list(ax.lines) + list(ax.collections):
            if not artist.get_visible() or id(artist) in gridlines:
                continue
            if isinstance(artist, Line2D):
                stroke = str(artist.get_linestyle()).strip().lower()
                if stroke in ("none", "", " "):
                    continue
                raw = [artist.get_linewidth()]
            elif isinstance(artist, LineCollection):
                raw = _collection_widths(artist)
            elif getattr(artist, "filled", None) is False:
                # An unfilled ContourSet is strokes. A *filled* one is bands
                # whose linewidth is the seam between two fills, which no
                # reader is being asked to see.
                raw = _collection_widths(artist)
            else:
                continue
            for w in raw:
                on_page = float(w) * scale
                if on_page <= 0:
                    continue
                widths.append(on_page)
                if on_page < LINE_FLOOR_PT:
                    name = str(artist.get_label() or "")
                    thin.append(f"{name if name and not name.startswith('_') else 'a stroke'}"
                                f" at {on_page:.2f}pt")

    if not widths:
        return True, "no strokes to measure"
    if not thin:
        return True, (f"{len(widths)} strokes, thinnest {min(widths):.2f}pt on "
                      f"page (floor {LINE_FLOOR_PT})")
    seen = list(dict.fromkeys(thin))
    return False, (f"under {LINE_FLOOR_PT}pt on page at scale {scale:.2f}: "
                   f"{seen[:4]}  [FIX] set linewidth to at least "
                   f"{LINE_FLOOR_PT / scale:.2f} at this scale"
                   "  [WHY] SIAM: lines thinner than one point break up or "
                   "disappear in print")

check_figure.check_banking

check_banking(fig: Figure) -> tuple[bool | str, str]

Whether a panel's aspect ratio lets the reader compare rates of change.

Cleveland banks a panel to 45 degrees: choose the height-to-width ratio that puts the median absolute segment slope at 1, because slope discrimination is most accurate near 45 and falls away either side of it. A cycle that is plain in one aspect ratio is invisible in another, and the wrong one is usually the one the default produced.

The failure is a resolution failure, and it is measurable. On a saw wave whose decay limbs alternate between two rates, one exactly twice the other: at 2.4 x 5.2 inches the two limbs land 1.6 degrees apart on the page and the alternation cannot be seen at all; at 6.4 x 1.9 they land 10.6 degrees apart and it is the first thing you see. Same data, same axes, same limits.

Advisory, and loose, because the right aspect is a judgement about what the reader's job is and this cannot know that. BANKING_SLOPE_MAX is a factor of ten either side of banked - a typical segment steeper than 84 degrees or flatter than 6 - which is a panel essentially vertical or essentially flat over its own typical step. See _banking_slopes for what is excluded and why; a scatter, a map at fixed aspect and a parametric curve are all cases where the median slope means nothing.

Source code in skill/scripts/check_figure.py
def check_banking(fig: Figure) -> tuple[bool | str, str]:
    """Whether a panel's aspect ratio lets the reader compare rates of change.

    Cleveland banks a panel to 45 degrees: choose the height-to-width ratio that
    puts the median absolute segment slope at 1, because slope discrimination is
    most accurate near 45 and falls away either side of it. A cycle that is
    plain in one aspect ratio is invisible in another, and the wrong one is
    usually the one the default produced.

    The failure is a resolution failure, and it is measurable. On a saw wave
    whose decay limbs alternate between two rates, one exactly twice the other:
    at 2.4 x 5.2 inches the two limbs land 1.6 degrees apart on the page and the
    alternation cannot be seen at all; at 6.4 x 1.9 they land 10.6 degrees
    apart and it is the first thing you see. Same data, same axes, same limits.

    Advisory, and loose, because the right aspect is a judgement about what the
    reader's job is and this cannot know that. `BANKING_SLOPE_MAX` is a factor
    of ten either side of banked - a typical segment steeper than 84 degrees or
    flatter than 6 - which is a panel essentially vertical or essentially flat
    over its own typical step. See `_banking_slopes` for what is excluded and
    why; a scatter, a map at fixed aspect and a parametric curve are all cases
    where the median slope means nothing.
    """
    import numpy as np
    floor = 1.0 / BANKING_SLOPE_MAX
    bad, seen = [], []
    for i, ax in enumerate(fig.axes):
        slopes = _banking_slopes(ax)
        if slopes is None or not len(slopes):
            continue
        median = float(np.median(slopes))
        seen.append(median)
        if floor <= median <= BANKING_SLOPE_MAX:
            continue
        degrees = math.degrees(math.atan(median))
        # Banking multiplies the height-to-width ratio by 1/median. Making the
        # panel wider multiplies its width, dividing the ratio; making it
        # taller multiplies the height, so the author acts on 1/median.
        factor = 1.0 / median if median < 1 else median
        bad.append(f"ax{i} typical segment at {degrees:.0f} deg "
                   f"(slope {median:.2g}); banking wants the panel "
                   f"{factor:g}x " + ("wider" if median > 1 else "taller"))
    if not seen:
        return True, "no line panel whose aspect encodes a rate"
    if not bad:
        return True, (f"{len(seen)} line panel"
                      f"{'s' if len(seen) != 1 else ''}, typical segment "
                      f"{math.degrees(math.atan(min(seen))):.0f}-"
                      f"{math.degrees(math.atan(max(seen))):.0f} deg")
    return "warn", ("; ".join(bad)
                    + "  [FIX] set the panel aspect, or the figure size, to the "
                      "ratio named above"
                      "  [WHY] Cleveland banks to 45 degrees, where slope "
                      "discrimination is most accurate; at this aspect two "
                      "rates that differ by a factor of two can land under 2 "
                      "degrees apart")

check_figure.check_ink

check_ink(fig: Figure, context_axes: Sequence[Axes] | None = None, canvas: Any = None) -> tuple[bool | str, str]

Ink as a fraction of each plotting area, measured off the rendered pixels rather than estimated from artist properties.

Near zero means a panel that did not need to be a panel. Very high means a panel with no ground left in it. Reported per-axes, and advisory only: the right density genuinely depends on the form, so this flags panels worth a second look rather than declaring them wrong.

Pass context_axes — a list of Axes whose fill is a context surface (e.g. a contourf landscape) rather than data-ink. For those axes, the ink fraction measures only marks ON TOP of the surface by separating the pixel values into two clusters (k-means with k=2) and removing the larger cluster (the surface). A figure with a filled terrain plus a few sparse marks will PASS rather than WARN.

Pass canvas — an already-drawn Agg canvas — to avoid a second render.

Source code in skill/scripts/check_figure.py
def check_ink(fig: Figure, context_axes: Sequence[Axes] | None = None,
              canvas: Any = None) -> tuple[bool | str, str]:
    """Ink as a fraction of each plotting area, measured off the rendered
    pixels rather than estimated from artist properties.

    Near zero means a panel that did not need to be a panel. Very high means a
    panel with no ground left in it. Reported per-axes, and advisory only: the
    right density genuinely depends on the form, so this flags panels worth a
    second look rather than declaring them wrong.

    Pass `context_axes` — a list of Axes whose fill is a context surface (e.g. a
    contourf landscape) rather than data-ink. For those axes, the ink fraction
    measures only marks ON TOP of the surface by separating the pixel values
    into two clusters (k-means with k=2) and removing the larger cluster (the
    surface). A figure with a filled terrain plus a few sparse marks will PASS
    rather than WARN.

    Pass `canvas` — an already-drawn Agg canvas — to avoid a second render.
    """
    import numpy as np

    if canvas is None:
        # Same reason as `check_text_readability`, and the same two context
        # managers in the same order: an ink fraction is a count of pixels, and
        # a count of pixels is only a measurement at a fixed resolution and a
        # fixed font list. See `MEASURE_DPI` and `METRIC_RC_KEYS`.
        with _at_draw_rc(fig), _at_measure_dpi(fig):
            _, canvas = _renderer(fig)
            return check_ink(fig, context_axes, canvas)
    buf = np.asarray(canvas.buffer_rgba())[:, :, :3].astype(int)
    h = buf.shape[0]
    bg = buf[0, 0]
    # anything more than a few levels off the page color counts as ink
    ink_mask = (np.abs(buf - bg).sum(axis=2) > INK_DELTA_MIN)

    if context_axes is None:
        context_axes = []
    context_ids = frozenset(id(ax) for ax in context_axes)

    rows = []
    for i, ax in enumerate(fig.axes):
        # A colorbar is a solid ramp by construction: 100% ink, always, on
        # every figure that has one. Measuring it means every heatmap in the
        # world stands at WARN for the one axes in it whose density is not a
        # choice anybody made. matplotlib labels the axes it creates.
        if ax.get_label() == "<colorbar>":
            continue
        bb = ax.get_window_extent(renderer=canvas.get_renderer())
        x0, x1 = int(max(bb.x0, 0)), int(min(bb.x1, buf.shape[1]))
        y0, y1 = int(max(bb.y0, 0)), int(min(bb.y1, h))
        # Agg's origin is top-left, the figure's is bottom-left
        sub = ink_mask[h - y1:h - y0, x0:x1]
        if sub.size == 0:
            continue

        if id(ax) in context_ids:
            # Separate surface pixels from mark pixels via 2-means on color.
            sub_buf = buf[h - y1:h - y0, x0:x1].astype(float)
            flat = sub_buf.reshape(-1, 3)
            m1 = flat.mean(axis=0)
            # init second centroid offset so they diverge
            m2 = m1 + 30.0
            for _ in range(12):
                d1 = np.abs(flat - m1).sum(axis=1)
                d2 = np.abs(flat - m2).sum(axis=1)
                c1 = d1 <= d2
                c2 = ~c1
                if c1.sum() == 0 or c2.sum() == 0:
                    break
                nm1 = flat[c1].mean(axis=0)
                nm2 = flat[c2].mean(axis=0)
                if (np.abs(nm1 - m1).sum() < 0.5
                        and np.abs(nm2 - m2).sum() < 0.5):
                    break
                m1, m2 = nm1, nm2
            surf = c1 if c1.sum() > c2.sum() else c2
            surf_mask = surf.reshape(sub.shape)
            # Ink = pixels in the ink_mask AND not in the surface cluster
            frac = float((sub & ~surf_mask).mean())
        else:
            frac = float(sub.mean())
        # An empty panel is structural, not a low number. Furniture is a
        # perimeter and the panel is an area, so what a blank axes measures on
        # its own depends on how big it is: at `MEASURE_DPI`, the blank half of
        # a 3x1.5in pair reads 0.03, over the 0.02 floor, and the blank half of
        # a 6x3in pair reads 0.01, under it. The first read as merely sparse and
        # the second is caught by the floor for a reason that has nothing to do
        # with it being empty. Ask whether anything was drawn.
        rows.append((i, frac,
                     _axes_drew_anything(ax) and INK_MIN <= frac <= INK_MAX))

    if not rows:
        return True, "no measurable axes"
    detail = ", ".join(f"ax{i} {f:.2f}" for i, f, _ in rows)
    odd = [i for i, _, g in rows if not g]
    if not odd:
        return True, f"ink fraction: {detail} (typical {INK_MIN}-{INK_MAX})"
    return "warn", (f"ink fraction: {detail} (typical {INK_MIN}-{INK_MAX})"
                    f"  [FIX] look at ax{odd}: empty panels and saturated ones both"
                    " read badly, though a heatmap legitimately runs high")

check_figure.check_series_color

check_series_color(fig: Figure) -> tuple[bool | str, str]

The hues actually drawn, put through the palette gates.

The hole this closes: check_palette.py judges a list of hexes someone remembered to paste into a terminal, and this file never looked at color at all. A figure on matplotlib's default tab10 cycle - whose orange and green measure CAM02-UCS dE 2.4 under protanopia, against a floor of 10.5 - passed every composition check clean. Two scripts in one project that never spoke.

Only what is never legitimate is gated: separation under color blindness, and separation in normal vision. The lightness-band and chroma-floor rows are deliberately not applied to harvested colors. A black or gray series is legal - a reference curve, a control group - and failing it is precisely the noise that teaches people to skim past the row.

Scoped per panel. The comparison, the hue count and the all-pairs mode are all asked of one axes at a time, because the panel is the unit a reader separates hues within - a figure-wide bag gated hues that never share a frame against each other.

Source code in skill/scripts/check_figure.py
def check_series_color(fig: Figure) -> tuple[bool | str, str]:
    """The hues actually drawn, put through the palette gates.

    The hole this closes: `check_palette.py` judges a list of hexes someone
    remembered to paste into a terminal, and this file never looked at color at
    all. A figure on matplotlib's default `tab10` cycle - whose orange and green
    measure CAM02-UCS dE 2.4 under protanopia, against a floor of 10.5 - passed
    every composition check
    clean. Two scripts in one project that never spoke.

    Only what is never legitimate is gated: separation under color blindness,
    and separation in normal vision. The lightness-band and chroma-floor rows
    are deliberately *not* applied to harvested colors. A black or gray series
    is legal - a reference curve, a control group - and failing it is precisely
    the noise that teaches people to skim past the row.

    Scoped per panel. The comparison, the hue count and the all-pairs mode are
    all asked of one axes at a time, because the panel is the unit a reader
    separates hues within - a figure-wide bag gated hues that never share a
    frame against each other.
    """
    by_ax = _data_colors_by_axes(fig)
    if not by_ax:
        return True, "no categorical series colors"

    fails, notes = [], []
    cp: Any = _sibling("check_palette")
    if cp is None:
        notes.append("check_palette.py is not importable beside this file, "
                     "so separation went unchecked")

    for ax, items in by_ax.items():
        distinct = list(dict.fromkeys(h for h, _, _ in items))

        if len(distinct) > MAX_SERIES_HUES:
            fails.append(f"{len(distinct)} distinct data hues in one panel, "
                         f"theme has {MAX_SERIES_HUES}  [FIX] fold the tail into "
                         "'Other' or facet")

        # One hue carrying two identities is what a seventh series looks like
        # once the cycler wraps: matplotlib reuses slot 1 without complaint and
        # the legend confidently lists both. Narrowed to labels on artists of the
        # *same kind*: a wrap reuses one artist type, whereas a band, its mean
        # line and its points in one hue is one series shown three ways, each
        # legitimately labelled. Keyed on kind, that reads as one identity.
        by_hue_kind: dict[Any, set[Any]] = {}
        for h, label, kind in items:
            if label:
                by_hue_kind.setdefault((h, kind), set()).add(label)
        for (h, kind), labels in sorted(by_hue_kind.items()):
            if len(labels) > 1:
                fails.append(f"{h} carries {len(labels)} identities "
                             f"{sorted(labels)} on {kind} artists"
                             "  [FIX] set an explicit color per series, or facet"
                             "  [WHY] the color cycle wrapped")

        if len(distinct) >= 2 and cp is not None:
            _, rows = cp.check(distinct, all_pairs=_axes_all_pairs(ax))
            for name, status, detail in rows:
                if not name.startswith(("CVD separation", "Normal-vision floor")):
                    continue
                if status is False:
                    fails.append(detail)
                else:
                    notes.append(detail.split("  [FIX]")[0].strip())

    max_per_panel = max(
        (len(list(dict.fromkeys(h for h, _, _ in items)))
         for items in by_ax.values()), default=0)
    head = f"up to {max_per_panel} data hues per panel"
    if fails:
        return False, f"{head}: " + "; ".join(fails)
    return True, f"{head}: " + ("; ".join(notes) if notes else "nothing to compare")

check_figure.check_dual_axis

check_dual_axis(fig: Figure) -> tuple[bool | str, str]

Two y scales in one frame, which nothing in this project banned and a twinx figure sailed straight through.

Both scales are set by the author, so the crossing point of the two curves is an artifact of the limits chosen rather than anything in the data. Move the limits and the story changes; a reader cannot tell that from the figure.

The escape hatch is the one legitimate case: a pure unit relabel - degrees C against degrees F, eV against nm - where the twin is furniture and carries no data of its own. So the discriminator is data on both, not a shared frame, which keeps the gate off secondary_yaxis and off correct work.

Source code in skill/scripts/check_figure.py
def check_dual_axis(fig: Figure) -> tuple[bool | str, str]:
    """Two y scales in one frame, which nothing in this project banned and a
    `twinx` figure sailed straight through.

    Both scales are set by the author, so the crossing point of the two curves
    is an artifact of the limits chosen rather than anything in the data. Move
    the limits and the story changes; a reader cannot tell that from the figure.

    The escape hatch is the one legitimate case: a *pure unit relabel* - degrees
    C against degrees F, eV against nm - where the twin is furniture and carries
    no data of its own. So the discriminator is data on both, not a shared
    frame, which keeps the gate off `secondary_yaxis` and off correct work.
    """
    pairs = []
    for i, j in itertools.combinations(range(len(fig.axes)), 2):
        a, b = fig.axes[i], fig.axes[j]
        if any(abs(x - y) > FRAME_TOL for x, y in
               zip(a.get_position().bounds, b.get_position().bounds)):
            continue
        if _has_data(a) and _has_data(b):
            pairs.append(f"ax{i}+ax{j}")
    if not pairs:
        return True, "one data scale per frame"
    # "two scales", not "two y scales": `twiny` lands here on exactly the same
    # argument, and naming the wrong axis sends the reader looking for a defect
    # on the one that is fine.
    return False, (f"two data scales sharing a frame: {', '.join(pairs)}  [FIX] "
                   "the crossing point is set by the limits, not the data. Two "
                   "panels, small multiples, or index both to a common base")

check_figure.check_form

check_form(fig: Figure) -> tuple[bool | str, str]

The mechanical subset of form choice - the three cases where the form is wrong no matter what the data is. references/choosing-a-form.md carries the judgement calls this cannot make.

Source code in skill/scripts/check_figure.py
def check_form(fig: Figure) -> tuple[bool | str, str]:
    """The mechanical subset of form choice - the three cases where the form is
    wrong no matter what the data is. `references/choosing-a-form.md` carries
    the judgement calls this cannot make.
    """
    from matplotlib.container import BarContainer
    from matplotlib.patches import Wedge

    bad = []
    for i, ax in enumerate(fig.axes):
        if any(isinstance(p, Wedge) for p in ax.patches):
            bad.append(f"ax{i} pie/donut: angle and area are the two tasks the "
                       "eye judges worst - a dot plot or a bar reads as position")
        if hasattr(ax, "get_zlim"):
            bad.append(f"ax{i} 3D: perspective makes the encoding unreadable and "
                       "occludes data - facet or use color for the third variable")
        for con in getattr(ax, "containers", []):
            if not isinstance(con, BarContainer):
                continue
            vertical = getattr(con, "orientation", "vertical") == "vertical"
            lim = ax.get_ylim() if vertical else ax.get_xlim()
            scale = ax.get_yscale() if vertical else ax.get_xscale()
            # A log axis cannot include zero, so a log bar chart is truncated by
            # construction and this gate has nothing to say about it.
            if scale == "linear" and min(lim) > 0:
                axis = "y" if vertical else "x"
                bad.append(
                    f"ax{i} bars on a truncated {axis} axis (starts at "
                    f"{min(lim):.4g}): bar length encodes the value, so a "
                    "cut baseline misstates every ratio  [FIX] the fix is the "
                    "form, not the axis - use a dot plot")
            break
    if not bad:
        return True, "no pie, no 3D, no truncated bar baseline"
    return False, "; ".join(bad)

check_figure.check_identity_channel

check_identity_channel(fig: Figure) -> tuple[bool | str, str]

Identity carried by color and nothing else.

A warning rather than a gate, and the reason is honesty about what the script can see: it can count the hues, but it cannot tell a direct label from any other piece of text in the axes. Failing on that guess would fire on correct work, and a gate people learn to skip is worse than no gate.

Source code in skill/scripts/check_figure.py
def check_identity_channel(fig: Figure) -> tuple[bool | str, str]:
    """Identity carried by color and nothing else.

    A warning rather than a gate, and the reason is honesty about what the
    script can see: it can count the hues, but it cannot tell a direct label
    from any other piece of text in the axes. Failing on that guess would fire
    on correct work, and a gate people learn to skip is worse than no gate.
    """
    labeled = {h for h, label in _data_colors(fig) if label}
    if len(labeled) < 2:
        return True, "fewer than two identified series"
    if fig.legends or any(ax.get_legend() is not None for ax in fig.axes):
        return True, f"{len(labeled)} series, legend present"
    if any(ax.texts for ax in fig.axes):
        return True, f"{len(labeled)} series, in-axes text (assumed direct labels)"
    return "warn", (f"{len(labeled)} series told apart by hue alone - no legend "
                    "and no text in the axes"
                    "  [FIX] add direct labels rather than a legend: they remove "
                    "the match-the-swatch step, and orange and sky blue are "
                    "under 3:1 on white, where that step is hardest")

check_figure.check_label_attribution

check_label_attribution(fig: Figure, r: Any) -> tuple[bool | str, str]

A direct label sitting nearer some other series than the one it names.

check_collisions compares text against text, so a label that clears every other label and still floats in the corridor between two curves passes it clean. That is not hypothetical: examples/demo.py shipped with "Tuned" closer to a neighbouring curve than to its own and the whole suite was green. Text against text and text against data are different questions.

Harvested narrowly on purpose - only text whose string matches exactly one series label is judged, because only there is the intent known. A callout, a panel letter, an "n = 300" attributes nothing to a curve, and failing those is the noise that teaches people to skim the row.

The threshold is a ratio rather than a distance because the judgement the reader makes is comparative: a label is unambiguous when its own curve is plainly the closest thing to it, not when it is some absolute number of points away.

Scatters count as series alongside lines, and so do the path-drawn ones: step's staircase, a stackplot band, a contour set. Reading ax.lines and offsets alone left a label sitting on top of a dense point cloud or inside a stacked band invisible to the gate twice over: the series could not own a label, and it could not be the neighbour that made one ambiguous. The premise here is that a reader resolves a direct label by proximity, and a reader does not know what artist class drew the ink.

A filled region that encloses the named series is not a rival for its label, and neither is a series enclosed by a named region. A confidence band lies on top of the curve it belongs to, so on distance alone it is always tied with that curve at zero and every direct label under a band failed. The test is _encloses, and it is geometric: an unlabelled band and one labelled for the legend get the same answer, which the label-based test this replaced did not - it passed _child3 and failed 95% CI.

Source code in skill/scripts/check_figure.py
def check_label_attribution(fig: Figure, r: Any) -> tuple[bool | str, str]:
    """A direct label sitting nearer some other series than the one it names.

    `check_collisions` compares text against text, so a label that clears every
    other label and still floats in the corridor between two curves passes it
    clean. That is not hypothetical: `examples/demo.py` shipped with "Tuned"
    closer to a neighbouring curve than to its own and the whole suite was
    green. Text against text and text against data are different questions.

    Harvested narrowly on purpose - only text whose string matches exactly one
    series label is judged, because only there is the intent known. A callout,
    a panel letter, an "n = 300" attributes nothing to a curve, and failing
    those is the noise that teaches people to skim the row.

    The threshold is a ratio rather than a distance because the judgement the
    reader makes is comparative: a label is unambiguous when its own curve is
    plainly the closest thing to it, not when it is some absolute number of
    points away.

    Scatters count as series alongside lines, and so do the path-drawn ones:
    `step`'s staircase, a `stackplot` band, a contour set. Reading `ax.lines`
    and offsets alone left a label sitting on top of a dense point cloud or
    inside a stacked band invisible to the gate twice over: the series could not
    own a label, and it could not be the neighbour that made one ambiguous. The
    premise here is that a reader resolves a direct label by proximity, and a
    reader does not know what artist class drew the ink.

    A filled region that encloses the named series is not a rival for its label,
    and neither is a series enclosed by a named region. A confidence band lies
    on top of the curve it belongs to, so on distance alone it is always tied
    with that curve at zero and every direct label under a band failed. The test
    is `_encloses`, and it is geometric: an unlabelled band and one labelled for
    the legend get the same answer, which the label-based test this replaced did
    not - it passed `_child3` and failed `95% CI`.
    """
    bad, checked = [], 0
    legend_ids = _legend_text_ids(fig) | _furniture_text_ids(fig)
    all_texts = _texts(fig, r)
    for ax in fig.axes:
        px = {}
        for artist in (*ax.lines, *ax.collections):
            if not artist.get_visible():
                continue
            p = _series_px(artist, ax)
            if p is not None and len(p):
                px[artist] = p
        if len(px) < 2:
            continue

        lines_list = list(px.keys())
        owners: dict[Any, Any] = {}
        for i, line in enumerate(lines_list):
            owners.setdefault(str(line.get_label()).strip(), []).append(i)

        for t, bb in all_texts:
            if t.axes is not ax or id(t) in legend_ids:
                continue
            match = owners.get(str(t.get_text()).strip())
            if not match or len(match) != 1:
                continue
            own_line = lines_list[match[0]]
            checked += 1
            # An annotation with a leader is judged at its anchor, not at its
            # string. See `_attribution_box`.
            bb = _attribution_box(t, r, bb)
            # A floor on the own-curve distance: without it a label printed
            # directly on its line divides by ~zero, and every other line in
            # the figure reads as infinitely far.
            d_own = max(_series_distance(own_line, bb, px[own_line]), 0.5)
            # The minimum over every OTHER curve, box-to-polyline. A KD-tree
            # over the pooled points was tried here for speed and was wrong:
            # it returns the nearest *points*, so for a label sitting close to
            # its own dense curve all the near points belong to that curve, no
            # other curve is ever reached, and `d_other` stays infinite. Which
            # is to say it passed every label it was closest to — the common
            # case, and the one the gate exists for.
            # Rivals only. A band that encloses this series, or a series this
            # one encloses, is the same thing on the page and cannot be the
            # neighbour a reader confuses it with.
            rivals = [(line, p) for line, p in px.items()
                      if line is not own_line
                      and not _encloses(line, px[own_line])
                      and not _encloses(own_line, p)
                      and not _rides_on(line, own_line, px[own_line], ax)]
            if not rivals:
                continue
            d_other = min(_series_distance(line, bb, p) for line, p in rivals)
            if d_other < LABEL_MARGIN * d_own:
                bad.append(f"{str(t.get_text())[:22]!r} is {d_own:.0f}px from "
                           f"its own curve and {d_other:.0f}px from another")
    if not checked:
        return True, "no direct labels matched to a series"
    if not bad:
        return True, f"{checked} direct label{'s' if checked != 1 else ''}, "\
                     f"each nearest the curve it names"
    return False, ("; ".join(bad) + "  [FIX] the reader resolves a direct label "
                   "by proximity, so it has to be plainly nearest its own "
                   "curve. Move it to where that curve is furthest from its "
                   "neighbours, or draw a leader line to the anchor")

check_figure.check_style_sheet

check_style_sheet(fig: Figure) -> tuple[bool | str, str]

Every key in the sheet against the rcParams that are actually in effect.

Three separate silent failures land here at once: a color written with a leading # (which is a comment in this format, so matplotlib keeps its own default), a forgotten plt.style.use, and an rcParams override applied later. All three ship stock matplotlib while every other check passes.

A warning, not a gate, for one honest reason: a figure built on a different project's sheet is correct work, and this compares against the global rcParams rather than what the figure was drawn under, so a figure built inside an rc_context that has since exited reads as drift when it is not. Both make a hard failure the wrong instrument. The row names the keys.

Source code in skill/scripts/check_figure.py
def check_style_sheet(fig: Figure) -> tuple[bool | str, str]:
    """Every key in the sheet against the rcParams that are actually in effect.

    Three separate silent failures land here at once: a color written with a
    leading `#` (which is a comment in this format, so matplotlib keeps its own
    default), a forgotten `plt.style.use`, and an rcParams override applied
    later. All three ship stock matplotlib while every other check passes.

    A warning, not a gate, for one honest reason: a figure built on a *different*
    project's sheet is correct work, and this compares against the global
    rcParams rather than what the figure was drawn under, so a figure built
    inside an `rc_context` that has since exited reads as drift when it is not.
    Both make a hard failure the wrong instrument. The row names the keys.
    """
    import matplotlib as mpl
    path = _style_sheet()
    if path is None:
        return True, ("no figure.mplstyle beside this script or in assets/, "
                      "nothing to compare")
    if not path.is_file():
        return "warn", (f"STYLE_SHEET is set to {path}, which is not a file: "
                        "nothing was compared, and the sheet you meant is not "
                        "the one in effect either")
    written = mpl.rc_params_from_file(path, use_default_template=False)
    drift = []
    for key, value in written.items():
        try:
            same = mpl.rcParams[key] == value
        except KeyError:
            continue
        if not isinstance(same, bool):        # a numpy array of comparisons
            # B023 reads the lambda as capturing the loop variable late. It is
            # called on the same line it is built, before the loop advances, so
            # there is no later binding for it to see.
            same = bool(getattr(same, "all", lambda: same)())  # noqa: B023
        if not same:
            drift.append(key)
    if not drift:
        return True, f"all {len(written)} keys match {path.name}"
    return "warn", (f"{len(drift)} of {len(written)} keys differ from "
                    f"{path.name}: {sorted(drift)[:5]}"
                    f"{' ...' if len(drift) > 5 else ''}  [FIX] the sheet is not "
                    "the one in effect: check plt.style.use, and check no color "
                    "in the sheet was written with a leading #")

check_figure.check_contour_dash

check_contour_dash(fig: Figure) -> tuple[bool | str, str]

Negative-level contours auto-dash via matplotlib default.

In a monochrome contour, rcParams["contour.negative_linestyle"] is "dashed" by default, so negative-Z contours ship dashed isolines nobody chose. The skill's own convention is dashing = unobserved / projected / threshold, making this a silent semantic error every existing gate misses.

Non-monochrome (colored) contours are always solid and unaffected.

The condition used to be that EVERY level was non-positive, which is the one shape a genuinely signed field never has: contour over data spanning zero draws levels either side of it, matplotlib dashes the negative half, and the gate skipped the figure entirely. It fired only on data that is non-positive throughout — which is what the original test drew, so the hole was invisible from inside the suite. The rule is now "any negative level", asked of the drawn strokes.

Source code in skill/scripts/check_figure.py
def check_contour_dash(fig: Figure) -> tuple[bool | str, str]:
    """Negative-level contours auto-dash via matplotlib default.

    In a monochrome contour, `rcParams["contour.negative_linestyle"]` is
    "dashed" by default, so negative-Z contours ship dashed isolines nobody
    chose. The skill's own convention is dashing = unobserved / projected /
    threshold, making this a silent semantic error every existing gate misses.

    Non-monochrome (colored) contours are always solid and unaffected.

    The condition used to be that EVERY level was non-positive, which is the
    one shape a genuinely signed field never has: `contour` over data spanning
    zero draws levels either side of it, matplotlib dashes the negative half,
    and the gate skipped the figure entirely. It fired only on data that is
    non-positive throughout — which is what the original test drew, so the hole
    was invisible from inside the suite. The rule is now "any negative level",
    asked of the drawn strokes.
    """
    from matplotlib.contour import ContourSet

    warned = []
    for i, ax in enumerate(fig.axes):
        for c in ax.collections:
            if not isinstance(c, ContourSet):
                continue
            if _negative_levels_are_dashed(c):
                warned.append(
                    f"ax{i}: negative-level contours auto-dashed — dashing "
                    "reads as projected/unobserved here"
                    '  [FIX] pass linestyles="solid" to contour on signed data')
                break

    if not warned:
        return True, "no auto-dashed negative contours"
    return "warn", "; ".join(warned)

check_figure.check_colormap

check_colormap(fig: Figure) -> tuple[bool | str, str]

Whether each colormap in the figure encodes what its data is.

Samples every named colormap an artist actually draws with, hands the samples to check_palette.cmap_kind, and fails the two kinds that are not an encoding. misc is a ramp whose lightness reverses, or is flat, or ends somewhere neither cyclic nor diverging would: a reader cannot put two of its values in order. A qualitative map is judged by the same all-pairs separation floor a hand-built palette is, because an image puts every category beside every other one.

Two ways this row passes without having judged anything, both deliberate. A colormap matplotlib built from colours the author set on an artist is skipped, since contour(colors=[...]) is three levels of one hue rather than three categories, and classifying it qualitative would fail it. And the row needs check_palette.py importable beside this file: without it there is nothing to classify with, so it says so in the detail and passes. A pass here is worth reading, not just counting.

Source code in skill/scripts/check_figure.py
def check_colormap(fig: Figure) -> tuple[bool | str, str]:
    """Whether each colormap in the figure encodes what its data is.

    Samples every named colormap an artist actually draws with, hands the
    samples to `check_palette.cmap_kind`, and fails the two kinds that are not
    an encoding. `misc` is a ramp whose lightness reverses, or is flat, or ends
    somewhere neither cyclic nor diverging would: a reader cannot put two of its
    values in order. A qualitative map is judged by the same all-pairs
    separation floor a hand-built palette is, because an image puts every
    category beside every other one.

    Two ways this row passes without having judged anything, both deliberate.
    A colormap matplotlib built from colours the author set on an artist is
    skipped, since `contour(colors=[...])` is three levels of one hue rather
    than three categories, and classifying it qualitative would fail it. And the
    row needs `check_palette.py` importable beside this file: without it there
    is nothing to classify with, so it says so in the detail and passes. A pass
    here is worth reading, not just counting.
    """
    cp = _sibling("check_palette")
    if cp is None:
        return True, ("check_palette.py is not importable beside this file, "
                      "so no colormap was classified")

    from matplotlib.colors import to_hex

    seen: dict[Any, Any] = {}
    for ax in fig.axes:
        if ax.get_label() == "<colorbar>":
            continue
        for artist in list(ax.images) + list(ax.collections):
            if not artist.get_visible():
                continue
            if getattr(artist, "get_array", lambda: None)() is None:
                continue
            cmap = getattr(artist, "get_cmap", lambda: None)()
            if cmap is not None:
                name = getattr(cmap, "name", "")
                # An unnamed colormap means the artist has explicit colours set
                # (e.g. `contour(colors="black")`), rather than a continuous
                # encoding named by the author.
                if not name or name in ANONYMOUS_CMAP_NAMES:
                    continue
                seen.setdefault(name, cmap)

    if not seen:
        return True, "no colormapped artists"

    fails, notes = [], []
    for name, cmap in sorted(seen.items()):
        if cmap.N < cp.CMAP_QUALITATIVE_N:
            levels = [to_hex(cmap(i)) for i in range(cmap.N)]
            floats = [tuple(cmap(i)[:3]) for i in range(cmap.N)]
        else:
            levels = [to_hex(cmap(i / (cp.CMAP_SAMPLES - 1)))
                      for i in range(cp.CMAP_SAMPLES)]
            # Classified before the 8-bit round trip. Rounding each channel to
            # 1/255 puts oscillations of about 0.001 OKLab into a smooth ramp,
            # and back travel divides by the lightness span, so a narrow-span
            # map turns that wobble into a large fraction: `winter` measured
            # 0.0343 through hex against 0.0139 in float, straddling the 0.02
            # floor, so a sequential map classified `misc`. `levels` stays hex
            # because the qualitative branch below hands it to `cp.check`,
            # which takes hex.
            floats = [tuple(cmap(i / (cp.CMAP_SAMPLES - 1))[:3])
                      for i in range(cp.CMAP_SAMPLES)]

        kind = cp.cmap_kind_rgb(floats)

        if kind == "misc":
            fails.append(
                f"{name}: lightness reverses over "
                f"{cp.cmap_back_travel_rgb(floats):.0%} of its span  [FIX] a "
                "reader cannot order two values in it. viridis for sequential, "
                "RdBu for diverging, twilight for cyclic")
            continue

        if kind == "qualitative":
            _, rows = cp.check(levels, all_pairs=True)
            bad = [detail.split("  [FIX]")[0].strip()
                   for row_name, status, detail in rows
                   if row_name.startswith(("CVD separation",
                                            "Normal-vision floor"))
                   and status is False]
            if bad:
                fails.append(f"{name} ({cmap.N} categories): " + "; ".join(bad)
                             + "  [FIX] re-step the categories onto hues that "
                               "separate at every pair, or cut their number"
                               "  [WHY] an image puts every category beside every "
                               "other, so every pair has to separate")
                continue

        notes.append(f"{name} {kind}")

    if fails:
        return False, "; ".join(fails)
    return True, ", ".join(notes)

check_figure.check_fonts

check_fonts(fig: Figure) -> tuple[bool | str, str]

Two silent failures between the figure on screen and the file you submit.

Type 3. Matplotlib defaults pdf.fonttype and ps.fonttype to 3. IEEE PDF eXpress requires embedded Type 1 or TrueType and does not accept Type 3, so the upload is refused before a reviewer sees it. ACM and Elsevier check embedding in production instead, which is the same problem surfacing after acceptance rather than a milder one. Neither publishes a rule rejecting a submission for it, and this docstring said they did until the prose audit read the sources. Nothing warns you either way: the figure renders identically, and the paper bounces at the latest and most expensive possible moment. Type 42 embeds TrueType outlines instead. figure.mplstyle sets it; this catches the project that did not copy the sheet, and the notebook that called rcParams.update afterwards.

Silent substitution. When none of the faces named in font.<family> is installed, matplotlib falls back to its own default and logs nothing at default verbosity. The guide calls the typeface the single largest visual lever in the whole method, so a figure set in DejaVu because someone named "Times New Roman" on a machine that does not have it is the lever quietly disengaged. Falling back within the named list is not flagged — that is what a fallback list is for, and the sheet ships one on purpose.

A warning rather than a gate, for the same reason check_style_sheet is: both read the global rcParams rather than anything the figure carries, so neither can tell a figure built under someone else's settings from a figure built under none. A figure that will only ever be a PNG in a README is also genuinely unaffected by the PDF font type. What is gated instead is the thing this repo controls — the shipped figure.mplstyle declares 42, and the suite fails if that line ever goes missing.

Source code in skill/scripts/check_figure.py
def check_fonts(fig: Figure) -> tuple[bool | str, str]:
    """Two silent failures between the figure on screen and the file you submit.

    *Type 3.* Matplotlib defaults `pdf.fonttype` and `ps.fonttype` to 3. IEEE
    PDF eXpress requires embedded Type 1 or TrueType and does not accept Type 3,
    so the upload is refused before a reviewer sees it. ACM and Elsevier check
    embedding in production instead, which is the same problem surfacing after
    acceptance rather than a milder one. Neither publishes a rule rejecting a
    submission for it, and this docstring said they did until the prose audit
    read the sources. Nothing warns you either way: the figure renders
    identically, and the paper bounces at the latest and most expensive possible
    moment. Type 42 embeds TrueType outlines instead. `figure.mplstyle` sets it;
    this catches the project that did not copy the sheet, and the notebook that
    called `rcParams.update` afterwards.

    *Silent substitution.* When none of the faces named in `font.<family>` is
    installed, matplotlib falls back to its own default and logs nothing at
    default verbosity. The guide calls the typeface the single largest visual
    lever in the whole method, so a figure set in DejaVu because someone named
    "Times New Roman" on a machine that does not have it is the lever quietly
    disengaged. Falling back *within* the named list is not flagged — that is
    what a fallback list is for, and the sheet ships one on purpose.

    A warning rather than a gate, for the same reason `check_style_sheet` is:
    both read the *global* rcParams rather than anything the figure carries, so
    neither can tell a figure built under someone else's settings from a figure
    built under none. A figure that will only ever be a PNG in a README is also
    genuinely unaffected by the PDF font type. What is gated instead is the
    thing this repo controls — the shipped `figure.mplstyle` declares 42, and
    the suite fails if that line ever goes missing.
    """
    import matplotlib as mpl
    from matplotlib.font_manager import FontProperties, findfont, get_font

    notes = []
    type3 = [k for k in ("pdf.fonttype", "ps.fonttype")
             if int(mpl.rcParams[k]) == 3]
    if type3:
        notes.append(f"{' and '.join(type3)} = 3 (Type 3)  [FIX] IEEE PDF eXpress "
                     "refuses the upload and ACM/Elsevier catch it in "
                     "production; set both to 42")

    family = mpl.rcParams["font.family"]
    generic = family[0] if isinstance(family, (list, tuple)) else family
    wanted = list(mpl.rcParams.get(f"font.{generic}", []))
    if wanted:
        try:
            got = get_font(findfont(FontProperties(family=generic))).family_name
        except Exception:
            got = None
        if got is not None and not any(got.lower() == w.lower() for w in wanted):
            notes.append(f"asked for {wanted[:3]}, rendering in {got!r} — none "
                         "of the named faces is installed on this machine")

    if notes:
        return "warn", "; ".join(notes)
    return True, f"Type 42 embedding, {generic} face resolves within the list"

check_figure.check_alt_text

check_alt_text(fig: Figure) -> tuple[bool | str, str]

Whether the figure carries a description for a reader who cannot see it.

A warning rather than a gate, and deliberately: on a paper the description frequently is the caption, and the caption lives in the .tex file where this cannot see it. Hard-failing every figure in that entirely reasonable setup is how a row becomes something everyone learns to skip, which is worse than not having it. Where there is no caption — a notebook, a README, a slide, a web page — nothing else is carrying this and the row is the only thing that will say so.

Source code in skill/scripts/check_figure.py
def check_alt_text(fig: Figure) -> tuple[bool | str, str]:
    """Whether the figure carries a description for a reader who cannot see it.

    A warning rather than a gate, and deliberately: on a paper the description
    frequently *is* the caption, and the caption lives in the .tex file where
    this cannot see it. Hard-failing every figure in that entirely reasonable
    setup is how a row becomes something everyone learns to skip, which is worse
    than not having it. Where there is no caption — a notebook, a README, a
    slide, a web page — nothing else is carrying this and the row is the only
    thing that will say so.
    """
    text = str(getattr(fig, ALT_TEXT_ATTR, "") or "").strip()
    if not text:
        return "warn", ("no description attached  [FIX] describe(fig, \"...\") "
                        "and pass alt_metadata(fig, path) to savefig; if the "
                        "document's caption carries it, this row is discharged")
    if len(text) < ALT_TEXT_MIN_CHARS:
        return "warn", (f"description is {len(text)} characters — that is a "
                        "title, not a description of what the reader would "
                        "have seen")
    return True, f"described in {len(text)} characters"