Skip to content

fix(webui): AA contrast, system theme, mobile layout and a11y gaps - #1054

Merged
github-actions[bot] merged 9 commits into
mainfrom
fix/ux-visual-a11y
Aug 25, 2026
Merged

github-actions[bot] merged 9 commits into
mainfrom
fix/ux-visual-a11y

Conversation

@Dumbris

@Dumbris Dumbris commented Aug 25, 2026 •

Copy link
Copy Markdown
Member

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 and
the 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):

Text Theme Before After
Docker isolation disabled — enable Docker… corporate 1.28 4.79
dig <hostname> remediation chip dark 1.07 5.24
https://docs.mcpproxy.app/errors/… in an alert corporate / dark 1.41 / 1.63 7.20 / 5.47
Manage in Settings (telemetry banner) dark 2.07 6.31
Quarantine protection active corporate 2.69 4.80
4 errors (Activity header) corporate 2.92 4.69
32.4% tokens saved corporate / dark 3.37 / 3.60 4.73 / 4.68
Add Server / Connect Clients / Restart (white on primary) both 4.13 4.66 / 4.71
text-base-content/40 secondary text corporate / dark 2.51 / 3.56 4.68 / 4.71
outline secondary button dark 2.96 4.70
stat-title / stat-desc corporate 4.21 4.68

The fix is a parallel --tone-* ramp used by text-* / link-* / outline,
soft, ghost and link buttons and outline badges (fills keep --color-*), one
step darker on the fills whose own content colour failed, a --tone-muted
colour replacing the sub-AA base-content alpha ramp, and on-colour
foregrounds 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
system resolves to: the first version used an approximate color-mix
fallback that still left e.g. pastel primary at 3.49:1.

Values are computed, not eyeballed. frontend/src/utils/contrast.ts implements
the CSS colour maths (oklch → oklab → sRGB, color-mix(in oklab, …), alpha
compositing, WCAG luminance) and frontend/tests/unit/contrast.spec.ts parses
the shipped main.css and the daisyUI theme files for all 15 themes, so an
upstream 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 to
table-fixed on narrow viewports so Status — the column that tells you a
call failed — stays fully on screen, and main gets min-h-0 so a tall page no
longer overflows its grid track and slides under the footer.

F21 — Server Logs tab

mockup-code clipped every entry at the right edge with no wrap and no
horizontal 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 and
adding level/text filters is left to the log-content track.)

F29 — "match system" theme

Adds a system pseudo-theme that resolves through prefers-color-scheme
(→ corporate / dark), makes it the default, keeps following the OS while it
is 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 the
auto-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-fixed rather 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.ts and matching the
YYYY-MM-DD HH:mm:ss the CLI already prints. Collapsed sidebar entries carry
aria-labels, truncated tool descriptions get a title, and the
View Activity / View All Servers labels stop wrapping into clipped lines.

Regression net

e2e/web-ui-sweep/visual-a11y-sweep.spec.ts (23 checks, wired into
scripts/run-web-smoke.sh so the release QA gate runs it) walks every visible
text 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:

  • The theme tokens are oklch(), and Chromium returns computed colours in the
    syntax they were authored in — the first version of the detector parsed only
    rgb(), silently skipped every element and passed vacuously. It now resolves
    colours through a canvas, which is how the audit sampled its numbers, and that
    immediately surfaced two real bugs in this branch.
  • The rgbToOklab middle matrix row was wrong (pure red came out at a=1.21
    instead of 0.22). The oklch → sRGB direction that every shipped token value
    was derived from was correct and unaffected, but the reverse and mixOklab
    were 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.
  • Playwright live-verify against an isolated instance (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.
  • Five rounds of cross-model (opencode/gpt) review, each round's findings
    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 losing
    contrast 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-link measures plain .btn styling, because
    Tailwind only compiles the classes the source actually uses.

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
Dumbris force-pushed the fix/ux-visual-a11y branch from c1fa8ac to f057c43 Compare August 25, 2026 12:00
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 25, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

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

View logs

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.
@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/ux-visual-a11y

Available Artifacts

  • archive-darwin-amd64 (29 MB)
  • archive-darwin-arm64 (26 MB)
  • archive-linux-amd64 (17 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (29 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (23 MB)
  • installer-dmg-darwin-arm64 (21 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 32882270938 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved (Model B): Paperclip review verdicts = ACCEPT and qa-gate green at this head SHA. Arming auto-merge; GitHub merges when all required checks pass.

@github-actions
github-actions Bot merged commit 3d1d7d0 into main Aug 25, 2026
39 checks passed
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants