Appearance
[UX] members — Sponsors (Admin SPA)
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch:
develop@2c22f5a· Files reviewed: 12 Patterns: none consulted — the baseline covers card-grid/table/form-validation for this feature; get_pattern budget spent elsewhere.
Summary
The Sponsors feature (annuaire sponsors-list, campaign board sponsors-campaigns, sponsor sponsors-detail, plus five modals and sponsors.store.ts) is functionally complete and, unlike the widgets, gets currency right — useCurrency.ts and every sponsor view/modal format money at maximumFractionDigits: 2 with no .toFixed(0) bypass (the known bypass at sponsor/SponsorApp.vue:421 is the separate sponsor-facing app, not these admin routes). The most important gap is accessibility: the entire per-row action set (invite, reminder, invoice, delete) is exposed only through TableData's right-click context menu, so a keyboard/AT user cannot invite or invoice a sponsor at all, and the invite/invoice modals announce success and failure only as silent DOM swaps.
Findings
1. Every per-row sponsor action is reachable only by right-click — no keyboard path — High · A11Y-03
Where: views/Sponsors/Campaigns.vue:187-237, views/Sponsors/List.vue:180-203 (surfaced via components/app/tables/TableData.vue:151) What: All row-scoped actions — "Inviter", "Envoyer un rappel", "Renvoyer le lien", "Créer la facture", "Voir le PDF signé", "Supprimer la campagne" — are pushed into TableData's extra-context-menu-items, which opens only on mouse right-click; the row itself navigates via a bare @click on <tr> with no role/tabindex/keydown. This is the shared TableData mouse-only defect (see PROJECT-LEVEL.md, "TableData … Row edit, sort headers and row actions are mouse-only"), but the feature consequence is specific: on sponsors-campaigns there is no non-context-menu path to any action, so the whole invite→facture workflow is mouse-only. List.vue's bulk #actions buttons and select checkboxes are keyboard-reachable, so only the bulk path survives. Why it matters: A keyboard or screen-reader admin cannot send an invitation, chase a reminder, or raise an invoice — the core job of the feature — and gets no hint the actions exist. Fix: Root fix belongs in TableData (add a visible per-row actions/kebab trigger that is a real focusable <button> with an ARIA menu, and make the row a <button>/link or key-activable). Until then, Sponsors could surface the same commands as focusable buttons in the row or a detail panel.
2. Invite/invoice success and error states are silent DOM swaps — Medium · MSG-01
Where: ModalSponsorInvite.vue:44-66, ModalSponsorInvoicePreview.vue:24-50What: The "Invitation envoyée!" / "Rappel envoyé!" / "Facture envoyée avec succès!" success blocks and the mirrored error blocks are plain <div>/<p> swapped in by v-if, carrying no role="status", role="alert" or aria-live. A screen-reader user who presses "Envoyer" hears nothing on either outcome. (BccMessage, used for loadError, may carry a role internally — unverifiable, node_modules absent.) Why it matters: The one moment the user needs confirmation that an email actually went out, or that it failed, is inaudible to AT — and the invite success auto-dismisses after 1500ms (ModalSponsorInvite.vue:229), so there is no lingering text to re-read. Fix: Put role="status" on the success block and role="alert" on the error block (or route both through a component known to announce). Do not auto-close the success modal on a timer — let the user dismiss it.
3. Raw server / HTTP <status> strings reach the user on send failures — Medium · MSG-02, MSG-03
Where: ModalSponsorInvite.vue:232, ModalSponsorInvoicePreview.vue:274,289,305, via lib/apiRequest.ts:24-26What: Every failure surface renders e.message verbatim, and apiRequest builds that message as result.error ?? result.message ?? \HTTP ${response.status}`. So an unmapped backend exception string or a bare "HTTP 500" / "HTTP 403" is shown to a non-technical admin, with no mapping to a human sentence and no next step. This mirrors the widgets-level raw-HTTP <status>finding (PROJECT-LEVEL.md) but in the adminapiRequest` path. Why it matters: "HTTP 500" tells the user nothing and offers no recovery; the sponsor-invoicing path (money) is exactly where a clear failure message matters most. Fix: Map known statuses/codes to French sentences with an action ("Réessayez", "Vérifiez l'adresse du sponsor"); fall back to one generic sentence rather than the status string.
4. Modal field labels are not programmatically associated with their inputs — Medium · FORM-01
Where: ModalSponsor.vue:57,65,69,74,79,83,88,93,98,102,107; ModalSponsorCampaign.vue:10,27,37; ModalSponsorInvite.vue:74,105,110; ModalSponsorInvoicePreview.vue:157What: Every field is a bare <label class="text-xs …">Texte</label> sitting as a sibling of a BccInput/BccSelect/BccTextarea, with no for/id pairing and no wrapping. Nothing in the template links label to control. (Whether the Bcc components auto-generate a matching id/aria-labelledby cannot be confirmed — node_modules is not installed — but the source provides no association.) Why it matters: Clicking the label does not focus the field, and a screen reader may announce the control unlabeled — across the whole sponsor add/edit/campaign/invite surface. Fix: Use the repo's Field UI primitive (per CLAUDE.md) or bind :id/for, so the association is explicit regardless of component internals.
5. Submit control is removed/disabled at the moment it is activated, dropping focus — Medium · FORM-06
Where: ModalSponsorInvite.vue:124-135 (v-if="!isSending && !isSent" removes the button), ModalSponsorInvoicePreview.vue:181-186 / ModalSponsorBulkInvite.vue:113-118 (:disabled="loading") What: Pressing "Envoyer" either unmounts the button (v-if) or disables it while the request runs. A just-activated control that vanishes or disables drops focus to <body>, so keyboard focus is lost during the async send and the subsequent status is not where focus lands. Why it matters: Keyboard users lose their place mid-action and, combined with finding 2, get no announced result to land on. Fix: Keep the button mounted; use aria-busy + an in-handler guard instead of v-if/disabled, and move focus to the status message when it appears.
6. Sub-24px icon controls — Medium · A11Y-02
Where: Detail.vue:128-137 (trash button, p-1 + text-xs icon) and :138-144 (lock indicator), ModalSponsorInvite.vue:91-99 (remove-recipient pi pi-times button), Campaigns.vue:30-35 (empty-state text link) What: The per-campaign delete button and the recipient-chip remove button are icon-only controls well under the 24×24 CSS-px floor; the empty-state "Ouvrir l'annuaire →" is a small text link. Why it matters: Hard to hit accurately, especially on touch, for a destructive delete. Fix: Pad the hit target to ≥24×24 (e.g. p-2 / min-w-6 min-h-6).
7. Campaign history shows raw enum keys instead of the labelled tags used everywhere else — Medium · proposed CONTENT-DISPLAY-LABEL (see Baseline additions)
Where: Detail.vue:116 ({{ c.Statut }}), Detail.vue:119 ({{ c.Package_Key }}) What: The sponsor detail page's "Historique des campagnes" table prints the raw values prospect/confirme/facture and formule-1…formule-5, whereas Campaigns.vue and List.vue map the identical fields to French BccTags ("Prospect", "Confirmé", "Formule 1"). Same data, two representations; the detail page leaks internal identifiers. Why it matters: A non-technical admin sees developer strings on the one screen dedicated to a single sponsor, and the app looks inconsistent with itself. Fix: Reuse the STATUS_LABELS / PACKAGE_LABELS maps (extract them to a shared module) and render the same tags on Detail.
8. "Envoyer" disables with no explanation when subject or message is empty — Low · FORM-05
Where: ModalSponsorInvite.vue:132What: The send button is :disabled when subject/message are blank, but only the empty-recipient case shows a warning; a cleared subject/body just greys the button with nothing telling the user why. Why it matters: The user has no cue to act on. Fix: Keep submit enabled and show a validation message, or add inline "Sujet requis"/"Message requis" hints.
9. Debounce/blur timers in the sponsor-search field are not cleared on unmount — Low · NAV-02
Where: ModalSponsor.vue:131-142 (searchTimer and the onBlur setTimeout) What: Neither timer is cleared in onBeforeUnmount; closing the modal mid-typing leaves a pending callback that touches showResults/entreprise.search. Why it matters: Minor — a harmless late state write; worth tidying for correctness. Fix: Clear both timers on unmount.
Unverified
- A11Y-01 (contrast): heavy use of
text-neutral-500,text-neutral-300icons andtext-2xs/text-3xsmicro-text (e.g.ModalSponsorInvite.vue:115,Campaigns.vue:26); needs a contrast tool on rendered colours. - A11Y-06 (short viewport / mobile keyboard): the tall invite composer (
BccTextarea rows="14") and the wide invoice-preview dialog need a rendered viewport to judge. - Whether
BccInput/BccSelect/BccMessage/BccDialoginternally supply label association (finding 4),role="alert"(BccMessage), or focus-trap behaviour —node_modulesis not installed, so component internals are unread.
Baseline additions
- CONTENT-01 — not-applicable — see project-level i18n finding (members has no i18n layer by design).
- NAV-03 — fails project-wide, one static « BCC Nancy Admin » title for all routes; see PROJECT-LEVEL.md. Not re-filed here.
- Proposed
CONTENT-DISPLAY-LABEL: user-facing surfaces must render an enum/status/package value through its display label, never the raw stored key, and consistently with how the same field is shown elsewhere in the app. (Cited by finding 7; orchestrator to renumber.)
Cross-project note
- Finding 1 (click-only rows/actions) is the members instance of the cross-project A11Y-03 row/chip theme already confirmed in playout, customer-portal and tt-time-tracker — here it removes the only path to a core workflow.
- Findings 2, 3, 5 (silent status swaps, raw error strings, focus-dropping submit) are library-agnostic and very likely recur in the other members admin list features that reuse TableData + these modal shapes, and the MSG-01/MSG-02 shapes were seen in customer-portal and tt-time-tracker.
- Finding 7 (raw enum keys shown to users) is worth grepping for across all four projects — an easy, low-visibility inconsistency.