Appearance
[UX] members — Coherence checks
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch:
develop@2c22f5a· Files reviewed: 5 Patterns: user-feedback/empty-states
Summary
The Coherence view (admin/coherence, "Cohérence des données") is a self-contained card list — it does not use the shared TableData.vue, so the project-level TableData cluster (mouse-only rows, dead isError branch, FilterDrawer focus) does not apply here, and its controls are genuinely real <button> elements (A11Y-03/04/05 pass). The substantive gaps are all in async state handling: a failed initial load and a failed row-fetch both silently render as "no data", and the bulk fix action mutates organisation data with one click, no confirmation, and — unlike the single-row edit next to it — no capability gate.
Findings
1. Failed initial load renders as a blank page, not an error — Medium · MSG-06 (proposed)
Where: admin/src/client/views/Admin/Coherence.vue:19-29,180-190What: refresh() runs on mount. On throw, the catch shows a 4s toast and finally sets loading=false; checks stays []. The v-if="loading && !checks.length" spinner then hides and the v-else block iterates an empty checks array, so the page renders as the PageHeader plus empty whitespace. There is no persistent inline error state and no empty-data state either (if /api/integrity legitimately returns [], same blank). Why it matters: After the toast auto-dismisses, a non-tech-savvy staff user is left on a blank page with no indication whether the checks all passed, there is no data, or the load failed. The empty-states pattern requires default/loading/error/success to be distinct states and async states to "reconcile correctly after a failure". Fix: Track the load error (the useApi error ref is already available) and render a persistent inline error block with a retry when !loading && !checks.length && error; render a distinct "all checks passed / no checks" empty state otherwise.
2. Failed row-fetch shows "Aucune ligne.", contradicting the count badge — Medium · MSG-06 (proposed)
Where: admin/src/client/views/Admin/Coherence.vue:107-112,192-209What: fetchRows() has try/finally but no catch, and toggleRows() awaits it with no handler either, so a failed /api/integrity/{key} request is an unhandled promise rejection and leaves rows=[], rowsLoading=false. The expanded panel — which only opens when check.count > 0 — then shows "Aucune ligne." ("no rows"). Why it matters: The count badge says e.g. "12" while the opened panel says there are no rows: a direct, self-contradicting message that reads as "the 12 offending records vanished" when in fact the fetch failed. No toast fires on this path, so the user gets no error signal at all. Fix: Wrap fetchRows in a try/catch, set a per-expansion error flag, and render an error+retry row instead of the "Aucune ligne." branch when the fetch failed; only show "Aucune ligne." after a successful empty response.
3. Bulk "fix" mutates data with no confirmation and no capability gate — Medium · MSG-05
Where: admin/src/client/views/Admin/Coherence.vue:64-71,98-105,211-228What: runFix() POSTs /api/integrity/{key}/fix immediately on click, launching background automations that alter organisation data (per the subtitle, "Les corrections relancent les automatisations en arrière-plan"). There is no confirmation step and no statement of how many rows will be affected before firing. Separately, the single-row "Ouvrir" edit button is gated on canEditTransactions (caps.can("Transactions","update")), but the more powerful bulk "fix" button has no client capability check, and the route itself is declared meta: { roles: [] } (router.ts:62). Why it matters: The lesser action (edit one transaction) is gated while the greater one (bulk-mutate many) is not — a user who cannot open a single row can still trigger a mass correction. MSG-05 requires destructive/bulk actions to confirm first and say what will change. Placed at Medium not High because the automations are re-linking (largely idempotent) rather than deleting, and server-side authz may still enforce access — but the client inconsistency and missing confirmation are real. Fix: Add a confirm dialog naming the check and the count before runFix; gate the fix button on the same (or a dedicated) capability so client affordances match authority.
4. setTimeout(refresh, 2500) is never cleared on unmount — Low · NAV-02
Where: admin/src/client/views/Admin/Coherence.vue:174,222What: After a save and after a fix, window.setTimeout(refresh, 2500) is scheduled to re-count. The timeout id is not stored and there is no onUnmounted cleanup, so navigating away within 2.5s still fires refresh() on a torn-down component. Why it matters: The stray refresh runs api.execute and, on failure, calls toast.add(...) — so an "Impossible de charger les contrôles" toast can surface on an unrelated screen the user has since navigated to, and reactive refs are written after unmount. Fix: Store the timeout id and clear it in onUnmounted; ignore the result if the component is gone.
5. "Ouvrir" row action is a sub-24px text-only target — Low · A11Y-02
Where: admin/src/client/views/Admin/Coherence.vue:98-105What: The row action is <button class="shrink-0 text-xs text-brand-700 hover:underline"> — 12px text with no padding, so its hit area is roughly the glyph height (well under the 24×24 CSS px floor). The row's tap target is also its only edit affordance. Why it matters: WCAG 2.5.8; small text-only controls are hard to hit on touch/coarse pointers, and this row list is the drill-down path staff use to fix flagged records. Fix: Give the button min-height/padding (or render it as a small BccButton) to reach 24×24, matching the sized buttons used in the card header.
6. Waiting spinners name nothing — Low · CONTENT-04
Where: admin/src/client/views/Admin/Coherence.vue:19-24,79-84What: Both the full-page load and the row-expansion load render a bare SpinnerLoading with no accompanying text ("Chargement des contrôles…", "Chargement des lignes…"). Why it matters: CONTENT-04 asks waiting states to name what is being waited on; a naked spinner leaves the user guessing, and (with finding 1) a spinner that resolves to a blank page is doubly ambiguous. Fix: Add a short label beside each spinner.
Minor, not itemised: error toasts (:186, :176, :223) state what went wrong but not what to do next (no "réessayez") — MSG-03; the header "Actualiser" button is the de-facto recovery but the copy does not point to it.
Unverified
- A11Y-01 (contrast) —
text-neutral-500descriptions,text-brand-700row link and thewarning-subtlestBccTag need a contrast tool against the OKLch tokens. Not asserted. - A11Y-06 (responsive / short viewport) — the card header wraps (
flex-wrap) but the effect at ≈700px height / mobile keyboard is not settled from code. - MSG-01 / FORM-06 (toast + BccButton internals) — whether
useToastrenders anaria-live/role="status"region, and whetherBccButton :loadingsetsdisabled(dropping focus to<body>), live in@bcc-code/component-library-vue;node_modulesis not installed, so both are Unverified rather than asserted.
Baseline additions
- MSG-06 (proposed) — "a failed fetch must not render as an empty/
Aucune/permanent-loading state; error, empty and loading are distinct." Findings 1 and 2 are two instances in this one file. This is the members MSG-06 theme already tracked in PROJECT-LEVEL.md; recorded here as two new call sites, not a new rule.
Cross-project note
- MSG-06 empty-for-error is the dominant cross-project theme (playout, customer-portal, members, tt-time-tracker all confirmed elsewhere); these two Coherence call sites add to the members tally.
- MSG-05 bulk action without confirmation + client gate weaker than the row action is worth checking on any admin "recompute/reconcile/bulk-fix" surface — customer-portal and tt-time-tracker both have admin maintenance screens.
- CONTENT-01 — not-applicable, see project-level i18n finding (members has no i18n layer).
- NAV-03 — fails project-wide (
admin/index.html:13static title); see PROJECT-LEVEL.md.