Skip to content

Accessibility, contrast, and the image pipeline - #195

Merged
DZPM merged 66 commits into
editionfrom
pr/4-a11y-images
Oct 7, 2026
Merged

DZPM merged 66 commits into
editionfrom
pr/4-a11y-images

Conversation

@DZPM

@DZPM DZPM commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Its base is edition, so it merges straight into the site. #194 and everything below it are in. Four more pull requests are stacked on this one and cannot move until it does: #196, #199, #200 and #202.

Please squash merge this one. Six commits in its history carry an HTML injection that two of its own changes create between them, and that a later commit fixes. Squashing means no state of edition ever holds it. More on that below.

The largest of the four and the one with the most visible effect.

Accessibility

Links with no accessible name: 3,536 down to 599, and all 599 that remain are inside the frozen snapshots under static/archives/. The live site is at zero.

Also: landmarks and a skip link; one h1 per page; the page name in the <title>, which every page used to share; a unique label on each modal with focus restored on close; alt text on the people cards; a repaired navigation list and dropdown labels; a focus ring that goes from 1.39:1 to 19.59:1; and the carousel no longer advances on its own and no longer steals focus every five seconds.

Contrast. Four of the agenda colours failed WCAG 1.4.3 for text, which is 13 of the 22 cells on PyDay BCN 2025. Every cell now passes 4.5:1. blue was 3.55:1 and is 4.54:1, pink was 3.64:1 and is 4.58:1, and yellow and orange move to dark text at 8.51:1 and 5.87:1.

Language. A talk title in Spanish or Catalan is now wrapped in a lang span so a screen reader pronounces it correctly, driven by an explicit title_lang on 22 agenda entries.

Images

Person photos, sponsor logos, hero banners, carousel slides, the navbar logo and the footer icon all go through the Hugo image pipeline, which needs them in assets/ rather than static/. WebP with a srcset, width and height to stop layout shift, and loading and decoding set.

The built site goes from 78.8 MB to 25.9 MB. The organizers page goes from 9.12 MiB of images to 0.39, and PyDay BCN 2025 from 12.27 to 0.50. One sponsor logo was 23,944 x 5,753 px, which needs about 551 MB of RAM to decode and which mobile browsers refuse outright.

Thirteen hero images were hotlinked to Unsplash, seven of them to source.unsplash.com, which Unsplash retired and which answers 503, so those pages had no hero image at all. All thirteen now use the association's own photos.

Adds bin/measure-images, so the image payload is measured rather than guessed at.

What review changed

Five review passes ran over this branch before it was opened. They did not pass it. Fixed here:

  • A lang regression this branch itself introduced. The first version derived a title's language from the event's language field, which describes the language the talk is given in, not the language of its title, so 13 English titles were tagged lang="es" or lang="ca" and read aloud with a Spanish voice. They had been read correctly before. The mapping is now explicit per title.
  • An accessibility failure in an accessibility branch. A sponsor logo is a 45 frame GIF that loops forever with no way to pause it, which is a WCAG 2.2.2 Level A failure. It now goes through the pipeline like every other logo and renders as a still, which also takes it from 32,474 bytes to 6,200.
  • Duplicate element ids. Eight pages had 32 duplicated ids, because a grid renders a person once per section they appear in and keyed everything on the person id. Now zero across the whole build.
  • favicon.png was a 79,028 byte, 546 x 543 image linked on every page as both the 32 px icon and the 180 px Apple touch icon. Now 1,547 and 12,907 bytes.
  • The orphan check counted prose as a reference. \bricardo\b matches inside ricardo-ander-egg, so two people who are listed nowhere were never reported. The check now compares against the ids it collects from the listings. That is why the content check reports 10 warnings here and 8 on the branch below: the two extra are real.
  • logo_image accepted a path, so ../people/Someone.jpg rendered a person's photo as a sponsor logo and the check passed. A path is now rejected in logo_image and photo.
  • Two pages list sponsors by id and nothing checked them, so a typo there dropped a sponsor silently, which is exactly the defect that left a sponsor invisible for two years. The check now discovers the keys instead of hardcoding directories.
  • The external logo: field let a sponsor pull request load a third party image on every page listing it. Zero sponsors used it; it is gone.
  • A latent contrast hole: the agenda fallback colour was orange with white text, 1.97:1, waiting for the first entry that forgets its colour. It is now 5.19:1, and a missing colour is an error.
  • bin/measure-images reported figures that understated the real payload, because a background declared in SCSS is invisible to it and the frozen archives/ copy was mixed into the site totals. The blind spot is now stated in its own help text.

Why squash merge

The injection in themes/pybcn_theme/layouts/partials/sponsor_summary.html is created by combining the sponsor name fallback with the image pipeline move. Neither has it alone. It was found, reproduced with 25 hostile sponsor fixtures in both the plain and the --minify build, and fixed; the fix is verified to escape every value in every path, and the pre-fix template reproduces a live phishing anchor in the sponsor grid.

But six commits in this branch's history, two of them merges, carry the vulnerable template. A regular merge writes them into edition for good, and a git bisect lands on them. A squash merge collapses this branch to one commit, so no state of edition ever holds it, and the history stays readable here in the pull request.

A linear rebase was tried and rejected: it re-fights the nine conflicts that were resolved by hand, and resolving them during a replay makes individual commits claim changes that were not theirs, which is worse for anyone reading them.

Verified

Build with zero warnings, 317 HTML files. bin/check-content: 0 errors, 10 warnings. bin/check-html-safety: passing. Zero duplicate element ids across 261 live pages. Zero unsplash.com and zero hello.myfonts.net requests in the build. Every contrast ratio recomputed from the resolved cascade rather than read from a comment. The cold build is deterministic: two runs from an empty cache produce 882 identical files.

Known and not fixed

Three agenda entries on the PyDay 2024 page have an empty title, so they render a heading that holds only an icon. That is pre-existing content and not touched here.

Six hundred links with no accessible name remain inside the frozen snapshots under static/archives/. Whether to remediate a frozen copy of the old site is a question for the organizers, not a defect of this change.

I will do the merge myself, in order.

DZPM added 30 commits September 30, 2026 00:34
The email and social links on the contact page, and the social links in
the footer, contain only a Font Awesome icon that is hidden from
assistive technology. The links had no accessible name at all, so a
screen reader announced only the target URL. The association email
address was not available in any text form.

- Add an aria-label to every icon-only link. The email links state the
  address, for example "Email PyBCN at pybcn@googlegroups.com".
- Add a name field to each social item in config.toml, to build the
  labels ("PyBCN on GitHub").
- Mark the decorative icons with aria-hidden="true".
- Give the navigation logo an alt text, because the logo is the only
  content of the home link on every page.
All 46 images on the organizers page had no alt attribute, and the
social links of each person had no accessible name.

- Give the card photo the person name as alt text.
- Give the placeholder avatar and the modal photo an empty alt, because
  they add no information: the name is next to them in text.
- Add an aria-label to each social link, for example "Alex Molas on
  GitHub", and hide the icons from assistive technology.
The script reads a built Hugo directory and reports the image bytes each
page references, the site-wide image totals, the count of img elements
that carry width, height, loading and srcset, and the largest image files
with their pixel dimensions. It uses only the Python standard library and
decodes PNG, JPEG, GIF and WebP headers directly, so it needs no extra
software.

Use it before and after an image change to get reproducible figures.
The agenda read three front matter fields through safeHTML:
python_level and topic_level in agenda.html and event_detail.html, and
legend in agenda.html. safeHTML bypasses escaping even when goldmark
unsafe is off, so any merged pull request could put script into every
agenda page. The site takes talk data from outside contributors, so this
was a persistent cross-site scripting path.

Front matter now carries tokens and the templates own the markup:

- python_level and topic_level take beginner, intermediate or advanced.
  A new level_badge.html partial maps the token to a label and a class.
  An unknown token prints as escaped text and logs a build warning.
- legend is a list of items: "type: <token>", "level: <token>" or
  "separator: true". A new agenda_legend.html partial renders it.
- event_types.html holds the one table of type tokens, icons and labels.
  event_type_icon.html renders an icon from it. This replaces the two
  copies of the if-else icon chain in agenda.html and event_detail.html,
  which had drifted: the modal was missing the "photo" type.

The change also fixes two accessibility defects:

- The level was given by the colour of a dot alone, which fails WCAG
  1.4.1 Use of Color. The badge now shows the text label next to the dot.
- The dots did not meet the 3:1 floor of WCAG 1.4.11 Non-text Contrast.
  A red dot on the red cell of the agenda gave 1.38:1. The badge now
  draws its own white background, so contrast no longer depends on the
  cell colour: beginner 5.1:1, intermediate 5.2:1, advanced 5.5:1. The
  badge border holds 3:1 or better against every cell colour.
- The legend was set to "opacity: 0.7" with full opacity on hover, which
  cut the contrast for every reader who does not hover. Removed.
- The gold star of a sponsored event gave 1.44:1 on the white page
  background. Darkened to #8C6900 inside the legend.

Migrated every affected file in content/events: 156 HTML level values in
6 files, 19 plain text level values in pyday_bcn_2020.md, and 6 legend
strings. The legend of pyday_bcn_2024.md had a copy and paste fault, a
second escaped icon where the word "Beginner" belonged. The structured
legend removes it.

Build verified: 310 HTML pages, no warnings from the new partials.
All 314 modals on the site used id="exampleModalLabel" for their title
and pointed aria-labelledby at that id. Only the first modal on a page
was a valid target, so every dialog announced the title of the first
one. The triggers were empty links, and Bootstrap 4.0.0 does not move
focus back to the trigger when a dialog closes.

- Build the title id from the person id or the agenda index, and point
  aria-labelledby at it.
- Replace the <a href=""> triggers with <button type="button">, because
  they open a dialog and do not navigate. Add the .modal_trigger class,
  which keeps the previous appearance.
- Make the person name a trigger too. Before, only the photo opened the
  modal.
- Add js/modal-focus.js, which stores the trigger on show.bs.modal and
  focuses it again on hidden.bs.modal.
The site merges pull requests from outside contributors. A speaker edits
their own page under content/people/, which stays unowned so that it
needs no organizer. The paths in this file are different: a change there
reaches every page, or reaches money, or reaches the deploy.

Owned paths: .github/, bin/, config.toml, layouts/, static/, themes/,
content/pybcn_association/ and CNAME.

layouts/ is covered because
layouts/shortcodes/membership/membership-process.html holds the PayPal
hosted_button_id of the association, 6XH6B9ZGJ4PQA. One changed
character in that value sends the membership fees to another PayPal
account, and the rendered page still looks correct.

CNAME is not in the original list. It is added because it sets the
custom domain of the GitHub Pages site, so a change to it points
pybcn.org somewhere else.

Owner is @pybcn/core. Verified read only through the GitHub API: the
team exists, has 17 members, and has push and admin permission on
pybcn/pybcn.github.io, so it is a valid code owner. The team privacy is
"secret"; if review requests do not reach it, make the team visible.

CODEOWNERS only requests review. It has no effect until branch
protection on the default branch requires review from code owners.

Build verified: 310 HTML pages.
Every page had the same title element, the site title, so a screen
reader user, a browser tab and a search result could not tell the pages
apart.

- Build the title from the page title, followed by the site title.
- Fall back to the name parameter, because person and sponsor pages hold
  their name there and have no title.
- Use the same fallback for the hero heading, which was empty on person
  pages.
The page had no main landmark, no header landmark, and no way to jump
over the navigation. A screen reader user had to read the whole menu on
every page.

- Change section#main_content to main#main_content, and keep the id, so
  the styles do not change.
- Wrap the navigation in a header element.
- Add a skip link as the first element in the body. The link stays out
  of sight until it takes focus.
- Give the main element tabindex="-1", so browsers move focus there when
  the skip link is used.
The h1 element was used as a styled blue box, not as the page heading.
The home page had nine h1 elements, and the event pages had one for each
section. A screen reader user could not tell the page heading from a
section heading.

- Change the styled boxes to h2 with the .h1 class. The layout already
  used this pattern in options.html.
- Add the .h1 class next to the h1 element in every style rule, so the
  appearance does not change.
- Add an h1 for assistive technology on the pages where the carousel
  takes the place of the hero, because those pages had no heading.

Every generated page now has exactly one h1. Only the alias redirect
pages, which hold no content, have none.
Hugo copies static/ verbatim and cannot process it, so every person photo
was served at full size. The largest were 5 MB and 24 megapixels for a
slot 350 px wide.

Move static/images/people/ to the theme assets directory, next to the
anon_member.png fallback that people_summary.html already loads with
resources.Get. Add an image_resize partial that resizes an image resource
to a target width and converts it to WebP, and never upscales.

people_summary.html now emits, for each card, a 360 px WebP with a 720 px
2x variant in srcset, plus width, height, loading=lazy and decoding=async.
The modal image gets a 400 px and an 800 px variant and the img-fluid class,
so the width and height attributes cannot distort it when the max-width
rule clamps the image. A person with an external photo_url keeps a plain
img, because Hugo cannot process a remote file. A person with no photo
still falls back to anon_member.png.

Ignore resources/_gen/images/, which is the local processing cache. The
GitHub Pages workflow builds from scratch and does not need it.

Measured on the built output:
- /pybcn_association/organizers/: 8.81 MiB to 0.67 MiB at 1x
- whole output directory: 74.83 MiB to 44.76 MiB
- 310 HTML pages before and after
- all 122 person photos still render
The carousel advanced every five seconds. It paused on mouse hover only,
so a keyboard user, a screen reader user or a person who reads slowly
could not stop it. This fails WCAG 2.2.2 Pause, Stop, Hide.

Autoplay is now off. A pause button was the other option, but it adds a
control that the user must find and use before the content stops, and it
does not help a user who never sees the button. With autoplay off the
motion only starts on a user action, so the previous and next controls
are enough and no reduced-motion rule is needed.

- Remove data-ride="carousel" and set data-interval="false".
The site merges markdown from outside contributors and goldmark renders
raw HTML, so a content change can carry script, a redirect or a fake
form. Nothing checked for that.

bin/check-html-safety scans content/ and exits 1 when it finds <script,
<iframe, an on...= event handler, a javascript: URL, <object, <embed,
<form, <meta, <link or <base.

It reads each file twice over:

- The YAML front matter as raw text, which gives true line numbers, and
  again as a parsed value tree, which covers nested lists and maps and
  catches a string that only becomes dangerous after YAML decodes it,
  such as "<scr\x69pt>". Neither pass alone is enough. The two are
  paired per pattern so that one problem is reported once.
- The markdown body.

The on...= pattern carries a word boundary. Without it the pattern also
matches the middle of ordinary words in prose.

Three iframes in the tree are legitimate, so ALLOWLIST holds one entry
each with the reason:

- content/events/pyday_bcn/pyday_bcn_2019.md, the PyBCN public Google
  Calendar that shows the agenda of that past edition
- content/pybcn_association/propose-a-talk.md, the talk proposal Google
  Form
- content/pyladies_bcn/call-for-proposals.md, the PyLadies BCN call for
  proposals Google Form

There is no <embed in content/. The only match for the word "embed" is
the path of the Google Calendar URL in the first file above.

An allowlist entry names the file, the pattern and a substring, and it is
matched against the single HTML tag that the pattern hit, not against the
whole file. A first draft matched the whole file, which let a second,
hostile iframe ride along inside a file that already had an allowed one.
Tested: an injected iframe next to the allowed calendar iframe fails the
check.

The script also reports an allowlist entry that matches nothing, so a
stale exception gets removed.

The existing workflow only runs after a merge to edition. The new
content-check workflow runs the script on every pull request, which is
where the problem has to be caught. This workflow was not asked for.
Drop the file if a separate check job is not wanted.

Verified: passes on the current tree, and fails with exit 1 on a fixture
that carries all ten patterns in front matter, in nested front matter and
in the body. Build unchanged at 310 HTML pages.
The PyDay 2025 agenda holds Spanish and Catalan talk titles inside an
English page. Without a lang attribute a screen reader reads them with an
English voice, which makes them hard to understand.

- Map the language field of the event to a lang attribute on the title,
  in the agenda cell and in the detail modal. Spanish becomes es and
  Catalan becomes ca.
- Add an optional title_lang field on the event, which overrides the map.
  The language field gives the language of the talk, and a few talks in
  Spanish have an English title.
The Bootstrap 4.0.0 focus ring is a blue shadow at 25% alpha. It computes
to about 1.39:1 against white, so a keyboard user cannot see where the
focus is. This fails WCAG 1.4.11 Non-text Contrast, which asks for 3:1.

- Add a dark outline with a white line inside it. The pair keeps 3:1 on a
  light and on a dark background.
- Show the ring for keyboard users only, in browsers that support
  :focus-visible. Older browsers show it for every focus.
- Match tabindex="0" only, so the ring does not draw around the main
  element or a modal, which take focus from a script.
The photos of the organizers were grey, and the colour came back on mouse
hover. A touch screen has no hover, so on a phone or a tablet the photos
stayed grey for good.

- Apply the grayscale filter inside a (hover: hover) media query.
- Show the colour photo on keyboard focus as well, with :focus-within,
  because each card now holds two focusable triggers.
Two faults found while working on the items above.

- The menu items were li elements inside a div, and the div was closed by
  a stray </ul>. The markup was invalid and the menu had no list
  semantics, so a screen reader did not announce the number of items. The
  div is now the ul that the </ul> already closed.
- Every dropdown pointed aria-labelledby at "navbarDropdownMenuLink", an
  id that no element carried. The toggle link now carries an id built
  from the menu name, and the menu points at it.
A cold build has to resize every person photo, and the people-grid partial
then needs more than the 30s that Hugo allows a single partial. The build
failed with a partial timeout error and produced 309 of 310 pages.

Cut the variants per person from four to two. The grid slot is 350 px wide
at most and the modal caps the image at 400 px, so one 400 px WebP serves
both, with an 800 px variant for a 2x screen.

Raise the Hugo timeout to 300s as well, because the GitHub Pages workflow
always builds from scratch.

A cold build now takes about 31s and peaks at 1.3 GB of memory.
Move static/images/sponsors/ to the theme assets directory and process each
logo in sponsor_summary.html. The grid caps a logo at 150 px high, so a
560 px WebP covers the widest slot, with a 1120 px variant for a 2x screen.
Each logo now carries width, height, loading=lazy and decoding=async.

Three cases stay unprocessed: an external logo URL, because Hugo cannot
process a remote file; an SVG, which needs no raster step; and a GIF,
because processing would flatten innovamat.gif from an animated logo to a
single frame.

This also fixes orpheus.png. The source is 23944x5753, which is 137.7
megapixels and about 551 MB of RGBA to decode, so a phone browser refuses
it or evicts it. The source file stays as it is; the page now gets a
560x135 WebP of 2.5 KB, or 1120x269 of 5.2 KB on a 2x screen.

Measured on the built output:
- /sponsors/: 3.28 MiB to 0.62 MiB at 1x
- /events/pyday_bcn/pyday_bcn_2025/: 11.96 MiB to 1.31 MiB at 1x
- whole output directory: 74.83 MiB to 36.52 MiB
- 310 HTML pages, all 66 sponsor logos still render
Move static/images/photos/ to the theme assets directory.

Add an image_hero partial. The hero is a CSS background with
background-size: cover and background-position: center, so the partial takes
a centred 1920x540 crop, which is what the browser shows today, and converts
it to WebP. The crop box shrinks to the source when the source is smaller,
so no image is upscaled. hero_header.html uses it for a local
heroBackground, and leaves a remote one alone.

carousel.html now emits a 600 px and a 1200 px WebP per slide with srcset,
sizes, width, height and decoding=async. Only the active slide loads
eagerly; the rest are lazy. The img-fluid class goes with the width and
height attributes, so the browser keeps the aspect ratio when the carousel
scales the slide.

Photographs that were stored as PNG now ship as WebP:
- photos/django_pyladies_2022/header.jpg 5.88 MiB to 100 KiB at 1920x540
- photos/canodrom.png 0.96 MiB to 74 KiB at 1920x540
- photos/canodrom_header.png 0.90 MiB to 50 KiB at 1200x338

Measured on the built output:
- /: 1.06 MiB to 0.11 MiB at 1x
- /pybcn_association/organizers/: 0.49 MiB to 0.32 MiB at 1x
- /events/pyday_bcn/pyday_bcn_2025/: 1.31 MiB to 0.42 MiB at 1x
- whole output directory: 36.52 MiB to 23.43 MiB
- 310 HTML pages, all 13 local hero banners still render
/events/monthly_events/ asked for the bare photo URL, so Unsplash served
the 3,632,478 byte original for a banner 450 px high. Add the same crop and
quality parameters the other pages use, aligned at 1600x450, which is the
hero size this site already uses elsewhere. The other Unsplash heroes ask
for a size that does not match the banner shape, so they also get the new
parameters.

Payload per hero, measured with curl and an Accept header that allows WebP:
- monthly_events photo-1603123656383: 3,632,478 to 70,496 bytes
- contact and pyladies contact-us photo-1512626120412: 536,886 to 267,714
- pyday 2018, 2020 and 2021 photo-1504384764586: 193,802 to 136,664

Change the local hero crop to 1600x450 as well, so every hero on the site
asks for the same shape.

310 HTML pages. Output directory 23.18 MiB.

The six heroes that use source.unsplash.com are untouched. They already
carry a size in the path, and the service answers 503: Unsplash retired it,
so those banners do not load at all. Fixing them needs a new image choice,
which is a content decision.
themes/pybcn_theme/assets/images/logo.svg is 3,630,739 bytes and the only
match for its own name is the sodipodi:docname attribute inside the file.
The navbar loads images/logo.png and the footer loads images/favicon.png.

themes/pybcn_theme/assets/css/style.css is 93,053 bytes and nothing
references it. head.html builds the stylesheet from scss/style.scss with
toCSS, and no SCSS file imports the CSS file. The other two files in
assets/css/, bootstrap.min.css and cookieconsent.min.css, are both loaded
and stay.

310 HTML pages.
Work in progress, committed so it is not lost. The site builds and the
safety check passes, but the goal of this change is not reached yet.

What is done:

- 13 shortcodes under layouts/shortcodes/ plus a render-link hook, covering the
  patterns the content actually used: grids, columns, alerts, icons, logo rows,
  prose blocks, schedules, the membership links, and Google embeds.
- 24 content files converted from raw HTML to those shortcodes, including both
  Google Form embeds, which are now a google-embed shortcode.
- shortcodes.scss added and imported, so the converted markup keeps its styling.

What is NOT done:

- config.toml still sets unsafe=true. Two files still carry raw HTML that needs
  a page redesign rather than a shortcode: content/events/pyday_bcn/pyday_bcn_2019.md
  and pyday_bcn_2018.md, which are pre-Hugo programmes pasted in as tables and
  sponsor logo blocks. Converting them is a content decision, not a mechanical
  one, so unsafe stays true until somebody makes it.
- bin/check-html-safety reports two stale allowlist entries, for the two Google
  Forms that are now shortcodes. Harmless, and a one-line cleanup each.

So the XSS surface is narrower but not closed. The three safeHTML sinks are gone
(previous commit) and the dangerous-HTML check now runs on pull requests, which
together stop a malicious person page from executing. Setting unsafe=false is the
remaining step.

Verified: build exits 0 with 310 HTML pages, and bin/check-html-safety passes.
Both images shipped at full size on all 310 pages. images/logo.png is
2195x652 and 159,660 bytes for a slot 230 px wide. images/favicon.png is
546x543 and 79,028 bytes for a slot 30 px wide.

Resize each to twice its display width and convert to WebP: the logo to
460x137 and 11,018 bytes, the footer icon to 60x60 and 1,978 bytes. That is
238,688 bytes down to 12,996 on the first page a visitor loads. Both now
carry width and height. The footer icon also gets loading=lazy; the navbar
logo stays eager because it is above the fold.

Add height: auto to the #logo and img.logo_icon rules. The img tags now
carry a height attribute, and without height: auto the browser would keep
that height while max-width or width clamps the width, which distorts the
image.

Also teach bin/measure-images to count an absolute URL that points at the
site itself. The two logos used .Permalink, so the earlier figures left them
out of the page payload.

Measured on the built output, before and after the whole branch:
- /: 1.36 MiB to 0.20 MiB at 1x
- /pybcn_association/organizers/: 9.12 MiB to 0.39 MiB at 1x
- /events/pyday_bcn/pyday_bcn_2025/: 12.27 MiB to 0.49 MiB at 1x
- /sponsors/: 3.59 MiB to 0.54 MiB at 1x
- output directory: 74.83 MiB to 23.00 MiB
- img elements with width and height: 2 to 1082 of 1349
- img elements with loading=lazy: 0 to 843
- 310 HTML pages before and after
A button is an inline-block box. Next to the type icon a long title moved
to the next line, which made the agenda cells taller.

- Open the button before the icon, so the button holds the whole heading.
- Give a trigger inside a heading width: 100%, so the title text wraps
  inside the button exactly as it wrapped inside the link.
Conflict in themes/pybcn_theme/layouts/partials/sponsor_summary.html,
resolved by writing the markup out in both branches through a template.

The obvious resolution is an injection route, and it was the one taken here
first. Combining the image pipeline from one branch with the no-logo fallback
from the other, the img tag was assembled with printf and emitted through
safeHTML, to avoid writing the markup twice. That bypasses Go's autoescaping
for the two values interpolated into it, the sponsor name and the logo URL,
both of which come from front matter.

Confirmed by planting a sponsor whose name is

  Evil"><a href="&#106;avascript:alert(document.domain)">click me</a><span x="

The payload reached the built event page verbatim, and bin/check-html-safety
exited 0, because it only scans content for dangerous patterns and this one is
a template problem. content/sponsors/ was not in CODEOWNERS either, so a pull
request adding a sponsor would not have required an organizer's review.

Neither feature branch had this. Edition used an autoescaped alt="{{ .name }}".
The defect belonged to the merge alone, which is the class of defect an
integration branch exists to surface.

Hugo escapes the attributes itself when the template writes them. Duplicating
six lines of markup is the right trade against constructing HTML by hand.

Verified: both paths, with and without a logo, escape the payload, and the 68
real sponsor logos render unchanged.
The timeout was added as a root-level key but placed after the [outputs] table,
so TOML read it as outputs.timeout and Hugo warned "Unknown kind timeout in
outputs configuration" and ignored it. The setting it was meant to apply, a
longer allowance for the partial that resizes every photo on a cold build, was
therefore not in effect.

This is the same TOML placement trap that voided enableGitInfo and
enableRobotsTXT before they were moved up, and which AGENTS.md now documents.
Verify a config key by building and checking the result, not by reading the file.

Verified: the warning is gone.
# Conflicts:
#	content/pybcn_association/collaborate.md
The check arrived with its own workflow, content-check.yml, which duplicated
the checkout and the Python setup of the pr-checks content job and ran on both
pull_request and push.

It is now a step of that job instead. One workflow, one checkout, and the two
content checks next to each other.

content-check.yml also used unpinned actions on an older runner image, so
retiring it removes the last floating action references in the repository.
The check read as a blocklist of exact strings, so several shapes of the
same markup passed:

- <script> matched only the lowercase literal with a following space.
  <SCRIPT/src=...> and a tag broken across a newline did not match.
- <style> was not checked at all. An inline stylesheet can cover the page.
- The event handler pattern missed handlers written with no space before
  the equals sign, and matched inside ordinary words in prose.
- javascript: was matched before entity decoding, so java&Tab;script:
  passed. A hand-written entity table is the wrong tool here: the entity is
  &Tab; with a capital T, and the first attempt at this used a lowercase key
  and let the payload through. It now decodes with html.unescape.
- The allowlist was matched against the whole tag, so an allowed substring
  anywhere in it cleared the tag. It now matches only the value of src,
  href, data, or action.

The check-content half of the original change waits for the image move, so
it is in the accessibility and images pull request.
# Conflicts:
#	themes/pybcn_theme/layouts/_default/home.html
#	themes/pybcn_theme/layouts/partials/agenda.html
#	themes/pybcn_theme/layouts/partials/event_detail.html
#	themes/pybcn_theme/layouts/partials/meetup-events.html
DZPM added 13 commits October 4, 2026 15:57
The style grammar accepts width, height and margin so that the logo rows on
the PyDay pages keep working. The length was unbounded, so an accepted
property could still deface a page: style="width:99999px" passes the grammar
and pushes everything else off the viewport.

Four digits is the cap the logo shortcode already puts on its own width and
height. The largest value any content file uses is 150px, so there is room to
spare: all 18 distinct style values in content still pass.
The sponsor logo partial left a GIF unprocessed so that an animated logo
kept its animation. innovamat.gif is 800x143 with 45 frames, a loop count
of 0 and a 2.28 s cycle, so it moved forever on the PyDay 2024 page with
no pause, stop or hide control. WCAG 2.2.2 (Level A) requires that control
for any motion that lasts more than five seconds next to other content.

Now a GIF goes through the image pipeline like every other raster logo.
Hugo keeps the first frame when it converts to WebP. Frame 0 of
innovamat.gif is the only frame that covers the full canvas and holds the
mark and the wordmark; the other 44 frames are small patches that animate
a tagline. The logo renders as a 560x100 WebP of 6,200 bytes with an
800 px 2x variant, down from a 32,474 byte GIF.
An event page renders the people grid once per people section, and a
person can sit in two of them. The modal id and the modal title id were
built from the person id alone, so the page held two dialogs with the same
id and the same aria-labelledby target. Both grids also emitted the same
row ids (level_0, level_1) and both people grids the same people_grid id.
The build had 32 duplicated ids on 8 pages: 2 on PyDay 2025 (jordi-bosch),
2 on PyData 2023 (amalia_vradi) and 10 on Django PyLadies 2022, plus the
row and grid ids on every event page with a sponsors grid.

The people grid now takes a key, the section id on an event page and
"people" on the organizers pages, and puts it into the grid id and the row
ids. It passes the key, the level index and the position to the person
card, which builds the modal id from them, so the same person listed twice
gets two distinct dialogs. The sponsors grid prefixes its row ids with
"sponsors". The people styles hook on the people_grid class instead of
the id, because there is no longer a single id to target. No stylesheet
or script referenced the row ids.

After the change the build has 0 duplicated ids across 261 pages, and all
412 person triggers point at exactly one dialog whose title holds the
person's name.
check_events walked content/events/ only, so content/sponsors/_index.md and
content/pyladies_bcn/sponsors.md were never checked: a typo in a sponsor id
there dropped the sponsor from the page with no error, which is how a
sponsor went missing for two years once already. The organizers pages were
checked through a hardcoded list of file names, and the check skipped a
name that did not exist, so a rename switched it off in silence.

The two functions are replaced by check_listings, which walks every file
under content/ and checks any page that declares one of the keys the
templates read: people_sections, people_levels or sponsor_levels. No
directory and no page name is hardcoded. The top-level people key is no
longer looked at: no template reads it and no page declares it.

Tested by adding an unknown sponsor id to each of the two sponsor pages
(both reported) and by copying organizers.md under another name with an
unknown person id (reported). The real tree still ends at 0 errors and 8
warnings.
check_orphans searched the text of every page for the person id with a
word-boundary regex. A regex boundary sits between a letter and a hyphen,
so 'ricardo' matched inside 'ricardo-ander-egg' and 'jordi' inside
'jordi-bosch' and 'jordi-mur'. Neither page is listed anywhere, and
neither was reported. Prose that happened to contain an id counted too.

The check now compares against the set of ids that check_listings collects
from the people_sections and people_levels keys, so only a listing that the
templates render counts as a reference.

Before: 8 orphans. After: 10, the same 8 plus jordi and ricardo. The two
new ones stay warnings, as the other orphans do.
The templates build the asset path as images/sponsors/<logo_image> and
images/people/<photo>, and resources.Get resolves a '..' segment. So
'logo_image: ../people/JavierSabariego.jpg' rendered a person's photo as a
sponsor logo through the image pipeline, and check_logos passed because
pathlib resolves '..' the same way. A content-only pull request could show
any image already in the repository on any page that lists the sponsor.

check-content now reports a photo or logo_image value that holds a slash,
a backslash or a '..' segment as an error that names the file and the
value, before it looks for the file. Tested with
'../people/JavierSabariego.jpg' on a sponsor, '../sponsors/lambdaloopers.png'
on a person and 'sub/lambdaloopers.png' on a sponsor: all three reported.
The 133 photo values and 68 logo_image values in the tree still pass.
The sponsor logo partial read a logo field and put its value into the img
src with no check on the scheme or the host. A sponsor pull request could
set logo: http://example.com/logo.png and load a third-party image on
every page that lists the sponsor, which leaks the visitor IP to that host
and is mixed content on the https site. check_logos skipped the field on
purpose, so nothing reported it.

The field is dropped rather than restricted to https: no sponsor uses it
(0 of 68), every logo now goes through the image pipeline, and a remote
file cannot, so it would also miss the size attributes and the WebP
variants every other logo gets. The partial no longer reads the field,
check-content reports any sponsor that declares it, with the directory to
copy the file into, and the README no longer documents it.

Tested by adding logo: http://example.com/logo.png to a sponsor
(reported). In the build all 172 logo img elements load from
/images/sponsors/.
The .event rule in agenda.scss kept background-color: orange as the
fallback for an entry with no colour class, with white text on top. That
is 1.97:1 and fails WCAG 1.4.3, which asks for 4.5:1. Every current entry
carries a colour class, so nothing hit the fallback, but the first future
entry that omits or misspells its color field would get unreadable text.

Two fixes. The fallback is now #A15C00, which is 5.19:1 against white
(computed with the WCAG relative luminance formula) and is already the
intermediate badge colour in the same file. And bin/check-content reports
an agenda entry with no color, or with a value that is not one of the
eight classes agenda.scss defines, so a missing colour fails the check
instead of landing on the fallback.

front_matter now keeps its result per path, because three checks walk the
same files and a YAML error should print once.

Tested by removing the color of one entry and by misspelling it (both
reported). The 157 current entries pass.
head.html linked static/favicon.png, a 546x543 PNG of 79,028 bytes, as the
PNG icon and again as the apple-touch-icon on every page. The first slot
shows 32 px and the second 180 px, and the footer already renders the same
source through the image pipeline at 60x60 in 1,978 bytes.

Both links now come from the pipeline, with a sizes attribute so the
browser picks the right one: a 32x32 PNG of 1,547 bytes and a 180x180 PNG
of 12,907 bytes, cropped by one pixel to a square. favicon.ico is
unchanged. static/favicon.png stays in place for a direct request, but no
page links it any more.
bin/measure-images walked the whole build for its site totals and its
attribute ratios, so the frozen copies under archives/ (56 pages, 71 image
files, 4.47 MiB) that no template controls were mixed into figures meant
to track the templates. The ten largest files it listed were all archive
files, which hid the largest images the site itself serves.

The site totals, the attribute counts and the largest-files list now
leave archives/ out, and a block of its own reports the frozen copies.
Before: 479 image files, 14.01 MiB, 317 pages, 1366 img elements. After:
408 image files, 9.54 MiB, 261 pages, 1148 img elements, plus the
archives block.

The docstring, which is also the help text, now states the blind spot
that stays: the per-page figures read only the url() values in an inline
style or data-bg attribute, so a background a stylesheet sets is not
counted. home.scss puts a 289,919 byte archive JPEG on the home page
banner and monthly_events.scss adds two PNGs, so those two pages weigh
more than the tool says. Parsing SCSS or CSS is out of scope.
# Conflicts:
#	.github/CODEOWNERS
#	README.md
#	content/events/pyday_bcn/pyday_bcn_2022.md
#	content/events/pyday_bcn/pyday_bcn_2023.md
#	content/events/pyday_bcn/pyday_bcn_2024.md
#	content/events/pyday_bcn/pyday_bcn_2025.md
#	themes/pybcn_theme/layouts/partials/agenda.html
#	themes/pybcn_theme/layouts/partials/event_detail.html
@DZPM
DZPM requested a review from a team as a code owner October 4, 2026 21:07
@DZPM DZPM self-assigned this Oct 4, 2026
@DZPM
DZPM requested review from ber2, mesejo and mrswats October 4, 2026 21:11
DZPM added 2 commits October 5, 2026 13:05
The grid gave every photo a square frame and let the picture overflow it,
with padding-bottom: 100% on the frame and the photo positioned absolutely
inside. That worked only while the photo was the one child of the frame:
the frame is not the containing block of an absolutely positioned element
unless nothing between them is positioned, so any element added in between
silently turned the crop off.

The crop now happens in the image pipeline. A new image_square partial
returns a square version of a photo, and the frame is square because its
content is.

The partial resizes instead of filling when the source is already square.
Hugo's Smart anchor runs smartcrop, which picks the best-scoring window of
the target ratio, and that window can be smaller than the whole picture, so
a square source came out cropped and zoomed. 76 of the 132 photos are
square and were being cut for no reason.

A photo that is not square has to lose something, and smartcrop does not
always keep the face. A photo_anchor key in the person's front matter picks
the edge to keep, and 28 photos now carry one.

check-content reports the photos it cannot serve well: the ones that are
not square, which the build crops, and the ones under 400px on the short
side, which the build enlarges. Both are reported once with a count, not
one line each, because there are dozens.
A person card held two controls that open the same dialog, the photo and
the name, so the keyboard stopped twice on every card for one action. The
photo is now out of the tab order and stays clickable with a pointer. The
agenda had the same pair and gets the same treatment.

The logo carried max-width and max-height together. Both were violated at
once on a tall viewport, the browser resolved them against each other, and
the picture came out 200x40, a ratio of 5 against the 3.36 of the file. The
height is now set and the width follows it, which keeps the shape whatever
the viewport does. 68px of height is 228px of width, which is what the
230px cap it replaces was aiming at.
@DZPM
DZPM force-pushed the pr/4-a11y-images branch 2 times, most recently from 08a4cfc to 86fc79e Compare October 5, 2026 12:54
@DZPM
DZPM changed the base branch from pr/3-third-party to edition October 5, 2026 22:23

@ber2 ber2 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

As you say: squash merge is mandatory for this one.

@DZPM
DZPM merged commit 98d4d3f into edition Oct 7, 2026
3 checks passed
@DZPM
DZPM deleted the pr/4-a11y-images branch October 7, 2026 09:19
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.

2 participants