Files
scadaproj/admin_ui_cleanup.md
T
Joseph Doherty d603a2f66e docs(playbook): cache-bypass on the live gate + trim-don't-delete on upstreamed blocks
Two lessons from the Theme 0.4.0 rollout across five apps.

Live gate: after a kit-CSS change a normal reload can serve the cached previous
layout.css while the server serves the new one. .btn then computes at
Bootstrap's 16px — the exact signature of a failed upstreaming — so the natural
response is to restore the local rule just deleted, entrenching the duplication
the change removed. Hard-reload before trusting any computed-style probe.

Upstreaming: the kit ships sizing only, so trim the local .btn rather than
deleting it. mxgw's block also carried border-radius/font-weight/white-space,
which a wholesale delete would have dropped silently; three of five apps' blocks
were pure sizing and safe to delete outright, and the difference is invisible
without diffing. Also records why a retained border-radius must stay a literal.

Marks the .btn block as upstreamed in 0.4.0 rather than a candidate.
2026-08-11 03:02:17 -04:00

28 KiB
Raw Blame History

Admin-UI cleanup playbook (ZB.MOM.WW.Theme + Bootstrap Blazor apps)

A reusable sweep recipe for the family's Blazor Server admin UIs, distilled from five completed rounds:

  • ignitionoee router — ../ignitionoee/docs/plans/2026-08-05-router-ui-sweeps.md (+ its render-validation companion plan)
  • OtOpcUa AdminUI — ../OtOpcUa/docs/plans/2026-08-10-adminui-ui-sweeps.md
  • MxAccessGateway dashboard — ../MxAccessGateway/docs/plans/2026-08-11-dashboard-ui-sweeps.md
  • HistorianGateway dashboard — ../HistorianGateway/docs/plans/2026-08-11-dashboard-ui-sweep.md
  • ScadaBridge CentralUI — ../ScadaBridge/docs/plans/2026-08-11-central-ui-cleanup-sweep.md

All five sit on ZB.MOM.WW.Theme 0.3.1 + self-hosted Bootstrap 5.3 (mxgw included — an earlier claim that it was Bootstrap-free was false; see the exemption rule below), and all five found the same classes of defect with different instances. Run the discovery phase before assuming which instances your app has. The spread so far: the router had ghost layout classes and OtOpcUa had none; OtOpcUa had a dead scoped-CSS bundle and mxgw/HistorianGateway have no *.razor.css at all while ScadaBridge's bundle was already correctly wired; HistorianGateway's phantom tokens were all fallback-masked and ScadaBridge had zero; ScadaBridge's pages were right and its docs were wrong, inverting §1c.

Determine the test harness per app — do not assume. The router and OtOpcUa have no bUnit, so a live browser pass is their only gate. HistorianGateway (bunit 1.40.0, ten Dashboard test files) and ScadaBridge (CentralUI.Tests, 990 tests) both have one, which changes discovery materially (see §1a-1). A live pass is still mandatory everywhere, because computed styles and cascade order are exactly what bUnit cannot see.

Exemptions need their premise re-derived

If a family-wide sweep or gate excuses one app on a property claim about that app — "it's Bootstrap-free", "it has no dark mode", "it has no scoped CSS" — re-derive that property from that repo at sweep time. It happened once already: the 2026-07-19 /admin/secrets modal gate skipped mxgw as "Bootstrap-free so never affected", which was false; mxgw self-hosts Bootstrap 5.3.3 and was affected, and only escaped because it had taken the fix independently. The exemption reached the right conclusion on false reasoning and nobody re-checked for three weeks. This is the same discipline §1c applies to cited config keys, aimed at scope decisions instead of prose.


1. Discovery — five read-only scans (1a–1d parallelize per file; 1d also needs a live walk)

Run these as parallel read-only agents (or by hand) and write the inventories down with file:line before changing anything. The inventories become the per-batch fix briefs.

1a. Foundation / CSS audit

The theme ships less than pages assume. Check, in order:

  1. Shipped-vs-used class matrix. Extract every CSS class used in *.razor markup and check each against (a) the theme package's staticwebassets/css/*.css (~/.nuget/packages/zb.mom.ww.theme/<ver>/), (b) Bootstrap, (c) the app's own stylesheet, (d) page <style> blocks, (e) the test sources. Anything defined in none of (a)–(d) is a silent no-op — the browser ignores it without any error. (Router: 13 ghost classes from the mockups' theme.css that 0.3.1 never shipped. OtOpcUa: zero — it was authored against what ships. mxgw: 3, one load-bearing. HistorianGateway: 4. ScadaBridge: 26, but only one lost intent.)

    Check (e) BEFORE writing the ghost list, not as a filter afterwards. A class matched only by a test is a selector, not a ghost — rename it deliberately or leave it, never delete it. The failure mode is nasty: deleting it reds the suite, and the tempting "fix" is to amend the assertions, which quietly destroys the coverage. HistorianGateway's 16 console-* classes look exactly like ghosts and are in fact driven by cut.Find("input.console-tag") / button.console-run, with the Viewer role gate resting on Assert.DoesNotContain("console-write", cut.Markup) — five tests. That sweep caught them only because it happened to read the test files while checking something else; ordering luck, not method. Hence: check first.

    The constraint is not limited to class names. A bUnit suite can pin markup by substring and by visible text: ApiKeysPageRenderTests asserts DoesNotContain("secret", …, OrdinalIgnoreCase) over the list markup, and elsewhere selects with Find("button:contains('Create API Key')"). So button label text is load-bearing too — a sweep that "improves" wording breaks a suite exactly as a deleted class does. This binds §1c as much as it binds this step; see the note there.

    Corollary, learned the hard way twice: a dead-code claim derived from grepping src/ alone is not a dead-code claim. ScadaBridge nearly deleted Shared/DataTable.razor as an orphan — DataTablePagerTests renders it. Same mistake as the console-* near-deletion, different clothes.

  2. Is the scoped-CSS bundle linked? If any *.razor.css exists, the build emits <App>.bundle.scp.css — but nothing links it automatically when the head is hand-authored. Compare the <link> list in App.razor against obj/…/scopedcss/bundle/. OtOpcUa's ContextMenu.razor.css built, served 200, and was referenced by nothing — every context menu rendered in-flow (no position:fixed, no backdrop) for the component's whole life.

  3. Phantom CSS custom properties. Grep every var(--…) in markup/inline styles against the tokens actually defined in the theme + app CSS. An undefined token makes the declaration invalid at computed-value time — silently dropped, no console error. A fallback argument (var(--x, #fff)) hides the defect. (OtOpcUa: var(--alert) ×13 — the theme's red family is --bad* — so intended red error tints stayed amber; var(--surface-2) likewise dead.)

  4. Button sizing. Theme 0.3.1 ships no .btn rule, so Bootstrap's hard-coded --bs-btn-font-size: 1rem applies against the theme's 0.9rem body. Count how many buttons carry btn-sm vs full-size; modal feet and shared form shells are where full-size accumulates. (ScadaBridge: 50 full-size + 13 py-0 px-* hacks.)

    Two inverted instances, where the standard §2 .btn block would not have helped:

    • A literal font-size in the app stylesheet (mxgw site.css:187, font-size: 0.82rem). Bootstrap sizes .btn-sm/.btn-group-sm purely by redefining --bs-btn-font-size, so a literal at equal specificity loaded later flattens every button to one size — btn-sm becomes a font-size no-op app-wide and differs only in padding. Tell: btn-sm present in quantity but visually indistinguishable.
    • A chassis class standing in for the button system (HistorianGateway: all 13 buttons were .rail-btn, the side-rail class, and 11 carried inline style="width:auto" to undo the width:100% a local override imposed). The .btn block would have applied to nothing. Fix is the block plus moving page buttons onto real .btn; the width hacks then delete themselves. Tell: inline sizing hacks clustered on one non-.btn class.
  5. Dark scheme: determine whether the app has one. Theme 0.3.1 itself is light-only (no prefers-color-scheme / data-bs-theme), and the router, OtOpcUa, mxgw and HistorianGateway are all light-only — for those, don't chase it, and fix any comment claiming otherwise. ScadaBridge is the exception: it implements its own [data-bs-theme="dark"] token block plus a pre-paint localStorage script. Where a second scheme exists this step inverts — hard-coded colours and inline token overrides are actively worse, not merely moot, because they are invisible in the scheme you're looking at and broken in the other.

  6. Per-app token overrides delivered as inline styles. Check how the app passes brand tokens to ThemeShell. The kit emits Accent="…" as an inline style="--accent: …" on the shell root — a descendant of <html> — so it beats every scheme-level :root override below it. ScadaBridge's Accent="#2f5fd0" killed the dark accent app-wide (brand mark, active rail-link border, .rail-btn) while being a no-op in light, because that hex already is theme.css's light default: invisible in both directions.

    The general rule: a per-app token override delivered as an inline style on a shell element silently defeats every scheme-level :root override. Declare the token in the app stylesheet's :root instead — it then loses to the dark block by source order and wins over theme.css by sheet order.

    Worked example of why to check even when nothing looks wrong: OtOpcUa passes the identical Accent="#2f5fd0" (MainLayout.razor:11) and has no dark scheme at all, so the override is inert today and latent forever — it does nothing now and silently kills the dark accent the day someone adds a dark block. That future session will see "dark mode doesn't work" with no reason to suspect a layout parameter set years earlier. ignitionoee passes no Accent and is unaffected.

1b. Button inventory

Quick tells: grep -rn "btn-group" Components/ returning zero means nothing is grouped (both apps' idiom was d-flex gap-2); also grep for &middot; separators between actions, btn-link p-0 on things that do rather than navigate, py-0/inline width: sizing hacks.

Inventory, with file:line:

  • Adjacent related buttons that should be one btn-group: page-head action pairs, modal/panel feet (Save/Cancel, Cancel/Confirm), pagers, browse-toolbar pairs, inline confirm pairs.
  • Row actions styled as links (<a href="#"> or btn-link text-links) → real btn btn-sm btn-outline-secondary, destructive → btn-outline-danger, grouped when ≥2 in a cell. Distinguish navigation from action: a link that routes somewhere stays a link; a link that mutates state becomes a button. Both directions can coexist in one row (OtOpcUa's UnsTree: keep ⚙ settings/Open as links, convert + Area/Edit/Delete to buttons).
  • Arm→confirm flows (click Delete → Confirm/Cancel appears): list them; the fix is restyle-only and must not collapse the two-step.
  • Size mismatches within one visual group (btn-sm beside full-size).

1c. Prose inventory (DELETE / KEEP / RELOCATE rubric — binding)

  • DELETE: unconditional documentation blocks — always-rendered banners explaining how a feature works, config-mode walkthroughs, "how X is decided" sections, column-meaning tables. These belong in docs/, not on the page. Verify each fact is already docs-covered (grep for distinctive phrases); if it is, plain delete.
  • RELOCATE-then-delete: facts stated nowhere else. Land them in the right doc file first, then delete the page copy — same ordering both sweeps used (foundation/doc task before page batches).
  • KEEP: state banners (empty/error/truncation/live status), form hints (form-text, field hints — they render only in form context), visual-encoding legends that are the only decoder of a chip/graphic, workflow cross-links, expander-gated notes.
  • Before rewording anything, re-read §1a-1(e). In a repo with bUnit, visible text is a selector: Find("button:contains('Create API Key')") makes a button's label load-bearing, and DoesNotContain("secret", …, OrdinalIgnoreCase) makes an absent substring load-bearing. Prose work is the activity most likely to trip this, because improving wording is the whole point of it. Grep the test sources for any string you are about to change.
  • The wrong document may be the docs, not the page. ScadaBridge inverted this section entirely: zero DELETEs, zero RELOCATEs — its pages carried no unconditional documentation at all — but its docs were wrong. Component-Transport.md claimed notification lists pull their SMTP config (they don't) and listed API keys as exportable (deliberately not; SMS configs were missing instead), plus stale ClusterClient claims in three files. When the page and the doc disagree, establish which one the code supports before assuming the page is the stale one.
  • Hunt stale claims while you're in there — every sweep found prose that was actively wrong, not just verbose: milestone labels for shipped features ("F8/F9 pending", "(F3)", "arriving in Batch 2/3"), architecture claims superseded by later phases, dead hyperlinks to repo paths the app doesn't serve, and config keys that don't exist (prose said Authentication:Ldap, the code's SectionName said Security:Ldap — always verify cited key names against the options class). Wrong facts outrank style: fix or delete them even if you skip everything else.

1d. Density / layout scan (found post-sweep, on OtOpcUa's /hosts)

The four scans above can all pass while a page is still unreadable — the sweep left /hosts structurally correct and visually overloaded ("text slams together, the table is overloaded, no separation between sections"). Walk each page (not each file) with realistic data and look for:

  • Sections running together. One panel wrapping several groups, each with its own nested panel-head — three headings stack flush with no card boundaries. Tell: panel-head appearing more than once inside one <section class="panel">.
  • Identity slam. A cell rendering name + id + status chips inline in one run (gate-modbus-renamedDRV-ce2fb5217f5d). Two identifiers in one cell need two lines.
  • Unbounded free-text columns. Any column bound to text the app doesn't control — error messages, exception text, paths, config values — with no truncation. One long driver exception widened the table until the actions column scrolled out of reach. Tell: @d.SomeError, @x.Message etc. rendered bare in a <td>.
  • Inline full-width expander/detail rows inside an already-dense table (a colspan row hosting a whole panel). They destroy scannability exactly when the operator most needs it.
  • Realistic data matters. On a healthy rig every error cell reads "—" and the defect is invisible — the live gate below must be run with at least one faulted/erroring row on screen.

1e. Tree tables — check the data shape before porting the component

The router built a generic TreeTable + TreeVisibility (depth-ordered flat ITreeRow list, forward-scan visibility — reference implementation in ../ignitionoee/router/src/ZB.MOM.Oee.Ui/Components/Shared/TreeTable.razor + ViewModels/TreeRows.cs) and adopted it on two pages. OtOpcUa rejected it entirely after the scan. The rubric:

  • Candidates: tables flattening real hierarchy through hacks — a repeated "SITE / AREA / LINE" path-prefix column, hand-rolled rowspan grouping, indent-by-padding rows, faked full-width group-header rows.
  • Not candidates: rank-ordered lists and fact/audit logs (a tree destroys load-bearing ordering), tables already grouped per parent (one table per group), rows that are all roots.
  • Nav/picker trees are a different animal. Lazy-loading navigation trees with context menus or selection (OtOpcUa has four) don't fit the flat-list TreeTable model (no columns, lazy expansion) — leave them alone. Duplicated expander scaffolding across nav trees is not, by itself, reason to unify in a repo with no component test harness.

If no adoption justifies the component, don't port it — an unused component is an orphan.


2. How to fix

Foundation first (one task, own files, everything else references it by name)

CSS class names aren't compile-checked, so batch agents can reference foundation classes before the foundation branch merges — all branches build independently and compose at merge.

  1. App stylesheet loaded after <ThemeHead /> so it wins the cascade (create one if the app lacks it; the router created app.css, OtOpcUa extended site.css).

  2. Button sizing as Bootstrap CSS-variable overrides — never redefine box properties wholesale (btn-group seam/radius machinery depends on them). The block both apps now carry:

    .btn { --bs-btn-font-size: .85rem; --bs-btn-padding-y: .35rem; --bs-btn-padding-x: .8rem; --bs-btn-line-height: 1.4; }
    .btn-group-sm > .btn, .btn-sm { --bs-btn-font-size: .78rem; --bs-btn-padding-y: .25rem; --bs-btn-padding-x: .6rem; }
    

    This normalizes every unsized button app-wide without touching call sites. Upstreamed in ZB.MOM.WW.Theme 0.4.0 — the kit now ships this block in layout.css, after all five apps had independently written it. On bump to 0.4.0+ it arrives automatically, so remove the local copy in the same change or the rule is declared twice.

    Trim the local block, don't delete it wholesale. The upstreamed rule covers sizing only — the four --bs-btn-* variables and the .btn-group-sm > .btn, .btn-sm rule. A local .btn may also carry app-specific shape declarations that predate the sweep and that the kit does not ship; mxgw's carried border-radius: 5px; font-weight: 500; white-space: nowrap, and a wholesale delete would have silently dropped all three. Diff your block against the kit's before removing anything: three of the five apps' blocks were pure sizing and safe to delete outright, one was not, and the difference is invisible unless you look. Note also that a retained border-radius should stay a literal rather than becoming --bs-btn-border-radius — .btn-sm redefines that variable, so the switch would shrink small buttons to Bootstrap's small radius.

  3. Phantom tokens → a real class, not a token alias. OtOpcUa's pattern: .notice-alert { color: var(--bad); background: var(--bad-bg); border-color: var(--bad-border); } then migrate the inline style="border-color:var(--alert)" hacks to the class in the page batches. Mirror how the theme's own variant classes (e.g. .chip-alert) use the tokens.

  4. Link the scoped bundle if 1a-2 found it dead: <link rel="stylesheet" href="_content/<AppNamespace>/<AppNamespace>.bundle.scp.css"/> — verify the exact asset name from the build output, not by guessing.

  5. Shared class for repeated inline sizing (e.g. .tree-twist { flex: 0 0 18px; width: 18px; } replacing four trees' style="width:18px" expanders).

  6. Relocate the orphaned prose facts into docs (from 1c) in this same task, so page deletions never race the relocation.

Button conventions (page batches)

  • <div class="btn-group" role="group"> (btn-group btn-group-sm when members are small); fold per-button btn-sm into the group class; drop ms-2/me-1 spacers between members. Budget for the fold breaking test locators: any selector pinning button.btn-*.btn-sm stops matching the moment btn-sm moves onto the group. ScadaBridge updated 16 selectors for exactly this and it is a mechanical, expected cost — not a signal that the fold was wrong. Three sweeps hit the fold and only the one with a dense suite noticed, which is why it is written down here.
  • Mixed <a class="btn"> + <button> members are valid Bootstrap — group them (keep download etc. attributes verbatim).
  • A group whose conditional member doesn't render is fine — keep @if inside the group.
  • Deliberately separated members stay outside the group (the ms-auto Delete in a form foot); inputs interleaved in a run split it into two groups (never put a form-control inside).
  • Destructive actions → btn-outline-danger; the armed/confirm state may go solid btn-danger.
  • Preserve behavior byte-for-byte: every @onclick, disabled, binding, AuthorizeView gate, and arm→confirm flow unchanged — wrappers, classes, and prose only. Grep the diff for @onclick|disabled=|href=|type="submit" appearing identically on both sides.
  • Respect existing "buttons-not-anchors" comments (Blazor's blazor.web.js navigation interceptor is why some tree labels are buttons) — never convert those to links.

Density fixes (the /hosts rework, applicable anywhere 1d fires)

  • One panel per group. Replace panel-wrapping-groups with one section.panel per group; hoist the group's metadata (node/host chips, counts) into that panel's head so it stops being a paragraph between heading and table.
  • Stack identities. Primary name on the first line; the machine id on its own <div class="mono small text-muted"> beneath. Chips stay with the name line.
  • Truncate unbounded text with a safe helper, never a raw slice. OtOpcUa's DisplayText.Abbreviate(value, 60) + the full text on title — a raw @x[..60] 500s the whole page when the value is shorter (OtOpcUa #504). Keep the full text reachable (tooltip + the row's detail dialog). Every app that hit this needed its own helper (HistorianGateway added Dashboard/DisplayText.cs with 7 tests pinning the short-value case); ScadaBridge also found three unguarded [..8] slices in Topology.razor that were a latent ArgumentOutOfRangeException on the deploy path, so grep for raw range operators generally, not only in the cells you're truncating.
  • CSS clipping is the wrong tool for user-resizable grid columns. nowrap + max-width raises the column's min-content width and breaks drag-to-resize. Cap the string there instead, and keep CSS clamping for fixed-width columns.
  • Inline expander rows → a dialog. Host the same detail component in a Bootstrap modal (modal-backdrop fade show + modal fade show d-block, modal-lg), one at a time. Lifecycle cautions that made the OtOpcUa version correct:
    • Hold only the id of the open row on the page component; re-resolve the row from current data every render — auto-refresh replaces row records, and a dialog caching one goes stale or strands. A row that disappears ⇒ the dialog renders nothing (self-closes).
    • Preserve any @key recycle discipline the inline version carried (OtOpcUa keys the panel on id + Enabled so a config flip disposes and remounts it, keeping store subscriptions honest).
    • Expect source-scan guard tests to pin the old markup (no bUnit ⇒ repos guard wiring by grepping the .razor source); re-pin them to the dialog markup in the same commit.

Execution pattern (what actually worked, twice)

  • Discovery scans → plan doc with the full inventories → one foundation agent + N page-batch agents on disjoint file sets, all parallel; shared components (form shells, collection editors) are the biggest lever — one file can fix a dozen forms.
  • Briefs must carry exact file:line inventories and the rubric that produced them, not just the line numbers — plus the instruction "verify by reading before editing; refuse items the markup contradicts." An agent holding only a line list can't tell a mis-derived item from a correct one. Every sweep so far has had agents correctly refuse brief items (buttons in mutually exclusive @if branches can't be grouped; toolbars the inventory over-counted), four of ScadaBridge's seven batches did, and one there caught a brief instruction that would have failed the test suite. Treat refusals as the mechanism working, not as friction.
  • Merge foundation first, then batches; run the full build + test suite on the merged tree even if every branch was green in isolation (parallel branches compose only at the full build).
  • Baseline any failure you intend to call pre-existing — measure it, don't assert it. Stash to a clean tree and run it more than once; a test that fails 2-in-3 on the untouched base is not your regression, and you can only know that by running it. Related trap: a suspiciously fast green is not a pass. HistorianGateway's RedundantWritePathLiveTests "passed in isolation" in 504 ms while the real code path requires a live historian round trip — it was taking an early-return guard and passing vacuously, and the full-suite run that failed was the honest one. This inverts the usual reading of "passes in isolation." An unguarded environment-dependent test that fails rather than skips on a dev box is a hygiene defect worth filing on its own, because it trains people to ignore a red suite.
  • Verification greps on the merged tree: zero remaining phantom-token usages, zero remaining &middot; action separators / inline width: hacks, btn-group count roughly matching the inventory, the bundle link present.

Live browser gate (mandatory in every app, bUnit or not)

bUnit cannot see computed styles, cascade order, or whether an element is actually reachable by a pointer — which is precisely what this sweep changes. Two probes worth stealing: hit-testability, not just visibility (document.elementFromPoint at a confirm button's centre must return the button, not the backdrop — mxgw's modal gate), and realistic-length injected data (ScadaBridge injected a 150-char OPC UA node id; HistorianGateway created a key with an 88-char display name, which rendered as 40 chars + ellipsis with Revoke still in the viewport).

Record what you could not exercise rather than rounding it up. HistorianGateway proved 2 of its 4 truncation sites live and said so: sf.Error needs a genuine drain failure, which needs a historian that accepts a connection and then rejects the write, and a box with nothing listening dies at setup and never enqueues. That is a rig capability ceiling — don't manufacture it, and don't claim the sites you couldn't reach.

Rebuild/redeploy the app's dev rig (all instances behind the load balancer — round-robin serves you the stale one otherwise), then hard-reload and walk the checks below.

The cache bypass is not optional, and skipping it inverts the result. After a kit-CSS change (a ZB.MOM.WW.Theme bump, a new rule in layout.css), a normal reload can serve the previous version of layout.css from cache while the server is serving the new one correctly. The symptom is precisely the signature of a failed change — HistorianGateway saw .btn computing at Bootstrap's 16px, exactly what an unreached override looks like — so the natural next move is to "fix" it by restoring the local rule you just deleted, entrenching the duplication the change removed. Hard-reload first; only then trust a computed-style probe.

  1. Computed-style probes (one javascript eval): a .btn's font-size vs body; the phantom-token replacement class resolving to real colors; the scoped bundle present in document.styleSheets. Measure behaviour rather than reading a computed value when the two can disagree. Chrome serializes -webkit-box as display: flow-root, so a working line-clamp reads like a silent failure; ScadaBridge measured instead (40px clamped vs 500px unclamped) and confirmed it works. Reading the value alone would have produced a "fix" for something that was never broken. If the app has a second colour scheme, probe both — flip it and confirm the accent tokens move together (ScadaBridge: rgb(47,95,208) → rgb(77,127,232), with --accent-deep following instead of desyncing).
  2. Every page leads with data — no unconditional documentation above the fold.
  3. Seamed btn-groups where the inventory says; destructive members red.
  4. One arm→confirm flow per style: arm → confirm UI appears → Cancel (never complete a real write on shared rigs); verify disarm.
  5. Anything the scoped bundle resurrected (context menus float at the cursor, backdrops cover).
  6. Legends/hints/empty states from the KEEP list still render.
  7. Density, with realistic data: at least one faulted/erroring row on screen (a healthy rig renders "—" in every free-text cell and hides the unbounded-column defect entirely); sections read as separate cards; no horizontal scroll; any detail dialog opens with live data and closes cleanly.

Record PASS/FAIL per check in the plan or a recipe doc; CSS-only fixes may land in-session, anything structural gets recorded instead.