fix(webui): accessible warn token, day-window model badges, review minors

Review round (v0.22) — MUST-1, SHOULD-2's frontend half, and every
folded minor that touched webui/.

MUST-1 (contrast): the degraded badge's amber previously reused
--color-chart-tokens-out, tuned for a chart mark, not body text —
measured ~2.22:1 against --card in light mode, a WCAG 1.4.3 failure on
exactly the "something's wrong" tier. A new semantic --status-warn token
pair (main.css, with a @theme inline mapping) replaces it: #a35c00 light
(~5.06:1), #f0ac1f dark (~8.97:1, already passing, redefined anyway so
every semantic-color token's dark value lives in one place).
ProviderRateBadge.vue's degraded tier now uses border-status-warn/40
text-status-warn.

SHOULD-2 (frontend half — see the backend commit for admin.go/limits.go):
AdminModelRateView drops attemptsMinute/failuresMinute (the backend no
longer sends them at all). ProviderRateBadge.vue's attempts-minute/
failures-minute props become optional; their absence (not a passed-
through 0) triggers a new dayRateTitle fallback (lib/provider-rate.ts) —
a real "0 attempts this minute" and "no minute data fetched at all" read
identically as 0 otherwise, which would misreport a model badge as having
live-minute data it never measured.

Folded minors:
- formatRatePercent floors instead of rounds — only a true 100% rate
  (zero failures) ever renders "100%"; 99.6% now shows "99%", not a
  round-tripped "100%" that would overstate it.
- providerSuccessRate NaN-guards both inputs (Number.isFinite).
- ProvidersView's modelRateFor uses `p.modelRates?.[model]` — a stale
  client against an older server build could otherwise throw rather than
  fall back, matching its own doc comment's stated intent.
- ProviderRateBadge's title is now also its aria-label — title alone is
  mouse-only; a keyboard/screen-reader user previously got none of the
  minute/day detail.
- The accordion's "Last refresh" DETAIL row (not just the trigger's
  compact text) now shares refreshLabel's three-state logic via a new
  refreshDetailLabel (lib/format.ts) — it previously called
  formatTimestamp directly, so a discovery-off provider's expanded row
  still said "never" beside a trigger that correctly said "discovery
  off".
- providerModelScopeID's Go doc comment (limits.go, backend commit)
  cross-references configNamePattern for why the "/" join is collision-
  safe even though a model id may itself contain "/".
- useTabHash.ts documents its two intentional URL-normalization quirks:
  case-sensitive hash matching, and an invalid/absent initial hash
  normalizing only in memory, not in the address bar, until the next tab
  change.
This commit is contained in:
2026-08-22 00:00:48 +01:00
parent 56817b41d1
commit abbf5d0967
9 changed files with 271 additions and 61 deletions
+26 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { refreshLabel } from './format'
import { refreshDetailLabel, refreshLabel } from './format'
const ZERO_TIME = '0001-01-01T00:00:00Z'
@@ -24,3 +24,28 @@ describe('refreshLabel', () => {
expect(refreshLabel(true, oneMinuteAgo)).toMatch(/^refreshed \(\d+s ago\)$/)
})
})
// TestAdminOverview_ProviderLastErrAfterFailedRefresh's webui counterpart:
// the accordion DETAIL row (folded review minor, v0.22 review round) must
// share refreshLabel's own three-state logic instead of calling
// formatTimestamp directly — the bug this closes was a discovery-off
// provider's expanded row still reading the raw "never" right next to a
// trigger row that correctly said "discovery off".
describe('refreshDetailLabel', () => {
it('reads "discovery off" when discovery is disabled, regardless of lastRefresh', () => {
expect(refreshDetailLabel(false, ZERO_TIME)).toBe('discovery off')
expect(refreshDetailLabel(false, undefined)).toBe('discovery off')
})
it('reads "pending" when discovery is enabled but lastRefresh is still the unset sentinel', () => {
expect(refreshDetailLabel(true, ZERO_TIME)).toBe('pending')
})
it('reads the full formatTimestamp date/time once discovery has completed a refresh, not a relative "(Ns ago)"', () => {
const iso = new Date(2026, 7, 21, 12, 0, 0).toISOString()
const got = refreshDetailLabel(true, iso)
expect(got).not.toMatch(/ago/)
expect(got).not.toBe('pending')
expect(got).not.toBe('discovery off')
})
})