UX-08 phase 2b: short, plain status rows and page banners
checks / unit (pull_request) Successful in 8m47s
infra-pin / fresh (pull_request) Failing after 1s

Every Commissioning row and every page banner is now worded in
app/messages.py (row_*, banner_*): one sentence of at most 15 words, a red
row up to three sentences so a latch still says how it clears, banners at
most 12 words. No row names a Home Assistant entity: sensors by their
setting, solar sources by their place in the list. Reason codes and limits
stay on the log line and event list; rows keep the numbers a reader acts on.

UX-07 item 1: ClampWatchdog counts over-rating and integrator-bound
engagements separately, and after an episode each count shows under its own
alarm's title instead of both under "output clamp".

No row colour, visibility or banner order changed. Rows carry an "id" so the
tests pin meaning rather than wording. The row and banner kinds leave the
allowlist (200 -> 168, cap equal). DOCS.md gains "Status page"; choices are in
DECISIONS/UX-08-short-notifications.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
2026-09-27 02:12:10 +02:00
co-authored by Claude Opus 5.5
parent 48ef06ac1f
commit d017fc67bf
19 changed files with 989 additions and 578 deletions
+67 -3
View File
@@ -7,8 +7,11 @@
2. No notification is worded anywhere else. An AST scan of `app/` fails any
`persistent_notification` call whose payload is not built by
`app/messages.py`.
3. THE RATCHET for the kinds not rewritten yet. Every status-row and banner
literal, every validator refusal, every WARNING+ log line and every option
3. The status page. Every row and banner in `app/messages.py` obeys the rule
for its colour: a row one sentence of at most 15 words, a red row up to 3
sentences and 30 words, a banner at most 12 words.
THE RATCHET for the kinds not rewritten yet. Every status-row and banner
literal left in `app/`, every validator refusal, every WARNING+ log line and every option
name and description in `translations/en.yaml` must either obey its limit
or appear in `ux08_allowlist.txt`. A new string that breaks the rule and is
not listed goes red. So does a listed entry that no longer matches a
@@ -302,7 +305,7 @@ ALLOW_FILE = HERE / "ux08_allowlist.txt"
# The allowlist may never grow. The test demands the list be exactly this long,
# so deleting lines means lowering this number in the same change, and adding
# one means raising it where a reviewer sees it.
ALLOWLIST_MAX = 200
ALLOWLIST_MAX = 168
def read_allowlist(path):
@@ -326,10 +329,57 @@ if "--violations" in sys.argv:
print(f"{k} # {where} {'; '.join(p)} :: {text[:70]!r}")
sys.exit(0)
STATUS = messages.status_samples()
def row_problems(colour, text):
"""STYLE.md section 1 for a status row (by colour) or a banner (None)."""
n_s, n_w = sentences(text), len(words(text))
out = []
if colour is None:
if n_w > 12:
out.append(f"banner over 12 words ({n_w})")
elif colour == messages.RED:
if n_s > 3:
out.append(f"red row over 3 sentences ({n_s})")
if n_w > 30:
out.append(f"red row over 30 words ({n_w})")
else:
if n_s > 1:
out.append(f"{colour} row over 1 sentence ({n_s})")
if n_w > 15:
out.append(f"{colour} row over 15 words ({n_w})")
if ";" in text:
out.append("semicolon: split it into sentences or drop a clause")
return out + text_problems(text, keys=False, entities=False)
for fn, colour, text in STATUS:
p = row_problems(colour, text)
check(f"{colour or 'banner':6} {text!r}{' - ' + '; '.join(p) if p else ''}",
not p)
_catalogue = {k for k, v in vars(messages).items()
if callable(v) and k.startswith(("row_", "banner_"))}
_missing = sorted(_catalogue - {fn for fn, _c, _t in STATUS})
check(f"the samples cover every row and banner in the catalogue "
f"(missing: {_missing})", not _missing)
# A red row with no banner of its own heads the page as "Not ready - <its first
# sentence>". The three trips have their own branch in status_payload(), and
# main.py gives the other two a "banner" key (both are sampled above).
_own_banner = {"row_direction_trip", "row_site_direction_trip",
"row_circulating_trip", "row_peak_reserve", "row_power_too_high"}
_long = [t for fn, c, t in STATUS if c == messages.RED and fn not in _own_banner
and len(words(messages.banner_not_ready({"text": t}))) > 12]
check(f"every other red row makes a 'Not ready' banner of at most 12 words "
f"(too long: {_long})", not _long)
allow = read_allowlist(ALLOW_FILE)
by_kind = {}
for kind, *_ in found:
by_kind[kind] = by_kind.get(kind, 0) + 1
for _fn, colour, _t in STATUS:
kind = "row" if colour else "banner"
by_kind[kind] = by_kind.get(kind, 0) + 1
check(f"the scan found every ratcheted kind ({by_kind})",
all(by_kind.get(k, 0) >= 5 for k in LIMITS))
new = sorted(k for k in violating if k not in allow)
@@ -361,6 +411,8 @@ section = part[-1].split("\n## ", 1)[0] if len(part) == 2 else ""
heads = set(re.findall(r"^### (.+)$", section, re.M))
missing = sorted({n.docs for n in SAMPLES} - heads)
check(f"...with a heading for every Details line (missing: {missing})", not missing)
check("DOCS.md explains the status page rows under "
f"'## {messages.DOCS_STATUS}'", f"\n## {messages.DOCS_STATUS}\n" in DOCS)
# ============================================================================== #
@@ -394,6 +446,18 @@ check("a message that lost its Details line is refused",
_fake = " x = {'text': 'THE METER IS FROZEN AND NOTHING WORKS AT ALL'}\n"
_n = [t for node in ast.walk(ast.parse(_fake.strip()))
if isinstance(node, ast.Dict) for _, t in strings_in(node.values[0])]
check("a second sentence in an amber row is refused",
bool(row_problems(messages.AMBER, "Meter stuck. Check it.")))
check("a fourth sentence in a red row is refused",
bool(row_problems(messages.RED, "One. Two. Three. Four.")))
check("a 16-word green row is refused",
bool(row_problems(messages.GREEN, " ".join(["word"] * 16))))
check("a 13-word banner is refused",
bool(row_problems(None, " ".join(["word"] * 13))))
check("an entity id in a status row is refused",
bool(row_problems(messages.GREEN, "Battery level: sensor.soc = 54")))
check("a semicolon in a status row is refused",
bool(row_problems(messages.RED, "It stopped; check it.")))
check("a new shouting status row is seen by the scan and is not listed",
_n and problems_of("row", _n[0])
and key_of("row", _n[0]) not in allow)