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:
25
.githooks/pre-commit
Executable file
25
.githooks/pre-commit
Executable 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
81
.github/workflows/tests.yml
vendored
Normal 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
|
||||
69
README.md
69
README.md
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
4
composer.lock
generated
@@ -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"
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user