mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
Centralize shared UI primitives and repair cross-surface chat, form, and interaction presentation while preserving existing task and Project behavior.
454 lines
21 KiB
Python
454 lines
21 KiB
Python
"""Static guard: the typography scale holds on the surfaces that adopted it.
|
|
|
|
The owner's report was "too much small high-contrast white text". Four
|
|
independent causes produced it (docs/DESIGN.md):
|
|
|
|
1. ``class="muted"`` was written at ~50 call sites while the ONLY rule that
|
|
matched it was the scoped ``.marketplace-card-title .muted`` — so muted text
|
|
everywhere else inherited near-white ``--text-primary``;
|
|
2. ``.harness-chip`` / ``.reviewer-slot-meta`` declared a size and no colour,
|
|
inheriting the same primary ink;
|
|
3. field labels were 12px UPPERCASE at a hand-written ``rgba(255,255,255,.68)``,
|
|
repeated dozens of times per panel;
|
|
4. there was no scale at all — every size was a literal px, across three
|
|
mutually inconsistent grey families.
|
|
|
|
This guard keeps that class closed on the **migrated** surfaces only. It is
|
|
deliberately NOT a sweep of the historical stylesheet: ``web/style.css`` still
|
|
carries unmigrated skills/marketplace/widget/log rules whose literals are a
|
|
later pass, and a guard that fails on all of them would be turned off. The
|
|
migrated slices of ``style.css`` are delimited in the file itself by
|
|
``design-system:migrated-begin`` / ``design-system:migrated-end`` marker PAIRS
|
|
— several, because migrated surfaces (harness accounts, the chat transcript,
|
|
the chat page chrome, structured chat delivery) are not contiguous in the file
|
|
and moving hundreds of unrelated lines to join them would destroy blame. So
|
|
migrating a new surface means moving a marker or adding a pair (or a file
|
|
below) in the same commit that migrates it.
|
|
|
|
Pattern follows ``tests/test_web_dialogs_static.py``: read the sources, assert
|
|
the structural fact, no browser needed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pathlib
|
|
from html.parser import HTMLParser
|
|
import re
|
|
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
WEB = REPO_ROOT / "web"
|
|
|
|
BEGIN_MARKER = "design-system:migrated-begin"
|
|
END_MARKER = "design-system:migrated-end"
|
|
|
|
# The four sizes, the three line heights, the two named foregrounds, and the
|
|
# four status pairs. The scale is closed: a fifth size token is a design change
|
|
# that goes through docs/DESIGN.md, not a stylesheet edit.
|
|
TYPE_TOKENS = ("--type-meta", "--type-body", "--type-section", "--type-page")
|
|
LINE_TOKENS = ("--line-meta", "--line-body", "--line-title")
|
|
FOREGROUND_TOKENS = ("--text-meta", "--text-disabled")
|
|
STATUS_TOKENS = (
|
|
"--status-ok-fg", "--status-ok-bg",
|
|
"--status-warn-fg", "--status-warn-bg",
|
|
"--status-error-fg", "--status-error-bg",
|
|
"--status-neutral-fg", "--status-neutral-bg",
|
|
)
|
|
|
|
# Any numeric font-size below the 12px meta floor, in any unit the stylesheets
|
|
# actually write: px directly; rem against the 16px root; em against the same
|
|
# 16px equivalence (an em resolves against the parent, but a sub-0.75em value
|
|
# is sub-meta against every parent size in the four-token scale). Fractions
|
|
# (10.5px, 11.5px, 0.7em) count — the old integer-only pattern waved them by.
|
|
FONT_SIZE_VALUE = re.compile(r"font-size\s*:\s*(\d+(?:\.\d+)?)(px|rem|em)\b")
|
|
TINY_FONT_FLOOR_PX = 12.0
|
|
|
|
|
|
def _is_tiny_font(line: str) -> bool:
|
|
m = FONT_SIZE_VALUE.search(line)
|
|
if not m:
|
|
return False
|
|
value, unit = float(m.group(1)), m.group(2)
|
|
if unit == "px":
|
|
return value < TINY_FONT_FLOOR_PX
|
|
return value * 16.0 < TINY_FONT_FLOOR_PX # rem/em vs the 12px equivalent
|
|
|
|
|
|
UPPERCASE = re.compile(r"text-transform\s*:\s*uppercase")
|
|
# Innermost rule blocks only: the body pattern forbids braces, so an @media
|
|
# wrapper cannot match as a selector and the rules nested inside it are matched
|
|
# individually. No CSS parser needed for a structural ban.
|
|
RULE = re.compile(r"([^{}]+)\{([^{}]*)\}")
|
|
COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
|
|
|
|
|
|
def _read(rel: str) -> str:
|
|
return (REPO_ROOT / rel).read_text(encoding="utf-8")
|
|
|
|
|
|
def _decommented(css: str) -> str:
|
|
"""Blank out ``/* ... */`` while preserving line numbers.
|
|
|
|
Comments must go before anything is matched: these stylesheets carry long
|
|
rationale comments that name the very selectors, sizes and rgba() literals
|
|
the rules below them retired, and every one of those mentions would read as
|
|
both a bogus violation and — worse — a bogus selector attached to the next
|
|
real rule."""
|
|
return COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), css)
|
|
|
|
|
|
def _style_marker_spans() -> list[tuple[int, int]]:
|
|
"""Every ``(begin, end)`` marker pair of style.css, structurally validated.
|
|
|
|
N pairs are allowed (migrated surfaces are not contiguous in the file), but
|
|
the pairing itself must stay honest: as many ends as begins, strictly
|
|
alternating begin/end — which rules out nested and overlapping regions and
|
|
a stray marker mention that would silently truncate a guarded slice."""
|
|
css = _read("web/style.css")
|
|
events = sorted(
|
|
[(m.start(), "begin") for m in re.finditer(re.escape(BEGIN_MARKER), css)]
|
|
+ [(m.start(), "end") for m in re.finditer(re.escape(END_MARKER), css)]
|
|
)
|
|
assert events, "no design-system markers in web/style.css"
|
|
kinds = [kind for _, kind in events]
|
|
assert kinds == ["begin", "end"] * (len(events) // 2), (
|
|
"design-system markers must be strictly alternating begin/end pairs — "
|
|
"nesting, overlap, or an unpaired mention silently reshapes the "
|
|
f"guarded regions; got sequence {kinds}"
|
|
)
|
|
return [
|
|
(events[i][0], events[i + 1][0]) for i in range(0, len(events), 2)
|
|
]
|
|
|
|
|
|
def _migrated_style_region(raw: bool = False) -> str:
|
|
"""The concatenated marked (migrated) slices of style.css."""
|
|
css = _read("web/style.css")
|
|
slices = [css[start:end] for start, end in _style_marker_spans()]
|
|
if not raw:
|
|
slices = [_decommented(s) for s in slices]
|
|
return "\n".join(slices)
|
|
|
|
|
|
def _migrated_sources() -> dict[str, str]:
|
|
return {
|
|
"web/ui.css": _decommented(_read("web/ui.css")),
|
|
"web/settings.css": _decommented(_read("web/settings.css")),
|
|
"web/onboarding.css": _decommented(_read("web/onboarding.css")),
|
|
"web/model_roles.css": _decommented(_read("web/model_roles.css")),
|
|
"web/model_wait.css": _decommented(_read("web/model_wait.css")),
|
|
"web/reviewer_slots.css": _decommented(_read("web/reviewer_slots.css")),
|
|
"web/style.css (migrated regions)": _migrated_style_region(),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The scale itself
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_type_scale_tokens_are_declared_once_in_the_root_block() -> None:
|
|
css = _decommented(_read("web/ui.css"))
|
|
root = css[: css.index("\n}")]
|
|
assert root.lstrip().startswith(":root"), "expected :root to open web/ui.css"
|
|
for token in TYPE_TOKENS + LINE_TOKENS + FOREGROUND_TOKENS + STATUS_TOKENS:
|
|
assert f"{token}:" in root, f"{token} missing from web/ui.css :root"
|
|
# Exactly four sizes: a fifth --type-* token means the scale grew without a
|
|
# docs/DESIGN.md decision.
|
|
declared = set(re.findall(r"(--type-[a-z]+)\s*:", root))
|
|
assert declared == set(TYPE_TOKENS), (
|
|
"the type scale is closed at four sizes (docs/DESIGN.md 'Type scale'); "
|
|
f"found {sorted(declared)}"
|
|
)
|
|
|
|
|
|
def _root_declarations(rel: str) -> dict[str, str]:
|
|
"""The ``:root`` block of a stylesheet as ``{token: value}``.
|
|
|
|
The block is the first rule of both files; reading only it keeps a
|
|
component-local ``--foo`` override out of the comparison."""
|
|
css = _decommented(_read(rel))
|
|
root = css[: css.index("\n}")]
|
|
assert root.lstrip().startswith(":root"), f"expected :root to open {rel}"
|
|
return {
|
|
name: " ".join(value.split())
|
|
for name, value in re.findall(r"(--[a-z0-9-]+)\s*:\s*([^;]+);", root)
|
|
}
|
|
|
|
|
|
class _Stylesheets(HTMLParser):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.paths: list[str] = []
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
values = dict(attrs)
|
|
href = values.get("href") or ""
|
|
if tag == "link" and values.get("rel") == "stylesheet" and href.startswith("/static/"):
|
|
self.paths.append("web/" + href.removeprefix("/static/").split("?", 1)[0])
|
|
|
|
|
|
def _document_stylesheets(document: str) -> list[str]:
|
|
parser = _Stylesheets()
|
|
parser.feed(_read(document))
|
|
assert parser.paths, f"no local stylesheets in {document}"
|
|
return parser.paths
|
|
|
|
|
|
def test_both_documents_load_one_real_shared_palette_before_page_styles() -> None:
|
|
"""No copied palette: both hosts must load the same nonempty source.
|
|
|
|
Required roles prevent an empty source from passing. Page declarations
|
|
cannot silently shadow a shared role and make onboarding a second theme.
|
|
Actual effective palette and controls are also exercised in the browser.
|
|
"""
|
|
shared = _root_declarations("web/ui.css")
|
|
required = TYPE_TOKENS + LINE_TOKENS + FOREGROUND_TOKENS + STATUS_TOKENS + ("--accent",)
|
|
for token in required:
|
|
assert shared.get(token), f"{token} missing from shared palette"
|
|
assert len(shared) > 20, "shared source must contain the actual palette"
|
|
for document in ("web/index.html", "web/onboarding_template.html"):
|
|
sheets = _document_stylesheets(document)
|
|
assert sheets[0] == "web/ui.css" and sheets.count("web/ui.css") == 1
|
|
for sheet in sheets[1:]:
|
|
declared = set(DECLARATION.findall(_decommented(_read(sheet))))
|
|
assert not (declared & shared.keys()), f"{sheet} shadows shared roles: {declared & shared.keys()}"
|
|
|
|
|
|
def test_no_tiny_raw_font_sizes_on_migrated_surfaces() -> None:
|
|
violations: list[str] = []
|
|
for label, source in _migrated_sources().items():
|
|
for lineno, line in enumerate(source.splitlines(), 1):
|
|
if _is_tiny_font(line):
|
|
violations.append(f"{label}:{lineno}: {line.strip()}")
|
|
assert not violations, (
|
|
"Raw sub-12px text is retired on migrated surfaces: below 12px this "
|
|
"dark theme forces a choice between illegible and glaring, and glaring "
|
|
"is what the owner reported. Use var(--type-meta) (docs/DESIGN.md "
|
|
"'Type scale').\n" + "\n".join(violations)
|
|
)
|
|
|
|
|
|
def test_no_uppercase_label_pattern_on_migrated_surfaces() -> None:
|
|
violations: list[str] = []
|
|
for label, source in _migrated_sources().items():
|
|
for selector, body in RULE.findall(source):
|
|
if "label" not in selector.lower():
|
|
continue
|
|
if UPPERCASE.search(body):
|
|
violations.append(f"{label}: {' '.join(selector.split())}")
|
|
assert not violations, (
|
|
"The 12px UPPERCASE label pattern is retired on migrated surfaces "
|
|
"(docs/DESIGN.md 'Hierarchy rule'): all-caps at a small size costs "
|
|
"legibility, widens every label, and a panel that repeats it dozens of "
|
|
"times makes the labels out-shout the values they describe. Author the "
|
|
"string in sentence case instead of manufacturing caps in CSS.\n"
|
|
+ "\n".join(violations)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The four root causes, pinned individually
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_muted_is_a_global_colour_only_utility() -> None:
|
|
"""Root cause #1. `.muted` must resolve globally, and must NOT set a size:
|
|
its call sites are sized by their contexts, so a font-size here would
|
|
silently resize all of them."""
|
|
css = _decommented(_read("web/ui.css"))
|
|
bodies = [body for selector, body in RULE.findall(css) if selector.strip() == ".muted"]
|
|
assert bodies, "no global `.muted` rule in web/ui.css"
|
|
declarations = [
|
|
part.split(":", 1)[0].strip()
|
|
for body in bodies
|
|
for part in body.split(";")
|
|
if part.strip()
|
|
]
|
|
assert declarations == ["color"], (
|
|
"`.muted` is a colour-only utility (docs/DESIGN.md '.muted'); it "
|
|
f"declares {declarations}"
|
|
)
|
|
assert any("var(--text-meta)" in body for body in bodies)
|
|
|
|
|
|
def test_chips_and_meta_lines_declare_their_own_foreground() -> None:
|
|
"""Root cause #2. A rule that declares a size and no colour inherits
|
|
near-white --text-primary — invisible in the CSS, loudest on screen."""
|
|
region = _migrated_style_region() + _decommented(_read("web/reviewer_slots.css"))
|
|
for selector in (".harness-chip", ".reviewer-slot-meta", ".harness-account-main strong"):
|
|
bodies = [
|
|
body for sel, body in RULE.findall(region) if sel.strip() == selector
|
|
]
|
|
assert bodies, f"{selector} missing from the migrated region of web/style.css"
|
|
assert any("color:" in body for body in bodies), (
|
|
f"{selector} declares no colour, so it inherits --text-primary "
|
|
"(docs/DESIGN.md 'Status and chips')"
|
|
)
|
|
|
|
|
|
def test_settings_field_labels_use_the_named_meta_foreground() -> None:
|
|
"""Root cause #3. The hand-written rgba(255,255,255,0.68) is now the named
|
|
--text-meta, in both stylesheets that carried a copy of it."""
|
|
for rel in ("web/settings.css", "web/onboarding.css"):
|
|
css = _read(rel)
|
|
# Only the shared source declares the value. Page rules name its role.
|
|
assert "rgba(255, 255, 255, 0.68)" not in css
|
|
# The wizard's former private grey family remains retired.
|
|
assert "rgba(237, 242, 247, 0.68)" not in css
|
|
assert "var(--text-meta)" in css, f"{rel} never names --text-meta"
|
|
|
|
|
|
def test_migrated_region_markers_do_not_swallow_unmigrated_surfaces() -> None:
|
|
"""Root cause #4's guard rail: the scoping must stay honest in BOTH
|
|
directions. The regions have to actually contain the migrated rules —
|
|
including the chat surface and its `.chat-live-executor-chip`, which
|
|
migrated with the chat typography pass — and they must not creep over
|
|
neighbours (skills, marketplace, logs, evolution) that still carry their
|
|
historical literals. Marker-pair structure itself (as many ends as begins,
|
|
strictly alternating) is asserted by ``_style_marker_spans`` on every call
|
|
that reads a region."""
|
|
region = _migrated_style_region(raw=True)
|
|
assert ".reviewer-slots-heading" in _read("web/reviewer_slots.css")
|
|
assert ".harness-account-row" in region
|
|
# The Dashboard -> Updates tab migrated on 2026-08-31; its rules must stay
|
|
# inside the guarded region so a later edit cannot drift them out of it.
|
|
assert ".updates-status" in region
|
|
assert ".updates-restore-row" in region
|
|
# Chat migrated on 2026-09-01 (frontend sprint, Q1=B): page chrome,
|
|
# transcript/bubbles/live cards/composer, and the structured-delivery +
|
|
# quiz-card slice, executor chip now included.
|
|
assert ".chat-page-header" in region
|
|
assert ".chat-bubble.progress" in region
|
|
assert ".chat-live-title" in region
|
|
assert ".chat-live-executor-chip" in region
|
|
assert ".chat-quiz-card" in region
|
|
# Unmigrated neighbours stay out until their own pass. (`.log-entry` and
|
|
# `.evo-runtime-pill` are NOT in this list: the shared status-tone rules
|
|
# inside the chat region legitimately name them as co-selectors.)
|
|
for selector in (".skills-card", ".marketplace-card", ".widgets-card", ".evo-runtime-card"):
|
|
assert selector not in region, (
|
|
f"{selector} is an unmigrated surface; a marker crept over it"
|
|
)
|
|
# NOTE: deliberately NOT asserting that debt still exists out there. A guard
|
|
# that fails when someone independently improves an unmigrated surface would
|
|
# punish exactly the work it wants. The marker pairing (asserted in
|
|
# ``_style_marker_spans``) is what proves the regions are really scoped.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Token hygiene: declared <-> used, in both directions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Derive consumers from the real documents; a declaration in the SPA cannot
|
|
# accidentally satisfy an unresolved wizard variable (or the reverse).
|
|
ROOT_CONSUMERS = tuple(dict.fromkeys(
|
|
_document_stylesheets("web/index.html") + _document_stylesheets("web/onboarding_template.html")
|
|
))
|
|
|
|
VAR_REFERENCE = re.compile(r"var\(\s*(--[a-z0-9-]+)")
|
|
DECLARATION = re.compile(r"^\s*(--[a-z0-9-]+)\s*:", re.MULTILINE)
|
|
|
|
|
|
def _js_sources() -> str:
|
|
"""Every web module, concatenated.
|
|
|
|
JS participates in the variable contract from both ends: it writes measured
|
|
values with ``setProperty('--chat-input-reserve', …)`` and it reads themed
|
|
ones with ``getComputedStyle(...).getPropertyValue('--diagram-bg')``. A
|
|
token at either end is live even though no CSS rule mentions it."""
|
|
return "".join(
|
|
path.read_text(encoding="utf-8") for path in sorted((WEB / "modules").rglob("*.js"))
|
|
)
|
|
|
|
|
|
def test_every_css_variable_is_declared_somewhere() -> None:
|
|
"""A `var(--typo)` is silent: the declaration simply does not apply and the
|
|
property keeps whatever it inherited. This codebase had six of them —
|
|
`--surface-1`, `--surface-2`, `--danger`, `--warning`, `--mono` and
|
|
`--text-link` — each carrying a hardcoded fallback that was the value
|
|
actually rendering, and three of those fallbacks (`#e5534b`, `#b58900`,
|
|
`#16181d`) were colours from no palette in this product."""
|
|
js = _js_sources()
|
|
dangling: list[str] = []
|
|
for document in ("web/index.html", "web/onboarding_template.html"):
|
|
sheets = _document_stylesheets(document)
|
|
declared = set().union(*(set(DECLARATION.findall(_decommented(_read(rel)))) for rel in sheets))
|
|
for rel in sheets:
|
|
for lineno, line in enumerate(_decommented(_read(rel)).splitlines(), 1):
|
|
for name in VAR_REFERENCE.findall(line):
|
|
if name not in declared and name not in js:
|
|
dangling.append(f"{document}: {rel}:{lineno}: var({name})")
|
|
assert not dangling, (
|
|
"these variables are never declared, in CSS or by a JS setProperty, so "
|
|
"every rule naming one silently renders its fallback (or nothing). Name "
|
|
"an existing token instead of declaring a new one — the point of the "
|
|
"palette is that it is small (docs/DESIGN.md).\n" + "\n".join(dangling)
|
|
)
|
|
|
|
|
|
def test_every_root_token_has_a_reader() -> None:
|
|
"""The other direction, and the one that actually bites. `--tone-ok`,
|
|
`--tone-warn`, `--tone-danger`, `--accent-task/system/user/project` and
|
|
`--ui-tone-*` were named in docs/DESIGN.md as the shared vocabulary and
|
|
referenced by NOTHING — so seven surfaces each invented their own literal
|
|
for the same four states while the file said they were unified. A token
|
|
with no reader is not a reserve; it is a claim the code does not make.
|
|
|
|
There is no allowlist. If a token is worth keeping, something uses it."""
|
|
root = _root_declarations("web/ui.css")
|
|
used = set()
|
|
for rel in ROOT_CONSUMERS:
|
|
used |= set(VAR_REFERENCE.findall(_decommented(_read(rel))))
|
|
js = _js_sources()
|
|
|
|
orphans = sorted(name for name in root if name not in used and name not in js)
|
|
assert not orphans, (
|
|
"these :root tokens in web/ui.css have no reader in the stylesheets "
|
|
"or the web modules. Either use them or delete them: a documented token "
|
|
"that resolves nowhere is why surfaces reach for literals "
|
|
"(docs/DESIGN.md 'Status and chips').\n"
|
|
+ "\n".join(f" {name}" for name in orphans)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Focus canon: one ring vocabulary across the whole app (docs/DESIGN.md "Focus")
|
|
# ---------------------------------------------------------------------------
|
|
|
|
FOCUS_FILES = ROOT_CONSUMERS
|
|
FOCUS_TOKENS = ("var(--focus-accent-border)", "var(--focus-accent-ring)")
|
|
|
|
|
|
def test_every_focus_visible_selector_gets_the_canonical_ring() -> None:
|
|
"""Keyboard focus has ONE appearance (docs/DESIGN.md 'Focus'): the
|
|
`--focus-accent-border` outline (or the field idiom's
|
|
`--focus-accent-ring` box-shadow). A `:focus-visible` rule painted in some
|
|
other colour is a second focus vocabulary; a `:focus-visible` selector with
|
|
no ring anywhere is hover paint masquerading as focus.
|
|
|
|
The unit is the SELECTOR, not the block: the sanctioned hybrid pattern
|
|
keeps shared hover/focus paint in one rule (no ring) and puts the ring in a
|
|
dedicated `:focus-visible` rule beside it, so a selector passes when ANY of
|
|
its blocks names a focus token. The exception allowlist is empty — a
|
|
legitimate exception must be added here with its justification."""
|
|
per_selector: dict[str, list[bool]] = {}
|
|
for rel in FOCUS_FILES:
|
|
source = _decommented(_read(rel))
|
|
for selector_list, body in RULE.findall(source):
|
|
has_token = any(token in body for token in FOCUS_TOKENS)
|
|
for selector in selector_list.split(","):
|
|
selector = " ".join(selector.split())
|
|
if ":focus-visible" not in selector:
|
|
continue
|
|
per_selector.setdefault(f"{rel}: {selector}", []).append(has_token)
|
|
assert per_selector, "no :focus-visible rules found; is the parse broken?"
|
|
unringed = sorted(
|
|
selector for selector, hits in per_selector.items() if not any(hits)
|
|
)
|
|
assert not unringed, (
|
|
"these :focus-visible selectors never name var(--focus-accent-border) "
|
|
"or var(--focus-accent-ring) in any of their rule blocks, so keyboard "
|
|
"focus there is either invisible or a second colour vocabulary "
|
|
"(docs/DESIGN.md 'Focus'):\n" + "\n".join(f" {s}" for s in unringed)
|
|
)
|