Add CI, a pre-commit hook, and an accessibility pass on the panel's own views

CI (.github/workflows/tests.yml) reuses docker-compose.test.yml verbatim for
the Postgres job rather than re-describing that environment in YAML, so the
Docker image stays the single source of truth for it.

Fixes a real mismatch found while wiring PHP versions into the workflow:
composer.json declared "php": "^8.3", but the locked Symfony packages require
>=8.4.1 — installing on 8.3 would fail at `composer install`, exactly what
the Dockerfile's own comment already warned about. Bumped the constraint and
resynced composer.lock's content-hash (no package versions changed).

The pre-commit hook (.githooks/pre-commit, activated via
`git config core.hooksPath` in post-autoload-dump) runs `pint --dirty --test`
and refuses to commit unformatted files rather than silently rewriting them.

The accessibility pass only touches this app's own hand-written Blade views,
not Filament's components: aria-hidden on icons that repeat adjacent text,
an aria-label on the one icon that is the only signal of a health check's
severity, rel="noopener" and a labelled announcement on preview links that
open in a new tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 08:35:17 +02:00
parent 3897113313
commit 618d3a56ad
11 changed files with 217 additions and 20 deletions

25
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/sh
set -e
###############################################################################
# Catches a formatting slip before it becomes a commit, rather than after —
# by which point it is a CI failure and a second commit to fix it.
#
# Only checks files with uncommitted changes (`--dirty`), not the whole tree:
# a hook that took as long as the full lint would be a hook people learn to
# skip. Read-only on purpose too — it reports what Pint would change and
# leaves fixing it to you, rather than silently rewriting a file you are
# about to commit without your having seen the diff.
#
# Activated by `composer run setup` (`git config core.hooksPath .githooks`),
# not by being physically inside .git/hooks/ — that directory is not tracked,
# so a hook living only there is never shared with anyone else who clones
# this repository.
###############################################################################
if ! ./vendor/bin/pint --dirty --test; then
echo >&2
echo "Pint found formatting issues in the files you are committing." >&2
echo "Run ./vendor/bin/pint --dirty to fix them, then stage and commit again." >&2
exit 1
fi

81
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: Tests
# Every push and every pull request, on any branch — a failure is cheapest to
# find here, before it is a review comment or, worse, a deploy.
on:
push:
pull_request:
# A second push to the same branch cancels whatever this workflow was still
# running for the first one: nobody is waiting on results for code that no
# longer exists.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Real PHP requirement per composer.lock, not the ^8.3 composer.json used to
# claim — the locked Symfony packages require >=8.4.1, so 8.3 fails at
# `composer install`, and this pins the version the Dockerfile already
# settled on rather than re-deriving it.
PHP_VERSION: "8.4"
# A throwaway key, checked into no secret store because it protects nothing
# here: sessions and signed URLs in a CI run that is discarded on exit.
# docker-compose.test.yml uses this exact value for the same reason.
APP_KEY: "base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
jobs:
# Fast and dependency-free: catches a formatting slip before anything as
# slow as booting the framework runs at all.
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ env.PHP_VERSION }}
tools: composer:v2
coverage: none
- uses: ramsey/composer-install@v3
- run: ./vendor/bin/pint --test
# SQLite, in memory: the fast loop, for feedback in the couple of minutes it
# actually takes. Postgres is what production runs on, which is what the
# job below is for.
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ env.PHP_VERSION }}
extensions: pdo_sqlite, pdo_pgsql, gd, exif, intl, zip, pcntl, opcache
tools: composer:v2
coverage: none
- uses: ramsey/composer-install@v3
- run: php artisan test
# Fails if public/api-types.d.ts was committed out of step with
# openapi.yaml — the one artefact in this repo that is generated and
# checked in rather than built at deploy time, so nothing else would
# ever notice it drifting.
- run: php artisan api:types --check
# The one this app's own README insists on before trusting a change: this
# project stores translations and image variants in JSON columns and
# queries them by path in about forty places, and SQLite and Postgres do
# not always agree on what that means. Reuses docker-compose.test.yml
# verbatim rather than re-describing the same environment in YAML a second
# time — one place to keep it correct.
test-postgres:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker compose -f docker-compose.test.yml run --rm test

View File

@@ -56,6 +56,15 @@ php artisan key:generate
php artisan migrate --seed # crea roles, permisos y contenido de ejemplo
```
`composer install` activa también [`.githooks/pre-commit`](.githooks/pre-commit)
(`git config core.hooksPath .githooks`, dentro de `post-autoload-dump`): antes
de cada commit corre `pint --dirty --test` sobre lo que has tocado y lo
rechaza si algo no está formateado. Solo avisa, nunca reescribe por su cuenta
un fichero que estás a punto de comitear — el arreglo es `pint --dirty` a
mano, ver el diff, y comitear otra vez. Nada de esto sustituye a la CI: el
hook protege el minuto a minuto de quien escribe, la CI protege lo que llega
al remoto por si alguien comitea con `--no-verify`.
### 1. Registrar la aplicación en el servidor de identidad
En `https://oauth.pireon.dev` crea un cliente OAuth con:
@@ -1340,6 +1349,14 @@ php artisan site:maintenance --up
php artisan test # 391 tests, 1121 aserciones
```
En cada push y cada pull request, [`.github/workflows/tests.yml`](.github/workflows/tests.yml)
corre tres cosas: `pint --test`, esta suite contra SQLite (la vuelta rápida) y
`docker compose -f docker-compose.test.yml run --rm test` — el mismo comando de
más abajo, no una reimplementación en YAML — contra Postgres (la que de verdad
importa). Reutiliza el fichero de Docker en vez de describir el entorno dos
veces: un cambio en las extensiones de PHP o en la versión de Postgres se
corrige en un solo sitio.
Cubren el acceso con email y contraseña (credenciales incorrectas
indistinguibles de una dirección desconocida, cuenta desactivada, pendiente de
aprobación o sin rol, límite de intentos y rotación de sesión), el flujo PKCE
@@ -1357,19 +1374,18 @@ detectan, los permisos que ocultan resultados y los códigos de salida), las
redirecciones (que se crean solas, que no encadenan ni dan vueltas y que la web
las recibe de una vez), los 404 que reporta el frontend (que el endpoint no se
abre sin secreto, que cuenta en vez de acumular filas, que normaliza igual que
una redirección, que tira los escaneos y que crear la regla cierra el aviso), el
la revisión (que se puede pedir sin permiso de publicar, a
quién avisa, que devolver exige motivo, que publicar la cierra y programar no),
las ediciones simultáneas (que el segundo guardado se rechaza, que guardarse a
uno mismo no cuenta como conflicto y que la presencia se olvida sola),
las cifras de lectura (que suman en una fila por día, que cada idioma cuenta
aparte y que en la tabla no cabe nada de un visitante), el contrato de la API
contra su especificación campo a campo y los tipos generados de ella, las
traducciones que se quedaron atrás (que el slug no cuenta, que una vacía no
cuenta y que ponerse al día lo limpia), las ediciones simultáneas y sus
acciones en lote, las sesiones activas (que no se puede cerrar la propia) y el
aviso de títulos parecidos,
el historial (quién publicó, el diff y restaurar), la
una redirección, que tira los escaneos y que crear la regla cierra el aviso), la
revisión (que se puede pedir sin permiso de publicar, a quién avisa, que
devolver exige motivo, que publicar la cierra y programar no) y sus acciones en
lote, las ediciones simultáneas (que el segundo guardado se rechaza, que
guardarse a uno mismo no cuenta como conflicto y que la presencia se olvida
sola), las cifras de lectura (que suman en una fila por día, que cada idioma
cuenta aparte y que en la tabla no cabe nada de un visitante), el contrato de
la API contra su especificación campo a campo y los tipos generados de ella,
las traducciones que se quedaron atrás (que el slug no cuenta, que una vacía
no cuenta y que ponerse al día lo limpia), las sesiones activas (que no se
puede cerrar la propia) y el aviso de títulos parecidos, el historial (quién
publicó, el diff y restaurar), la
limpieza (qué se conserva siempre y qué nunca se borra sin pedirlo), las
ventanas de publicación (que se respetan sin nada corriendo, que preview las
salta y que el cierre de convocatorias es idempotente), la sincronización con
@@ -1394,6 +1410,33 @@ inyecta `Accept-Language: en-us` por defecto, y la API respeta la negociación d
idioma. Si un test espera castellano tiene que pedirlo explícitamente
(`Accept-Language: es` o `?lang=es`), como hace `PublicApiTest`.
## Accesibilidad del propio panel
El texto alternativo de la web pública tiene su sección propia; esto es sobre
la herramienta, no sobre lo que publica. Filament ya se ocupa de lo suyo —
contraste, roles ARIA, navegación por teclado entre pestañas— y no se ha
tocado nada de eso. Lo que se ha revisado son las **pocas vistas Blade
escritas a mano** en `resources/views/filament/`, donde nada garantiza esas
cosas por defecto.
Dos reglas, aplicadas donde tocaba:
**Un icono junto a un texto que ya dice lo mismo lleva `aria-hidden="true"`.**
El aviso de mantenimiento, el de salud del sitio: el icono de aviso al lado de
«La web pública está en mantenimiento» no añade nada que un lector de
pantalla necesite oír dos veces.
**Un icono que es la única señal de algo lleva su propio `aria-label`.** El de
cada comprobación de salud es el único caso: la gravedad —fallo o aviso— no
está escrita en ningún sitio, solo se ve en la forma y el color del icono.
Ocultarlo habría sido perder esa información, no limpiarla.
De paso, los enlaces de vista previa que abren en pestaña nueva llevan
`rel="noopener"` (la página de destino, en otro origen, no debe quedarse con
una referencia a esta) y el campo de solo lectura que copia la URL tiene su
propio `aria-label`, porque el texto que lo identifica visualmente es un
párrafo aparte sin relación programática con él.
## Estructura
```

View File

@@ -6,7 +6,7 @@
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"php": "^8.4",
"filament/filament": "^5.7",
"laravel/framework": "^13.17",
"laravel/tinker": "^3.0",
@@ -57,7 +57,8 @@
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi",
"@php artisan filament:upgrade"
"@php artisan filament:upgrade",
"@php -r \"is_dir('.git') && exec('git config core.hooksPath .githooks');\""
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"

4
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2c4a3ac0f2adf4cac823692240e8ca81",
"content-hash": "e9bbf5ad6f9f32f9f506caf56db81710",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -12591,7 +12591,7 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.3"
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"

View File

@@ -196,6 +196,8 @@ return [
'heading' => 'Enllaços de vista prèvia',
'hint' => 'Es poden compartir i caduquen en :hours hores. Cadascun serveix només per a aquest registre.',
'open' => 'Obrir',
'opens_new_tab' => '(s\'obre en una pestanya nova)',
'link_for' => 'Enllaç de vista prèvia en :language',
],
'trash' => [

View File

@@ -196,6 +196,8 @@ return [
'heading' => 'Preview links',
'hint' => 'Shareable, and they expire in :hours hours. Each one only works for this record.',
'open' => 'Open',
'opens_new_tab' => '(opens in a new tab)',
'link_for' => 'Preview link for :language',
],
'trash' => [

View File

@@ -196,6 +196,8 @@ return [
'heading' => 'Enlaces de vista previa',
'hint' => 'Se pueden compartir y caducan en :hours horas. Cada uno sirve solo para este registro.',
'open' => 'Abrir',
'opens_new_tab' => '(se abre en una pestaña nueva)',
'link_for' => 'Enlace de vista previa en :language',
],
'trash' => [

View File

@@ -1,11 +1,18 @@
<x-filament-panels::page>
{{-- The headline. Whether anything is actually wrong is the one thing
somebody opening this page came to find out. --}}
somebody opening this page came to find out.
Most of the icons below are `aria-hidden`: colour and shape repeating
what the text beside them already says. The one on each check's own
heading is the exception — severity there is never written out in
words, only implied by an icon and a badge colour — so that one gets
a spoken name instead of being hidden. --}}
@if ($this->problems()->isEmpty())
<div class="rounded-xl border border-success-300 bg-success-50 p-4 dark:border-success-700/50 dark:bg-success-500/10">
<div class="flex items-center gap-3">
<x-filament::icon
icon="heroicon-o-check-circle"
aria-hidden="true"
class="h-5 w-5 shrink-0 text-success-600 dark:text-success-400"
/>
@@ -25,6 +32,7 @@
<div class="flex items-start gap-3">
<x-filament::icon
icon="heroicon-o-exclamation-triangle"
aria-hidden="true"
@class([
'mt-0.5 h-5 w-5 shrink-0',
'text-danger-600 dark:text-danger-400' => $tone === 'danger',
@@ -60,6 +68,8 @@
<span class="flex items-center gap-2">
<x-filament::icon
:icon="$finding->severity->getIcon()"
:aria-label="$finding->severity->getLabel()"
role="img"
@class([
'h-5 w-5 shrink-0',
'text-danger-500' => $finding->severity->getColor() === 'danger',
@@ -123,6 +133,7 @@
<li class="flex items-start gap-2 text-sm">
<x-filament::icon
icon="heroicon-o-check-circle"
aria-hidden="true"
class="mt-0.5 h-4 w-4 shrink-0 text-success-500"
/>

View File

@@ -1,11 +1,17 @@
<x-filament-panels::page>
{{-- Current state, stated plainly. Whether the public site is down is the
one thing this page must never leave ambiguous. --}}
one thing this page must never leave ambiguous.
The icons beside it are `aria-hidden`: what they say is already in
the text next to them, so a screen reader announcing "warning
triangle icon" would be noise repeating what it is about to read
anyway. --}}
@if ($this->isEnabled())
<div class="rounded-xl border border-danger-300 bg-danger-50 p-4 dark:border-danger-700/50 dark:bg-danger-500/10">
<div class="flex items-start gap-3">
<x-filament::icon
icon="heroicon-o-exclamation-triangle"
aria-hidden="true"
class="mt-0.5 h-5 w-5 shrink-0 text-danger-600 dark:text-danger-400"
/>
@@ -35,6 +41,7 @@
<div class="flex items-center gap-3">
<x-filament::icon
icon="heroicon-o-check-circle"
aria-hidden="true"
class="h-5 w-5 shrink-0 text-success-600 dark:text-success-400"
/>

View File

@@ -6,16 +6,39 @@
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">{{ $language }}</p>
<div class="flex items-center gap-2">
{{-- Readonly and click/focus-to-select rather than a plain
display of the URL: copying it is the entire point of
this field, for a mouse and a keyboard alike. Its own
`aria-label` names it explicitly — the language above is
a separate element with no `for`/`id` link to this one,
so a screen reader landing directly on the input would
otherwise hear only "text, read only". --}}
<input
type="text"
readonly
value="{{ $url }}"
onclick="this.select()"
onfocus="this.select()"
aria-label="{{ __('panel.preview.link_for', ['language' => $language]) }}"
class="fi-input w-full rounded-lg border-gray-300 bg-gray-50 px-3 py-2 font-mono text-xs text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-300"
/>
<x-filament::button tag="a" href="{{ $url }}" target="_blank" size="sm" color="gray">
{{-- `noopener`: the page it opens runs on the public site's
origin, and a target="_blank" link without it hands that
page a live reference back to this one (`window.opener`).
The visually-hidden text is for whoever is not looking at
the screen — a new tab opening is a context change
nothing else here announces. --}}
<x-filament::button
tag="a"
href="{{ $url }}"
target="_blank"
rel="noopener"
size="sm"
color="gray"
>
{{ __('panel.preview.open') }}
<span class="sr-only">{{ __('panel.preview.opens_new_tab') }}</span>
</x-filament::button>
</div>
</div>