ouroboros/scripts/ui_control_inventory.py
Ouroboros 9d87d3dbcc Review round 2: an owner's lowercase Project name is a name
The door read `name === id` as "never named". The New Project dialog derives
the id from the name, so `blog`, `my-app` or a repo folder name gave the same
string for both, and the reference said `Project` where the base showed the
name. Only the minted shape `proj_<hex>` is a non-name now; the test pins both
directions.

Also: the question flag on the reference is `data-opens-question`, so an
unqualified `[data-quiz-id]` selector still matches the quiz card alone; the
single-raiser scan reads code, not comments, and says what it cannot see; the
inventory scans the first-party shell scripts too (`theme.js` raises
`ouro:theme-changed`); DESIGN no longer claims the reference names the Project
on the routed message, nor that the inventory lists every control.
2026-09-21 02:11:26 +03:00

131 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Generated inventory of FIXED control text in the web UI (docs/DESIGN.md "References and actions").
Design drift in this UI happens at the level of owner INTENTS, not tokens: one intent
("take me to that Project") was once drawn six ways, and every copy truthfully "reused a
shared primitive". No per-diff check can see that, because the siblings of a new control
are not in the diff. This inventory puts them there: it is SORTED BY TEXT, so a new
`Retry` lands in the same hunk as the `Retry` rows that already exist, with their classes
and files beside it.
It is review evidence, never a gate on wording: the only red is staleness
(``tests/test_generated_inventories.py``). Extraction is a plain scan of source text for
countable facts — a ``<button>`` literal whose text is fixed, a created button with a
literal ``textContent``, the fixed accessible name of an icon-only button, and every
``ouro:*`` CustomEvent with the modules that raise it. Interpolated text is counted, not
listed; it never judges what a control means.
"""
from __future__ import annotations
import html
import pathlib
import re
from collections import Counter
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
UI_CONTROLS_OUT = REPO_ROOT / "docs" / "inventories" / "UI_CONTROL_TEXT_INVENTORY.md"
# A `>` inside a `${…}` expression of an attribute (`${rows.length >= MAX ? 'disabled' : ''}`) is not the tag's end.
_BUTTON = re.compile(r"<button\b((?:\$\{[^}]*\}|[^>])*)>(.*?)</button>", re.S)
_ATTR = {name: re.compile(name + r'="([^"]*)"') for name in ("class", "aria-label", "title")}
_TAG = re.compile(r"<[^>]+>", re.S)
_CREATED = re.compile(r"(?:const|let)\s+(\w+)\s*=\s*document\.createElement\('button'\)")
_EVENT = re.compile(r"new CustomEvent\(\s*['\"`](ouro:[\w:.-]+)['\"`]")
_LITERAL = r"\s*=\s*(['\"])((?:(?!\1).)+)\1"
_WINDOW = 900 # characters after `createElement('button')` in which its own text and class are set
def _sources() -> list[pathlib.Path]:
web = REPO_ROOT / "web"
shell = [path for path in sorted(web.glob("*.js")) if not path.name.endswith((".min.js", ".config.js"))]
return [*sorted((web / "modules").glob("*.js")), *shell, web / "index.html"]
def _clean(text: str) -> str:
return re.sub(r"\s+", " ", html.unescape(_TAG.sub(" ", text))).strip()
def _classes(value: str) -> str:
return re.sub(r"\$\{[^}]*\}", "…", value).strip() or "(no class)"
def scan_controls(text: str) -> tuple[list[tuple[str, str]], int]:
"""``([(label, classes)], interpolated)`` for one source text."""
rows: list[tuple[str, str]] = []
interpolated = 0
for match in _BUTTON.finditer(text):
attrs, inner = match.group(1), match.group(2)
found = {name: (rx.search(attrs) or [None, ""])[1] for name, rx in _ATTR.items()}
label = _clean(inner)
if not label: # an icon-only control is named by its fixed accessible name
spoken = found["aria-label"] or found["title"]
label = f"[icon] {spoken}" if spoken else ""
if not label or "${" in label:
interpolated += 1
continue
rows.append((label, _classes(found["class"])))
for match in _CREATED.finditer(text):
var = re.escape(match.group(1))
window = text[match.end(): match.end() + _WINDOW]
label = re.search(rf"\b{var}\.textContent{_LITERAL}", window)
classes = re.search(rf"\b{var}\.className{_LITERAL}", window)
if not label:
interpolated += 1
continue
rows.append((_clean(label.group(2)), _classes(classes.group(2) if classes else "")))
return rows, interpolated
def _sort_key(row: tuple[str, str, str]) -> tuple[str, str, str, str]:
label, classes, path = row
return (re.sub(r"^[^\w]+", "", label.replace("[icon] ", "")).casefold(), label, classes, path)
def _cell(value: str) -> str:
return value.replace("|", "\\|")
def build_ui_control_inventory() -> tuple[str, list[str]]:
counts: Counter[tuple[str, str, str]] = Counter()
events: dict[str, Counter[str]] = {}
interpolated = 0
for path in _sources():
text = path.read_text(encoding="utf-8").replace("\r\n", "\n")
rel = path.relative_to(REPO_ROOT / "web").as_posix()
rows, skipped = scan_controls(text)
interpolated += skipped
for label, classes in rows:
counts[(label, classes, rel)] += 1
for name in _EVENT.findall(text):
events.setdefault(name, Counter())[rel] += 1
listed = sum(counts.values())
lines = [
"# UI control text inventory",
"",
"Generated by `python scripts/regenerate_inventories.py` — do not edit. Why it exists and how to read",
"it: `docs/DESIGN.md` \"References and actions\". Rows are sorted by text so that the controls which say",
"the same thing sit together: when your diff adds a row, read its neighbours.",
"",
f"{listed} controls with fixed text in {len(counts)} rows, {len({key[0] for key in counts})} distinct texts;",
f"{interpolated} more controls build their text at run time and are not listed.",
"",
"## Fixed control text",
"",
"| text | classes | file | n |",
"|---|---|---|---|",
]
for label, classes, rel in sorted(counts, key=_sort_key):
lines.append(f"| {_cell(label)} | `{_cell(classes)}` | {rel} | {counts[(label, classes, rel)]} |")
lines += [
"",
"## `ouro:*` events and the modules that raise them",
"",
"An owner intent that has a door is raised by exactly one module (`ouro:open-project` →",
"`modules/project_reference.js`).",
"",
"| event | raised by |",
"|---|---|",
]
for name in sorted(events):
raisers = ", ".join(f"{rel} ×{n}" if n > 1 else rel for rel, n in sorted(events[name].items()))
lines.append(f"| `{name}` | {raisers} |")
return "\n".join(lines) + "\n", []