Add accessilbilty check and fixes
Some checks failed
/ build (push) Successful in 15s
/ accessibility (push) Failing after 23s

This commit is contained in:
Stefan Bethke 2026-10-17 10:03:54 +02:00
commit 71098eb6d7
13 changed files with 132 additions and 29 deletions

View file

@ -78,3 +78,26 @@ jobs:
with:
name: website-build
path: public/
# Separate from the build, so that accessibility findings show up on PRs without blocking deploys.
accessibility:
runs-on: docker
container:
image: docker.io/hugomods/hugo:0.165.0
steps:
- name: Install packages
run: |
apk update
# For CI actions and pa11y.
apk add nodejs-current npm
# For website build.
apk add tzdata git
# For pa11y: a browser, fonts for it, and a web server for the built site.
apk add chromium font-dejavu python3
- uses: actions/checkout@v7
with:
submodules: recursive
- name: Check accessibility with pa11y
run: ./scripts/pa11y.sh

View file

@ -50,3 +50,18 @@ Wide screens get one column per day on an hour grid, with tours that start at th
For a different event than the page's section, pass its name: `{{< schedule 39c3 >}}`.
Without a data file, or without any tours in it, the shortcode renders nothing and the build logs a warning. To silence it, for example for an upcoming event, add `warning-schedule-empty` to `ignoreLogs` in `hugo.yaml`.
## Accessibility check
CI runs [pa11y-ci](https://github.com/pa11y/pa11y-ci) with the axe and HTML_CodeSniffer rules for WCAG 2 AA on every push and pull request, in its own job (`accessibility`), so findings show up without blocking the deploy. To run the same check locally (needs Node.js, Python 3 and Chrome or Chromium):
```shell
./scripts/pa11y.sh
```
The script builds the site, serves it on `localhost:8080` and checks every page in the sitemaps, except the past events and the individual news posts, which are kept as they were. The list of skipped sections is `EXCLUDE` in [scripts/pa11y.sh](scripts/pa11y.sh); the rules are in [.pa11yci.json](.pa11yci.json). pa11y only checks the light colour scheme.
Some things to keep in mind when writing pages:
- Give every image an alt text that says what it shows. The image title is the photo credit (`![Alt text](photo.jpg "© Photographer")`); it is shown below the image.
- Headings in a page start at `##`; `#` is the page title.
- Don't put information only into images, like directions on a map; write it out as text as well.

View file

@ -1,6 +1,7 @@
@import './_schedule.css';
.header__intro_headline {
margin: 0;
font: bold var(--fs-xxl) "Arial";
padding-bottom: 10px;
}
@ -32,6 +33,7 @@ th, td {
--color-header-bg: #242424;
--color-text: #faf5f5;
--color-link: #66f2ff;
--color-link-visited: #66f2ff;
--color-footer-bg: #242424;
--color-border: #faf5f5;
--color-button: #141414;
@ -49,7 +51,9 @@ th, td {
.main-menu a:active {
text-decoration: none;
}
a {
/* Links in text keep their underline: the link colour alone is too close to the text colour. */
nav a,
.header a {
text-decoration: none;
}
}
@ -61,3 +65,43 @@ h2 a {
.title-post-date {
margin-left: 0em;
}
/* Visible when focused with the keyboard; the visually-hidden class hides it otherwise. */
.skip-link:focus {
position: absolute;
inset-block-start: 0.5rem;
inset-inline-start: 0.5rem;
z-index: 100;
padding: 0.5rem 1rem;
background: var(--color-button);
color: var(--color-button-text);
}
#main-content:focus {
outline: none;
}
/* Content images with their credit, see layouts/_markup/render-image.html. */
.content-image {
margin: 1rem 0;
img {
display: block;
max-width: 100%;
height: auto;
}
}
span.content-image {
display: inline-block;
vertical-align: top;
max-width: 100%;
margin: 0 0.5rem 0.5rem 0;
}
.content-image__credit {
display: block;
margin-block-start: 0.25rem;
font-family: var(--ff-alt);
font-size: var(--fs-xs);
color: var(--color-text-meta);
}

View file

@ -68,16 +68,16 @@
}
}
/* Lined up with the time on the cards. */
/* Lined up with the time on the cards, but in normal weight to stay in the background.
No transparency: it has to keep text contrast. */
.schedule__hour-label {
display: block;
padding: calc(0.5rem + 0.25rem) 0.75rem 0.5rem calc(0.75rem + 3px);
font-family: var(--ff-alt);
font-size: var(--fs-s);
font-weight: bold;
font-weight: normal;
font-variant-numeric: tabular-nums;
color: var(--color-text-meta);
opacity: 0.45;
}
.schedule__day-title {
@ -202,6 +202,11 @@
}
@container (width < 48rem) {
/* Keeps a focused card from scrolling under the sticky day picker. */
.schedule__title {
scroll-margin-block-start: 6rem;
}
.schedule__picker:not([hidden]) {
position: sticky;
inset-block-start: 0;

View file

@ -15,8 +15,6 @@ cascade:
intro: Wir von Hamburg Hackertours zeigen euch die Stadt!
---
## Willkommen bei Hamburg Hackertours!
Für den [39C3](https://events.ccc.de/category/39c3/) haben wir interessante Orte in und um Hamburg gefunden, an denen ihr für ein paar Stunden in andere Welten eintauchen und spannende Dinge erleben könnt. [Wie das funktioniert, erklären wir euch hier in unseren FAQ]({{< relref "faq/_index" >}})!
HackerTours ist auch auf **[Mastodon](https://chaos.social/@hackertours)**!
@ -31,4 +29,4 @@ Hackertours-Desk: DECT 8687 "TOUR", +49 40 573 0830 8687
Frühere Veranstaltungen: [37C3](37c3) / [38C3](38c3) / [EH22](eh22)
# News
## News

View file

@ -15,8 +15,6 @@ cascade:
intro: We at Hamburg Hackertours show you around the city!
---
## Welcome to Hamburg Hackertours!
For [39C3](https://events.ccc.de/category/39c3/) we have found interesting places in and around Hamburg where you can delve into other worlds and experience fascinating things for a few hours. [We are going to explain how this works here in our FAQ article]({{< relref "faq/_index" >}})!
HackerTours is also on **[Mastodon](https://chaos.social/@hackertours)**!
@ -31,5 +29,5 @@ Hackertours Desk: DECT 8687 "TOUR", +49 40 573 0830 8687
Previous events: [37C3](37c3) / [38C3](38c3) / [EH22](eh22)
# News
## News

View file

@ -44,3 +44,9 @@ languages:
pagination:
pagerSize: 25
title: "Hamburg Hackertours"
markup:
goldmark:
parser:
# Lets the image render hook tell images on their own line (.IsBlock) from inline ones.
wrapStandAloneImageWithinParagraph: false

View file

@ -1,3 +1,6 @@
logo_alt: "Hamburg Hackertours – Startseite"
map_39c3_title: "Karte der 39C3-Touren mit Wegen vom CCH (uMap)"
schedule_day: "Tag {{ .Day }}"
schedule_pick_day: "Tag auswählen"
schedule_soldout: "ausgebucht"
skip_to_content: "Zum Inhalt springen"

View file

@ -1,3 +1,6 @@
logo_alt: "Hamburg Hackertours – home page"
map_39c3_title: "Map of the 39C3 tours with routes from the CCH (uMap)"
schedule_day: "Day {{ .Day }}"
schedule_pick_day: "Choose a day"
schedule_soldout: "fully booked"
skip_to_content: "Skip to content"

View file

@ -1,21 +1,25 @@
{{- /* Language badge for a tour. Params: code ("de", "en" or "deen"), class (optional; without it the badge is styled as an inline superscript). */ -}}
{{- /* Language badge for a tour. Params: code ("de", "en" or "deen"), class (optional; without it the badge is styled as an inline superscript).
The visible label ("DE/EN") is hidden from screen readers, which read a short text instead; the full sentence is a tooltip. */ -}}
{{- $code := .code | lower -}}
{{- $validCodes := slice "de" "en" "deen" -}}
{{- if not (in $validCodes $code) -}}
<span style="color: red; font-weight: bold;">Warning: Invalid or missing language code '{{ $code }}'. Use 'de', 'en', or 'deen'.</span>
{{- else -}}
{{- $label := cond (eq $code "de") "DE" (cond (eq $code "en") "EN" (cond (eq $code "deen") "DE/EN" "")) -}}
{{- $de := eq site.Language.Name "de" -}}
{{- $label := cond (eq $code "de") "DE" (cond (eq $code "en") "EN" "DE/EN") -}}
{{- $title := "" -}}
{{- $spoken := "" -}}
{{- if eq $code "deen" -}}
{{- $title = cond (eq site.Language.Name "de") "Diese Tour wird zweisprachig stattfinden (Deutsch und Englisch)" "This tour will be bilingual (German and English)" -}}
{{- $title = cond $de "Diese Tour wird zweisprachig stattfinden (Deutsch und Englisch)" "This tour will be bilingual (German and English)" -}}
{{- $spoken = cond $de "Sprache: Deutsch und Englisch" "Language: German and English" -}}
{{- else if eq $code "de" -}}
{{- $title = cond (eq site.Language.Name "de") "Diese Tour wird auf Deutsch stattfinden" "This tour will be held in German" -}}
{{- $title = cond $de "Diese Tour wird auf Deutsch stattfinden" "This tour will be held in German" -}}
{{- $spoken = cond $de "Sprache: Deutsch" "Language: German" -}}
{{- else if eq $code "en" -}}
{{- $title = cond (eq site.Language.Name "de") "Diese Tour wird auf Englisch stattfinden" "This tour will be held in English" -}}
{{- end -}}
{{- with .class -}}
<abbr class="{{ . }}" title="{{ $title }}">{{ $label }}</abbr>
{{- else -}}
<abbr style="font-size: .65em; vertical-align: super;" title="{{ $title }}">{{ $label }}</abbr>
{{- $title = cond $de "Diese Tour wird auf Englisch stattfinden" "This tour will be held in English" -}}
{{- $spoken = cond $de "Sprache: Englisch" "Language: English" -}}
{{- end -}}
{{- $style := "font-size: .65em; vertical-align: super;" -}}
{{- with .class }}{{ $style = "" }}{{ end -}}
<span{{ with .class }} class="{{ . }}"{{ end }}{{ with $style }} style="{{ . | safeCSS }}"{{ end }} title="{{ $title }}"><span aria-hidden="true">{{ $label }}</span><span class="visually-hidden">{{ $spoken }}</span></span>
{{- end -}}

View file

@ -1 +1 @@
<iframe style="width: 100%; height: 300px; border: 0;" allowfullscreen allow="geolocation" src="//umap.openstreetmap.fr/en/map/39c3-hackertours_1329184?scaleControl=true&miniMap=false&scrollWheelZoom=true&zoomControl=true&editMode=disabled&moreControl=true&searchControl=false&tilelayersControl=false&embedControl=false&datalayersControl=false&onLoadPanel=none&captionBar=false&captionMenus=true&datalayers=bdd7ff4a-820c-4d08-acd6-a4e1f457b09a%2C82c00520-3aac-454b-96c1-f84d76734b59%2C0b4c18d6-f604-40b0-a322-05b69635ba5c%2Cde42bd8b-d6e8-4bc0-9a2d-2b0168c693c1%2C3bb4795a-1e46-4755-b729-17aeb462498c%2C3a5870ba-4539-4f58-adc9-d0b86b82b4f1%2Cf9d4edf5-c7ac-4e8a-bb39-816312075660%2C3e12b58e-cf69-458d-86ff-6c3d7df79365%2C706815ab-ff61-4840-bc05-cae55242648f%2C6ebae518-b9c0-4900-8f52-085690afa3e7%2C8324f966-450f-4dcf-bec7-e893a3716b42&captionControl=false&locateControl=false&measureControl=false&printControl=null&homeControl=false"></iframe>
<iframe title="{{ i18n "map_39c3_title" }}" style="width: 100%; height: 300px; border: 0;" allowfullscreen allow="geolocation" src="//umap.openstreetmap.fr/en/map/39c3-hackertours_1329184?scaleControl=true&miniMap=false&scrollWheelZoom=true&zoomControl=true&editMode=disabled&moreControl=true&searchControl=false&tilelayersControl=false&embedControl=false&datalayersControl=false&onLoadPanel=none&captionBar=false&captionMenus=true&datalayers=bdd7ff4a-820c-4d08-acd6-a4e1f457b09a%2C82c00520-3aac-454b-96c1-f84d76734b59%2C0b4c18d6-f604-40b0-a322-05b69635ba5c%2Cde42bd8b-d6e8-4bc0-9a2d-2b0168c693c1%2C3bb4795a-1e46-4755-b729-17aeb462498c%2C3a5870ba-4539-4f58-adc9-d0b86b82b4f1%2Cf9d4edf5-c7ac-4e8a-bb39-816312075660%2C3e12b58e-cf69-458d-86ff-6c3d7df79365%2C706815ab-ff61-4840-bc05-cae55242648f%2C6ebae518-b9c0-4900-8f52-085690afa3e7%2C8324f966-450f-4dcf-bec7-e893a3716b42&captionControl=false&locateControl=false&measureControl=false&printControl=null&homeControl=false"></iframe>

View file

@ -17,15 +17,16 @@
</head>
<body class="{{ if .IsPage }}single-page{{ else }}list-page{{ end }}{{ if .IsHome }} front{{ end }}{{ with .Section }} section-page--{{ . | urlize }}{{ end }}{{ with $.Param "bodyclass" }} {{ . }}{{ end }}">
<a class="skip-link visually-hidden" href="#main-content">{{ i18n "skip_to_content" }}</a>
<div class="page layout__page{{ if $.Param "sidebar" }} layout__sidebar-second{{ end }}">
<header class="header layout__header">
{{ if $.Param "logo" | default true -}}
<a href="{{ site.Home.RelPermalink }}" title="Home" rel="home" class="header__logo">
<img src="{{ with $.Param "logopath" }}{{ . | relURL }}{{ else }}{{ "images/logo.png" | relURL }}{{ end }}"{{ with $.Param "logowidth" }} width="{{ . }}"{{ end }}{{ with $.Param "logoheight" }} height="{{ . }}"{{ end }} alt="Home" class="header__logo-image">
<a href="{{ site.Home.RelPermalink }}" rel="home" class="header__logo">
<img src="{{ with $.Param "logopath" }}{{ . | relURL }}{{ else }}{{ "images/logo.png" | relURL }}{{ end }}"{{ with $.Param "logowidth" }} width="{{ . }}"{{ end }}{{ with $.Param "logoheight" }} height="{{ . }}"{{ end }} alt="{{ i18n "logo_alt" }}" class="header__logo-image">
</a>
{{ end -}}
<div class="header__intro">
<div class="header__intro_headline"><a href="{{ site.Home.RelPermalink }}" title="Home" class="header__site-link" rel="home"><span>{{ site.Title }}</span></a></div>
<div class="header__intro_headline"><a href="{{ site.Home.RelPermalink }}" class="header__site-link" rel="home"><span>{{ site.Title }}</span></a></div>
<div class="header__intro_text">{{ .Param "intro" }}</div>
</div>
<div class="region header__region">
@ -37,6 +38,7 @@
{{ if not ($.Param "menuinheader") }}{{ partial "menu.html" . }}{{ end -}}
{{ if $.Param "mobilemenu" }}{{ partial "mobilemenu.html" . }}{{ end -}}
<div id="main-content" tabindex="-1"></div>
{{ block "main" . }}{{ end }}
{{ if $.Param "sidebar" }}{{ partial "sidebar.html" . }}{{ end -}}

View file

@ -17,15 +17,16 @@
</head>
<body class="{{ if .IsPage }}single-page{{ else }}list-page{{ end }}{{ if .IsHome }} front{{ end }}{{ with .Section }} section-page--{{ . | urlize }}{{ end }}{{ with $.Param "bodyclass" }} {{ . }}{{ end }}">
<a class="skip-link visually-hidden" href="#main-content">{{ i18n "skip_to_content" }}</a>
<div class="page layout__page{{ if $.Param "sidebar" }} layout__sidebar-second{{ end }}">
<header class="header layout__header">
{{ if $.Param "logo" | default true -}}
<a href="{{ site.Home.RelPermalink }}" title="Home" rel="home" class="header__logo">
<img src="{{ with $.Param "logopath" }}{{ . | relURL }}{{ else }}{{ "images/logo.png" | relURL }}{{ end }}"{{ with $.Param "logowidth" }} width="{{ . }}"{{ end }}{{ with $.Param "logoheight" }} height="{{ . }}"{{ end }} alt="Home" class="header__logo-image">
<a href="{{ site.Home.RelPermalink }}" rel="home" class="header__logo">
<img src="{{ with $.Param "logopath" }}{{ . | relURL }}{{ else }}{{ "images/logo.png" | relURL }}{{ end }}"{{ with $.Param "logowidth" }} width="{{ . }}"{{ end }}{{ with $.Param "logoheight" }} height="{{ . }}"{{ end }} alt="{{ i18n "logo_alt" }}" class="header__logo-image">
</a>
{{ end -}}
<div class="header__intro">
<div class="header__intro_headline">{{ .Param "headline" }}</div>
<h1 class="header__intro_headline">{{ .Param "headline" }}</h1>
{{ if .Param "intro" }}
<div class="header__intro_text">{{ .Param "intro" }}</div>
{{ end }}
@ -39,6 +40,7 @@
{{ if not ($.Param "menuinheader") }}{{ partial "menu.html" . }}{{ end -}}
{{ if $.Param "mobilemenu" }}{{ partial "mobilemenu.html" . }}{{ end -}}
<div id="main-content" tabindex="-1"></div>
{{ block "main" . }}{{ end }}
{{ if $.Param "sidebar" }}{{ partial "sidebar.html" . }}{{ end -}}