phase: 27_mobile_tables_and_audit
The audit re-ran green (exit 0). Now checking the docs for defects introduced by the phase.
This commit is contained in:
+195
-3
@@ -1205,9 +1205,13 @@ def test_the_certs_agents_table_scrolls_to_its_last_column(client) -> None:
|
||||
|
||||
`TODO.md:9`: at phone width the serial, expiry, `san` and controls columns were clipped by
|
||||
`.card { overflow: hidden }` with no way to reach them. `dash.css` turns `table.data` into
|
||||
a scroll container **only** through the `scroll` class, so the class and the rule are one
|
||||
mechanism and are pinned together — the phase-18 idiom of comparing the two places a
|
||||
contract lives, because a rename on either side silently un-scrolls the table.
|
||||
a scroll container through the `scroll` class, so the class and the rule are one mechanism
|
||||
and are pinned together — the phase-18 idiom of comparing the two places a contract lives,
|
||||
because a rename on either side silently un-scrolls the table.
|
||||
|
||||
(Phase 27 task 01 added a second door: at ≤640px *every* `table.data` scrolls horizontally.
|
||||
That does not retire this pin — `.scroll` out-ranks it, two classes to one, so this table's
|
||||
both-axis box and its 320 px height cap are still decided here, on a phone included.)
|
||||
"""
|
||||
html = client.get(CERTS_PAGE).text
|
||||
assert '<table class="data scroll" id="agents-table">' in html, \
|
||||
@@ -5507,6 +5511,194 @@ def test_summary_names_the_drive_the_health_verdict_called_failed(
|
||||
store.close()
|
||||
|
||||
|
||||
# ---- every table scrolls sideways on a phone (phase 27 task 01) -------------- #
|
||||
#
|
||||
# `TODO.md:7` — "None of the tables are horizontally scrollable on mobile." The measurement is
|
||||
# AGENTS.md gap 12: at 360 px `/`'s fleet table is 450-510 px inside a 320 px `section.card` and
|
||||
# `.card { overflow: hidden }` clips `WORST DISK` / `GPU BUSY` / `TEMP` / `UP` with nothing to
|
||||
# reach them. The fix is one rule inside the repo's existing ≤640px block, taken from the shape
|
||||
# the all-widths `table.data.scroll` modifier already proves — `display: block` plus a horizontal
|
||||
# scrollbar, so the scroll container is the table and never the document (phase 18's standing
|
||||
# guarantee). It is a class rule rather than seven hand-placed `.scroll` classes because four of
|
||||
# the seven tables exist only in JS-built markup and the class is the whole contract.
|
||||
#
|
||||
# Two failure modes the pins below are aimed at, both named in the task's assumptions: a
|
||||
# `max-height` migrating in from `.scroll` (a phone then gets a height-capped table on top of a
|
||||
# horizontal one), and the all-widths `.scroll` rule being edited to "share" the new mobile one.
|
||||
#
|
||||
# The task assumed one rule would reach every table. Measured on the live fleet at 360 px it did
|
||||
# not reach the disclosure tables: `/system`'s GPU / filesystem / process tables came back 520 px
|
||||
# wide inside a 320 px card with `scrollWidth === clientWidth`, because the card body is
|
||||
# `.body.stack` — a one-column grid — and a grid item's automatic minimum size is its content's
|
||||
# min-content width, so the `<details>` grew the column instead of the table overflowing its own
|
||||
# box. `min-width: 0` on the card body's items is therefore pinned as the second half of one
|
||||
# mechanism: without it the scroll rule is inert for exactly the tables the task asked about.
|
||||
# `data/logs/27-task01-scroll.cjs` is the browser half; task 02's audit is the phase's proof.
|
||||
|
||||
def _media_body(css: str, condition: str) -> str:
|
||||
"""The declarations of one `@media (…)` block, brace-matched, comments stripped.
|
||||
|
||||
`_rule_body` stops at the first `}` because no rule nests; a media block does, and the
|
||||
≤640px block's own comments contain example rules with braces in them — hence running the
|
||||
section through `_code` first. The caller picks the block by condition, so a pin can ask about
|
||||
*the mobile block's* `table.data` without mistaking it for the all-widths rule of the same
|
||||
name further down the file.
|
||||
"""
|
||||
body = _code(css)
|
||||
match = re.search(rf"@media\s*\({re.escape(condition)}\)\s*\{{", body)
|
||||
assert match, f"dash.css no longer has an `@media ({condition})` block"
|
||||
depth, index = 1, match.end()
|
||||
while depth:
|
||||
char = body[index]
|
||||
depth += (char == "{") - (char == "}")
|
||||
index += 1
|
||||
return body[match.end():index - 1]
|
||||
|
||||
|
||||
def _table_tags(text: str) -> list[str]:
|
||||
"""Every `<table …>` opening tag in a document or a JS template, comments already gone."""
|
||||
return re.findall(r"<table\b([^>]*)>", text)
|
||||
|
||||
|
||||
def test_every_table_data_scrolls_sideways_at_the_phone_breakpoint(client) -> None:
|
||||
"""One mobile rule, horizontal-only, no height cap — and the desktop `.scroll` untouched.
|
||||
|
||||
Each assertion is one half of the mechanism: `display: block` is what lets the element be the
|
||||
card's width while the table inside it is wider, `overflow-x: auto` is the scrollbar that
|
||||
appears only when it overflows (so a table that fits paints nothing new at 1280 px), and the
|
||||
absence of `max-height` / `overflow-y` / the `overflow` shorthand is the assumption that a
|
||||
phone gets a sideways scroll and *only* a sideways scroll.
|
||||
"""
|
||||
css = _css(client)
|
||||
# Scoped to the section the nav pins read, so "the mobile rule" means the repo's one mobile
|
||||
# boundary block rather than whichever `@media (max-width: 640px)` happens to come first.
|
||||
mobile_block = _media_body(_css_section(css, "mobile nav"), "max-width: 640px")
|
||||
mobile = _declarations(mobile_block, "table.data")
|
||||
assert mobile.get("display") == "block", (
|
||||
"the mobile `table.data` is not a scroll container — its tail columns are clipped by "
|
||||
"`.card { overflow: hidden }` again (AGENTS.md gap 12)")
|
||||
assert mobile.get("overflow-x") == "auto", (
|
||||
"the mobile rule no longer gives a wide table a way to reach its last column")
|
||||
for forbidden in ("max-height", "overflow-y", "overflow"):
|
||||
assert forbidden not in mobile, (
|
||||
f"the mobile rule declares `{forbidden}`: the 320 px height cap is `.scroll`'s "
|
||||
"vertical-use contract, not what a phone needs on top of a horizontal scroll")
|
||||
|
||||
# The second half of the mechanism, in the same block: a grid item may not be narrower than
|
||||
# its content unless something says so, and the card bodies are one-column grids — so without
|
||||
# this clause the rule above cannot overflow anything inside a disclosure and the tail columns
|
||||
# stay clipped by the card. Pinned together because one without the other is the bug.
|
||||
stack = _declarations(mobile_block, ".body.stack > *")
|
||||
assert stack.get("min-width") == "0", (
|
||||
"the mobile block lost the `min-width: 0` that lets a card body be narrower than the "
|
||||
"table inside it — the disclosure tables grow the grid column and clip again")
|
||||
|
||||
# The all-widths rules are the *other* half of the contract: `table.data` still lays out as a
|
||||
# table above the breakpoint (no scrollbar where the table fits, no layout shift at 1280 px),
|
||||
# and `.scroll` still caps and scrolls both axes wherever it is applied. Read out of the
|
||||
# `tables` section because the mobile rule above has the same selector and comes first.
|
||||
tables = _css_section(css, "tables")
|
||||
base = _declarations(tables, "table.data")
|
||||
assert base.get("display", "table") == "table", (
|
||||
"`table.data` is a block at every width now — the mobile rule leaked out of its media "
|
||||
"block and every table grew a scroll container it does not need")
|
||||
assert base.get("width") == "100%", "the base table lost the width that makes it fill its card"
|
||||
scroll = _declarations(tables, "table.data.scroll")
|
||||
assert scroll == {"display": "block", "overflow": "auto", "max-height": "320px"}, (
|
||||
f"`table.data.scroll` changed shape: {scroll} — `#agents-table`'s both-axis box and "
|
||||
"320 px cap are phase 21's contract and this phase may not edit them")
|
||||
|
||||
|
||||
def test_a_card_header_wraps_its_facts_instead_of_losing_its_tail(client) -> None:
|
||||
"""`.card > header` wraps, and the two free-text pieces are allowed to become narrower.
|
||||
|
||||
The 6 FAILs of the first phase-27 audit run were not tables: at 360 px the header was a
|
||||
single `nowrap` flex line, so its tail — the `.updated` age stamp on `/system` (right
|
||||
402-415 px), `/llm` (374-393) and `/ups` (392-531), plus the `apc` card's `.chip.model`
|
||||
(376) and `.chip.health` (463) — stood past the card and `.card { overflow: hidden }` ate it.
|
||||
"How old is this" and "which box is this" are facts, so the row wraps instead (measured:
|
||||
`data/logs/27-task03-verify.cjs`, and `27-task03-control.cjs` shows these same offenders again
|
||||
with the declarations neutralised).
|
||||
|
||||
`min-width: 0` is pinned with `flex-wrap` because one without the other is not the fix: a
|
||||
flex item's automatic minimum size is its content's min-content width, so a long OS or model
|
||||
string still cannot wrap inside its own box (the `apc` chip measured 290 px against a 252 px
|
||||
card). And `white-space: normal` is scoped to a header chip, not to `.chip`: a chip is a
|
||||
label elsewhere in this app and keeps its one-line shape.
|
||||
"""
|
||||
header = _declarations(_css(client), ".card > header")
|
||||
assert header.get("flex-wrap") == "wrap", (
|
||||
"the card header is a single nowrap flex line again — its tail facts are painted past "
|
||||
"the card and clipped (AGENTS.md gap 12's header finding)")
|
||||
shrink = _declarations(_css(client), ".card > header .chip")
|
||||
assert shrink.get("min-width") == "0" and shrink.get("max-width") == "100%", (
|
||||
"a header chip may not become narrower than its own text: with `flex-wrap` alone the "
|
||||
"longest chip still cannot wrap and the row's tail leaves the card")
|
||||
assert shrink.get("white-space") == "normal", (
|
||||
"a header chip that has to share a line now truncates or overflows instead of wrapping; "
|
||||
"the label has to stay readable in full")
|
||||
assert _declarations(_css(client), ".card > header h3").get("min-width") == "0", (
|
||||
"the header title lost the same allowance — a long host name cannot wrap either")
|
||||
# Read out of the chips section, because `_rule_body` anchors on the selector text and the
|
||||
# header exception above *contains* `.chip {` — the base vocabulary lives further down.
|
||||
assert _declarations(_css_section(_css(client), "bars, dots, chips"), ".chip").get(
|
||||
"white-space") == "nowrap", (
|
||||
"`.chip` itself stopped being one line: this fix is an exception for header chips, not a "
|
||||
"change to the chip vocabulary every other page draws with")
|
||||
|
||||
|
||||
def test_a_card_disclosure_scrolls_what_it_reveals_at_every_width(client) -> None:
|
||||
"""The control-width half of gap 12: the *disclosure* scrolls, so the card stops clipping.
|
||||
|
||||
Task 01 logged this as a FINDING at 1280 px and left the decision here. Measured, `/system`
|
||||
three cards across: the "More detail" item grew to its table's min-content (520 px inside a
|
||||
404 px card), and because the card clips, the GPU table's last column — "97.8 GiB", tail
|
||||
right 554 against a card content edge of 424 — was painted *under the next card* (which
|
||||
starts at 438 px), unreachable by any gesture. The `charts-box` item beside it was stretched
|
||||
to the same 520 px, so a chart's newest edge went with it.
|
||||
|
||||
The fix is one declaration on the disclosure, and these assertions are why it is that one:
|
||||
horizontal only (no `max-height`, no `overflow-y`, no `overflow` shorthand — a height cap is
|
||||
`.scroll`'s contract, and a vertical scrollport inside a card body would be a second one),
|
||||
and **outside** the ≤640px block, because the defect it fixes is at the control width. It is
|
||||
not the mobile rule hoisted: `table.data` above stays `display: table`, which is what keeps
|
||||
every table that fits stretched across its card instead of shrink-wrapped — pinned next door
|
||||
and re-measured by `data/logs/27-task03-verify.cjs`.
|
||||
"""
|
||||
css = _css(client)
|
||||
disc = _declarations(_css_section(css, "tables"), ".card > .body.stack > details")
|
||||
assert disc.get("overflow-x") == "auto", (
|
||||
"a disclosure no longer scrolls what it reveals: a table wider than its card grows the "
|
||||
"item past the card again and `.card { overflow: hidden }` clips the tail column")
|
||||
for forbidden in ("max-height", "overflow-y", "overflow"):
|
||||
assert forbidden not in disc, (
|
||||
f"the disclosure rule declares `{forbidden}`: this fix is a sideways scroll only — "
|
||||
"the 320 px cap belongs to `table.data.scroll` and a vertical scrollport here would "
|
||||
"hide rows the operator has not chosen to hide")
|
||||
mobile = _media_body(_css_section(css, "mobile nav"), "max-width: 640px")
|
||||
assert ".card > .body.stack > details" not in mobile, (
|
||||
"the disclosure scroll moved inside the ≤640px block — the finding it repairs was measured "
|
||||
"at 1280 px, where it would then do nothing")
|
||||
|
||||
|
||||
def test_every_table_on_every_page_carries_the_class_the_rule_targets(client) -> None:
|
||||
"""The mobile rule is class-level, so a `<table>` without `data` would silently not scroll.
|
||||
|
||||
Both halves are read from the served bytes: the seven static tables in the page templates and
|
||||
the five built at runtime by `page-system.js` / `page-ups.js` (four inside `/system`'s "More
|
||||
detail", one the NUT variables list). This is the drift the task chose the class rule over
|
||||
seven hand-placed `.scroll` classes for — and it cuts both ways, which is why it is a test.
|
||||
"""
|
||||
tags = [tag for page in MARKUP_PAGES for tag in _table_tags(_code(client.get(page).text))]
|
||||
tags += [tag for script in PAGE_SCRIPTS.values()
|
||||
for tag in _table_tags(_code(client.get(f"/static/{script}").text))]
|
||||
assert len(tags) >= 7, f"only {len(tags)} tables found — the sweep stopped reading the pages"
|
||||
for tag in tags:
|
||||
assert re.search(r'class="[^"]*\bdata\b', tag), (
|
||||
f"a table outside the rule's class selector: <table{tag}> — it will clip its tail "
|
||||
"columns at 360 px instead of scrolling them")
|
||||
|
||||
|
||||
# ---- events --------------------------------------------------------------- #
|
||||
|
||||
def test_events_are_returned_in_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
Reference in New Issue
Block a user