fix(webui): ARIA-valid hover text, alias metadata guard, context-chip polish

CompactNumber.vue and ProviderRateBadge.vue both put aria-label on a
plain <span> — axe's aria-prohibited-attr rule: a <span>'s implicit
ARIA role ("generic") does not support accessible-name computation at
all, so the attribute was simply invalid there, not just discouraged.
Both now use the visible-text-hidden-plus-sr-only-full-text pattern
instead: the compact/label text is marked aria-hidden="true", and a
sr-only sibling span carries the exact value/detail for assistive tech.
title stays on the outer element for mouse hover, unchanged. Folded
review minor.

ProvidersView.vue: the alias table's ModelChip cell read
row.original.modelMeta.contextTokens without a guard, unlike the
provider path's own modelMetaFor fallback — a response from a server
build older than this field (version skew) would throw instead of just
omitting the hover detail. Now optional-chained to match. The visible
context chip beside each model gains title="context window" and
text-xs (was an arbitrary text-[10px]), matching the rest of the panel's
muted-label sizing convention. Folded review minors.
This commit is contained in:
2026-08-22 02:13:12 +01:00
parent 5e3cf919d9
commit 9b60c0afae
3 changed files with 44 additions and 18 deletions
+20 -8
View File
@@ -5,13 +5,22 @@ import { formatCompactCount, formatExactInt } from '@/lib/format'
/**
* CompactNumber renders a large raw token/request count in its compact
* SI-style form ("1.23M") while carrying the exact value via both
* `title` (mouse hover) and `aria-label` (keyboard/screen-reader parity
* — the same established pattern ProviderRateBadge.vue and ModelChip.vue
* already apply to their own hover detail). Used anywhere a usage count
* renders: DataTable numeric columns (usage-columns.ts, target-
* columns.ts, via `h(CompactNumber, { value })`), the Usage tab's Total
* card, and a group accordion's trigger/detail stat grid (UsageView.vue).
* SI-style form ("1.23M") while carrying the exact value via `title`
* (mouse hover) and a visually-hidden sibling span (keyboard/screen-
* reader parity). Used anywhere a usage count renders: DataTable numeric
* columns (usage-columns.ts, target-columns.ts, via
* `h(CompactNumber, { value })`), the Usage tab's Total card, and a
* group accordion's trigger/detail stat grid (UsageView.vue).
*
* Review fix (folded minor, ARIA): `aria-label` on a plain `<span>` is
* an axe `aria-prohibited-attr` violation — a `<span>`'s implicit ARIA
* role ("generic") does not support name computation at all, so
* `aria-label`/`aria-labelledby` there is simply invalid, not merely
* discouraged. The visible, compact text is marked `aria-hidden="true"`
* so assistive tech skips the abbreviated form, and a `sr-only` sibling
* span (visually hidden, still in the accessibility tree) carries the
* exact value instead — the same visible-abbreviation-plus-hidden-full-
* text pattern ProviderRateBadge.vue now applies too.
*
* Cost figures are NOT this component's concern — formatCost already
* shows full 4-decimal precision, so there is nothing to compact or
@@ -24,5 +33,8 @@ const exact = computed(() => formatExactInt(props.value))
</script>
<template>
<span :title="exact" :aria-label="exact">{{ compact }}</span>
<span :title="exact">
<span aria-hidden="true">{{ compact }}</span>
<span class="sr-only">{{ exact }}</span>
</span>
</template>
+13 -6
View File
@@ -37,10 +37,16 @@ const label = computed(() => (status.value === 'no-traffic' ? 'no traffic' : for
* detail carries the "right now" (provider) or "today" (model) counts —
* the spec's minute-window requirement where that data exists at all
* (minuteRateTitle), falling back to dayRateTitle when it does not. Used
* as BOTH the badge's title (mouse hover) and its aria-label (folded
* review minor, v0.22 review round: title alone is mouse-only — a
* keyboard or screen-reader user gets none of it without an aria-label
* carrying the same sentence).
* as BOTH the badge's title (mouse hover) and, via the sr-only span in
* the template, its accessible name for keyboard/screen-reader users
* (folded review minor, v0.22 review round: title alone is mouse-only).
*
* Review fix (v0.23 round): this used to sit directly on `:aria-label`
* on the Badge/`<span>` element, which axe flags as `aria-prohibited-
* attr` — a `<span>`'s implicit role ("generic") does not support name
* computation. See CompactNumber.vue's own doc comment for the identical
* fix and the shared visible-abbreviation-plus-sr-only-full-text
* pattern this component now uses too.
*/
const detail = computed(() =>
props.attemptsMinute === undefined || props.failuresMinute === undefined
@@ -68,7 +74,8 @@ const extraClass = computed(() => {
</script>
<template>
<Badge as="span" :variant="variant" :class="extraClass" class="font-normal tabular-nums" :title="detail" :aria-label="detail">
{{ label }}
<Badge as="span" :variant="variant" :class="extraClass" class="font-normal tabular-nums" :title="detail">
<span aria-hidden="true">{{ label }}</span>
<span class="sr-only">{{ detail }}</span>
</Badge>
</template>
+11 -4
View File
@@ -179,12 +179,18 @@ const aliasColumns: ColumnDef<AdminAliasView, unknown>[] = [
id: 'alias',
header: 'Alias',
accessorFn: (a) => a.alias,
// Optional-chained (review fix, folded minor) the same way
// modelMetaFor guards the provider path below: AdminAliasView.
// modelMeta is typed as always-present, but a real response from a
// server build older than this field (version skew) would otherwise
// throw on `.modelMeta.contextTokens` here instead of just omitting
// the hover detail.
cell: ({ row }) =>
h(ModelChip, {
id: row.original.alias,
contextTokens: row.original.modelMeta.contextTokens,
inputPerMTokUsd: row.original.modelMeta.inputPerMTokUsd,
outputPerMTokUsd: row.original.modelMeta.outputPerMTokUsd,
contextTokens: row.original.modelMeta?.contextTokens,
inputPerMTokUsd: row.original.modelMeta?.inputPerMTokUsd,
outputPerMTokUsd: row.original.modelMeta?.outputPerMTokUsd,
}),
},
{
@@ -331,7 +337,8 @@ const aliasEmptyMessage = computed(() =>
-->
<span
v-if="modelMetaFor(p, m).contextTokens !== undefined"
class="text-[10px] text-muted-foreground tabular-nums"
title="context window"
class="text-xs text-muted-foreground tabular-nums"
>
{{ formatContextWindow(modelMetaFor(p, m).contextTokens as number) }}
</span>