Repository navigation
fix(webui): AA contrast, system theme, mobile layout and a11y gaps - #1054
Merged
Merged
Conversation
Web UI UX audit (docs/qa/ux-audit-webui-2026-08.md), visual/theming/a11y
cluster: F9, F14, F21, F29, F30, F32, F35 and the F36 items in this area.
F9 — WCAG AA. daisyUI treats the semantic colours as fills, so using one as
text ("Docker isolation disabled" on its own tinted chip) measured 1.28:1, and
white-on-primary was 4.13:1 in BOTH themes, i.e. every filled primary button in
the app. Adds a parallel --tone-* ramp for text/link/outline usage, nudges the
few fill/content pairs that failed on their own (primary, corporate
success/info/accent, dark secondary), replaces the sub-AA base-content alpha
ramp with a --tone-muted colour, and gives coloured alerts on-colour
foregrounds (the `dig <host>` chip inside an error alert was 1.07:1). Values are
computed, not eyeballed: utils/contrast.ts implements the CSS colour maths and
the unit test resolves the shipped theme CSS and fails below 4.5:1.
F14 — 390px. Alerts are grids that flow in columns, so the telemetry banner
rendered as a ~10-character column ~440px tall; it now stacks below sm. The
activity table folds its secondary columns and goes table-fixed on narrow
viewports so Status — the column that says a call failed — stays on screen, and
main gets min-h-0 so the footer stops overlapping the last row.
F21 — Server Logs. mockup-code clipped every entry with no wrap and no
scrollbar; entries now wrap, keep a monospace ramp with real line spacing and
sit on a themed surface.
F29 — adds a "System" theme that follows prefers-color-scheme, makes it the
default, keeps explicit choices pinned and persisted, and groups the rest.
F30 — accessible names for the controls that had none, aria-live + caption on
the auto-refreshing activity table.
F32 — the header search button stays enabled at rest (empty submit is a no-op).
F35 — one date/time format, centralised in utils/datetime.ts, matching the
YYYY-MM-DD HH:mm:ss the CLI already prints.
F36 (this area) — aria-labels for the collapsed icon-only sidebar, tooltips on
truncated tool descriptions, non-wrapping "View Activity"/"View All Servers".
e2e/web-ui-sweep/visual-a11y-sweep.spec.ts makes all of this a regression net:
it resolves colours through a canvas (theme tokens are oklch(), so an rgb()
parse measures nothing at all) and asserts contrast, layout, accessible names,
the live region and the theme resolution on the main screens in both themes.
/70 already clears AA on every base surface (5.15-8.52:1), so folding it into the muted ramp only flattened the secondary/tertiary text hierarchy. Only /60 and below — which fail on base-200/300 — resolve to --tone-muted.
Dumbris
force-pushed
the
fix/ux-visual-a11y
branch
from
August 25, 2026 12:00
c1fa8ac to
f057c43
Compare
Deploying mcpproxy-docs with
|
| Latest commit: |
4dc2818
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://59df89b0.mcpproxy-docs.pages.dev |
| Branch Preview URL: | https://fix-ux-visual-a11y.mcpproxy-docs.pages.dev |
rgbToOklab used the L/b coefficients in its middle row, so any sRGB -> Oklab conversion (and therefore mixOklab) produced a wrong a channel — pure red came out at a=1.21 instead of 0.22. The oklch -> sRGB direction, which every shipped token value was derived from, was correct and is unaffected; the ratios in the theme spec and the live sweep do not move. Adds reference-value, round-trip and color-mix assertions that pin all three matrices.
Findings from an opencode (gpt) review of this branch, all verified before
fixing:
- The generic `color-mix` tone fallback did NOT reach AA on the other themes
the picker offers (pastel primary 3.49:1, emerald warning 3.13:1 on
base-100, worse on base-300). Every offered theme now carries a generated
tone ramp — hue and chroma from the upstream token, lightness moved until it
clears 4.65:1 on base-100/200/300 and their 10% tints. The unit test walks
all 15 themes and fails if the picker ever offers one without a ramp.
- `.btn-link` ignores `--btn-rest-fg` (daisyUI paints it straight from
`--btn-color`), so it needed the colour set directly.
- `scripts/run-web-smoke.sh` ran only the original sweep spec, so the new
visual/a11y checks never executed in the QA gate.
- The primary-button contrast check could pass vacuously: the walker reports
the text node's parent, usually a nested <span>, so filtering its output by
`btn-primary` matched nothing. It now measures the button elements directly
and asserts at least one was found.
- `formatDate('2026-08-25')` was parsed as UTC midnight and printed with local
getters, i.e. the 24th anywhere west of UTC. Date-only strings are now read
as calendar dates.
- Hiding the row-open chevron on phones left activity rows unreachable by
keyboard; rows are now focusable and respond to Enter/Space, with a sweep
check.
- The live region lived inside the populated-table branch, so the assertion
depended on the instance having traffic (and an empty log announced
nothing). It renders in every state now.
- The contrast spec rounded ratios before comparing, so 4.496 passed as 4.50.
- `addInitScript` accumulates per call, so the two-theme test could measure the
wrong theme; themes are set via localStorage + reload and asserted.
- The accessible-name check trusted any `aria-labelledby`; it now resolves the
reference and requires text.
- The `prefers-color-scheme` listener is removed on scope dispose.
Verified as a non-issue and pinned with a test instead: ghost buttons on a
coloured alert keep >= 4.5:1 through daisyUI's 10%/20% hover and active tints.
Documented as a deliberate limit: the detector reads `background-color` only,
so text over a gradient or image is not scored — it errs toward silence, never
toward a false failure.
`.btn-link.btn-primary` and `.badge-outline.badge-error` outrank the alert-scoped `:where()` rules on specificity, so a tone colour would have been painted on a coloured alert fill — the exact pairing F9 was about. Each tone now resolves through `var(--alert-fg, …)`, which only exists inside a coloured alert, so the on-colour foreground wins there without a specificity duel.
Round 2 of the cross-model review: - A focusable `<tr>` with Enter/Space handlers was both the wrong semantics (a row is not a control, and its aria-label is unreliable) and buggy: the handlers fired for events bubbling from the nested server link. The row-open chevron button — which already carries an accessible name — is back at every width instead, now named per row, and the table still fits 390px because the narrow layout is `table-fixed`. The sweep exercises it at 1440 and 390. - A date-only string with an impossible day rolled over (`2026-02-31` became March 3rd); it is now rejected like any other unparseable input. - The per-theme tone assertions skipped `secondary` and `accent` while claiming to cover every semantic ramp. - Added tests that the `prefers-color-scheme` listener is removed when the store is disposed and that repeated `loadTheme()` calls do not stack listeners.
Round 3 of the cross-model review, both findings measured on a live page before and after: - daisyUI paints `--btn-bg: var(--btn-color, var(--color-base-200))` on hover/active, i.e. a BASE surface dropped onto the alert's coloured fill. A ghost button on a corporate info alert measured 3.47:1 while hovered. Tinting the background does not help — the fill/content pair is only ~4.7:1 to start with, so any tint erodes it — so inside a coloured alert those buttons keep a transparent background and signal hover with a border plus an underline, which preserves the rest-state contrast exactly (3.47 -> 4.71). - `2026-02-31T12:00:00` still normalised into March; the calendar check now runs on the date part of any ISO string, not just bare dates. The sweep gained a hover check that walks the REAL ghost/link controls inside coloured alerts on the main screens. It deliberately does not inject synthetic markup: Tailwind only compiles the classes it finds in the source, so an injected `.btn-link` (a class the app never uses) falls back to plain `.btn` styling and reports a failure that cannot occur in the product — which is exactly the false positive that made the first version of this check look broken.
Round 4 of the cross-model review: - `new Date(50, 0, 1)` is 1950, so a date-only string with a year below 100 was silently moved into the 20th century. The year is now restated with `setFullYear`, and `formatDate` pads it to four digits. - The hover check's coverage assertion counted any measured control, so an ordinary link in one theme could satisfy it. It now requires a hovered `.btn-ghost` — the variant daisyUI repaints — in each theme.
Contributor
📦 Build ArtifactsWorkflow Run: View Run Available Artifacts
How to DownloadOption 1: GitHub Web UI (easiest)
Option 2: GitHub CLI gh run download 32882270938 --repo smart-mcp-proxy/mcpproxy-go
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Dumbris
added a commit
that referenced
this pull request
Aug 26, 2026
The document read as an open P0-P3 backlog, but seven PRs merged on 2026-08-25 (#1044, #1048, #1049, #1051, #1052, #1053, #1054) closed it. Anyone opening this file against current main was being handed 36 stale defects. Added a status banner and a Resolution section that separates three things honestly: - Re-checked live on v0.61.0 against a running isolated core with real /mcp traffic: F1, F4, F5, F7, F8, F12, F13, F20, F24, F26, F33 confirmed fixed, each with the observed evidence rather than a PR reference. - Still open: F6 is half fixed. Focus enters the Add Server dialog and submit is in a sticky footer, but Escape still does not close it - AddServerModal.vue has no key handler and JsonViewer.vue is the only component under components/ that handles Escape at all. F28's "key called optional" half is fixed; its three-surfaces state is not. - Not re-checked: named explicitly rather than implied, so the next reader knows which closures rest on a PR body and which on an observation. Five defects found on main while re-checking are recorded separately, since they are not regressions of anything in the audit. The first is security-relevant: quarantining a server through the config file leaves its tools in the search index, so retrieve_tools still hands the agent their names and descriptions - which is exactly what quarantine exists to prevent - before the call is refused. Runtime.QuarantineServer purges the index; the reload path detects the same transition (quarantined_changed: true) and does not. Also corrects the record on #1053: its body names F1-F16, which is the tray audit's numbering mis-pasted. #1055 is the tray PR; #1053 touches no Swift file.
Dumbris
added a commit
that referenced
this pull request
Aug 26, 2026
The document read as an open P0-P3 backlog, but eight PRs merged on 2026-08-25 (#1044, #1048, #1049, #1050, #1051, #1052, #1053, #1054) closed it. Anyone opening this file against current main was being handed 36 stale defects. Added a status banner and a Resolution section that separates four things: - Re-checked live on v0.61.0 against a running isolated core with real /mcp traffic: F1, F4, F5, F6, F7, F8, F12, F13, F20, F24, F26, F33 confirmed fixed, each with the observed evidence rather than a PR reference. - Not re-checked: named explicitly rather than implied, so the next reader knows which closures rest on a PR body and which on an observation. F28 is called out as partial - its "key called optional" half is fixed, its three-surfaces-at-once state is not. - Corrections to the audit itself: F23 mis-diagnosed a per-request structural estimate as a stale aggregation, and F31's "Manual badge has no tooltip" half was wrong even at audit time (spec-088 trust-mode badge, already titled). - Five defects found on main while re-checking, recorded separately since they are not regressions of anything in the audit. The first is security-relevant: quarantining a server through the config file leaves its tools in the search index, so retrieve_tools still hands the agent their names and descriptions - which is exactly what quarantine exists to prevent - before the call is refused. Runtime.QuarantineServer purges the index; the reload path detects the same transition (quarantined_changed: true) and does not. F6 carries a methodology note. Re-checking it produced a false negative: Escape driven through the browser-automation layer never reaches the page's document keydown listener, so the dialog looked stuck open. Dispatched in-page it closes at once, and #1050's useModalA11y is wired into all five <dialog :open> modals. The sweep assertion this document proposes must dispatch in-page, or it tests the automation layer instead of the app. Also corrects the record on #1053: its body names F1-F16, which is the tray audit's numbering mis-pasted. #1055 is the tray PR; #1053 touches no Swift file.
Dumbris
added a commit
that referenced
this pull request
Aug 26, 2026
* docs(qa): Web UI UX audit — ranked findings (ux-audit epic) Heuristic + live-instance audit of every routed Web UI view in light/dark, first-run and populated states, at 1440/820/390px. 36 findings ranked P0-P3 with view, evidence, heuristic violated, suggested fix and effort. Audited against standard heuristics plus the house activity-UI rules (grouping, failure-only status, intent reason, histogram/log parity). * docs(qa): record what the Web UI audit's backlog actually resolved to The document read as an open P0-P3 backlog, but eight PRs merged on 2026-08-25 (#1044, #1048, #1049, #1050, #1051, #1052, #1053, #1054) closed it. Anyone opening this file against current main was being handed 36 stale defects. Added a status banner and a Resolution section that separates four things: - Re-checked live on v0.61.0 against a running isolated core with real /mcp traffic: F1, F4, F5, F6, F7, F8, F12, F13, F20, F24, F26, F33 confirmed fixed, each with the observed evidence rather than a PR reference. - Not re-checked: named explicitly rather than implied, so the next reader knows which closures rest on a PR body and which on an observation. F28 is called out as partial - its "key called optional" half is fixed, its three-surfaces-at-once state is not. - Corrections to the audit itself: F23 mis-diagnosed a per-request structural estimate as a stale aggregation, and F31's "Manual badge has no tooltip" half was wrong even at audit time (spec-088 trust-mode badge, already titled). - Five defects found on main while re-checking, recorded separately since they are not regressions of anything in the audit. The first is security-relevant: quarantining a server through the config file leaves its tools in the search index, so retrieve_tools still hands the agent their names and descriptions - which is exactly what quarantine exists to prevent - before the call is refused. Runtime.QuarantineServer purges the index; the reload path detects the same transition (quarantined_changed: true) and does not. F6 carries a methodology note. Re-checking it produced a false negative: Escape driven through the browser-automation layer never reaches the page's document keydown listener, so the dialog looked stuck open. Dispatched in-page it closes at once, and #1050's useModalA11y is wired into all five <dialog :open> modals. The sweep assertion this document proposes must dispatch in-page, or it tests the automation layer instead of the app. Also corrects the record on #1053: its body names F1-F16, which is the tray audit's numbering mis-pasted. #1055 is the tray PR; #1053 touches no Swift file.
Merged
3 tasks done
Dumbris
added a commit
that referenced
this pull request
Aug 29, 2026
…inish the ux-audit sweep (#1085) * chore(roadmap): truth-sync ux-audit and upgrade-nudge Two epics claimed a state the repo contradicts. upgrade-nudge was `done` with all four tasks done, but Spec 079 FR-002 — the human-readable "N releases / M weeks behind" delta required on status, doctor, the startup log, the Web UI and both trays — was never built. The four shipped tasks are US-slices and the FR belonged to none of them; spec 079 has only spec.md, so no checkbox surface could catch the gap. It survived only as TODO(spec-079/FR-002) comments in internal/updatecheck/checker.go and cmd/mcpproxy/status_cmd.go, which is how an outside contributor found it (#1081). Epic back to in_progress with an explicit upgrade-nudge-delta task. ux-audit was `todo` with both sweeps todo, but both shipped in August (#1046 Web UI, 36 findings; #1043 macOS tray, 16 findings) along with ten PRs closing every finding and the five defects the re-check pass raised (#1061/#1064/#1065, all closed). Recorded the sweeps and fix waves as done and added the two items that really are open: folding the audit's F6 modal Escape/focus and F1 cross-view total assertions into e2e/web-ui-sweep, and verifying the tray fixes on a running tray (the 2026-08-26 re-walk found the installed tray still on v0.60.0, so #1055 remains unobserved). Also cleared the two --check-github errors, neither introduced here: - tpa-db and mcp-2026-upgrade each linked a docs(specs) PR in `pr:`. That field is implementation evidence, so a spec merge read as "epic done". Both entries' own notes already said implementation had not started; moved the refs into the notes and left the statuses honest at todo. - analytics-default-landing (#1044) and schema-deferred (#1063, tasks.md 89/89) were genuinely finished and are now done. * feat(079): "N releases / M weeks behind" upgrade delta (FR-002) Closes the last outstanding slice of Spec 079. FR-002 requires the current version, the latest version AND a human-readable delta on `mcpproxy status`, `doctor`, the startup log, the Web UI and both trays; what shipped was latest-version-only, and the deferral survived as TODO(spec-079/FR-002) comments that an outside contributor found (#1081). The blocker was data, not rendering: the checker fetched only /releases/latest, so it had the offered release's publish date but not the running one's, and no tag sequence to count. internal/updatecheck/delta.go computes the delta as a pure, network-free, clock-free function, so every spec rule is a table row. Fetching is a best-effort enrichment hung off the existing check — never a second check pipeline (FR-001): - one page of /releases (ETag-conditional, so a repeat costs no rate-limit budget), plus a by-tag lookup only when the running build predates that page and its date is therefore unknown; - both requests share one context deadline. A per-request timeout bounds each hop and nothing bounds the total, and CheckNow runs synchronously inside GET /api/v1/info?refresh=true, so an unbounded delta would stall a request a user is waiting on; - counting is by semver, never by list position: GitHub orders the list by created_at, and the live data has v0.53.0-rc.9 sitting ahead of the later-published rc.10. Three degradation rules matter more than the feature. A delta miss must not set CheckError (statusVersionSuffix returns "" whenever it is non-empty, so that would DELETE the nudge instead of enriching it), must not touch the FR-018 backoff counters, and must leave every surface rendering exactly what it rendered before. There is a regression test per rule. The clause is rendered once in Go (FormatBehindSummary) and printed verbatim by every surface, with the raw numbers alongside for machine consumers. That is deliberate: GuidanceLine is already hand-ported into UpdateBanner.vue with a "keep the two in sync" comment, and FR-002 asks for consistent framing across five surfaces. Fields are additive per FR-021 and had to be threaded through six hand-maintained mirrors — VersionInfo, the InfoResponseUpdate DTO and its copier, internal/contracts (→ make swagger), the contracts.ts generator, status_cmd's map decoding, and the Swift tray's Codable, which turned out never to have decoded the US2 fields either. Two adjacent bugs fixed because the delta sits on the same code: - GetLatestReleaseIncludingPrereleases returned releases[0] from a created_at-ordered list, so an RC user could be offered a release older than one already published. Now max-by-semver. - GetReleaseByTag interpolated an unvalidated tag into the request path and deferred validation to callers by comment only. The delta feeds it the ldflags-stamped version, so it now validates and escapes. Both trays render the delta in the update item's tooltip rather than its title: Spec 092 FR-010 fixes the title's exact shape, and a menu row reading "Update v0.62.0 — ready to restart? — 8 releases / ~14 weeks behind" stops being scannable. Known bound, accepted deliberately: the scan reads one 100-release page, so a build older than that window reports a lower bound ("59+ releases") and a semver-in-range release created long ago can be missed without setting that flag. Spec 079's own assumption (L159) allows an approximation where an exact count is unavailable, and the figure is motivational, not load-bearing. Verified: unit + integration tests against an httptest GitHub (delta, saturation, tag fallback, 304 reuse, budget, malformed tags), `go test -race` across internal and cmd, golangci-lint v2 clean, 962 frontend unit tests, and a live version-stamped instance confirming the degradation path renders the pre-delta output when the check fails. * test(ux-audit): finish the sweep's regression net, and fix what it caught The August Web UI audit closes by asking for four assertions to be folded into the committed sweep. Three were already there (F9 contrast, F30 accessible names + aria-live, F14 responsive); this adds the two that were not, and then fixes the defects they immediately exposed on main. F6 — the Add Server modal takes focus, traps Tab, closes on Escape and restores focus to its trigger. These `<dialog :open>` modals are opened by the `open` attribute rather than showModal(), so none of that comes from the browser; it all comes from useModalA11y, whose own comment delegates the real-browser half of its coverage to "the Playwright sweep" — a promise the sweep did not keep. Escape is dispatched IN-PAGE, never via page.keyboard.press: re-checking F6 during the audit produced a false negative for exactly that reason, because a synthetic key never reaches the document listener under test. F1 — Activity and Usage must report the same 24h numbers as the API. The invariant is not naive equality: Usage folds `blocked` into its error tile while Activity splits them, and /activity/usage is served from a short read cache while /activity/summary counts live, so the assertion polls for convergence and encodes the real relationship. Running the completed sweep turned up three defects already on main, none of them regressions of the audit's 36: 1. The LANDING PAGE failed AA in both themes (3.09-4.23:1) and two of its controls had no accessible name. Usage.vue uses the raw `opacity-*` utility for muted text; #1054 fixed that failure mode for the text-base-content ramp and .stat-* but never covered raw opacity, and nothing caught it because the audit walked `/` before #1044 made Usage the landing page. Moved onto the AA-checked --tone-muted; both selects got an aria-label. 2. The Activity status pill clips at 390px for any status outside the known four. An unmapped status falls through to a ghost pill carrying the raw value, and `tool_auto_approved` measured 145px inside a 93px cell (the table is table-fixed below 640px). The pill now truncates against the cell and keeps the full value in its title, rather than the column being sized to today's longest label — which would hold only until the next status is added. The phone check that finally caught #2 had been passing vacuously: it measures rows.first(), and a `success` row — ~90% of traffic — renders an sr-only label of zero width. It only became a real assertion once the new F1 check generated an error row and put one first. That coupling is worth knowing about: the sweep shares one instance, so one check's traffic is the next check's data, and a newly-failing neighbour after adding a check is more likely a real find than a flake. Both were separated from the change under test by running the same sweep against clean HEAD. Sweep: 25 passed / 3 failed on clean HEAD, 30 passed / 0 failed now. Roadmap: ux-audit was still carrying both sweeps as `todo` although they and every finding shipped in August; recorded here along with what the completed net found.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the visual / theming / accessibility cluster from the Web UI UX audit
(
docs/qa/ux-audit-webui-2026-08.md): F9, F14, F21, F29, F30, F32, F35 andthe F36 items in this area. No behaviour or data changes — styling, tokens,
attributes and one shared date formatter.
F9 — WCAG AA contrast
daisyUI's semantic colours are fills; using one as text on a tinted chip is
sub-AA by construction, and several fill/content pairs failed on their own.
Measured before → after (canvas-resolved on a live page, effective ratio incl.
opacity, AA = 4.5:1):
Docker isolation disabled — enable Docker…dig <hostname>remediation chiphttps://docs.mcpproxy.app/errors/…in an alertManage in Settings(telemetry banner)Quarantine protection active4 errors(Activity header)32.4% tokens savedAdd Server/Connect Clients/Restart(white on primary)text-base-content/40secondary textstat-title/stat-descThe fix is a parallel
--tone-*ramp used bytext-*/link-*/ outline,soft, ghost and link buttons and outline badges (fills keep
--color-*), onestep darker on the fills whose own content colour failed, a
--tone-mutedcolour replacing the sub-AA
base-contentalpha ramp, and on-colourforegrounds inside coloured alerts (code chips invert into the alert's own
fill/content pair). Chroma and hue are preserved from the upstream tokens —
only lightness moves, so each theme still looks like itself.
Every theme the picker offers gets its own generated ramp, not just the two
systemresolves to: the first version used an approximatecolor-mixfallback that still left e.g. pastel primary at 3.49:1.
Values are computed, not eyeballed.
frontend/src/utils/contrast.tsimplementsthe CSS colour maths (oklch → oklab → sRGB,
color-mix(in oklab, …), alphacompositing, WCAG luminance) and
frontend/tests/unit/contrast.spec.tsparsesthe shipped
main.cssand the daisyUI theme files for all 15 themes, so anupstream bump — or a new theme added to the picker without a ramp — fails CI.
F14 — 390px layout
A daisyUI alert is a grid that flows in columns, so the telemetry banner
shared its row with the action buttons and rendered as a ~10-character column
~440px tall — over half the viewport before any content. Alerts now stack below
sm. The activity table folds its secondary columns and switches totable-fixedon narrow viewports so Status — the column that tells you acall failed — stays fully on screen, and
maingetsmin-h-0so a tall page nolonger overflows its grid track and slides under the footer.
F21 — Server Logs tab
mockup-codeclipped every entry at the right edge with no wrap and nohorizontal scrollbar. Entries now wrap, keep a monospace ramp with real line
spacing and separators, and sit on a themed surface whose foreground is the
theme's AA-checked
base-content. (Parsing the double-encoded payload andadding level/text filters is left to the log-content track.)
F29 — "match system" theme
Adds a
systempseudo-theme that resolves throughprefers-color-scheme(→
corporate/dark), makes it the default, keeps following the OS while itis selected, and keeps an explicit choice pinned and persisted. The picker lists
System first, then "More themes".
F30 — accessibility
Accessible names for every previously unnamed control (server enable toggles,
activity and tools filters, tool row checkboxes, the Add Server command select,
search boxes, the header's menu/search buttons), an
aria-live="polite"row-count region that reports in every state and a
<caption>on theauto-refreshing activity table, and a per-row "Open details" button that keeps
its accessible name and its keyboard path at every width — the narrow layout is
table-fixedrather than dropping that column.F32 / F35 / F36
Header search stays enabled at rest (empty submit is a no-op). One date/time
format, centralised in
frontend/src/utils/datetime.tsand matching theYYYY-MM-DD HH:mm:ssthe CLI already prints. Collapsed sidebar entries carryaria-labels, truncated tool descriptions get atitle, and theView Activity/View All Serverslabels stop wrapping into clipped lines.Regression net
e2e/web-ui-sweep/visual-a11y-sweep.spec.ts(23 checks, wired intoscripts/run-web-smoke.shso the release QA gate runs it) walks every visibletext node on the main screens in both themes and scores it against WCAG AA, then
checks hover states inside coloured alerts, the 390/820/1440px layouts,
accessible names, the live region and caption, keyboard row activation, the
system-theme resolution and the header search button.
Two traps worth calling out, both caught by measuring rather than reading:
oklch(), and Chromium returns computed colours in thesyntax they were authored in — the first version of the detector parsed only
rgb(), silently skipped every element and passed vacuously. It now resolvescolours through a canvas, which is how the audit sampled its numbers, and that
immediately surfaced two real bugs in this branch.
rgbToOklabmiddle matrix row was wrong (pure red came out at a=1.21instead of 0.22). The oklch → sRGB direction that every shipped token value
was derived from was correct and unaffected, but the reverse and
mixOklabwere not; all three matrices are now pinned to published reference values.
Verification
npx vitest run— 748 tests pass (85 new across contrast, datetime, theme).npx vue-tsc --noEmit— clean.make build, port 18477,scratch config + data dir): 23/23 visual-a11y checks and the 5 existing sweep
checks pass at 1440 / 820 / 390px in light, dark and system, with screenshots
reviewed on Dashboard, Servers, Activity, Server Detail (Logs), an errored
server and four of the non-default themes.
measured before fixing and pinned with a test; the final round reports no
blockers. What the rounds caught, in order: the approximate tone fallback not
reaching AA on the non-default themes, a wrong Oklab matrix row, the QA gate
not running the new spec, a vacuous primary-button assertion, a UTC date-only
shift, a specificity duel between the tone rules and the alert scope, a
focusable
<tr>that swallowed bubbled key events, ghost buttons losingcontrast on hover inside alerts, and two-digit years being remapped to the
1900s. One reported finding was a false positive and is documented as such:
probing an injected
.btn-linkmeasures plain.btnstyling, becauseTailwind only compiles the classes the source actually uses.