diff --git a/CLAUDE.md b/CLAUDE.md index 02caf0e..043e20a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -311,12 +311,14 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow that differ from row to row. As a chip it is the map's yellow star on neutral dark grey, padded like every other chip. - Chip seams are **one** interlocked chevron (previous fill on next). Two chips of the - same fill are the exception the interlock can't draw — and so are two hues too close to - tell apart, first key bytes under `pathline.SEAM_HUE_GAP` (0x10) apart on the wheel, - circularly; the greys blend only with their exact selves. There the seam is the **thin** - chevron (`POWERLINE_THIN`) in the chip's ink on the shared fill, so the ribbon runs on - unbroken and the join is a line drawn on it, never a wedge of page cut out of the - route. An elision breaks the ribbon rather than joining it — bare `⋯` + same fill are the exception the interlock can't draw — and so are two fills the eye + can't tell apart: under `pathline.SEAM_BLUR` apart in OKLab (`ui/oklab.py`, THE + perceptual colour distance — never a hue gap, never sRGB, both of which mis-size the + greens against the cyans). There the seam is the **thin** chevron (`POWERLINE_THIN`) + in the previous chip's own fill shaded `SEAM_SHADE` darker (lighter for a dark fill), + drawn on the next, so the ribbon runs on unbroken and the join is a line the chip draws + on itself, never a wedge of page cut out of the route and never a third colour. An + elision breaks the ribbon rather than joining it — bare `⋯` on the page between a closing point and the next chip's notch, no fill, no padding. - The ribbon's **outer ends**: the chevron means *the route continues*, so an end that is the route's own never wears one. It ends **square** — nothing appended, the last chip's diff --git a/meshterm/ui/oklab.py b/meshterm/ui/oklab.py new file mode 100644 index 0000000..1aaf908 --- /dev/null +++ b/meshterm/ui/oklab.py @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: Apache-2.0 +"""OKLab — the perceptual colour space the UI measures and shades ``#rrggbb`` values in. + +Where the app has to ask a question about two colours *as the eye sees them* — are these +two chip fills the same to a reader, what is a slightly darker version of this one — it +asks here, not in RGB and not by hue. sRGB is a device encoding: a fixed step in it is a +different size to the eye in every region of the wheel (a step across the greens is a +whisper, the same step across the cyans a shout), and a hue-only comparison is worse, +blind to lightness and saturation altogether. OKLab (Björn Ottosson, 2020) is built so +that Euclidean distance approximates perceived difference and so that changing ``L`` +alone changes lightness and nothing else, which is exactly the two operations wanted. + +Everything is the reference transform, no dependencies: linear sRGB → LMS → cube root → +Lab. ``L`` runs 0 (black) to 1 (white); ``a``/``b`` are the two chroma axes, roughly +±0.4 at the sRGB gamut's edge. A distance of about 0.02 is a just-noticeable difference +between two large patches. +""" + +from __future__ import annotations + +import math + +#: An OKLab triple ``(L, a, b)``. +Lab = tuple[float, float, float] + + +def _linear(channel: float) -> float: + """The sRGB transfer function, 0–1 encoded → 0–1 linear.""" + return channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4 + + +def _encoded(channel: float) -> float: + """The inverse: 0–1 linear → 0–1 sRGB encoded.""" + return 12.92 * channel if channel <= 0.0031308 else 1.055 * channel ** (1 / 2.4) - 0.055 + + +def from_hex(colour: str) -> Lab: + """``#rrggbb`` → OKLab. + + Args: + colour: A six-digit hex colour, ``#`` optional, either case. + + Raises: + ValueError: If the string is not six hex digits. + """ + raw = colour.removeprefix("#") + if len(raw) != 6: + raise ValueError(f"not a #rrggbb colour: {colour!r}") + r, g, b = (_linear(int(raw[i : i + 2], 16) / 255) for i in (0, 2, 4)) + l_ = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3) + m_ = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3) + s_ = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3) + 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_, + ) + + +def to_hex(lab: Lab) -> str: + """OKLab → ``#rrggbb``, each channel clipped to the sRGB gamut. + + Clipping is the honest answer for the one caller that can leave the gamut — a + saturated fill darkened at constant chroma pushes a channel below zero — because the + clipped colour is the nearest the terminal can show, and it stays the right hue to + the eye at the shifts the app asks for. + """ + lightness, a, b = lab + l_ = lightness + 0.3963377774 * a + 0.2158037573 * b + m_ = lightness - 0.1055613458 * a - 0.0638541728 * b + s_ = lightness - 0.0894841775 * a - 1.2914855480 * b + lms = (l_**3, m_**3, s_**3) + linear = ( + 4.0767416621 * lms[0] - 3.3077115913 * lms[1] + 0.2309699292 * lms[2], + -1.2684380046 * lms[0] + 2.6097574011 * lms[1] - 0.3413193965 * lms[2], + -0.0041960863 * lms[0] - 0.7034186147 * lms[1] + 1.7076147010 * lms[2], + ) + return "#" + "".join(f"{round(_encoded(min(1.0, max(0.0, c))) * 255):02x}" for c in linear) + + +def distance(first: str, second: str) -> float: + """Perceived difference between two ``#rrggbb`` colours: Euclidean distance in OKLab. + + ``0`` for the same colour; about ``0.02`` at the threshold of noticing between two + large patches; ``1`` is black to white. + """ + return math.dist(from_hex(first), from_hex(second)) + + +def shaded(colour: str, by: float, *, floor: float | None = None) -> str: + """``colour`` moved ``by`` in lightness, away from whichever end it is nearer. + + A light colour comes back darker and a dark one lighter, at the same chroma, so the + result is recognisably *the same colour, shaded* rather than a different one — the + version of a fill that draws a line on itself. + + Args: + colour: The ``#rrggbb`` to shade. + by: The lightness shift, in OKLab ``L`` (``0.1`` is a clear step, ``0.25`` bold). + floor: If given, the shift starts from this lightness rather than the colour's + own where that is further along — a caller guaranteeing the result clears a + *second* colour's lightness by ``by`` as well as this one's passes that + colour's ``L``. Read as "at least this far from both". + """ + lightness, a, b = from_hex(colour) + if lightness >= 0.5: + start = lightness if floor is None else min(lightness, floor) + return to_hex((start - by, a, b)) + start = lightness if floor is None else max(lightness, floor) + return to_hex((start + by, a, b)) diff --git a/meshterm/ui/pathline.py b/meshterm/ui/pathline.py index 1c286d8..d22ac39 100644 --- a/meshterm/ui/pathline.py +++ b/meshterm/ui/pathline.py @@ -16,10 +16,12 @@ drawn*, so every surface the survey found can eventually route through it: hash-derived hue (:func:`~meshterm.ui.theme.node_style`), our own node the map's yellow ``★`` on a neutral dark grey, a faded hop dark slate, a keyless hop grey. Two chips that land on the *same* fill — a mirrored return leg, a stretch of keyless greys — or on two - hues too close to tell apart (first key bytes under :data:`SEAM_HUE_GAP` apart on the - wheel) take the one exception the interlock can't draw: the seam is the *thin* chevron - instead, ink on the shared fill, so the ribbon runs on unbroken and the join is a line - drawn on it (:meth:`PathLine._seam`). Hops elided out of the middle break the + fills too close to tell apart (under :data:`SEAM_BLUR` apart in OKLab, the perceptual + distance :mod:`~meshterm.ui.oklab` measures) take the one exception the interlock can't + draw: the seam is the *thin* chevron instead, the previous fill shaded a step darker + (lighter, for a dark fill) and drawn on the next, so the ribbon runs on unbroken and the + join is a line the chip draws on itself (:meth:`PathLine._seam`). Hops elided out of + the middle break the ribbon instead of joining it: the mark sits bare on the page between a closing point and the next chip's notch (:func:`elision_hop`), because a filled ``⋯`` chip would read as a node by that name. A chip caught by a *cut* — a lane that ran out, a line scrolled past @@ -77,7 +79,6 @@ earned, and a cursor can never be stranded on a line break. from __future__ import annotations -import colorsys import re from collections.abc import Callable, Sequence from dataclasses import dataclass @@ -88,6 +89,7 @@ from rich.measure import Measurement from rich.text import Text from ..core.models import LOCAL_DEVICE_LABEL +from . import oklab from .marks import SELF_MARK from .termfont import powerline_enabled, powerline_full from .theme import active_theme, node_style @@ -101,8 +103,8 @@ POWERLINE_SEP = "\ue0b0" #: The thin right-pointing chevron (U+E0B1), the solid point's outline sibling in the #: same core set — powerline's own mark for a join *inside* one colour. It draws the seam -#: between two chips whose fills the eye can't tell apart (:meth:`PathLine._seam`): a -#: line of ink on the fill they share, where a solid point would have vanished into it. +#: between two chips whose fills the eye can't tell apart (:meth:`PathLine._seam`): the +#: previous fill, shaded, drawn on the next, where a solid point would have vanished. POWERLINE_THIN = "\ue0b1" #: The rounded caps (U+E0B6 opening, U+E0B4 closing) that finish a path's two outer @@ -258,49 +260,40 @@ def _style_hex(style: str) -> str | None: return None -#: How far apart two chip hues must sit on the 256-step wheel for a seam to interlock -#: them: closer than this and the seam is the thin chevron in ink instead, as it is for -#: two fills that are identical (:meth:`PathLine._seam`). The wheel *is* the node's first key -#: byte (:func:`~meshterm.ui.theme.node_style` maps ``0x00``–``0xff`` straight round it), -#: so this is a difference in first bytes — under ``0x10`` and two neighbours are one -#: ribbon to the eye, a chevron of one shade of green laid on the next (JP, 2026-09-15). -#: The gap is circular: ``0xf8`` and ``0x04`` are twelve apart, not two hundred and forty. -SEAM_HUE_GAP = 0x10 +#: How far apart two chip fills must sit, as the eye measures it, for the seam between +#: them to interlock: closer than this and the solid point would be laid on a field it +#: can't be told from, so the seam is the thin chevron instead (:meth:`PathLine._seam`). +#: The unit is OKLab distance (:func:`~meshterm.ui.oklab.distance`), where ``0.02`` is a +#: just-noticeable difference between two large patches and ``1`` is black to white. +#: +#: Calibrated against the rule it replaced — first key bytes under ``0x10`` apart on the +#: hue wheel (JP, 2026-09-15) — which measures ``0.087`` at the median hue but ``0.026`` +#: across the greens and ``0.165`` across the cyans: the wheel is not uniform to the eye, +#: so a hue gap blurred too little there and too much here, and an sRGB distance would +#: only have restated the hue gap (a fixed byte step is a near-constant ``55``–``60`` of +#: 8-bit RGB all the way round). ``0.10`` is five noticeable differences, the typical +#: ``0x10`` pair with a little to spare, and it errs toward the thin chevron on purpose: +#: a wrong blur still draws a legible seam, a wrong interlock draws none (JP, 2026-09-16). +SEAM_BLUR = 0.10 -#: Below this saturation a fill is a grey — a keyless hop, the dim slate, our own -#: neutral — whose hue is noise, so it blends with nothing but its exact self. The -#: spectrum fills and the console's six slots all sit well above it. -_HUE_SAT_FLOOR = 0.5 - - -def _hue_byte(fill: str) -> int | None: - """Where a ``#rrggbb`` fill sits on the 256-step hue wheel, or ``None`` for a grey. - - The inverse of :func:`~meshterm.ui.theme._node_style_spectrum`: every one of its 256 - fills recovers the exact first key byte that minted it (the round trip through 8-bit - RGB is lossless at the spectrum's saturation), so two chips are compared by the very - bytes that coloured them — and an override fill or a console slot, which no byte - minted, is still placed on the same wheel by the hue it actually shows. - """ - try: - r, g, b = (int(fill[i : i + 2], 16) / 255 for i in (1, 3, 5)) - except (ValueError, IndexError): - return None - hue, sat, _ = colorsys.rgb_to_hsv(r, g, b) - if sat < _HUE_SAT_FLOOR: - return None - return round(hue * 256) % 256 +#: How far the thin chevron's colour steps from the fill it is drawn in, in OKLab ``L`` +#: — a lightness shift alone, so it reads as *that chip's colour, shaded* rather than as +#: a third colour on the line. The step is taken from the previous fill but measured from +#: whichever of the two fills is further along (:func:`~meshterm.ui.oklab.shaded`'s +#: ``floor``), so the mark clears the fill it actually sits on by this much even when the +#: two differ by everything :data:`SEAM_BLUR` allows. Above the blur on its own, so the +#: line it draws is always one the eye can find (JP, 2026-09-16). +SEAM_SHADE = 0.15 def _fills_blur(before: str, after: str) -> bool: - """Whether two fills are the same to the eye — identical, or hues under the gap.""" - if before == after: - return True - a, b = _hue_byte(before), _hue_byte(after) - if a is None or b is None: - return False - apart = abs(a - b) - return min(apart, 256 - apart) < SEAM_HUE_GAP + """Whether two fills are the same to the eye: identical, or under :data:`SEAM_BLUR`.""" + return before == after or oklab.distance(before, after) < SEAM_BLUR + + +def _seam_ink(before: str, after: str) -> str: + """The thin chevron's colour: ``before`` shaded :data:`SEAM_SHADE` past both fills.""" + return oklab.shaded(before, SEAM_SHADE, floor=oklab.from_hex(after)[0]) #: A chip's fill, as it appears in a rendered span's style — the ``on #rrggbb`` half. @@ -990,7 +983,7 @@ class PathLine: elif hop.gap: text.append(POWERLINE_SEP, style=fills[i - 1]) # …and stops here else: - text.append_text(self._seam(fills[i - 1], fills[i], hop)) + text.append_text(self._seam(fills[i - 1], fills[i])) if hop.gap: text.append(hop.label, style="faint" if hop.dim else "muted") else: @@ -1004,7 +997,7 @@ class PathLine: return text @staticmethod - def _seam(before: str, after: str, ahead: PathHop) -> Text: + def _seam(before: str, after: str) -> Text: """The one cell between two chips: the previous fill's point, laid on the next. The classic interlock — foreground the chip behind, background the chip ahead — @@ -1014,35 +1007,33 @@ class PathLine: to spend two cells leaving a sliver of page between every pair, insurance against a collision that the palette makes rare). - Two fills that *do* land the same are the exception the interlock cannot draw — + Two fills the eye can't tell apart are the exception the interlock cannot draw — a chevron in its own background is no chevron — and they are not always bad luck: a mirrored return leg is a run of identically faded hops by construction, and so - is a stretch of keyless greys. *Nearly* the same is the same exception: two hues a - few steps apart on the wheel draw a chevron the eye can't find either, one shade of - green on the next, so two first bytes under :data:`SEAM_HUE_GAP` apart count too - (:func:`_fills_blur`, JP, 2026-09-15). The greys are exempt: a keyless hop beside a - dimmed one shows two distinct fills and keeps its interlock. + is a stretch of keyless greys. Nor is "can't tell apart" the same as "identical": + two hues a few steps apart on the wheel draw a point the eye can't find either, + one shade of green on the next, so the test is a perceptual distance under + :data:`SEAM_BLUR` (:func:`_fills_blur`), not equality and not a hue gap. - There the seam is the **thin** chevron, :data:`POWERLINE_THIN`, in the chip's ink - on the fill ahead — powerline's own mark for a join inside one colour (JP, - 2026-09-16). The ribbon runs on unbroken and the join is a line drawn on it, - where the solid point drawn bare used to cut a wedge of page out of the route: a - run of near hues read as dashes, and on a light terminal the wedge was a hole. It - wears no fill of its own, so it can't be mistaken for a chip, and the ink is the - one the chip ahead uses for its label — the dim ink on a faded leg — so the mark - recedes with what it joins. One cell either way. + There the seam is the **thin** chevron, :data:`POWERLINE_THIN`, in the previous + chip's own fill shaded a step darker — lighter, for a dark fill — and drawn on + the fill ahead (:func:`_seam_ink`, JP, 2026-09-16): powerline's own mark for a + join inside one colour, and the chip it belongs to drawing a line on itself, so + the ribbon runs on unbroken and nothing on it is a colour that isn't a chip's. + The solid point drawn bare used to cut a wedge of page out of the route here (a + run of near hues read as dashes, and on a light terminal the wedge was a hole), + and a chevron in the dark chip ink read as a mark laid on the route rather than a + seam in it. One cell either way. Args: before: The fill the point is drawn in (the previous chip's). after: The fill it is laid on (the next chip's). - ahead: The hop the seam leads into — its ink is the thin chevron's. Returns: The seam's single styled cell. """ if _fills_blur(before, after): - ink = _DIM_FG if ahead.dim else _CHIP_FG - return Text(POWERLINE_THIN, style=f"{ink} on {after}") + return Text(POWERLINE_THIN, style=f"{_seam_ink(before, after)} on {after}") return Text(POWERLINE_SEP, style=f"{before} on {after}") @staticmethod diff --git a/tests/test_oklab.py b/tests/test_oklab.py new file mode 100644 index 0000000..d02d15f --- /dev/null +++ b/tests/test_oklab.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: Apache-2.0 +"""``ui/oklab.py`` — the perceptual colour space the seam and its shade are measured in.""" + +from __future__ import annotations + +import pytest + +from meshterm.ui import oklab +from meshterm.ui.theme import _node_style_spectrum + + +def _fill(key: str) -> str: + return _node_style_spectrum(key).split()[-1] + + +def test_every_node_fill_round_trips_through_oklab_exactly() -> None: + """Hex → Lab → hex is lossless on all 256 spectrum fills and the fixed greys.""" + for byte in range(256): + colour = _fill(f"{byte:02x}") + assert oklab.to_hex(oklab.from_hex(colour)) == colour + for grey in ("#000000", "#ffffff", "#64748b", "#334155", "#3f3f46"): + assert oklab.to_hex(oklab.from_hex(grey)) == grey + + +def test_lightness_runs_black_to_white_and_distance_is_a_metric() -> None: + """``L`` spans 0–1, black to white is distance 1, and distance is symmetric.""" + black, white = oklab.from_hex("#000000"), oklab.from_hex("#ffffff") + assert black[0] == pytest.approx(0.0, abs=1e-6) and white[0] == pytest.approx(1.0, abs=1e-3) + assert oklab.distance("#000000", "#ffffff") == pytest.approx(1.0, abs=1e-3) + assert oklab.distance("#f25555", "#f25555") == 0.0 + assert oklab.distance("#f25555", "#5557f2") == oklab.distance("#5557f2", "#f25555") + + +def test_the_wheel_is_not_uniform_to_the_eye() -> None: + """The reason this module exists: one hue step is a whisper in green, a shout in cyan.""" + green = oklab.distance(_fill("4c"), _fill("5c")) + cyan = oklab.distance(_fill("80"), _fill("90")) + assert green < 0.03 < 0.15 < cyan + + +def test_shaded_moves_lightness_alone_away_from_the_nearer_end() -> None: + """A light colour comes back darker, a dark one lighter, chroma untouched.""" + light, dark = "#55f255", "#334155" + for colour, sign in ((light, -1), (dark, +1)): + before, after = oklab.from_hex(colour), oklab.from_hex(oklab.shaded(colour, 0.1)) + assert (after[0] - before[0]) * sign == pytest.approx(0.1, abs=0.01) + assert after[1] == pytest.approx(before[1], abs=0.02) # in-gamut: chroma kept + assert after[2] == pytest.approx(before[2], abs=0.02) + + +def test_shaded_floor_clears_a_second_colour_too() -> None: + """With a floor, the shift starts from whichever lightness is further along.""" + a, b = _fill("4c"), _fill("5c") # b is the darker of two near greens + plain = oklab.shaded(a, 0.15) + floored = oklab.shaded(a, 0.15, floor=oklab.from_hex(b)[0]) + assert oklab.from_hex(floored)[0] <= oklab.from_hex(plain)[0] + assert oklab.from_hex(b)[0] - oklab.from_hex(floored)[0] == pytest.approx(0.15, abs=0.01) + + slate, lighter_slate = "#334155", "#3f3f46" # dark: the floor pushes *up* instead + floored = oklab.shaded(slate, 0.15, floor=oklab.from_hex(lighter_slate)[0]) + assert oklab.from_hex(floored)[0] >= oklab.from_hex(oklab.shaded(slate, 0.15))[0] + + +def test_from_hex_rejects_anything_but_six_digits() -> None: + """Six hex digits, ``#`` and case optional; anything else is a ``ValueError``.""" + with pytest.raises(ValueError): + oklab.from_hex("#fff") + assert oklab.from_hex("F25555") == oklab.from_hex("#f25555") diff --git a/tests/test_pathline.py b/tests/test_pathline.py index 9407a10..780fc88 100644 --- a/tests/test_pathline.py +++ b/tests/test_pathline.py @@ -14,10 +14,9 @@ import random import pytest import meshterm.ui.pathline as pathline +from meshterm.ui import oklab from meshterm.ui.pathline import ( - _CHIP_FG, _DIM_BG, - _DIM_FG, _SELF_INK, _YOU_BG, CRACK_HEAD, @@ -30,10 +29,13 @@ from meshterm.ui.pathline import ( POWERLINE_ROUND_OPEN, POWERLINE_SEP, POWERLINE_THIN, + SEAM_BLUR, + SEAM_SHADE, SELF_GLYPH, WRAP_OFFSET, PathHop, PathLine, + _seam_ink, _style_hex, cut_mark, cut_to, @@ -653,54 +655,59 @@ def _seams(*hops: PathHop) -> list[tuple[str, str]]: ] -def test_a_seam_between_two_of_one_colour_is_the_thin_chevron_in_ink() -> None: - """Where two chips land on the same fill, the seam is the thin chevron in ink. +def test_a_seam_between_two_of_one_colour_is_the_thin_chevron_shaded() -> None: + """Where two chips land on the same fill, the seam is the thin chevron in that fill, shaded. The interlock can't draw a solid point in its own background, so the join is drawn *on* the shared fill instead — powerline's own mark for a join inside one colour — - and the ribbon runs on unbroken. It wears the chip's ink, the dim ink on a faded leg, - and no fill of its own, so it can never be taken for a chip. + and the ribbon runs on unbroken. Its colour is the previous chip's own, a lightness + step darker, so nothing on the line is a colour that isn't a chip's; a dark fill (the + faded slate) steps lighter instead. Not a rare accident, either: a mirrored return leg is a run of identically faded hops by construction, and a stretch of keyless hops shares one grey. Either way it stays a single cell — the fallback trades the interlock for the outline, not for width. """ hue = _style_hex(node_style("aa")) - assert _seams(PathHop("A", key="aa"), PathHop("B", key="aa"))[0] == ( - POWERLINE_THIN, - f"{_CHIP_FG} on {hue}", - ) + glyph, style = _seams(PathHop("A", key="aa"), PathHop("B", key="aa"))[0] + assert glyph == POWERLINE_THIN and style == f"{_seam_ink(hue, hue)} on {hue}" + shade = _seam_ink(hue, hue) + assert oklab.from_hex(shade)[0] < oklab.from_hex(hue)[0] # darker… + assert oklab.distance(shade, hue) > SEAM_BLUR # …by more than the blur, so it reads + assert oklab.distance(shade, hue) < oklab.distance(hue, _style_hex(node_style("77"))) + assert _seams(PathHop("A", key="aa"), PathHop("B", key="77"))[0] == ( POWERLINE_SEP, f"{hue} on {_style_hex(node_style('77'))}", ) # blended, one cell - assert _seams(PathHop("A", dim=True), PathHop("B", dim=True))[0] == ( - POWERLINE_THIN, - f"{_DIM_FG} on {_DIM_BG}", - ) # a dimmed return leg reads as hops, not one bar, and recedes with them + + glyph, style = _seams(PathHop("A", dim=True), PathHop("B", dim=True))[0] + assert glyph == POWERLINE_THIN and style.endswith(f" on {_DIM_BG}") + lighter = style.split()[0] + assert oklab.from_hex(lighter)[0] > oklab.from_hex(_DIM_BG)[0] # a dark fill steps up -def test_a_seam_between_two_near_hues_is_the_thin_chevron_too() -> None: - """Two hues under ``SEAM_HUE_GAP`` apart on the wheel blend like an exact match. +def test_a_seam_blurs_by_perceived_distance_not_by_hue_gap() -> None: + """Two fills under ``SEAM_BLUR`` apart to the eye blur like an exact match. - The wheel is the first key byte, so the gap is a byte difference — and a circular - one, so ``f8`` and ``04`` are neighbours. At the gap itself the interlock is back. - The greys are exempt: a keyless hop beside a dimmed one keeps its blend, because two - distinct greys are two distinct fills and the chevron between them still reads. + The eye, not the wheel: sixteen first-byte steps across the greens are one colour to + a reader and sixteen across the cyans are two, so the same hue gap blurs on one side + and interlocks on the other. The greys work the same way with no exemption needed: + the keyless grey and the faded slate sit far apart in lightness and keep their seam. """ - hue = _style_hex(node_style("a0")) - thin = lambda key: (POWERLINE_THIN, f"{_CHIP_FG} on {_style_hex(node_style(key))}") # noqa: E731 - assert _seams(PathHop("A", key="a0"), PathHop("B", key="af"))[0] == thin("af") - assert _seams(PathHop("A", key="af"), PathHop("B", key="a0"))[0] == thin("a0") - assert _seams(PathHop("A", key="f8"), PathHop("B", key="04"))[0] == thin("04") # wraps - assert _seams(PathHop("A", key="a0"), PathHop("B", key="b0"))[0] == ( - POWERLINE_SEP, - f"{hue} on {_style_hex(node_style('b0'))}", - ) # exactly the gap apart: distinct - assert _seams(PathHop("A", key="a0"), PathHop("B", key="20"))[0] == ( - POWERLINE_SEP, - f"{hue} on {_style_hex(node_style('20'))}", - ) # opposite sides of the wheel + green = _seams(PathHop("A", key="4c"), PathHop("B", key="5c"))[0] + cyan = _seams(PathHop("A", key="80"), PathHop("B", key="90"))[0] + assert green[0] == POWERLINE_THIN and cyan[0] == POWERLINE_SEP + assert oklab.distance(_style_hex(node_style("4c")), _style_hex(node_style("5c"))) < SEAM_BLUR + assert oklab.distance(_style_hex(node_style("80")), _style_hex(node_style("90"))) > SEAM_BLUR + + # The shade is taken from the chip behind but clears the chip ahead as well. + before, after = _style_hex(node_style("4c")), _style_hex(node_style("5c")) + assert green[1] == f"{_seam_ink(before, after)} on {after}" + assert oklab.distance(_seam_ink(before, after), after) >= SEAM_SHADE - 1e-6 + + wrapped = _seams(PathHop("A", key="f8"), PathHop("B", key="04"))[0] + assert wrapped[0] == POWERLINE_THIN # neighbours across the wheel's seam, too glyph, style = _seams(PathHop("A"), PathHop("B", dim=True))[0] assert glyph == POWERLINE_SEP and " on " in style # two greys, still interlocked