frontend-a11y — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited frontend-a11y (Agent Skill) and scored it 96/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 1 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
@rules/laravel/livewire.mdc — keep Blade presentation-only; use wire:model for bindings; components stay slim.@rules/laravel/filament.mdc — reuse Filament's built-in accessible form/action components rather than rebuilding them.@rules/security/frontend.md — sanitize any dynamic content before DOM insertion; prefer textContent/setAttribute over innerHTML.useState/useEffect/useRef/JSX.<input>, <select>, <textarea>).<div>/<span> with wire:click or x-on:click instead of a button.aria-* attribute.Missing for/id pairing and disconnected error messages are the most commonly flagged issues. Livewire's @error directive pairs naturally with aria-describedby.
{{-- BAD: no association --}}
<label>Email</label>
<input type="email" wire:model="email">
{{-- GOOD: for matches id --}}
<label for="email">Email</label>
<input id="email" type="email" wire:model="email">{{-- visual asterisk hidden from SR; required + aria-required convey state --}}
<label for="email">Email <span aria-hidden="true">*</span></label>
<input id="email" type="email" required aria-required="true" wire:model="email"><label for="email">Email <span aria-hidden="true">*</span></label>
<input
id="email"
type="email"
wire:model.blur="email"
autocomplete="email"
@error('email') aria-invalid="true" aria-describedby="email-error" @enderror
>
@error('email')
<span id="email-error" role="alert" class="text-danger">{{ $message }}</span>
@enderror@error controls both the aria-invalid/aria-describedby wiring and the message, so the link is always consistent with the validation state. Use fieldset/legend to group related controls (radio sets, address blocks).
Filament forms already emit connected labels, aria-describedby error wiring, and required markers. Reuse Filament form components instead of hand-rolling markup; do not strip their generated attributes.
Use the element that matches intent. Screen readers and keyboard users depend on native semantics.
{{-- BAD: div has no role, no keyboard support --}}
<div wire:click="save">Submit</div>
{{-- GOOD: button is focusable, fires on Enter/Space, announced as "button" --}}
<button type="button" wire:click="save">Submit</button>
{{-- BAD: fake navigation --}}
<div wire:click="goHome">Home</div>
{{-- GOOD: real anchor — supports middle-click, right-click, keyboard --}}
<a href="{{ route('home') }}">Home</a>Keep heading levels sequential (h1 → h2 → h3); never skip a level for styling.
Wrong ARIA is worse than none.
{{-- aria-label: when no visible text exists --}}
<button type="button" aria-label="Close" wire:click="close">
<x-heroicon-o-x-mark aria-hidden="true" class="h-5 w-5" />
</button>
{{-- aria-labelledby: when a visible heading exists --}}
<section aria-labelledby="orders-title">
<h2 id="orders-title">Recent Orders</h2>
</section>Expandable trigger:
<button type="button" aria-expanded="false" aria-controls="panel"
x-data x-on:click="$el.setAttribute('aria-expanded', $el.getAttribute('aria-expanded') === 'true' ? 'false' : 'true')">
Details
</button>
<div id="panel" x-show="open" hidden>…</div>Every interactive element must be reachable and operable by keyboard alone. Build custom widgets with Alpine event modifiers.
<div x-data="{ open: false, active: 0, options: @js($options) }"
role="combobox" :aria-expanded="open" aria-haspopup="listbox" tabindex="0"
x-on:keydown.arrow-down.prevent="active = Math.min(active + 1, options.length - 1)"
x-on:keydown.arrow-up.prevent="active = Math.max(active - 1, 0)"
x-on:keydown.enter.prevent="$wire.select(options[active]); open = false"
x-on:keydown.escape="open = false"
x-on:click="open = !open">
<span x-text="options[active]"></span>
<ul x-show="open" role="listbox">
<template x-for="(opt, i) in options" :key="opt">
<li role="option" :aria-selected="i === active" x-text="opt"
x-on:click="$wire.select(opt); open = false"></li>
</template>
</ul>
</div><div x-data="{ open: @entangle('showModal') }" x-show="open" x-cloak
role="dialog" aria-modal="true" aria-labelledby="modal-title"
x-on:keydown.escape.window="open = false"
x-trap.noscroll="open"
{{-- x-trap (Alpine focus plugin) cycles Tab/Shift+Tab inside and restores focus on close --}}
tabindex="-1">
<h2 id="modal-title">{{ $title }}</h2>
{{ $slot }}
<button type="button" wire:click="$set('showModal', false)">Close</button>
</div>x-trap from the official Alpine Focus plugin handles the trap and restoring focus to the opener. If the plugin is not installed, save document.activeElement on open and call .focus() on it when closing.
A Livewire request swaps DOM fragments; focus and announcements must survive it.
wire:loading / wire:target toggle visible state without script.<div aria-live="polite" aria-atomic="true" class="sr-only">
<span wire:loading wire:target="save">Saving…</span>
</div>
@if (session('status'))
<div role="status" aria-live="polite">{{ session('status') }}</div>
@endifaria-live="assertive" (or role="alert"), nothing else..focus() the trigger, or rely on x-trap.wire:key; without it Livewire may discard the focused node.wire:loading.attr="disabled" so double-submits are blocked accessibly.<img src="/decoration.png" alt="" aria-hidden="true"> {{-- decorative --}}
<img src="/chart.png" alt="Revenue rose 23% from Jan to Mar"> {{-- meaningful --}}
<button type="button" aria-label="Delete"><x-heroicon-o-trash aria-hidden="true" /></button>dark: variants. Never rely on color alone to convey state — pair with text or an icon.motion-safe:/motion-reduce: variants instead of always-on transitions.<div class="transition-transform motion-reduce:transition-none">…</div>For Alpine transitions, branch on the media query:
<div x-data="{ reduce: window.matchMedia('(prefers-reduced-motion: reduce)').matches }"
x-transition:enter="reduce ? '' : 'transition duration-300'">…</div>WCAG 2.2 added criteria that templated Blade/Livewire UI frequently misses. These complement the patterns above.
Interactive targets must be at least 24×24 CSS px (or have 24px spacing around them). Icon-only buttons and tight table-row actions are the usual offenders.
{{-- BAD: 16px hit area --}}
<button type="button" class="h-4 w-4" aria-label="Edit" wire:click="edit">
<x-heroicon-o-pencil aria-hidden="true" />
</button>
{{-- GOOD: 24px minimum hit area (padding counts toward the target) --}}
<button type="button" class="h-6 w-6 p-1 -m-1" aria-label="Edit" wire:click="edit">
<x-heroicon-o-pencil aria-hidden="true" class="h-4 w-4" />
</button>Every focusable element needs a clearly visible focus indicator. Never strip the outline without replacing it; prefer focus-visible: so the ring shows for keyboard users without firing on mouse click.
{{-- BAD: focus removed, keyboard users lose their place --}}
<button class="focus:outline-none" wire:click="save">Save</button>
{{-- GOOD: high-contrast ring, keyboard-only --}}
<button class="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
wire:click="save">Save</button>Within one process (multi-step form, checkout) never ask the user to re-enter information they already provided. Persist it on the Livewire component (or session) and prefill, offering an explicit "same as …" shortcut instead of a blank field.
<label><input type="checkbox" wire:model.live="billingSameAsShipping"> Billing address same as shipping</label>
@unless ($billingSameAsShipping)
{{-- billing fields, prefilled from the shipping step --}}
@endunlessAny drag-to-reorder/drag-to-move interaction must have a single-pointer alternative (move up/down buttons or a position field), so users who cannot drag can still operate it.
<li>
{{ $item->name }}
<button type="button" aria-label="Move up" wire:click="moveUp({{ $item->id }})"><x-heroicon-o-arrow-up aria-hidden="true" /></button>
<button type="button" aria-label="Move down" wire:click="moveDown({{ $item->id }})"><x-heroicon-o-arrow-down aria-hidden="true" /></button>
</li>When validation fails and a fix is known, the message must suggest the correction, not just report invalidity. Keep the suggestion generic enough not to leak sensitive data (see @rules/security/backend.md).
// BAD: 'date' => 'Invalid value.'
// GOOD: 'date' => 'Enter the date as YYYY-MM-DD, e.g. 2026-06-15.'<div wire:click="save">Save</div> {{-- non-interactive element with handler --}}
<div aria-label="Navigation">…</div> {{-- aria-label on element with no role --}}
<input placeholder="Email"> {{-- placeholder used instead of label --}}
<button tabindex="3">Submit</button> {{-- positive tabindex breaks tab order --}}
<button aria-hidden="true">Open</button> {{-- aria-hidden on a focusable element --}}<input>/<select>/<textarea> has a connected <label for>.@error → aria-describedby and carry role="alert".wire:click/x-on:click on a <div>/<span> without role, tabindex, and key handling.aria-label; their icons are aria-hidden.alt="" and aria-hidden="true".x-trap or manual save/restore).aria-live; controls disable during requests.wire:key so focus survives re-render.motion-reduce: / prefers-reduced-motion.focus-visible: and never stripped without replacement (SC 2.4.11).~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.