figure-gate 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- figure_gate-0.1.0/.gitignore +15 -0
- figure_gate-0.1.0/CHANGELOG.md +350 -0
- figure_gate-0.1.0/CONTRIBUTING.md +49 -0
- figure_gate-0.1.0/LICENSE +21 -0
- figure_gate-0.1.0/PKG-INFO +270 -0
- figure_gate-0.1.0/README.md +240 -0
- figure_gate-0.1.0/examples/demo.png +0 -0
- figure_gate-0.1.0/examples/demo.py +150 -0
- figure_gate-0.1.0/examples/gallery-convergence.png +0 -0
- figure_gate-0.1.0/examples/gallery-field.png +0 -0
- figure_gate-0.1.0/examples/gallery-forms.png +0 -0
- figure_gate-0.1.0/examples/gallery-orbit.png +0 -0
- figure_gate-0.1.0/examples/gallery-schematic.png +0 -0
- figure_gate-0.1.0/examples/gallery-small-multiples.png +0 -0
- figure_gate-0.1.0/examples/gallery.py +471 -0
- figure_gate-0.1.0/pyproject.toml +97 -0
- figure_gate-0.1.0/skill/SKILL.md +394 -0
- figure_gate-0.1.0/skill/assets/figure.mplstyle +129 -0
- figure_gate-0.1.0/skill/references/choosing-a-form.md +240 -0
- figure_gate-0.1.0/skill/references/style-guide.md +367 -0
- figure_gate-0.1.0/skill/scripts/check_figure.py +1759 -0
- figure_gate-0.1.0/skill/scripts/check_palette.py +229 -0
- figure_gate-0.1.0/tests/conftest.py +13 -0
- figure_gate-0.1.0/tests/test_docs_match_code.py +148 -0
- figure_gate-0.1.0/tests/test_example.py +62 -0
- figure_gate-0.1.0/tests/test_figure.py +1264 -0
- figure_gate-0.1.0/tests/test_palette.py +204 -0
- figure_gate-0.1.0/tests/test_style_sheet.py +204 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-07-27
|
|
4
|
+
|
|
5
|
+
First release on PyPI. Everything below is the work that preceded it.
|
|
6
|
+
|
|
7
|
+
### Four gates that were measuring the wrong thing
|
|
8
|
+
|
|
9
|
+
Found by running the checker over ordinary matplotlib figures nobody built for
|
|
10
|
+
it — the corpus check CONTRIBUTING asks for, applied to the checker itself
|
|
11
|
+
rather than to a new gate.
|
|
12
|
+
|
|
13
|
+
- **Polar radial tick labels are no longer judged by `check_text_readability`.**
|
|
14
|
+
Every ordinary polar plot failed. matplotlib places radial labels inside the
|
|
15
|
+
disc and `set_rlabel_position` only moves them to another angle, so "move the
|
|
16
|
+
label to clear ground" named a move that does not exist. The first fix
|
|
17
|
+
exempted only the clutter clause and was not enough: the contrast clause went
|
|
18
|
+
on failing **eight of eight** polar plots built on this project's own
|
|
19
|
+
`figure.mplstyle`, at 2.0:1 against a curve the radial axis crosses by
|
|
20
|
+
construction. A gate the bundled sheet cannot satisfy is measuring the
|
|
21
|
+
projection, not the figure. Both clauses now skip these labels and the row
|
|
22
|
+
reports how many went unjudged, so the author is told rather than left to
|
|
23
|
+
assume they passed. Author-placed text on a polar axes is still judged.
|
|
24
|
+
|
|
25
|
+
- **`check_redundancy` no longer reads furniture that never renders.** Three
|
|
26
|
+
`imshow` panels at `axis("off")` were told to "use sharex/sharey" on the
|
|
27
|
+
strength of the tick `Text` objects matplotlib keeps for an axis it is not
|
|
28
|
+
drawing. Axes with `axison` false are skipped, and the tick clause now counts
|
|
29
|
+
only visible labels.
|
|
30
|
+
|
|
31
|
+
- **`check_ink` can see an empty panel again.** The README has always claimed
|
|
32
|
+
the row catches "a panel is empty", and the pixel fraction could not: a blank
|
|
33
|
+
axes measures about 0.03 from its frame and ticks alone, over the 0.02 floor.
|
|
34
|
+
The blank subplot in a grid — the case that actually ships in papers — read as
|
|
35
|
+
merely sparse. Emptiness is now structural: was anything drawn into this axes.
|
|
36
|
+
The fraction clause is unchanged and still governs sparse and saturated
|
|
37
|
+
panels.
|
|
38
|
+
|
|
39
|
+
- **`check_line_weight` has tests.** It shipped with none, which is the one
|
|
40
|
+
thing CONTRIBUTING says a gate may not do. Four now: a hairline fails, a
|
|
41
|
+
legal stroke passes, a 1.2pt stroke fails once placement thins it to 0.6pt on
|
|
42
|
+
the page, and the grid is not held to the data floor. The gate was correct —
|
|
43
|
+
it had simply never been watched work.
|
|
44
|
+
|
|
45
|
+
### Text readability — the gate the demo needed
|
|
46
|
+
|
|
47
|
+
- **`check_text_readability` measures whether each string can be read where it
|
|
48
|
+
sits.** Two clauses, both off rendered pixels. *Clutter*: the figure is drawn
|
|
49
|
+
a second time with every string hidden, and that render is the backdrop each
|
|
50
|
+
label was placed onto; pixels inside a label's box that no blend of {its
|
|
51
|
+
surface, the grid, the axis rule} explains are data ink passing through the
|
|
52
|
+
text. *Contrast*: the text against the backdrop it actually got, at the WCAG
|
|
53
|
+
**text** threshold (4.5:1, or 3:1 large) rather than the 3:1 a mark gets,
|
|
54
|
+
because a glyph stem is thinner than a mark.
|
|
55
|
+
- **Measuring the backdrop rather than the finished render is the point.**
|
|
56
|
+
Casing hides the evidence: a white halo over an orange curve renders as clean
|
|
57
|
+
white while punching a visible gap through the data. Both halves of that are
|
|
58
|
+
defects and this reads them as one number.
|
|
59
|
+
- **Clutter is an *edge*, not a deviation from the dominant color.** The first
|
|
60
|
+
version tested each pixel against the box's modal color and failed every
|
|
61
|
+
annotation ever placed on a heatmap, because a smooth ramp differs from its
|
|
62
|
+
own mode everywhere while being, to a reader, one surface. Now each pixel is
|
|
63
|
+
tested against a local average of its neighbours, so a viridis field is
|
|
64
|
+
ground and a hairline is a mark.
|
|
65
|
+
- **It found the sheet's own ticks.** `xtick.color`/`ytick.color` shipped at
|
|
66
|
+
`898781`, which is 3.59:1 on white — under the text threshold on every figure
|
|
67
|
+
in the repo. Now `777570` at 4.6:1, still below the 7.94:1 axis label, so the
|
|
68
|
+
hierarchy axis label > tick label > grid survives.
|
|
69
|
+
- **`examples/demo.py` was the first thing it failed.** All three direct labels
|
|
70
|
+
sat *on* their own curves — `'Baseline'` on data ink over 17% of its box —
|
|
71
|
+
while `check_label_attribution` passed them, correctly, because a label
|
|
72
|
+
printed on its own line is attributed perfectly. The cause was alignment:
|
|
73
|
+
`ha="center"` clears a sloped curve at the anchor and puts both ends of the
|
|
74
|
+
box back down on the line. Fixed by aligning toward the side the curve is
|
|
75
|
+
leaving, and by anchoring on the extreme of the noise across the label's own
|
|
76
|
+
span rather than on one point.
|
|
77
|
+
|
|
78
|
+
### Research-standard gates
|
|
79
|
+
|
|
80
|
+
- **`check_line_weight`: 1pt on the page, per SIAM's instructions for authors**
|
|
81
|
+
("lines one point or thicker; thinner lines may break up or disappear").
|
|
82
|
+
Measured through `page_scale` like the type floor, since it is the same
|
|
83
|
+
failure. Gridlines are held to a lower floor than data on purpose.
|
|
84
|
+
- **`check_fonts`: Type 42 embedding, and the face you named.** matplotlib
|
|
85
|
+
defaults `pdf.fonttype`/`ps.fonttype` to 3; IEEE PDF eXpress rejects the
|
|
86
|
+
upload and ACM/Elsevier reject the submission, with nothing warning you
|
|
87
|
+
because the figure renders identically. Also warns when none of the faces in
|
|
88
|
+
`font.<family>` is installed and matplotlib has silently substituted its own.
|
|
89
|
+
Advisory, because it reads global rcParams rather than anything the figure
|
|
90
|
+
carries — the hard gate is on the shipped sheet, in the test suite.
|
|
91
|
+
- **`figure.mplstyle` now sets `pdf.fonttype: 42` and `ps.fonttype: 42`.**
|
|
92
|
+
|
|
93
|
+
### Venue content widths
|
|
94
|
+
|
|
95
|
+
- **`audit(fig, venue="neurips")`** replaces hand-measuring `CONTENT_WIDTH_PT`
|
|
96
|
+
for twelve known venues (NeurIPS, ICLR, ICML, ACL, IEEE, Nature, LaTeX
|
|
97
|
+
`article`, and the column widths of the two-column ones). `python
|
|
98
|
+
check_figure.py --venues` lists them. `CONTENT_WIDTH_PT` still works and still
|
|
99
|
+
wins for anything not in the table.
|
|
100
|
+
|
|
101
|
+
### Alt text
|
|
102
|
+
|
|
103
|
+
- **`describe(fig, ...)` / `alt_metadata(fig)` / `check_alt_text`.** Across
|
|
104
|
+
100,000 public Jupyter notebooks, 99.81% of programmatically generated images
|
|
105
|
+
shipped with no alt text, nearly all matplotlib. `alt_metadata` produces the
|
|
106
|
+
`metadata=` dict for `savefig`, so the description survives into the PNG, PDF
|
|
107
|
+
or SVG. Advisory: on a paper the description frequently *is* the caption, and
|
|
108
|
+
the caption lives where this cannot see it.
|
|
109
|
+
|
|
110
|
+
### `check_label_attribution` was passing nearly everything
|
|
111
|
+
|
|
112
|
+
- **Fixed a regression that had disabled the gate in its common case.** The
|
|
113
|
+
comparison distance had been changed from "the minimum over every other curve"
|
|
114
|
+
to a KD-tree query for the *k* nearest pooled points. For a label sitting near
|
|
115
|
+
its own dense curve, every one of those points belongs to that curve, no other
|
|
116
|
+
curve is ever reached, and `d_other` stays infinite — so the gate passed every
|
|
117
|
+
label whose own curve was nearest, which is nearly all of them. Back to the
|
|
118
|
+
explicit minimum, with a regression test for a label in the corridor between
|
|
119
|
+
two curves.
|
|
120
|
+
- **It found a real defect the moment it worked.** `gallery-convergence.png` had
|
|
121
|
+
its direct labels past the right-hand end of each curve, 29px from their own
|
|
122
|
+
and 35px from a neighbour's: a label outside the data is not resolved by
|
|
123
|
+
proximity to anything. Moved to the left of the panel where the log-log fan is
|
|
124
|
+
three decades wide.
|
|
125
|
+
|
|
126
|
+
### No hard scipy dependency
|
|
127
|
+
|
|
128
|
+
- The README promises three files and no install, and a hard `scipy` import had
|
|
129
|
+
quietly broken it. `scipy.ndimage.uniform_filter` is replaced by a separable
|
|
130
|
+
cumulative-sum box blur in numpy; `scipy.spatial.KDTree` is gone from label
|
|
131
|
+
attribution entirely; `check_overplotting` uses `cKDTree` when it is
|
|
132
|
+
importable and an O(n²) numpy path when it is not. scipy is now an optional
|
|
133
|
+
`fast` extra. Tested with the import forced to fail.
|
|
134
|
+
|
|
135
|
+
### `examples/gallery.py` — six harder figures
|
|
136
|
+
|
|
137
|
+
- Small multiples on shared scales; a filled field with isolines and a colorbar;
|
|
138
|
+
an axis-free schematic; the three forms `choosing-a-form.md` argues for; a
|
|
139
|
+
log-log convergence plot with a slope triangle; a dense orbit diagram. Each is
|
|
140
|
+
audited, and CI fails if any figure fails.
|
|
141
|
+
- **Writing them found six defects in the checks themselves**, which is what
|
|
142
|
+
they are for:
|
|
143
|
+
- the readability gate reported a schematic's tick labels, which
|
|
144
|
+
`ax.axis("off")` means never reach the page — it now skips `_ghost_ticks`
|
|
145
|
+
the way `check_clipping` already did;
|
|
146
|
+
- `check_ink` measured colorbar axes, which are a solid ramp by construction,
|
|
147
|
+
so every figure with a colorbar stood at WARN for the one axes in it whose
|
|
148
|
+
density nobody chose;
|
|
149
|
+
- `check_line_weight` measured a colorbar's own 0.4pt dividers;
|
|
150
|
+
- `_artist_kind` called `plot(..., linestyle="none", marker="o")` a line, so a
|
|
151
|
+
path and its own start marker in one hue read as a wrapped color cycle;
|
|
152
|
+
- the clutter metric failed every heatmap annotation, as above;
|
|
153
|
+
- `check_label_attribution` was passing nearly everything, as above.
|
|
154
|
+
- **And two defects in the figures that no gate caught**, which is what step 7
|
|
155
|
+
of the procedure is for: the schematic's feedback loop ran off the bottom of
|
|
156
|
+
the canvas (`connectionstyle="bar"` drops by a *fraction* of the span, so a
|
|
157
|
+
wide loop went fifteen units below an axes that stops at zero), and the
|
|
158
|
+
convergence plot's slope triangle sat in the only corner its direct labels
|
|
159
|
+
could use. Both were obvious in the PNG and invisible to every check.
|
|
160
|
+
|
|
161
|
+
### Overplotting / mark-density WARN
|
|
162
|
+
|
|
163
|
+
- **`check_overplotting` detects scatter marks that merge into a blob.** For
|
|
164
|
+
each scatter (`PathCollection` with offsets), estimates the fraction of points
|
|
165
|
+
whose nearest neighbour in display pixels is within one marker radius — points
|
|
166
|
+
that visually overlap. Above ~50% overlap the WARN fires: reduce counts, use
|
|
167
|
+
hollow markers, add transparency, or switch to hexbin. The 0.5 threshold keeps
|
|
168
|
+
the entire existing corpus clean (all well-separated scatters pass). A WARN,
|
|
169
|
+
not a FAIL — dense marks are legitimate for some forms (e.g. a swarm plot).
|
|
170
|
+
|
|
171
|
+
### Context-surface ink stops a standing WARN
|
|
172
|
+
|
|
173
|
+
- **Ink coverage accepts `context_axes`.** A filled contourf backdrop (loss
|
|
174
|
+
landscape, terrain) saturated 100% of the axes pixels, triggering a standing
|
|
175
|
+
ink WARN on every such figure — the "advisory everyone learns to ignore." New
|
|
176
|
+
`audit(fig, context_axes=[ax])` declaration tells the checker the fill is a
|
|
177
|
+
context surface, not data-ink. The pixel buffer is split into two clusters via
|
|
178
|
+
2-means on color; the larger cluster (the surface) is subtracted from the ink
|
|
179
|
+
count, so only marks *on top* of the backdrop are measured. A terrain panel
|
|
180
|
+
with a few sparse marks now PASSes instead of WARNing. Existing heatmap
|
|
181
|
+
behavior is unchanged (no `context_axes` → same as before).
|
|
182
|
+
|
|
183
|
+
### Two false-signal fixes, no new gates
|
|
184
|
+
|
|
185
|
+
- **Contour dash warns on auto-dashed negative levels.** A monochrome contour
|
|
186
|
+
with all-negative Z ships dashed isolines via matplotlib default
|
|
187
|
+
(`contour.negative_linestyle`). The skill's own convention is dashing =
|
|
188
|
+
unobserved / projected / threshold, so this is a silent semantic error no
|
|
189
|
+
existing gate saw. New `check_contour_dash` warns; pass `linestyles="solid"`
|
|
190
|
+
on signed data. Style guide note added.
|
|
191
|
+
- **`check_palette.py` learns `--ink`.** Ink/neutral hexes were flagged by the
|
|
192
|
+
chroma-floor and lightness-band rows, but an ink token is not a series hue.
|
|
193
|
+
New `--ink "#52514e,#898781"` flag exempts listed hexes from those checks
|
|
194
|
+
while keeping them in CVD/normal separation and contrast-vs-surface coverage.
|
|
195
|
+
Mirrors `check_figure`'s `INK_TOKENS`.
|
|
196
|
+
|
|
197
|
+
### The series-color and label gates learn the figure's structure
|
|
198
|
+
|
|
199
|
+
The new gates above read a flat bag of hues and text, and a bag throws away the
|
|
200
|
+
structure the author encoded — which panel a hue lives in, what kind of artist
|
|
201
|
+
drew it, whether a piece of text is a direct label or a legend key. Four figures
|
|
202
|
+
that were mechanically correct got failed for it. Each fix gives the harvester
|
|
203
|
+
back one piece of that structure.
|
|
204
|
+
|
|
205
|
+
- **Series color is scoped per panel.** The comparison, the hue count and the
|
|
206
|
+
adjacent-versus-all-pairs mode are now asked of one axes at a time. A
|
|
207
|
+
figure-wide harvest gated hues that never share a frame — a flow-chart node in
|
|
208
|
+
one panel measured against a regression curve in another — and let a single
|
|
209
|
+
scatter flip every line-only panel into the stricter all-pairs regime. The
|
|
210
|
+
panel is the unit a reader separates hues within.
|
|
211
|
+
- **One series drawn several ways is one identity.** The wrap check — one hue,
|
|
212
|
+
two labels — is narrowed to labels on artists of the *same kind*. A cycler that
|
|
213
|
+
wraps reuses one artist type; a posterior shown as a credible band, its mean
|
|
214
|
+
line and its observed points is three kinds in one hue, one series shown three
|
|
215
|
+
ways. Two lines in one hue with two labels is still the wrap it always caught.
|
|
216
|
+
- **Label attribution ignores legend entries.** A legend is a lookup key placed
|
|
217
|
+
away from the curves by design, so judging its entries by proximity to those
|
|
218
|
+
curves could only ever fail. They slipped the `t.axes is ax` guard because a
|
|
219
|
+
legend child's `.axes` is the parent axes; they are now dropped explicitly.
|
|
220
|
+
- **Ordinal ramps on marks belong in a colormap** (guidance). A ramp is built to
|
|
221
|
+
violate the categorical floor, and a `scatter(c=[rgba, …])` list reads as that
|
|
222
|
+
many independent hues. The style guide now says to draw it
|
|
223
|
+
`scatter(c=values, cmap=ListedColormap(ramp))`, which the harvester already
|
|
224
|
+
exempts as a value encoding — the intent lands in the code instead of a flat
|
|
225
|
+
bag of hues.
|
|
226
|
+
|
|
227
|
+
### Five new gates, and the two scripts finally speak
|
|
228
|
+
|
|
229
|
+
The motivating failure, reproduced end to end: a figure drawn on matplotlib's
|
|
230
|
+
default `tab10` cycle with a `twinx` second y axis passed every check in
|
|
231
|
+
`check_figure.py` and printed `-> COMPOSED`. The same three hues through
|
|
232
|
+
`check_palette.py` reported `CVD separation (adjacent) worst #ff7f0e vs
|
|
233
|
+
#2ca02c dE 1.4 (protan)` — one hue to a protanopic reader. Two scripts in one
|
|
234
|
+
project, and nothing connected them.
|
|
235
|
+
|
|
236
|
+
- **`figure.mplstyle` sets `axes.prop_cycle`** to the six Okabe-Ito series slots,
|
|
237
|
+
in canonical order. Until now the sheet set every visual default except the one
|
|
238
|
+
that decides whether a colorblind reader can separate two curves, so a figure
|
|
239
|
+
built on it inherited `tab10`. Yellow and black stay held out — yellow at 1.32:1
|
|
240
|
+
vanishes as a hairline, black is the ink token.
|
|
241
|
+
- **Series color** reads the hues off the figure's own artists and puts them
|
|
242
|
+
through the palette gates, inferring adjacent-versus-all-pairs from whether the
|
|
243
|
+
marks are scatter. Also fails a seventh distinct hue, and one hue carrying two
|
|
244
|
+
labelled identities — which is what the cycler wrapping looks like, and was
|
|
245
|
+
prose only before. Harvest excludes colormapped artists and the sheet's ink
|
|
246
|
+
tokens; the lightness-band and chroma rows are deliberately not applied, because
|
|
247
|
+
a gray control curve is legal.
|
|
248
|
+
- **Dual axis** fails two axes sharing a frame when both carry data: the crossing
|
|
249
|
+
point of the curves is then set by the limits rather than the data. A bare unit
|
|
250
|
+
relabel — `secondary_yaxis` with no artists of its own — still passes.
|
|
251
|
+
- **Form** fails the mechanical subset of form choice: pie/donut, 3D axes, and
|
|
252
|
+
bars on a truncated baseline. Log-scale bars are exempt, since a log axis cannot
|
|
253
|
+
contain zero.
|
|
254
|
+
- **Identity channel** warns when two or more labelled hues carry identity with no
|
|
255
|
+
legend and no in-axes text. A warning rather than a gate: the script cannot tell
|
|
256
|
+
a direct label from any other annotation, and guessing would fire on correct work.
|
|
257
|
+
- **Style sheet** warns when the keys in `figure.mplstyle` are not the ones in
|
|
258
|
+
effect. Catches the `#`-is-a-comment trap, a forgotten `plt.style.use`, and a
|
|
259
|
+
later rcParams override in one row.
|
|
260
|
+
- **Label attribution** fails a direct label that is not plainly nearest the
|
|
261
|
+
curve it names. `Text collision` compares text against text, so a label alone
|
|
262
|
+
in the corridor between two curves clears it; text against *data* is a
|
|
263
|
+
different question and nothing was asking it. Measured in display space as a
|
|
264
|
+
ratio — the reader's judgement is comparative, not a distance in points — and
|
|
265
|
+
harvested only from text matching exactly one series label, because a callout
|
|
266
|
+
or a panel letter attributes nothing to a curve.
|
|
267
|
+
|
|
268
|
+
### Decided: identity does not ride on label color
|
|
269
|
+
|
|
270
|
+
The usual advice is to color a direct label to match its series. Measured on
|
|
271
|
+
this palette, against this project's own thresholds, it cannot be done: text
|
|
272
|
+
needs 4.5:1 to be legible, and darkening the Okabe-Ito hues that far puts orange
|
|
273
|
+
at dE 18.6 from its own line and sky blue at 17.1 — both past the `NORMAL_FLOOR`
|
|
274
|
+
of 15 at which `check_palette` calls two colors different series. The label
|
|
275
|
+
would read as a fourth hue. Below 15 the text is not legible. No setting
|
|
276
|
+
satisfies both, so labels stay ink black and identity rides on proximity, which
|
|
277
|
+
`Label attribution` now gates rather than assumes.
|
|
278
|
+
|
|
279
|
+
`examples/demo.py` gains cartographic casing on its labels — a surface-colored
|
|
280
|
+
stroke under the glyphs — so a gridline crossing behind a label stops breaking
|
|
281
|
+
its edges.
|
|
282
|
+
|
|
283
|
+
### Decided: a legend entry is not a direct label
|
|
284
|
+
|
|
285
|
+
The guide required a "visible direct label" for a sub-3:1 hue and `examples/demo.py`
|
|
286
|
+
used a legend, so one of the two was wrong. Settled the strict way, because the
|
|
287
|
+
obligation follows from the measurement: a faint mark plus a legend leaves the
|
|
288
|
+
reader matching a small faint swatch to a small faint curve, which is the step a
|
|
289
|
+
direct label removes. The demo now labels its curves directly.
|
|
290
|
+
|
|
291
|
+
### New reference
|
|
292
|
+
|
|
293
|
+
`skill/references/choosing-a-form.md`, plus a step in `SKILL.md`'s procedure.
|
|
294
|
+
Grounded in statistical graphics rather than general information design —
|
|
295
|
+
Cleveland & McGill's ordering of the elementary perceptual tasks, and the
|
|
296
|
+
statistical results behind the rules that matter most in teaching material: what a
|
|
297
|
+
box plot hides at small n, why a cut baseline misstates every ratio, why two bars
|
|
298
|
+
are the wrong form for paired data, and why overlapping confidence intervals are
|
|
299
|
+
not a significance test.
|
|
300
|
+
|
|
301
|
+
## Earlier in Unreleased
|
|
302
|
+
|
|
303
|
+
Corrections, no new gates. Nothing that passed before fails now.
|
|
304
|
+
|
|
305
|
+
- **Surface is white everywhere.** `check_palette.py` defaulted to `#fcfcfb`, a
|
|
306
|
+
surface `figure.mplstyle` never rendered, so every contrast ratio in the style
|
|
307
|
+
guide was measured against a page that did not exist. Reddish purple was
|
|
308
|
+
listed as needing a mandatory direct label at 2.98:1; against the surface
|
|
309
|
+
actually used it clears 3:1 at 3.06. Table recomputed, `--surface` still
|
|
310
|
+
available for a genuinely tinted page.
|
|
311
|
+
- **`tests/test_docs_match_code.py`** parses the guide's contrast table and
|
|
312
|
+
checks every quoted number, and every † marker, against `contrast()`. The
|
|
313
|
+
drift above is now a test failure rather than something noticed in a year.
|
|
314
|
+
- **`placed_frac` on `audit()`/`report()`.** A figure placed at
|
|
315
|
+
`0.48\textwidth` was measured as if it were full width and certified at twice
|
|
316
|
+
the type size it shipped at. Passing `placed_frac` without `CONTENT_WIDTH_PT`
|
|
317
|
+
raises rather than silently assuming 1.0.
|
|
318
|
+
- **Mark ratio sees line markers.** It read only `ax.collections`, so
|
|
319
|
+
`markersize=3` beside `markersize=30` passed clean. Bars stay excluded on
|
|
320
|
+
purpose — a bar 30× another bar is the encoding working, and the docstring now
|
|
321
|
+
says so instead of leaving it to look like an oversight.
|
|
322
|
+
- **`figure.mplstyle` carries the mark and output defaults**: `lines.linewidth`,
|
|
323
|
+
`lines.markersize`, `axes.linewidth`, `patch.linewidth`,
|
|
324
|
+
`legend.handlelength`, `errorbar.capsize`, `axes.axisbelow`, plus
|
|
325
|
+
`figure.dpi`, `savefig.dpi`, `savefig.bbox`, `savefig.facecolor`. These were
|
|
326
|
+
prose passed by hand at each call site; at matplotlib's default 100 dpi the
|
|
327
|
+
"render a PNG and look at it" step produced a PNG too soft to show the
|
|
328
|
+
defects it was opened to find.
|
|
329
|
+
- **`bbox_inches="tight"` documented as a trap** beside the `#`-is-a-comment
|
|
330
|
+
one, and pinned in the sheet. It trims to drawn content, so the saved width
|
|
331
|
+
stops being the authored width the type gate derives its floor from — the
|
|
332
|
+
gate then certifies a size the shipped file does not have.
|
|
333
|
+
- `axes.titlesize`/`titleweight` annotated as panel-title settings, resolving a
|
|
334
|
+
contradiction with the guide's ban on in-figure titles.
|
|
335
|
+
|
|
336
|
+
## 0.1.0 — 2026-07-22
|
|
337
|
+
|
|
338
|
+
First release.
|
|
339
|
+
|
|
340
|
+
- `check_palette.py` — lightness band, chroma floor, CVD separation (protanopia
|
|
341
|
+
and deuteranopia, OKLab ΔE), normal-vision separation, contrast against the
|
|
342
|
+
surface, and a four-gate mode for ordered ramps. Standard library only.
|
|
343
|
+
- `check_figure.py` — clipping, text collision, alpha stacking, mark ratio, axis
|
|
344
|
+
redundancy, type size on the printed page, and ink coverage, read off a built
|
|
345
|
+
matplotlib figure's own artists.
|
|
346
|
+
- `figure.mplstyle` — typeface, ink, type sizes, spines, grid, frameless
|
|
347
|
+
legends.
|
|
348
|
+
- `skill/` — Agent Skill wrapper (`SKILL.md` plus the full style guide).
|
|
349
|
+
- 49 tests, including per-gate failure cases and a guard against the style
|
|
350
|
+
sheet's colors silently not applying.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## The bar for a new gate
|
|
4
|
+
|
|
5
|
+
This project is a pile of elimination gates, and the failure mode it guards
|
|
6
|
+
against hardest is a gate that sounds sensible and never fires. So a new check
|
|
7
|
+
ships with three things:
|
|
8
|
+
|
|
9
|
+
1. **A named failure.** Not "figures should have good contrast" — a real figure
|
|
10
|
+
that was really broken, and how. The style guide is written this way
|
|
11
|
+
throughout, and it's the reason the thresholds are arguable rather than
|
|
12
|
+
arbitrary.
|
|
13
|
+
2. **A test proving it fails.** Build a figure with exactly that defect and
|
|
14
|
+
assert *that* gate is the one that catches it. Asserting only that `audit`
|
|
15
|
+
returned `False` would pass even if every other check had silently broken.
|
|
16
|
+
3. **A test proving it doesn't over-fire.** Whatever legitimate case sits
|
|
17
|
+
nearest the line — side-by-side panels each needing their own x label, a
|
|
18
|
+
heatmap at 0.98 ink coverage. Every false positive spends the credibility the
|
|
19
|
+
gate runs on, and three of the existing checks fired on things that never
|
|
20
|
+
render before they were tuned.
|
|
21
|
+
|
|
22
|
+
If the right density or spacing genuinely depends on the form, make it a `WARN`
|
|
23
|
+
rather than a `FAIL`. A gate people learn to skip is worse than no gate.
|
|
24
|
+
|
|
25
|
+
## Changing a threshold
|
|
26
|
+
|
|
27
|
+
Thresholds are measured, and several are quoted in the guide and pinned in
|
|
28
|
+
`tests/test_palette.py`. If you change one, update both — a reader who catches
|
|
29
|
+
the docs quoting a number the code doesn't produce has no reason to trust
|
|
30
|
+
anything else in them.
|
|
31
|
+
|
|
32
|
+
## Style
|
|
33
|
+
|
|
34
|
+
Comments explain *why*, since the *what* is usually legible from the code.
|
|
35
|
+
Several of the longer comments exist to stop a future reader from "fixing"
|
|
36
|
+
something that was already tried and reverted for a measured reason; keep them.
|
|
37
|
+
|
|
38
|
+
## Running things
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install -e ".[dev]"
|
|
42
|
+
pytest -q # the suite
|
|
43
|
+
python skill/scripts/check_figure.py # the checker rejects a bad figure
|
|
44
|
+
python examples/demo.py # end-to-end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`check_palette.py` must keep importing nothing outside the standard library.
|
|
48
|
+
That's what makes it usable from a non-Python toolchain, and CI has a job with
|
|
49
|
+
no `pip install` in it to make sure the claim stays true.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Naren Prakash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|