diff --git a/pyxray/src/pyxray/theme.py b/pyxray/src/pyxray/theme.py index ec7e61d..4a2eab7 100644 --- a/pyxray/src/pyxray/theme.py +++ b/pyxray/src/pyxray/theme.py @@ -25,8 +25,26 @@ MUTED = "#5c5f66" #: Rules, gridlines, box borders that are not carrying meaning. LINE = "#ced4da" +#: The boundary of a control: the edge of a button, the edge of a box you can type in. +#: A separate value from LINE because the two have different jobs. A rule under a column +#: heading is decoration and can be faint. The edge of a button is the only thing telling +#: somebody there is a button there, and WCAG asks for 3:1 against whatever is behind it. +#: LINE is 1.5:1 against white, which is right for a rule and not enough for a button. +#: This one is 3.3:1 against white and 5.2:1 against the dark page, so it does both themes +#: without a second value. +EDGE = "#868e96" PAPER = "#ffffff" +#: The same four neutrals for a dark page. Only the neutrals move between themes. The six +#: tones below keep their colours, because they are pale fills with dark text on them and a +#: chip carries its own background wherever it is put. Giving them a second set of values +#: would mean a second palette to keep in step with the diagrams, and an SVG committed to a +#: repository has one set of colours in it. +DARK_INK = "#e9ecef" +DARK_MUTED = "#adb5bd" +DARK_LINE = "#495057" +DARK_PAPER = "#1a1b1e" + @dataclass(frozen=True) class Tone: @@ -46,6 +64,24 @@ def matplotlib(self) -> dict[str, str]: """The same tone as matplotlib keyword arguments, so a chart matches a diagram.""" return {"edgecolor": self.stroke, "facecolor": self.fill} + @property + def text(self) -> str: + """The colour to put words in when they sit on this tone's fill. + + It is INK for every tone, and it is a method rather than a constant because the + answer is not obvious and the reason is worth having somewhere. The tempting choice + is the stroke, since that is the tone's dark colour and it looks tidy. Measured, it + is between 2.7:1 and 3.8:1 against its own fill on five of the six tones, where + readable text needs 4.5:1. Getting there would mean strokes close to black, which + would leave the diagrams drawing six lines you cannot tell apart. + + So the tone carries meaning through the fill and the border, and the words on top + of it are INK, which is 10:1 or better on all six. The Excalidraw diagrams have + always done this, because bound text in a box takes the default stroke colour. This + makes the widgets agree with them instead of quietly disagreeing. + """ + return INK + TONES: dict[str, Tone] = { # What the reader wrote. Everything else in the picture was derived from this. @@ -97,3 +133,41 @@ def cycle(index: int) -> Tone: STROKE_WIDTH = 2 CORNER_RADIUS = 12 + + +#: What WCAG AA asks for. Ordinary text needs the first number against whatever is behind +#: it. Large text, and the boundary of a control, need the second. These are here rather +#: than in the test so the numbers and the palette live in the same file, and so anything +#: else that wants to check a colour is checking against the same bar. +BODY_TEXT = 4.5 +LARGE_TEXT = 3.0 + + +def luminance(colour: str) -> float: + """How bright a hex colour is, on the scale WCAG measures contrast on. + + Not the same as how bright it looks on a monitor. The channel values coming out of a + hex string are gamma encoded, so each one gets straightened out first, and then the + three are weighted, because the eye gets most of its brightness from green and very + little from blue. + """ + text = colour.lstrip("#") + if len(text) != 6: + raise ValueError(f"expected a six digit hex colour, got {colour!r}") + channels = [] + for start in (0, 2, 4): + value = int(text[start : start + 2], 16) / 255 + channels.append(value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4) + red, green, blue = channels + return 0.2126 * red + 0.7152 * green + 0.0722 * blue + + +def contrast(one: str, other: str) -> float: + """The contrast ratio between two colours, between 1 and 21. + + Order does not matter. Black on white and white on black are the same number, which is + why this sorts the two brightnesses rather than trusting the caller to pass a + foreground first. + """ + bright, dim = sorted((luminance(one), luminance(other)), reverse=True) + return (bright + 0.05) / (dim + 0.05) diff --git a/pyxray/tests/test_theme.py b/pyxray/tests/test_theme.py new file mode 100644 index 0000000..7fe8b11 --- /dev/null +++ b/pyxray/tests/test_theme.py @@ -0,0 +1,171 @@ +"""The palette checking its own contrast, so nobody has to take it on trust. + +Issue 69 asked for the widget contrast to be measured rather than assumed, and for the +measurement to end up as a test rather than a spreadsheet, because a spreadsheet goes stale +the first time somebody picks a prettier blue. The ratios are computable from the hex +values, so this file computes them. + +Every threshold here is one of the two numbers WCAG AA asks for. `theme.BODY_TEXT` is 4.5:1 +and applies to words. `theme.LARGE_TEXT` is 3:1 and applies to headings and to the boundary +of a control, which is what the standard calls non text contrast. + +The failure message on each assertion prints the ratio and both colours. That is deliberate. +Somebody who changes a tone and breaks this wants to know how far off they are, not that a +boolean came back false. +""" + +from __future__ import annotations + +import pytest + +from pyxray import theme + +#: The two pages a widget can end up on. Named so a parametrised failure says which theme +#: broke instead of printing a hex string at somebody. +PAGES = [("light", theme.PAPER), ("dark", theme.DARK_PAPER)] + +#: The foreground neutrals, paired with the page they appear on. Ink is the body text and +#: muted is the notes and the column headings, and both are ordinary text, so both are held +#: to the body threshold. +NEUTRALS = [ + ("ink on the light page", theme.INK, theme.PAPER), + ("ink on the dark page", theme.DARK_INK, theme.DARK_PAPER), + ("muted on the light page", theme.MUTED, theme.PAPER), + ("muted on the dark page", theme.DARK_MUTED, theme.DARK_PAPER), +] + + +def ratio(front: str, back: str) -> float: + return theme.contrast(front, back) + + +def test_black_on_white_is_the_number_the_standard_uses(): + assert round(theme.contrast("#000000", "#ffffff"), 2) == 21.0 + + +def test_a_colour_against_itself_has_no_contrast(): + assert theme.contrast(theme.INK, theme.INK) == 1.0 + + +def test_the_order_of_the_two_colours_does_not_matter(): + assert theme.contrast(theme.INK, theme.PAPER) == theme.contrast(theme.PAPER, theme.INK) + + +def test_the_hash_is_optional(): + assert theme.luminance("#ffffff") == theme.luminance("ffffff") + + +def test_a_colour_that_is_not_six_hex_digits_is_refused(): + with pytest.raises(ValueError, match="six digit hex"): + theme.luminance("#fff") + + +def test_green_counts_for_more_than_blue(): + """The weighting is the whole reason this is not just an average of the channels.""" + assert theme.luminance("#00ff00") > theme.luminance("#ff0000") > theme.luminance("#0000ff") + + +@pytest.mark.parametrize("name", sorted(theme.TONES)) +def test_words_on_a_chip_are_readable(name): + """The chip text against the chip, which is the pair issue 69 asked about first.""" + tone = theme.tone(name) + measured = ratio(tone.text, tone.fill) + assert measured >= theme.BODY_TEXT, ( + f"{name}: text {tone.text} on fill {tone.fill} is {measured:.2f}:1, " + f"needs {theme.BODY_TEXT}:1" + ) + + +@pytest.mark.parametrize("name", sorted(theme.TONES)) +def test_a_tone_stroke_is_a_visible_line_on_the_diagram_page(name): + """The strokes draw box borders and arrows on white, which is non text contrast.""" + tone = theme.tone(name) + measured = ratio(tone.stroke, theme.PAPER) + assert measured >= theme.LARGE_TEXT, ( + f"{name}: stroke {tone.stroke} on paper is {measured:.2f}:1, needs {theme.LARGE_TEXT}:1" + ) + + +@pytest.mark.parametrize(("what", "front", "back"), NEUTRALS) +def test_the_neutral_text_is_readable_on_its_own_page(what, front, back): + measured = ratio(front, back) + assert measured >= theme.BODY_TEXT, ( + f"{what}: {front} on {back} is {measured:.2f}:1, needs {theme.BODY_TEXT}:1" + ) + + +@pytest.mark.parametrize(("theme_name", "page"), PAGES) +def test_the_focus_outline_is_visible_on_both_pages(theme_name, page): + """The one thing telling a keyboard user where they are, so it has to clear both. + + The outline sits two pixels off the control, so what it is drawn against is the page + and not the control's own background. + """ + outline = theme.tone("focus").stroke + measured = ratio(outline, page) + assert measured >= theme.LARGE_TEXT, ( + f"the focus outline {outline} on the {theme_name} page is {measured:.2f}:1, " + f"needs {theme.LARGE_TEXT}:1" + ) + + +@pytest.mark.parametrize(("theme_name", "page"), PAGES) +def test_the_edge_of_a_control_is_visible_on_both_pages(theme_name, page): + """One grey for both themes. If this stops holding, dark mode needs its own value.""" + measured = ratio(theme.EDGE, page) + assert measured >= theme.LARGE_TEXT, ( + f"the control edge {theme.EDGE} on the {theme_name} page is {measured:.2f}:1, " + f"needs {theme.LARGE_TEXT}:1" + ) + + +@pytest.mark.parametrize("name", sorted(theme.TONES)) +def test_a_chip_keeps_its_border_apart_from_the_dark_page(name): + """A chip on the dark page is a pale shape, and its border must not vanish into it. + + On the light page the fill is what separates the chip from the paper and the border is + decoration. On the dark page it is the other way around, and the border is the outline + of a shape that is much brighter than what is behind it, so this checks the fill rather + than the border. + """ + tone = theme.tone(name) + measured = ratio(tone.fill, theme.DARK_PAPER) + assert measured >= theme.LARGE_TEXT, ( + f"{name}: fill {tone.fill} on the dark page is {measured:.2f}:1, needs {theme.LARGE_TEXT}:1" + ) + + +def test_every_tone_puts_its_words_in_ink(): + """Not a taste check. Anything on a fill is written as a literal, so it cannot move. + + The fills do not change between themes, so a tone's text colour must not be read from a + CSS variable that does. `Tone.text` returning INK is what lets `style.py` write the + value straight into the sheet. A tone that answered something else would need the same + argument made again for that colour. + """ + assert {tone.text for tone in theme.TONES.values()} == {theme.INK} + + +def test_the_faint_rule_colour_is_not_used_where_a_control_edge_is_needed(): + """Records why EDGE exists at all, so nobody merges the two back together. + + LINE is right for a rule under a column heading and is nowhere near the bar for the + edge of a button. This asserts that it fails, which is the thing worth knowing. + """ + assert ratio(theme.LINE, theme.PAPER) < theme.LARGE_TEXT + assert ratio(theme.DARK_LINE, theme.DARK_PAPER) < theme.LARGE_TEXT + + +def test_the_stroke_would_not_have_worked_as_chip_text(): + """The measurement that decided `Tone.text`, kept so the decision is not re argued. + + Five of the six tones put their own stroke on their own fill at under 4.5:1. Deleting + this test is fine the day somebody finds six strokes that are both readable on a pale + fill and still tell each other apart as lines on white. Until then it is the evidence. + """ + failing = [ + name + for name, tone in theme.TONES.items() + if ratio(tone.stroke, tone.fill) < theme.BODY_TEXT + ] + assert sorted(failing) == ["durable", "focus", "input", "intermediate", "warning"] diff --git a/xraymanim/VISUAL-SYSTEM.md b/xraymanim/VISUAL-SYSTEM.md index a9dadf5..ceaef74 100644 --- a/xraymanim/VISUAL-SYSTEM.md +++ b/xraymanim/VISUAL-SYSTEM.md @@ -52,6 +52,8 @@ Six tones, defined once in [pyxray/theme.py](../pyxray/src/pyxray/theme.py) and Colour never carries meaning on its own. A tone always arrives with a label, a position or a shape that says the same thing, so a reader who cannot tell the green from the orange loses nothing. +The contrast is measured rather than eyeballed. `pyxray/tests/test_theme.py` works the WCAG ratios out from the hex values and fails when a pair drops under the bar, which is 4.5:1 for words and 3:1 for a line or the edge of a control. Two of those assertions are worth knowing about when you draw something. Each tone's stroke clears 3:1 against white, so it is a line you can see. Words that sit on a tone's fill are `INK`, never the tone's own stroke, because the stroke is only about 3:1 against its own fill and text needs more. Excalidraw's bound text already defaults to `INK`, so a `box` gets this right without asking, and a label you place yourself on top of a filled shape should do the same. + ## Type, spacing and timing Three type sizes and no more: title, body, caption. Two families, one sans and one monospace, and anything that is source code or an opcode name is monospace without exception. diff --git a/xraywidgets/README.md b/xraywidgets/README.md index 2db31d4..254572f 100644 --- a/xraywidgets/README.md +++ b/xraywidgets/README.md @@ -89,11 +89,19 @@ Every coloured thing in a widget is a chip, and `parts.chip` will not build one The toggles are real `