ui: establish a coherent shared design system

Centralize shared UI primitives and repair cross-surface chat, form, and interaction presentation while preserving existing task and Project behavior.
This commit is contained in:
Ouroboros 2026-09-09 20:24:35 +03:00
parent 031183e723
commit 227b99b4f3
71 changed files with 4035 additions and 1574 deletions

View file

@ -25,16 +25,14 @@ from devtools.e2e_live.ui_probe import GuardedUI
SM1_NEW_ACCENT = "#2f7de1"
SM1_COMMIT_MESSAGE = "ui: e2e_live SM1 accent token change (reviewed commit)"
SM1_CSS_PATH = "web/style.css"
# ``web/onboarding.css`` is inlined into the standalone first-run page and mirrors the app's
# ``:root`` tokens BY VALUE; ``tests/test_web_typography_static.py`` pins that every token both
# files declare resolves to the same value. ``--accent`` is one of them, so the change lands in
# BOTH files in one reviewed commit (the first paid run edited style.css alone and the tests
# preflight of ``commit_reviewed`` refused the commit on the parity invariant).
SM1_MIRROR_CSS_PATH = "web/onboarding.css"
SM1_CSS_PATHS = (SM1_CSS_PATH, SM1_MIRROR_CSS_PATH)
SM1_CSS_PATH = "web/ui.css"
# One source, two real documents. Shared-source presence alone cannot detect a missing link
# or a page-local override; the browser checks both documents after the reviewed restart.
SM1_CSS_PATHS = (SM1_CSS_PATH,)
SM1_REQUIRED_PALETTE = frozenset({"--accent", "--accent-light", "--accent-dim",
"--accent-04", "--accent-65", "--focus-accent-border", "--focus-accent-ring"})
# The stub's README Version History row (the release preflight requires one per VERSION).
SM1_HISTORY_ROW = "Live E2E stand SM1 rehearsal: the brand accent changed in both stylesheets."
SM1_HISTORY_ROW = "Live E2E stand SM1 rehearsal: the shared brand accent changed for the app and setup wizard."
_ACCENT_RE = re.compile(r"^(\s*--accent:\s*)([^;]+);", re.MULTILINE)
_CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
_CSS_TOKEN_RE = re.compile(r"(--[a-z0-9-]+)\s*:\s*([^;]+);")
@ -177,8 +175,7 @@ def css_with_accent(css_text: str, value: str) -> str:
def css_root_tokens(css_text: str) -> dict[str, str]:
"""``{token: value}`` of the FIRST ``:root`` block, comments stripped — the same reading
``tests/test_web_typography_static.py`` applies to both stylesheets."""
"""``{token: value}`` of the shared stylesheet's FIRST ``:root`` block, comments stripped."""
css = _CSS_COMMENT_RE.sub("", css_text)
end = css.find("\n}")
root = css if end < 0 else css[:end]
@ -187,11 +184,10 @@ def css_root_tokens(css_text: str) -> dict[str, str]:
return {name: " ".join(value.split()) for name, value in _CSS_TOKEN_RE.findall(root)}
def css_mirror_drift(style_css: str, onboarding_css: str) -> dict[str, tuple[str, str]]:
"""Shared ``:root`` tokens whose values differ between the two files (empty == parity)."""
style, onboarding = css_root_tokens(style_css), css_root_tokens(onboarding_css)
return {token: (style[token], onboarding[token])
for token in sorted(set(style) & set(onboarding)) if style[token] != onboarding[token]}
def sm1_palette_tokens(css_text: str) -> dict[str, str]:
"""Accent roles and ladder from the actual shared source, not a second palette table."""
return {name: value for name, value in css_root_tokens(css_text).items()
if name == "--accent" or name.startswith(("--accent-", "--focus-accent-"))}
def commit_refusal_facts(ledger: dict, tools_rows: list, stored: dict) -> dict:
@ -416,9 +412,10 @@ def sm1_prompt() -> str:
return (
f"Change the product's brand accent to {SM1_NEW_ACCENT} (a blue) consistently with the design "
"system (docs/DESIGN.md §3: the one --accent value, its named roles and the accent alpha "
f"ladder) in BOTH {SM1_CSS_PATH} and {SM1_MIRROR_CSS_PATH}, keeping the two sheets' token "
"parity (tests/test_web_typography_static.py pins it); verify the visible result the way the "
"review policy requires (exercise at least one real consumer flow in the browser and inspect "
f"ladder) in the shared {SM1_CSS_PATH}, loaded by both the app and the setup wizard. "
"Keep the effective palette consistent on / and /onboarding, including their actual controls "
"(tests/test_web_typography_static.py pins the shared source); verify the visible result the way the "
"review policy requires (exercise real consumers in both browser documents and inspect "
"it); then land it as a reviewed release through preflight_review and commit_reviewed with "
f"commit message '{SM1_COMMIT_MESSAGE}', following the release policy the review organs "
"enforce (every commit is a release: the synchronized version carriers are bumped in the same "
@ -500,7 +497,7 @@ def release_carriers_desync_at(clone: pathlib.Path, rev: str) -> str:
def sm1_out_of_scope(clone: pathlib.Path, rev: str, files: list[str]) -> list[str]:
"""Committed paths OUTSIDE the SM1 contract: the two stylesheets, the release carriers the
"""Committed paths OUTSIDE the SM1 contract: the shared stylesheet, the release carriers the
SSOT names, ``docs/DESIGN.md``, and any other ``web/**/*.css`` whose change is comment-only."""
from ouroboros.tools.release_sync import CARRIER_SPAN_PATHS
@ -532,20 +529,20 @@ def run_sm1(ctx: LaneContext) -> None:
ctx.check("committed_css_carries_new_accent",
all(accent_value(committed[p]) == SM1_NEW_ACCENT and committed[p] != before[p] for p in SM1_CSS_PATHS),
accent_committed={p: accent_value(committed[p]) for p in SM1_CSS_PATHS})
# The WHOLE shared ``:root`` token set agrees between the sheets: a re-derived accent family
# is fine exactly as long as both carry the same values.
drift = css_mirror_drift(committed[SM1_CSS_PATH], committed[SM1_MIRROR_CSS_PATH])
ctx.check("committed_css_mirror_parity", not drift, css_mirror_drift=drift)
palette = sm1_palette_tokens(committed[SM1_CSS_PATH])
ctx.check("committed_palette_roles_present",
SM1_REQUIRED_PALETTE <= palette.keys() and all(palette.values()),
palette_committed=palette)
version_after = _git_show(ctx.clone, rev, "VERSION").strip()
ctx.check("committed_version_bumped", version_is_bumped(version_before, version_after), version_committed=version_after)
desync = release_carriers_desync_at(ctx.clone, rev)
ctx.check("committed_release_carriers_in_sync", desync == "", release_carriers_desync=desync[:500])
files = [f for f in _git(["show", "--format=", "--name-only", rev], ctx.clone).splitlines() if f]
# The stand pins only the observable contract: both sheets are IN the commit. Files beyond
# the sheets, the carriers and the documented companions are recorded as a fact, never a
# The stand pins only the observable contract: the shared source is IN the commit. Files beyond
# the source, the carriers and the documented companions are recorded as a fact, never a
# failure: a scope reviewer may legitimately name another accent touchpoint (an inline
# colour on the unlock page, the site stylesheet), and the reviewers own that judgment.
ctx.check("committed_diff_includes_sheets", all(path in files for path in SM1_CSS_PATHS),
ctx.check("committed_diff_includes_palette", all(path in files for path in SM1_CSS_PATHS),
committed_files=files, committed_companions=sm1_out_of_scope(ctx.clone, rev, files))
clean, porcelain, transient = worktree_after_commit(ctx.clone)
ctx.check("worktree_clean_after_commit", clean, worktree_porcelain=porcelain, worktree_transient=transient)
@ -565,17 +562,29 @@ def run_sm1(ctx: LaneContext) -> None:
ctx.check("scope_review_complete_event",
bool(ctx.wait_events(task_oracle, "scope_review_complete", lambda _row: True)))
ctx.check_paid_tokens([task_id])
# R12: the computed style is read from the COMMITTED CSS after a restart.
# Include the original names too: silently dropping a role cannot shrink the browser proof.
names = SM1_REQUIRED_PALETTE | palette.keys() | sm1_palette_tokens(before[SM1_CSS_PATH]).keys()
check_sm1_rendered_palette(ctx, names)
def check_sm1_rendered_palette(ctx: LaneContext, names: set[str]) -> None:
"""R12: both real documents consume the landed palette after restart. This proves delivery
and consistency, not design quality; the existing reviewers still judge the derived colors."""
ctx.restart()
if ctx.ui is None:
ctx.check("ui_computed_style", False, ui_reason=ctx.ui_reason)
return
ctx.ui.goto("/")
observed = str(ctx.ui.computed_property(":root", "--accent") or "").strip()
# Only meaningful on a landed commit: a served working-tree edit would show the same value.
ctx.check("ui_computed_style", observed == SM1_NEW_ACCENT and ctx.checks["commit_landed"],
accent_computed=observed)
ctx.screenshot("sm1_after_restart")
observed = {}
for label, path, ready in (("app", "/", "#chat-input"), ("onboarding", "/onboarding", ".wizard-shell")):
ctx.ui.goto(path, ready_selector=ready)
values = {name: str(ctx.ui.computed_property(":root", name) or "").strip() for name in sorted(names)}
observed[label] = values
ctx.check(f"ui_{label}_palette", bool(names) and all(values.values())
and values.get("--accent") == SM1_NEW_ACCENT and ctx.checks["commit_landed"])
ctx.screenshot(f"sm1_{label}_after_restart")
# A correct / value alone must not hide an unavailable or differently styled wizard.
ctx.check("ui_computed_style", ctx.checks["ui_app_palette"] and ctx.checks["ui_onboarding_palette"]
and observed["app"] == observed["onboarding"], palette_computed=observed)
def sm1_next_version(version: str, taken: "frozenset[str] | set[str]" = frozenset()) -> str:
@ -664,7 +673,7 @@ def sm1_stub_script(clone: pathlib.Path) -> dict:
{"tool": "commit_reviewed", "arguments": {
"commit_message": SM1_COMMIT_MESSAGE, "paths": [w["arguments"]["path"] for w in writes],
"goal": "Change the brand accent for the live E2E stand and release it",
"scope": f"{SM1_CSS_PATH}, {SM1_MIRROR_CSS_PATH} and the release version carriers."}},
"scope": f"{SM1_CSS_PATH} and the release version carriers."}},
{"final": "SM1 done: the brand accent change landed as a reviewed release."},
]}

View file

@ -41,9 +41,9 @@ class UIProbe:
"""A restarted lane server listens on new ports; the page stays open."""
self.base_url = str(base_url).rstrip("/")
def goto(self, path: str = "/") -> None:
def goto(self, path: str = "/", *, ready_selector: str = "#chat-input") -> None:
self.page.goto(self.base_url + path, wait_until="domcontentloaded", timeout=60_000)
self.page.wait_for_selector("#chat-input", timeout=60_000)
self.page.wait_for_selector(ready_selector, timeout=60_000)
def computed_property(self, selector: str, prop: str) -> str:
return str(self.page.evaluate(
@ -99,8 +99,8 @@ class GuardedUI:
self.close()
return None
def goto(self, path: str = "/") -> None:
self._call("goto", path)
def goto(self, path: str = "/", *, ready_selector: str | None = None) -> None:
self._call("goto", path, **({"ready_selector": ready_selector} if ready_selector else {}))
def computed_property(self, selector: str, prop: str) -> str | None:
return self._call("computed_property", selector, prop)