Skip to main content

legible

The mechanical half of legible-slides: the deck linter, and everything that reads a theme file — the validator, the CSS emitter, and the figure archetypes.

A theme is one JSON palette with named roles (docs/token-contract.md). This package is the single reader of that schema, so nothing downstream re-implements it and drifts.

from legible import load_palette, validate

report = validate(load_palette("themes/leuven-blue.json"))
report.passed          # True
report.min_delta_e     # {'normal': …, 'deuteranomaly': …, 'protanomaly': …, 'tritanomaly': …}
report.failures        # pairs below the floor, named by role — never by hex
report.warnings        # grayscale collisions; advisory, never a failure
report.contrast        # the attention pairings and the WCAG contrast each achieves
report.text_contrast   # the text pairings, likewise

What validate() checks

The rule is separation-floor in docs/method.md, which owns the floor, the metric, the conditions and the grayscale carve-out. This package quotes those numbers out of the canon at import — change the rule and the validator changes with it, because there is no second copy here to update:

from legible import DELTA_E_FLOOR, rule_thresholds

rule_thresholds("separation-floor")   # {'delta-e-floor': …, 'delta-e-metric': …, 'cvd-severity': …}

What the package adds on top of the rule is fixed by docs/cvd-validator-contract.md: the palette is checked as two co-occurrence groups — G1 = {reference, muted, series…} (the per-series ramp) and G2 = {reference, muted, brand} (the two-group highlight). They live on mutually exclusive slides, so brand ↔ series is never compared: those roles cannot share an axis.

It also measures the pairings accent-is-attention names, in WCAG contrast. The canon owns the pairings and the ratio they clear; a pairing below it fails the palette like a pair below the floor, and is listed in report.contrast_failures. The pairings decorative-neutral-never-text names, every role the template sets as text on every ground it sits on, are measured the same way and listed in report.text_contrast_failures.

Pass your own floor to move the boundary:

validate(palette, threshold=20.0)

cvd-validate

The same check as a command, so an accessibility claim about your palette is something you can run rather than something you assert:

cvd-validate my-theme.json
FAIL  my-theme.json — min ΔE … (floor …)

  min ΔE per condition
    normal          …
    deuteranomaly   …
    protanomaly     …
    tritanomaly     …
    grayscale       …  advisory

  attention contrast (min …)
         …  ink on accent
  fail   …  accent-strong on surface
         …  accent-strong on surface-alt

  text contrast (min …)
         …  ink on surface
         …  …

  fail  deuteranomaly   …  series-1 ↔ series-2
  warn  grayscale       …  muted ↔ series-1

Pairs and pairings are named by role, never by hex, so the output says which colour in your theme file to change. The achieved minimum prints on a pass too — headroom, or the lack of it, is the thing worth seeing when you are deciding whether the ramp has room for one more series.

--json emits the report verbatim, one object per theme in the order given.

Exit code — 0 on pass, 1 if any pair falls below the floor, 2 if a theme file cannot be read. Grayscale collisions are warnings and never change it.

Installing it without this repo

uv tool install legible-slides

The build copies docs/method.md in beside the module, so an install with no checkout still reads its thresholds out of the canon instead of a hardcoded copy (see hatch_build.py). Only the themes this repo ships are gated by its CI; your palette is yours.

legible gen-css

The same palette, emitted as the deck's CSS custom properties — every scalar role a property of the same name, series expanded to --series-1 … --series-n. The mapping is docs/token-contract.md §5's:

legible gen-css themes/leuven-blue.json --output theme/styles/tokens.css
from legible import gen_css, load_palette

gen_css(load_palette("themes/leuven-blue.json"))    # '/* Generated … */\n\n:root {\n  --ink: …;\n}\n'

Python emits the CSS because Python already reads this schema to validate a palette and to draw a figure: the token contract is understood in exactly one language, so there is no JavaScript copy of it to drift. The output is committed and regenerated when the palette changes, like a lockfile, which is what lets a deck build import it and never invoke Python.

--check writes nothing and exits 1 when the file is not what the theme emits today — the gate that keeps that lockfile honest, and what CI runs here:

legible gen-css themes/leuven-blue.json --output theme/styles/tokens.css --check

Without --output the stylesheet goes to stdout. Exit code — 0 written or already current, 1 stale under --check, 2 if the theme file cannot be read.

legible lint

The rules the canon marks decided by script, as a command with an exit code — so the objective half of a review is something CI runs rather than something a reader has to remember:

legible lint deck/slides.md --theme themes/leuven-blue.json
FAIL  deck/slides.md — 2 errors, 1 warning

  slide 4
    error    bullet-ceiling        … bullets, ceiling …
    warning  no-inflated-register  '…' inflates register without adding information
  deck
    error    separation-floor      FAIL  themes/mine.json — min ΔE … (floor …); …

Findings are grouped by slide and named by the rule they enforce, so a finding is something you can look up in docs/method.md and disagree with. Seven rules are decided per slide — bullet-ceiling, word-ceiling, headline-shape, no-em-dash-headline, no-inflated-register, opener-variety and type-scale, which flags a font size the slide's markup sets inline in px or below the floor — section-locator over the sections the deck declares, reported on the slide that declares the one at fault, conclusion-stays-up on the last slide outside the backups, and separation-floor per theme, for each --theme named. A deck does not record which palette it wears, so naming none checks the slides alone.

headline-shape counts lines, not words. It sets each headline, the cover's title aside, the way the slide does: in the bundled Inter at the headline's size, weight and tracking, kerned as the face kerns it, and wrapped greedily in the theme's headline width, breaking at a space or after a hyphen. legible.headline does the setting, and its tests hold its widths to Chromium's.

Five more are the canon's advisories, reported as warnings and never gating: element-ceiling, on-slide-words and signal-budget per slide, acronym-budget on the slide where the talk's abbreviations pass their budget, and pace-budget on the slide where the time budgets in the speaker notes pass their share of the duration the headmatter declares. Whatever the canon lets clear an advisory is the review's to weigh, not this command's.

from legible import lint

report = lint("deck/slides.md", themes=["themes/leuven-blue.json"])
report.passed      # False if any finding is an error
report.findings    # (Finding(rule=…, severity=…, slide=…, message=…), …)
report.unchecked   # themes that could not be read; neither a pass nor a violation

Exit code — 0 clean, 1 on any violation, 2 if the deck or a theme could not be read. Severity is the canon's call rather than the linter's: a rule whose threshold sets … -severity = warning reports without ever changing the exit code, because established-terminology can legitimately override the inflated-register wordlist. --json emits the report verbatim.

What it refuses to decide

  • The rules the canon marks judgment. Whether a slide carries one message, whether a headline is a claim. Those are the review skill's, and they are advisory because they are fallible; this is a gate, and a gate may only hold what is decidable.
  • Colour. The palette is checked by the validate() that cvd-validate runs. There is one implementation of Machado (2009) in this package, so a linter saying a palette collapses and a report saying it holds cannot disagree. What it measured is carried over as it stands, each failing pair under the rule that names it: separation-floor, accent-is-attention or decorative-neutral-never-text.
  • Its own thresholds. Every number and wordlist above is quoted from the rule that owns it in the canon. Change bullets-per-slide there and this command enforces the new one.

legible rules

The other way the canon reaches a caller. lint quotes the numbers; this prints the rules themselves, verbatim from docs/method.md, for the half of a review no script settles:

legible rules one-message assertion-headline
legible rules --section voice --decided-by judgment
### `one-message`: …

…the rule's own paragraphs, as the canon writes them…

**Decided by** judgment

Elided here, and only here: what the command prints is the canon word for word. This file is not allowed to carry the words, which is the same rule every other document in the repo follows.

The reviewer this exists for is the agent skill (skill/), which carries the procedure and none of the rules. Loading them at review time is what keeps the two in sync: edit a rule in the canon and the next review is against the new one. A wheel built from this package carries the canon, so the command works on a machine with no checkout to read.

Filters are questions to the canon, not lists kept here. --decided-by splits the rules a script settles from the rules a reader must; --section narrows to one part of the method. Asking for --section voice --decided-by judgment is how a caller reviews a rule that section grows without being edited to know about it. --json emits each rule as an object — ID, section, statement, seam, thresholds and markdown.

from legible import rule, rules

rule("bullet-ceiling").decided_by          # ('script',)
rule("never-sole-channel").decided_by      # ('script', 'judgment') — the canon splits it
rules(section="voice", decided_by="judgment")

Exit code — 0 having printed, 2 if the canon cannot be read, names a rule that is not in it, or carries nothing matching the selection. There is no 1: quoting a rule is not a check, so nothing about it can fail. An empty selection is loud rather than silent, because the caller is a review, and one that loaded no rule would find no fault and read as a pass.

The figure helper

Result charts are regenerated from data, never redrawn — which is the whole reason a palette swap propagates. Three archetypes, because three is what the reference implementation prescribes (docs/slidev-reference-impl.md §4). This is not a charting library, and a fourth shape belongs in a ticket rather than a keyword argument.

from legible import load_palette
from legible.figures import Series, multi_series, two_group, save

palette = load_palette("themes/leuven-blue.json")

save(
    multi_series(
        palette,
        [
            Series("Chronos", chronos_mae),
            Series("MOIRAI", moirai_mae),
            Series("Ground truth", truth_mae, role="reference"),
            Series("XGBoost", xgb_mae, role="muted"),
        ],
        x=windows,
        x_label="forecast window",
        y_label="MAE",
    ),
    "deck/public/figures/mae-by-window.png",
)

A Series carries no dash and no marker. Both are assigned by position, so never-sole-channel holds by construction and there is nowhere to opt out of it — and a chart asking for more series than there are distinct dashes is refused rather than quietly repeating one. Leave role unset and a line takes the next colour off the theme's ramp; set it to pin the anchors.

The two-group archetype is the default headline chart — the de-emphasised comparison against one highlight, every bar labelled where it stands, so it needs no legend and no value axis:

two_group(palette, {"ARIMA": 0.51, "XGBoost": 0.44}, {"Ours": 0.29}, y_label="MAE")

The small-multiples archetype is the same few series measured on groups that don't share a scale, such as one error measure on four datasets. Each group gets a panel of bars, titled with its name, and every series appears in every panel in the order given. The names run down the start of each row of panels and every bar carries its value, so colour only repeats what the name and the place already say. Each panel takes its own scale from zero, and value_format can give each its own precision:

small_multiples(
    palette,
    [
        Series("Seasonal naive", [8.30, 0.117], role="reference"),
        Series("XGBoost", [8.50, 0.169], role="muted"),
        Series("Best pre-trained", [6.87, 0.084]),
    ],
    ["BPI 2017", "Sepsis"],
    value_format=["{:.2f}", "{:.3f}"],
)

What the archetypes will not draw

  • Colour comes only from palette roles. Nothing here takes a hex, which is what keeps the theme file the single authority the deck's CSS and the validator already read.
  • Only roles the validator measured may encode data — the anchors and the ramp for a multi-series chart or small multiples, the de-emphasised role plus one highlight for a two-group one. That puts the attention roles out of reach (accent-is-attention) along with every structural neutral, because a pair nobody checked is a pair nobody can vouch for.
  • Small multiples draw magnitudes. Each panel runs from zero, so a negative value is an error, with the series and the group it sits in.
  • The deck's typeface is registered before anything is drawn, and a resolution landing outside the bundle is an error. See theme/assets/fonts/.

Rendering under a colour-vision deficiency

Any chart can render as one pair of eyes receives it — the demonstration two of the flagship's beats are built on, and the reason a hand-drawn approximation would not do:

multi_series(palette, series, condition="deuteranomaly")
multi_series(palette, series, condition="grayscale")

The simulation is the validator's own. There is one implementation of Machado (2009) in this package, so a chart showing a palette collapse and a report saying it holds cannot disagree.

Sizes, and why the same figure comes back twice

Sizes are in the canon's canvas pixels — the units a slide layout is written in — so a chart asked for at 640 wide occupies 640 of them when it lands. save writes PNG at twice that by default, for the projector.

Type is set at the body size. A pane too small for body type can take the floor type-scale holds every other piece of text to, and nothing goes under it:

multi_series(palette, series, size_px=(420, 300), tight_panel=True)

The floor holds where the figure lands, not where it was drawn: a chart drawn at 960 and shown in a 480 pane has every label at half its size. Ask for the size of the pane, and check it with smallest_type_px:

smallest_type_px(figure, lands_at_px=420)   # the smallest label, in canvas px, at that width
save(figure, path, scale=2)          # a 960 × 540 chart → a 1920 × 1080 file

save is deterministic: an unchanged palette and unchanged data produce a byte-identical file, so rerunning the generation rewrites without churning and the one figure that did change is the one you see in the diff.

Development

uv sync --extra dev
uv run pytest
uv run ruff check . && uv run ruff format --check .

An editable install reads docs/method.md from the checkout directly, so a canon edit takes effect with no rebuild.

Metadata

Release files for legible-slides 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for legible-slides 0.1.1
File Size Uploaded
legible_slides-0.1.1.tar.gz 2.3 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for legible-slides 0.1.1
File Interpreter ABI Platform
legible_slides-0.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 4.5 MB

Release files / legible_slides-0.1.1.tar.gz

Download URL legible_slides-0.1.1.tar.gz
Size 2.3 MB
Tags Source
SHA-256 checksum
How to use checksums
76352c4a2ca959e9cd40e4d689aa8da69c665786e1e83a5eec9b8ac416f97487
BLAKE2b-256 checksum
How to use checksums
67cf375f56d7adfaceb157cca48cedec0051369220ac680a4ae02860c3cc6ea6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.

Transparency log

Release files / legible_slides-0.1.1-py3-none-any.whl

Download URL legible_slides-0.1.1-py3-none-any.whl
Size 2.2 MB
Tags Python 3
SHA-256 checksum
How to use checksums
e89f789f1d58a7af46b527d37295720f4d42fa59df4cedc7693c26f3defa0353
BLAKE2b-256 checksum
How to use checksums
cee989bba53e8feb090f6d8969787de8b49bf5c3543d79f586336d77c46e73a4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page