Repository navigation
Accessibility, contrast, and the image pipeline - #195
Merged
Merged
Conversation
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="javascript: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	script: passed. A hand-written entity table is the wrong tool here: the entity is 	 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
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
This was referenced Oct 4, 2026
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
force-pushed
the
pr/4-a11y-images
branch
2 times, most recently
from
October 5, 2026 12:54
08a4cfc to
86fc79e
Compare
ber2
approved these changes
Oct 7, 2026
ber2
left a comment
There was a problem hiding this comment.
As you say: squash merge is mandatory for this one.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.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
h1per 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.
bluewas 3.55:1 and is 4.54:1,pinkwas 3.64:1 and is 4.58:1, andyellowandorangemove to dark text at 8.51:1 and 5.87:1.Language. A talk title in Spanish or Catalan is now wrapped in a
langspan so a screen reader pronounces it correctly, driven by an explicittitle_langon 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 thanstatic/. WebP with asrcset,widthandheightto stop layout shift, andloadinganddecodingset.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:
langregression this branch itself introduced. The first version derived a title's language from the event'slanguagefield, which describes the language the talk is given in, not the language of its title, so 13 English titles were taggedlang="es"orlang="ca"and read aloud with a Spanish voice. They had been read correctly before. The mapping is now explicit per title.favicon.pngwas 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.\bricardo\bmatches insidericardo-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_imageaccepted a path, so../people/Someone.jpgrendered a person's photo as a sponsor logo and the check passed. A path is now rejected inlogo_imageandphoto.logo:field let a sponsor pull request load a third party image on every page listing it. Zero sponsors used it; it is gone.orangewith 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-imagesreported figures that understated the real payload, because a background declared in SCSS is invisible to it and the frozenarchives/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.htmlis 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--minifybuild, 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
editionfor good, and agit bisectlands on them. A squash merge collapses this branch to one commit, so no state ofeditionever 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. Zerounsplash.comand zerohello.myfonts.netrequests 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.