Skip to content

Programa: vista accesible + mejoras de a11y del widget de Pretalx#215

Merged
itziarZG merged 15 commits into
mainfrom
feat/programa-a11y
Jul 13, 2026
Merged

Programa: vista accesible + mejoras de a11y del widget de Pretalx#215
itziarZG merged 15 commits into
mainfrom
feat/programa-a11y

Conversation

@itziarZG

@itziarZG itziarZG commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Resumen

Closes #216 con un approach de dos puntas:

  1. Tanda 1 (bb27cbc): parches de a11y sobre el widget embebido (skip link, ARIA en dialog, focus restoration, modality script, etc.)
  2. Tanda 2 (39584b0): vista de lista semántica renderizada server-side desde la API de Pretalx. Es la solución de fondo — un grid CSS complejo no se puede hacer accesible desde afuera con parches ARIA.

El widget sigue existiendo como vista opt-in para usuarios visuales, pero la lista accesible es el default.

Cambios principales

Vista accesible (default)

  • <ol> por día con <article> por sesión
  • <time datetime>, <h3>, <dl> para metadata
  • <details> para abstracts colapsables
  • Atributo lang en abstracts según idioma del talk
  • <fieldset> + radios para el toggle, persiste en localStorage

Widget de Pretalx (vista opt-in)

  • Skip link global en el Layout
  • Live region anunciando el conteo de sesiones al filtrar
  • ARIA reforzado en el dialog de filtros vía Shadow DOM script
  • Labels accesibles para search input y timezone select
  • Focus restoration al cerrar el dialog
  • Modality script: body[modality=keyboard] (atributo directo)
  • Override de --pretalx-clr-success a #2e7d32 (distinguible para deuteranopia)

Data layer

  • src/lib/schedule.ts — fetch del schedule en build time, con fallback amigable
  • src/types/schedule.ts — tipos del export de Pretalx
  • Usa el endpoint legacy schedule/export/schedule.json (todo viene resuelto: room names, tracks, types en string, ya agrupado por día)

i18n

  • src/i18n/menu/{es,en,ca}.tsskipToContent
  • src/i18n/programa/{es,en,ca}.tsa11y (status, labels) + list (toggle, day, speakers, room, abstract, empty state)

Archivos

Archivo Cambio
src/types/schedule.ts nuevo — tipos TS del export
src/lib/schedule.ts nuevo — fetch + normalización
src/components/ScheduleList.astro nuevo — render semántico
src/components/ProgramaPage.astro reescrito — toggle + ambas vistas
src/pages/[lang]/programa.astro simplificado — wrapper fino
src/layouts/Layout.astro skip link + .skip-link + .sr-only
src/i18n/menu/{es,en,ca}.ts +1 string c/u
src/i18n/programa/{es,en,ca}.ts +22 strings c/u

Bugs encontrados durante implementación

El widget de Pretalx es Vue 3 compilado con attachShadow({mode:'open'}), no renderiza en light DOM como se asumió inicialmente. Implicó 3 correcciones:

  1. Queries en shadow rootpretalx-schedule.querySelector('#filter-bottom-sheet-dialog') no encuentra el dialog porque está dentro del shadow root. Helper $inWidget() prueba light DOM y luego shadowRoot.querySelector.
  2. body[modality=keyboard] es atributo directo, no data-modality.
  3. CSS custom properties sí cruzan el shadow boundary pero los selectores de clase no. El override de --pretalx-clr-success va en el atributo style del host.

Testing

  • pnpm format → OK
  • pnpm exec astro check → 0 errors, 0 warnings
  • Pendiente (manual):
    • NVDA en /es/programa → ¿la lista suena natural al navegar con flechas?
    • VoiceOver en /ca/programa → ¿pronunciación correcta del catalán?
    • Toggle a "Grilla visual" → ¿el widget carga con sus parches?
    • Refrescar → ¿se mantiene la elección del usuario?
    • axe-core en las 3 locales → 0 violaciones serias
    • Simular API caída (cambiar URL en schedule.ts) → ¿se ve el fallback?

Lo que dejé fuera (a propósito)

  • Breaks (coffee/lunch) — el legacy export no los trae, el REST sí. Para v1 alcanza con talks/workshops/keynotes. Si los piden, mergeamos REST+legacy por start + room.
  • Filtros propios en la lista — actualmente se muestra todo por día. Filtros por track/sala se pueden agregar.
  • Traducción de abstracts — Pretalx no expone traducciones de los abstracts. Quedan en el idioma original del talk (en/es/ca según el campo language).

- Add global skip link to main content (3 i18n locales)
- Wrap schedule in region with aria-labelledby, add live region
  announcing session count changes after filtering
- Reinforce dialog ARIA (aria-modal, role, aria-labelledby) via
  Shadow DOM CSS/attribute injection
- Add labels to search input and timezone select inside the widget
- Restore focus to filter trigger when dialog closes
- Set body[modality] attribute so widget's :focus-visible rings
  appear for keyboard users
- Override --pretalx-clr-success to a distinguishable green

Fixes 3 issues from initial a11y pass:
- Widget uses Shadow DOM (Vue 3 attachShadow), not light DOM —
  all queries now use shadow root
- modality must be a direct attribute, not data-modality
- success color override must live on host's style attribute
  to cross the shadow boundary

@ctrl-alt-d ctrl-alt-d left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debo advertir que el control UI de horarios no es accesible a lectores de pantalla. No es que tenga algún pequeño problema, es que directamente no es navegable.

Apruebo para tener la versión visual en producción y, si os parece, estudiamos si podemos implementar un control complementario y compatible con el lector de pantalla.

Mirar #216

…dget

Addresses #216 — the Pretalx widget's CSS grid layout is fundamentally
hard to navigate with a screen reader, no matter how much ARIA we inject.

Adds a parallel accessible list view rendered server-side from the
Pretalx API, with the widget preserved as an opt-in visual alternative.

Data layer:
- src/lib/schedule.ts — fetches schedule.json at build time, normalises
  per-day structure, returns null on failure (graceful fallback)
- src/types/schedule.ts — TS types for the Pretalx export shape

UI:
- src/components/ScheduleList.astro — semantic <ol> per day with
  <article> per session, <time datetime>, <details> for abstracts,
  lang attribute on text in non-default languages
- Toggle in ProgramaPage.astro: <fieldset> + radio group, default to
  the accessible list, persists choice in localStorage
- Widget remains as opt-in 'Visual grid' view, with all a11y
  enhancements from the previous commit intact

i18n:
- Adds 15 new strings to programa/{es,en,ca} (view toggle, day label,
  speaker/room/track/abstract labels, empty state)

Edge cases:
- Pretalx down or schedule not published: render friendly empty state
  with link to pretalx.com
- Build-time fetch is silent on failure (warning to stderr, no crash)
@itziarZG

Copy link
Copy Markdown
Collaborator Author

Update: address #216

Hecho un commit extra (39584b0) en la misma branch que ataca el issue #216 directamente. En vez de seguir parchando el widget, agregamos una vista de lista semántica renderizada server-side desde la API de Pretalx.

Qué cambió

Nueva vista: lista accesible (default ahora)

  • <ol> por día con <article> por sesión
  • <time datetime="...">, <h3>, <dl> para metadata
  • <details> para abstracts
  • Atributo lang en abstracts según idioma del talk
  • Toggle con <fieldset> + radios (a11y nativo)
  • Preferencia persiste en localStorage

Widget sigue como vista opt-in ("Grilla visual")

  • Todo el código de a11y de la primera tanda queda intacto
  • Solo se carga si el usuario la elige (sigue cargándose la JS del widget, pero queda oculta por default)

Resiliencia

  • src/lib/schedule.ts fetcha schedule.json en build time
  • Si Pretalx está caído o el evento no tiene schedule, renderiza mensaje amigable con link a pretalx.com — no rompe el build
  • El schedule actual de pycones-2026 SÍ está publicado (56 charlas, 3 días, 10 salas, 12 tracks) así que vamos a ver la lista real

Archivos nuevos

  • src/lib/schedule.ts — fetch + normalización
  • src/types/schedule.ts — tipos
  • src/components/ScheduleList.astro — render

Testing manual pendiente

  • NVDA + Firefox en /es/programa → ¿la lista suena natural al navegar con flechas?
  • Toggle a "Grilla visual" → ¿el widget carga bien con todos los parches de a11y?
  • Refrescar la página → ¿se mantiene la elección del usuario?
  • axe-core → ¿0 violaciones?
  • Si la API está caída (simular con localStorage mock) → ¿se ve el fallback?

Probá y decime si el compi queda conforme con esto o si hay que iterar.

@itziarZG itziarZG changed the title Programa: mejora accesibilidad del widget de Pretalx Programa: vista accesible + mejoras de a11y del widget de Pretalx Jul 10, 2026
Visual users land on the Pretalx widget by default; the accessible
list is now a one-click toggle away for screen reader users and
anyone who prefers a semantic structure.
@ctrl-alt-d ctrl-alt-d self-requested a review July 10, 2026 08:59
@itziarZG itziarZG requested a review from francescarpi July 10, 2026 09:17

@ctrl-alt-d ctrl-alt-d left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

El commit a7bcdb7 soluciona el #216, fantástico.

Algunas cositas que repensaría:

time

El atributo time nos resta información sobre la hora fin:

<time datetime="2026-11-06T16:00:00+01:00">
  16:00–17:30
</time>

Cambiar por algo parecido a:

<p>
  <span class="sr-only">De </span>
  <time datetime="2026-11-06T16:00:00+01:00">16:00</time>
  <span aria-hidden="true"></span>
  <span class="sr-only"> a </span>
  <time datetime="2026-11-06T17:30:00+01:00">17:30</time>
</p>

Duración

La duración se indica dos veces:

<span class="text-xs text-pycon-gray-25/70 shrink-0">01:30</span>
<-!...->
<span class="text-xs text-pycon-gray-25/70 shrink-0">Taller (90min)</span>

Supongo que Taller (90min) no se debe tocar pues debe ser el tipo de actividad. El 1:30 puede dar pie a confusión (que alguien piense que es a la una y media), añadir duración al menos para SR:

<span class="text-xs text-pycon-gray-25/70">
  <span class="sr-only">Duración: </span>
  01:30
</span>

Otros

En las descripciones no se han respetado los saltos de línea, el json nos retorna:

... en Venecia.\n\nLos aeropuertos ...

Pero esos saltos no se trasladan a html, nos falta algo como <p class="whitespace-pre-line">

izg added 2 commits July 12, 2026 13:14
# Conflicts:
#	src/components/ProgramaPage.astro
#	src/pages/[lang]/programa.astro
itziarZG added 10 commits July 12, 2026 19:51
When a talk is in English (or any non-page language), screen readers
were pronouncing the original-language content with the page's voice
because the surrounding elements inherited the document lang.

Add lang={talkLang(s)} on:
- <h3> (talk title)
- <p> subtitle (when present)
- <p> abstract

Helper talkLang() maps s.language to a valid BCP-47 value and falls
back to the page lang if Pretalx returns an unknown code.
The Pretalx widget already persists favourites in localStorage under
'pycones-2026_favs' as a JSON array of session codes. This commit
mirrors that behaviour on the accessible list so users can bookmark
sessions in either view and have the preference stick.

UI:
- Heart icon button on every session card, pushed to the right of
  the metadata row
- aria-pressed for state, aria-label that flips between
  'Add to favourites' / 'Remove from favourites' based on state
- Visual state: outline heart (gray) when off, filled (orange) when on
- sr-only label 'Favourite' for screen readers

Script:
- Reads/writes the same 'pycones-2026_favs' key the widget uses
- Click handler: toggle, persist, update DOM
- 'storage' event listener: re-sync on cross-tab changes
- 'pycones:view-changed' custom event: re-sync when user switches
  to the list view (in case the widget mutated localStorage in
  the same session)

i18n:
- favorite, favoriteAdd, favoriteRemove (es/en/ca)
Two improvements to the favorite button accessibility:

1. Static label, state via aria-pressed only.
   The W3C ARIA Authoring Practices Guide recommends that toggle
   button labels stay stable across states. Screen reader users
   had been hearing a different label each time the button changed
   ('Add to favourites' → 'Remove from favourites'), which is more
   confusing than just 'Favourite, button, pressed/not pressed'.

2. Live region announces the result of the click.
   Previously the user clicked and got no audible confirmation. Now
   a polite live region says 'Added to favourites' / 'Removed from
   favourites' after each toggle. The text is cleared and re-set
   with a forced reflow so identical back-to-back messages still
   re-announce.

i18n: replaced favoriteAdd/favoriteRemove with favoriteAdded/
favoriteRemoved (past tense, used as announcement text).
…lose

The favorite button was being pushed to the far right of the full-width
viewport via ml-auto, which on desktop made it feel disconnected from
the rest of the session metadata.

Add max-w-3xl mx-auto to the schedule-list container so the day
headings and session cards stay within a comfortable reading width
(768px), keeping the heart visually close to the time/track info on
the same row.
A toggle button at the top of the list view that filters the
schedule to show only the user's bookmarked sessions.

- aria-pressed toggle button with heart icon
- Live count of favourites in the label
- CSS hides non-favourite <li> elements when the filter is active
  (via the is-favorite class added by syncFavorites on the <li>)
- Empty state: if the filter is on and there are 0 favourites,
  show a friendly message instead of a blank page
- i18n: favoritesOnly, noFavorites (es/en/ca)

Accessibility:
- The button itself is a proper toggle (aria-pressed, not a checkbox)
- The filter state is purely visual + local, no new localStorage key
  (the favourite list itself is already persisted)
…status

The 'Only favourites' filter button wasn't doing anything because two
click handlers were firing on each click:
1. The new event-delegation handler that toggles aria-pressed and
   the CSS class
2. The old direct listener (from the previous commit) that toggled
   aria-pressed again

Net effect: aria-pressed went true → false → true, leaving the
button exactly as it was. No visible change, no class added.

Fix: remove the old direct listener; the event-delegation handler
in the document click listener now owns both the per-session
favourite toggle and the filter toggle. Also refactored to share
a single announce() helper.

While here:
- Replace the buggy 'has-[:checked]:' Tailwind utilities (the button
  has no :checked state, it uses aria-pressed) with the correct
  'aria-pressed:' variants — those have proper specificity and
  the orange-pressed look now actually applies
- Add status announcements for the filter toggle via the existing
  #favorite-status live region: 'Favourites filter on. Showing N
  favourite session(s).' / 'Favourites filter off. Showing all
  sessions.'
- Decompose the i18n message into prefix + singular + plural words
  (filterOnPrefix, filterOnSingular, filterOnPlural, filterOff) so
  the script can build the count-aware message at runtime
Switch from event delegation to a direct click handler bound to the
button via getElementById. Adds try/catch + console.log statements
so we can see in the browser console whether the script is running,
the button is being found, and clicks are reaching the handler.

The button now has id='favorites-filter-btn' (was data-filter-favorites).
The script tries to bind the handler:
- immediately (in case the DOM is ready)
- on DOMContentLoaded (in case the script runs before DOM)
- on astro:page-load (in case the page is a view transition)
- guarded with dataset.bound so it only runs once

If the filter still doesn't work, the browser console will show
which of these steps is failing.
The previous attempt to fix the initial-load 'jump' by ignoring stale
localStorage values was too restrictive. The team wants the user's
choice to actually persist across visits.

New behavior:
- First visit (no saved value): default to visual grid
- After user picks list: save to localStorage, restore list on next visit
- After user picks grid: save to localStorage, restore grid on next visit

Legacy users who had 'list' saved from the old default will see a one-time
jump from grid → list on their first visit after this change. After that
they can click the grid radio to overwrite the saved value and stop the
jump.

Also cleaned up:
- Removed debug console.log statements left over from the filter fix
- Fixed a duplicated function body in the inline script that was
  breaking astro check
…scope

The 'only favourites' filter was toggling the button state and
adding the .filter-favorites-only class to the list view, but the
session cards were not being hidden. Reason: the CSS rule was in
ProgramaPage.astro and Astro's scoped styles injected
data-astro-cid-nm3rvpko into every selector, including .schedule-list
and li. But those elements are rendered by ScheduleList.astro which
has its own data-astro-cid-zmszfzov, so the selector never matched.

Fix: extract the filter CSS into an is:global block so the selector
reaches across the component boundary. Verified end-to-end with
Playwright:
- 0 favourites: 56/56 hidden (everything is filtered out)
- 1 favourite set: 1 visible, 55 hidden
- Toggle off: 56 visible again
Pre-paint script in <head> now sets data-schedule-view on <html>
before the first paint, so the saved view (list/grid) is applied
without a flash of the other view. When the URL has a session hash,
the view is forced to 'list' so the user lands directly on the
targeted session.

CSS now drives both view visibility and the visual highlight on the
view-mode toggle labels from the same data-schedule-view attribute,
so the active state matches the view on first paint. The radio's
hardcoded checked attribute is removed to avoid a visual mismatch
where grid would flash as active before the JS sync ran.

The favorites filter CSS was moved to Layout.astro's global styles
because the target <li> elements live in a child component
(ScheduleList) with a different Astro scope, which made the
previously-scoped rule unable to match.

@francescarpi francescarpi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Funciona perfecto!

@itziarZG itziarZG merged commit 8c2fde7 into main Jul 13, 2026
1 check passed
@itziarZG itziarZG deleted the feat/programa-a11y branch July 13, 2026 06:00
@ctrl-alt-d

ctrl-alt-d commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Funciona perfecto!

A mi no me funciona el filtro de preferidos en la vista accesible. Puede ser?

He probado el desplegado y funciona perfecto!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Schedule Screen Reader Friendly

3 participants