Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions pyxray/src/pyxray/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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)
171 changes: 171 additions & 0 deletions pyxray/tests/test_theme.py
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 2 additions & 0 deletions xraymanim/VISUAL-SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion xraywidgets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,19 @@ Every coloured thing in a widget is a chip, and `parts.chip` will not build one

The toggles are real `<button>` elements with `aria-pressed`, not styled `<div>`s, so they are reachable by tab and operable by space bar without a line of JavaScript, and a screen reader can say whether one is on. The static rendering marks them disabled, because a button that looks live and does nothing is worse than one that admits it.

`tests/test_xraywidgets_access.py` reads the rendered markup back and checks the pieces are still there: no styled div where a button should be, `aria-pressed` on everything pressable, a name on every group, `scope="col"` on every heading, a focus outline on every control and nothing anywhere that removes one. It also checks the front end still puts focus back after it redraws, because replacing the markup throws focus away and a widget that drops you to the top of the page every time you press a button is unusable without a mouse.

That file is deliberately clear about what it does not cover. Reading the markup cannot tell you whether tabbing through the widget in a real browser lands somewhere sensible, or whether a screen reader announces a toggle flipping. Those need somebody at a machine with the software running, and they are tracked separately rather than being counted as done because the markup looked right.

## The palette comes from one place

There is no hex colour written in this package. `style.py` reads `pyxray.theme`, the same module the Excalidraw diagrams, the matplotlib charts and the manim animations read, and writes it out as CSS custom properties. A test greps the stylesheet for hand written colours and fails if it finds one, so a widget cannot quietly fork away from the diagrams next to it.

Dark mode swaps the neutrals and leaves the tones alone. The tones are pale fills with dark strokes and they read as chips sitting on the page rather than as page colour, so they work against either background. 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 mode swaps the neutrals and leaves the tones alone. The tones are pale fills with dark text and they read as chips sitting on the page rather than as page colour, so they work against either background. 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.

That choice was checked rather than assumed. `pyxray/tests/test_theme.py` computes the WCAG contrast ratio from the hex values and asserts every pair a reader actually looks at, which found two things. Words on a chip are `theme.INK` and not the tone's own stroke, because a stroke on its own fill measures between 2.7:1 and 3.8:1 on five of the six tones and readable text needs 4.5:1. And the edge of a button is `--xw-edge` rather than `--xw-line`, because the rule colour is 1.5:1 against white, which is right for a line under a column heading and invisible as the boundary of a control.

Anything sitting on a tone fill is written into the sheet as a literal colour rather than as a variable. The fills do not move between themes and the variables do, so a variable there would put pale grey text on a pale blue chip the moment somebody's system went dark, which is the sort of thing a screenshot taken on a light machine never shows you.

## Layout

Expand Down
14 changes: 12 additions & 2 deletions xraywidgets/src/xraywidgets/disassembler.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,14 +171,24 @@ def markup(self, state: dict[str, object], *, live: bool = False) -> Raw:
parts = [
head(state["title"], state["version"], count),
source(state["code"], live=live),
toggles([(one["name"], one["label"], one["on"]) for one in state["flags"]], live=live),
toggles(
[(one["name"], one["label"], one["on"]) for one in state["flags"]],
live=live,
label=text("disassembler.toggles_label"),
),
]
if state["error"]:
parts.append(error(state["error"]))
elif not state["rows"]:
parts.append(element("p", text("common.nothing"), class_=f"{PREFIX}-note"))
else:
parts.append(table(self.headings(), [self.row(one) for one in state["rows"]]))
parts.append(
table(
self.headings(),
[self.row(one) for one in state["rows"]],
label=text("disassembler.table_label"),
)
)
if self.exceptions:
parts.append(self.exception_block(state["handlers"]))
if not live:
Expand Down
26 changes: 20 additions & 6 deletions xraywidgets/src/xraywidgets/parts.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from pyxray import theme

from .html import Raw, element, join
from .strings import text
from .style import PREFIX


Expand Down Expand Up @@ -65,38 +66,51 @@ def error(message: str) -> Raw:
return element("div", message, class_=f"{PREFIX}-error")


def toggles(options: Sequence[tuple[str, str, bool]], *, live: bool = False) -> Raw:
def toggles(
options: Sequence[tuple[str, str, bool]], *, live: bool = False, label: str = ""
) -> Raw:
"""The row of on and off buttons above a widget.

Real `<button>` elements with `aria-pressed`, not styled `<div>`s. That is what makes
them reachable by tab and operable by space bar without a line of JavaScript, and it is
what a screen reader needs in order to say whether one is on. The static rendering
disables them, because a button that looks live and does nothing is worse than one that
says it is not.

The row is a `role="group"` with a name on it. A group without a name is announced as
the word group and nothing else, which is worse than no group at all, because it spends
the reader's attention and tells them nothing.
"""
return element(
"div",
join(
element(
"button",
label,
words,
type="button",
class_=f"{PREFIX}-toggle",
data_flag=name,
data_flag=flag,
aria_pressed="true" if on else "false",
disabled=not live,
)
for name, label, on in options
for flag, words, on in options
),
class_=f"{PREFIX}-toggles",
role="group",
aria_label=label or text("common.toggles"),
)


def table(headings: Sequence[str], rows: Sequence[Raw]) -> Raw:
"""A table with a real header row, so the columns are announced with the cells."""
def table(headings: Sequence[str], rows: Sequence[Raw], *, label: str = "") -> Raw:
"""A table with a real header row, so the columns are announced with the cells.

The name matters as much as the header row does. A screen reader can list the tables on
a page and jump between them, and an unnamed one comes out as table, which is no use in
a notebook that has several.
"""
return element(
"table",
element("thead", element("tr", join(element("th", one, scope="col") for one in headings))),
element("tbody", join(rows)),
aria_label=label or None,
)
Loading
Loading