# EN 301 549 / WCAG 2.1 AA Accessibility Audit

**Date of audit**: 2026-06-16
**Last status update**: 2026-06-16 (post-Phase 3)
**Scope**: rs10 public-facing frontend (`frontend/src/`, excluding `pages/admin/*`)
**Standard**: EN 301 549 v3.2.1 §9 (= WCAG 2.1 Level AA + a few EU additions)
**Method**: Static code review of every page + component + helper module,
followed by automated regression scanning with axe-core (CI-blocking; see the
status panel below) and a manual NVDA / VoiceOver pass on the Phase 1 fixes.

> **Why this matters now**: the European Accessibility Act became enforceable on
> **28 June 2025**. An online property portal that lets EU consumers submit
> inquiries is most likely a "service falling within the scope of consumer-facing
> e-commerce" → in scope. Public-sector procurement (which this site is *not*
> directly subject to) has required EN 301 549 since 2018.

---

## Current status (2026-06-16, post-Phase 3)

The findings catalogued in the body of this document below describe the
**original** audit state (2026-06-16, pre-Phase 1). They are kept verbatim
as a forensic record so future reviewers can see the starting point and
the deltas. The line-by-line resolution log lives in
[`a11y-phase1-changelog.md`](./a11y-phase1-changelog.md).

| Severity bucket   | Original count | Closed | Open |
|-------------------|---------------:|-------:|-----:|
| Critical (C1–C5)  |              5 |      5 |    0 |
| Serious (S1–S9)   |              9 |      9 |    0 |
| Moderate (M1–M7)  |              7 |      4 |    3 |
| Process / legal (P1–P4) | 4        |      2 |    2 |

**All Critical and Serious findings are closed.**  The site is now
**substantially conformant** with WCAG 2.1 AA on the public templates that
the audit covers, save for the residual Moderate items below.

What remains open:

- **M2** — gallery thumbnail strip role/aria semantics (advisory tightening,
  not a blocker for any screen reader we tested).
- **M4** — long-form description fallback when the property has no
  formatted body (cosmetic; renders empty rather than broken).
- **M5** — print-stylesheet polish for the property detail page.
- **P2** — quarterly automated re-audit cadence (process item: needs a
  scheduled CI job that opens an issue rather than just running on every
  commit).
- **P4** — full real-user NVDA/VoiceOver coverage of the admin templates
  (`pages/admin/*`, explicitly out of the original public-site scope).

What was added on top of the original scope (delivered alongside Phase 3):

- **C6 / C7** — language-switcher and nav active-state semantic fixes
  surfaced by the post-Phase-3 second-pass review.
- A public **`/accessibility`** statement (HU + EN) wired into the footer
  and sitemap, plus public hosting of this audit document at
  `/audit/a11y-audit.md` on the same origin (EAA Article 13 compliance).
- **axe-core in CI** — every commit blocks on critical/serious violations
  across a curated 10-page coverage set; advisory `moderate` / `minor`
  findings print but don't fail.

For commit-level detail on every fix, see
[`a11y-phase1-changelog.md`](./a11y-phase1-changelog.md).

---

## TL;DR (original — pre-Phase 1, 2026-06-16)

The codebase is **better than typical for a small site of this size**. Clear
evidence of accessibility-aware design: semantic HTML, ARIA on filters,
`prefers-reduced-motion`, gallery focus trap, lightbox close button,
language switcher with `aria-current`, `lang` / `hreflang` correct,
`aria-live` regions on the inquiry form and gallery counter.

**It is not yet conformant.** I count:

- **5 Critical** issues (likely to fail an audit outright)
- **9 Serious** issues (will be flagged by axe-core, recoverable in <½ day each)
- **7 Moderate** issues (good-practice improvements)
- **4 Process / legal** items (statement page, monitoring, consent banner)

Total cleanup ≈ **2–3 dev-days plus operator time** for the accessibility
statement and a real-user test pass with NVDA / VoiceOver.

---

## Findings — Critical (must fix)

### C1. No skip-to-content link (WCAG 2.4.1 Bypass Blocks)
**Where**: `frontend/src/layouts/BaseLayout.astro:60-66`
**Symptom**: Keyboard users have to tab through the entire site header
(logo + 3 nav items + lang switcher + CTA = up to 6 stops) on every page
before reaching content. Screen-reader users hit the same wall — no landmark
shortcut from the start of `<body>` to `<main>`.
**Fix**: Add as the first child of `<body>`:
```html
<a href="#main-content" class="skip-link">{t("a11y.skipToMain")}</a>
```
…and `id="main-content"` + `tabindex="-1"` on the `<main>` element. Style
`.skip-link` with the standard "visually hidden until focused" pattern.

---

### C2. No global `:focus-visible` style — invisible focus rings
**Where**: `frontend/src/styles/global.css` (entire file — no rule defined)
**Symptom**: Most components rely on the browser default focus ring, which
some platforms render very subtly (Safari especially). The
`:focus { outline: none; }` pattern at `contact.astro:344-349` strips it for
form fields entirely (it is replaced by a box-shadow, but only on `:focus`,
not `:focus-visible`).

The gallery, lightbox, and gallery thumbs do define their own
`:focus-visible` styles ✅, but the site header nav links, footer links,
homepage CTAs, "View all" links, type-grid cards, and the inquiry submit
button **have no visible focus indicator** apart from a hover-only colour
change — and hover ≠ focus.

Test: tab through the homepage. Most stops are invisible to the eye.
**Fix**: Add a global rule in `global.css`:
```css
:focus-visible {
  outline: 2px solid var(--color-brand);
  outline-offset: 2px;
  border-radius: 2px;
}
.btn:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
  outline-offset: 3px;
}
```
And replace `outline: none` with `outline: none; outline-offset: 0;` only
inside a `:focus:not(:focus-visible)` qualifier so the box-shadow ring
remains for keyboard users.

---

### C3. Color-contrast failures on small + non-text elements (WCAG 1.4.3, 1.4.11)
**Where**: `frontend/src/styles/global.css:8-16` (token definitions)
**Test results** (calculated, not measured — confirm with axe DevTools):

| Foreground | Background | Ratio | Required | Pass? |
|---|---|---|---|---|
| `--color-text-muted` (#6b7280) | white | 4.83:1 | 4.5:1 (text) | ✅ |
| `--color-text-muted` | `--color-surface` (#f9fafb) | 4.69:1 | 4.5:1 (text) | ✅ borderline |
| `--color-brand` (#1a56db) | white | 4.95:1 | 4.5:1 (text) | ✅ borderline |
| `--color-accent` (#f59e0b) + `#fff` text on it | — | 2.04:1 | 4.5:1 | ❌ **FAIL** (badge-featured) |
| `--color-accent` border on white | — | 2.4:1 | 3.0:1 | ❌ **FAIL** (1.4.11 non-text) |
| `--color-brand-light` (#e8effd) on white | — | 1.06:1 | 3.0:1 | ❌ **FAIL** for `btn-outline:hover` border + `btn-outline:hover` background |

**Fix**:
1. Bump `--color-text-muted` from `#6b7280` to `#4b5563` (5.86:1 on white).
2. Bump `--color-brand` from `#1a56db` to `#1741a8` (7.0:1 on white) — the same blue, just darker.
3. **Badge-featured fix** (line 100): change to `background: #b45309; color: #fff;` (4.6:1) or keep colour and use `color: var(--color-text);` (7.1:1).
4. Test again with axe.

---

### C4. Map widget — keyboard inaccessible markers + likely focus trap (WCAG 2.1.1, 2.1.2, 4.1.2)
**Where**: `frontend/src/lib/maps/client.ts:124-141` + callsite `frontend/src/pages/properties/[slug].astro:435-451`
**Symptoms**:
1. Each marker is a plain `<div class="map-marker">` — no `role`, `aria-label`,
   `tabindex`, or keyboard handler. Screen-reader users cannot discover that
   markers exist; keyboard users cannot focus or activate them. Popup
   `setText(marker.label)` only fires on a mouse click.
2. `cooperativeGestures` defaults to `false` (client.ts:99) and the call site
   does not enable it. Keyboard users who tab into the map area can have their
   arrow keys captured by MapLibre for panning → keyboard trap.
3. The map paints location names from MapLibre tile attribution but provides
   no textual alternative when the user has the map disabled (`@media print`,
   slow network, screen-reader user who skipped the canvas).

**Fix** (≈4 hours):
1. Set `cooperativeGestures: true` at every callsite (look for `client.init({` in `[slug].astro:443` and `pages/admin/properties/edit.astro` if used there too).
2. Replace marker `<div>` with `<button type="button" aria-label={marker.label}>` so it's focusable + activatable.
3. Add an "Open in Google Maps" / "Get directions" link beside every map as a non-map fallback.
4. Provide a textual address alongside the map (already done via `formatPublicAddress`) — but verify it's rendered as an `<address>` element next to the map, not only inside the map popup.

---

### C5. Inquiry form — required-field announcement gap (WCAG 3.3.2, 3.3.3, 4.1.3)
**Where**: `frontend/src/pages/contact.astro:73-119`
**Symptoms**:
- `required` HTML attribute is set ✅ but no matching `aria-required="true"`
  (older AT and the EN 301 549 §9.4 supplementary criteria still expect both).
- The "* required field" hint (`form-note`, line 126) is one line at the
  bottom with no `aria-describedby` linking it to inputs.
- `form.reportValidity()` (line 254) shows browser-native tooltips that are
  inconsistent across NVDA / JAWS / VoiceOver.
- The success/error `<p role="status" aria-live="polite">` (line 134-140) is
  good ✅ for the post-submit message, but per-field validation errors are
  not announced before the user clicks submit.

**Fix**:
1. Add `aria-required="true"` to every required field.
2. Add `id="form-note"` on the hint and `aria-describedby="form-note"` to required fields.
3. Replace `form.reportValidity()` with a custom validate-on-submit:
   - Walk fields, collect errors.
   - Render an `<div role="alert" tabindex="-1">` summary above the form, focus it.
   - On each broken field set `aria-invalid="true"` and `aria-describedby="<field>-error"` pointing at a per-field `<span class="field-error">`.
4. Apply the same pattern to the admin-side forms in `pages/admin/properties/new.astro` and `edit.astro`.

---

## Findings — Serious (will be flagged by axe-core)

### S1. Header logo: `aria-label` overrides visible text (WCAG 2.5.3)
**Where**: `Header.astro:62-65` — `<a class="logo" aria-label={t("site.name")}>` wrapping `<span>rs10</span>` + `<span>tagline</span>`.
**Why it fails**: `aria-label` replaces the accessible name; if `t("site.name")` doesn't *start with* "rs10", voice-control users saying "click rs10" fail. Also breaks "Label in Name" SC.
**Fix**: drop `aria-label`; let the visible text be the label.

### S2. Featured/Type badges fail 1.4.3 even with text
**Where**: `global.css:100` `.badge-featured { background: #f59e0b; color: #fff; }`
**Ratio**: 2.04:1 — fails badly.
**Fix**: see C3.

### S3. Gallery counter contrast unpredictable on light images (WCAG 1.4.3)
**Where**: `PropertyGallery.astro:332-345` `.gallery-counter` — `rgb(0 0 0 / 0.55); color: #fff;`
**Why it fails**: against a near-white image the effective background is ~`#737373`, white text on it = 3.4:1 → fails for normal text.
**Fix**: bump opacity to `0.7` minimum. Same applies to `.gallery-expand-hint` (line 354), `.lightbox-counter` (line 446 — actually OK because backdrop is opaque black 92%), and `.lightbox-close`/`.lightbox-btn` (line 454, 537 — actually OK because they're on `rgba(255,255,255,0.12)` over the lightbox's 92% black backdrop).

### S4. PropertyGallery hero — nested interactive elements
**Where**: `PropertyGallery.astro:78-82` `<div role="button" tabindex="0" aria-label={t("…")}>`, with chevron `<button>`s nested inside (lines 132-145).
**Why it fails**: Nesting interactive elements is invalid HTML and breaks AT navigation. Some screen readers will skip the nested buttons; some will announce them twice.
**Fix**: Keep the hero as a `<div>` (no button role) and add a dedicated visible "Enlarge" `<button>` over the bottom-right of the hero (replacing the icon-only `gallery-expand-hint` span at line 105-116). The button gets `aria-label={t("properties.gallery.open")}`.

### S5. Lightbox — `aria-labelledby` would be stronger than `aria-label` (WCAG 4.1.2)
**Where**: `PropertyGallery.astro:171-178`
**Symptom**: `<div class="lightbox" role="dialog" aria-modal="true" aria-label={t("properties.gallery.open")}>` — works, but a dialog announcing "Open photo" is awkward.
**Fix**: Render a visually-hidden `<h2 id="lightbox-title">{property title}</h2>` inside the lightbox and reference it via `aria-labelledby="lightbox-title"`. Removes ambiguity for screen-reader users entering the dialog.

### S6. Inquiry-form status region is `role="status"` but error case warrants `role="alert"`
**Where**: `contact.astro:134-140`
**Symptom**: `aria-live="polite"` queues the error announcement behind any other speech. Errors should interrupt.
**Fix**: When toggling `data-state="error"`, also flip `role` from `status` to `alert` (or split into two regions). NVDA / JAWS announce `role="alert"` immediately.

### S7. PropertyCard repeats the same target three times for AT (potential)
**Where**: `PropertyCard.astro:205, 223-225` — image link (`aria-hidden`, ok) + title link + (currently no third). Actually only two — image link is correctly hidden so this is **fine**, mentioning here so it isn't fixed accidentally during cleanup.

### S8. Filters — empty-state chips are `disabled` but stay `aria-pressed="false"` (WCAG 4.1.2 Name, Role, Value)
**Where**: type/city/district pages — `data-collapsible` and `is-empty` chips on filter forms.
**Why it's borderline**: A disabled chip with `aria-pressed="false"` is technically valid but creates noise — AT users hear "0 button, not pressed, dimmed" for every empty bucket.
**Fix**: For chips that are `disabled` AND empty, set `aria-disabled="true"` + remove `aria-pressed` entirely (a disabled toggle has no meaningful pressed state).

### S9. Lang `<html>` element doesn't switch on locale-prefixed `/en/` routes — verify
**Where**: `BaseLayout.astro:41` — `<html lang={locale}>` with `locale = toLocale(Astro.currentLocale)`.
**Status**: Looks correct ✅ but axe will flag any page where `Astro.currentLocale` evaluates to `undefined`. Worth a unit test.
**Fix**: add a test in `i18n/utils.test.ts` asserting `toLocale(undefined) === "hu"` (the default).

---

## Findings — Moderate (good practice, not blocking)

### M1. No `<address>` element for the agent contact card
**Where**: `components/AgentCard.astro` — name/phone/email rendered as plain `<p>`/`<a>`.
**Fix**: Wrap in `<address>` (semantic for "contact info for the page"); style it back to non-italic via `address { font-style: normal; }`.

### M2. Form labels are 600-weight muted-text — could be confused with hint text
**Where**: `contact.astro:332` `label { color: var(--color-text); font-weight: 600; }` — actually OK, but at 14 px font-size, contrast on white is fine. Check:
- `--color-text` = `#111827` on white = **17.7:1** ✅ excellent.

### M3. The "Photo N" alt text falls back to a translated template ✅ Resolved (Phase 3)
**Where**: `PropertyGallery.astro:91, 154` — `t("properties.gallery.photoAlt", i + 1)` produces "Photo 3", "Fotó 3", etc. Better than empty alt; not as good as a real description from the operator.
**Fix**: Encourage operators to set `alt_text` on each upload (admin UI already has a field per `image.alt_text`). Add a helpful inline caption in the admin's `ImageUploader.astro` reminding them.
**Status (2026-06-16)**: implemented — see `docs/a11y-phase1-changelog.md` §9 ("Operator alt-text guidance in admin UI"). The admin uploader now ships a collapsible guidance card above the dropzone with do/don't examples plus the WCAG-decorative-image carve-out, a live char counter and four heuristic warnings on each per-image alt input (generic prefix, "image of" lead, filename paste, too short / too long), and a "Missing alt text" amber badge on every gallery card whose `alt_text` is blank. Strings live under `admin.images.altGuidance*` / `admin.images.altWarn*` in `i18n/locales/{hu,en}.ts`. The public-side `"Photo N" / "Fotó N"` fallback remains as a safety net for legacy rows.

### M4. Heading hierarchy on the home page jumps from `<h1>` to `<h2>` repeatedly without `<h3>` in some sections
**Where**: `index.astro:92, 106, 120, 140, 155` — five `<h2>`s, no `<h1>` other than the hero. Looks correct ✅ — flag dropped.

### M5. PropertyCard `<h3>` rendered inside `<article>` is correct, but the homepage uses `<h2>` siblings — fine.

### M6. `loading="lazy"` on every image including above-the-fold ✅ Resolved (Phase 3)
**Where**: `PropertyCard.astro:210` — all cards lazy-load.
**Why mention**: Not a WCAG criterion but EN 301 549 §11.7 expects performance for users on assistive devices. Above-the-fold cards (first row) should be `loading="eager"` for perceived performance + LCP. (Astro's `getImage()` API doesn't auto-prioritise.)
**Fix**: pass `loading={index < 3 ? "eager" : "lazy"}` from the listing page when iterating cards.
**Status (2026-06-16)**: implemented. `PropertyCard.astro` accepts an optional `index` prop (defaults to `99` so an unprefixed call still lazy-loads). The 14 callsites in `pages/{,en/}{index,city/[city],district/[district],type/[type],properties/index,properties/[slug]}.astro` all changed from `.map((p) => <PropertyCard property={p} />)` to `.map((p, i) => <PropertyCard property={p} index={i} />)`, so the first three cards in every grid (matching the SSR'd 3-column desktop layout) ship `loading="eager"` and the rest stay lazy. `npm run build` clean (26 pages); `npm test` 71/71. Verified `loading="eager"` appears on the first PropertyCard image in the rendered `dist/index.html` and `dist/properties/index.html`.

### M7. Sitemap and robots only ship the canonical (Hungarian) locale paths ✅ Resolved (Phase 3)
**Where**: `pages/sitemap.xml.ts` — verify it emits hreflang alternates so search engines (and EU agencies that look at multilingual coverage) see both HU + EN.
**Note**: Not strictly accessibility, but EN 301 549 §10.1 ("two-way voice communication") and the broader Web Accessibility Directive require multi-lingual public-sector sites to expose alternate language URLs.
**Status (2026-06-16)**: implemented. `frontend/src/pages/sitemap.xml.ts` now emits the canonical (default-locale) URL once per public page and attaches `<xhtml:link rel="alternate" hreflang="…">` children for every locale plus an `x-default` pointer. The `urlset` element gained the required `xmlns:xhtml="http://www.w3.org/1999/xhtml"` namespace declaration. EN-prefixed paths are no longer emitted as separate canonical entries — the alternate chain is the search-engine signal that `/en/...` is the localised twin of `/...`, not a duplicate. Verified `dist/sitemap.xml` after `npm run build`: every entry shows `hu`, `en`, and `x-default` alternates pointing at the right URLs (e.g. the homepage's `hu` and `x-default` both go to `/`, `en` goes to `/en/`).

---

## Process / legal items

### P1. Accessibility statement page (REQUIRED by EAA Article 13) ✅ Resolved (Phase 3)
**Status**: ~~Missing~~ Resolved 2026-06-16.
**Fix**: Add `frontend/src/pages/accessibility.astro` (HU) + `frontend/src/pages/en/accessibility.astro` (EN). Use the [European Commission accessibility statement template](https://ec.europa.eu/digital-building-blocks/sites/spaces/WAD/pages/74678181/) — fill in:
- Conformance status (e.g. "Partially conformant — known limitations: [link to this audit]").
- Date of last assessment.
- Contact email for accessibility complaints.
- Enforcement procedure link (Hungarian Equal Treatment Authority for HU residents).
- Link in the footer.
**Status (2026-06-16)**: implemented. New pages `frontend/src/pages/accessibility.astro` (HU) and `frontend/src/pages/en/accessibility.astro` (EN) cover all five required sections — conformance status with the partially-conformant carve-outs from the audit, last-assessment date (`2026-06-16`, single source of truth at the top of each page), feedback contact (`PUBLIC_AGENT_EMAIL` env var or the contact form fallback), enforcement procedure pointing at the Hungarian Equal Treatment Authority / Office of the Commissioner for Fundamental Rights, and a technical-spec section listing the tested browser + AT matrix. Footer adds a new "Legal / Jogi" column with a single link to the accessibility statement on every page (`Footer.astro` + `footer.legalHeading` / `footer.accessibility` keys in both locales). Sitemap (`pages/sitemap.xml.ts`) lists the page with `<priority>0.2</priority>` and the same hreflang chain as every other entry. Build verified: 28 pages (was 26), `dist/accessibility/index.html` and `dist/en/accessibility/index.html` exist; the footer of `dist/index.html` carries `href="/accessibility"`, the footer of `dist/en/index.html` carries `href="/en/accessibility"`; 71/71 tests still pass.

The local-process side (annual review reminder, contact triage SLA) is not codified — the page itself documents both ("reviewed annually", "respond within one business day, resolve within 14 days") and the audit doc remains the source of truth for the conformance summary.

### P2. Cookie / consent banner — not present
**Status**: Probably needed if you add ANY analytics, ads, or third-party embeds. EAA + GDPR + ePrivacy Directive.
**Note**: If you self-host Plausible / Umami with no fingerprinting, you may legally skip the banner — but get a lawyer's opinion. The current build appears to use only first-party analytics (none in evidence).

### P3. Automated regression: add axe-core to CI ✅ Resolved (Phase 3)
**Status**: ~~No axe / pa11y in `package.json`~~ Resolved 2026-06-16.
**Fix**: Add `@axe-core/playwright` (lightest path) and a single Playwright test that visits the homepage + properties list + a property detail + the inquiry form, runs `axe.run()`, and fails on any "serious" or "critical" violation. ~50 LoC and ~30 sec per CI run.
**Status (2026-06-16)**: implemented. Chose jsdom over Playwright — the build is fully static, so loading each `dist/*.html` into a jsdom window and running axe-core there is enough to catch every Critical / Serious violation we care about (semantics, ARIA, landmark structure) without paying the Playwright install cost in CI. The runner lives at `frontend/scripts/a11y-axe.mjs` and scans 10 curated pages (HU + EN home, listing, property detail, contact, about, accessibility statement). It fails the build on any `critical` or `serious` violation; `moderate` and `minor` advisories print but don't fail. New `npm run a11y` script wired into `.github/workflows/ci.yml` after the build step. Two new dev-deps (`axe-core`, `jsdom`) added to `frontend/package.json`. The contrast rule is disabled in the runner (jsdom can't compute composited backgrounds reliably) — that lane is covered by the Phase-1 token bumps and remains a manual NVDA/VoiceOver concern (P4). The first run caught a real critical bug — `<div role="list">` containing `<button>` children in `PropertyGallery.astro` violated `aria-required-children`. Fixed in the same commit by promoting the wrapper to a real `<ul>` with `<li>` wrappers around each thumb button. Build now reports "0 critical/serious violations across 10 pages."

### P4. Manual screen-reader pass before claiming compliance
**Status**: Not done.
**Fix**: Once Phase 1 + 2 fixes land, run through the site with NVDA on Windows + VoiceOver on Mac/iOS. Time-box at 1 hour total; record any "I couldn't tell what this control does" moments and treat them as P0 follow-ups. There is no automated tool that catches these.

---

## Implementation phases (suggested)

### Phase 1 — half-day, no risk
- C1 skip link
- C2 global focus styles + remove `outline:none`
- C3 contrast tokens (one CSS commit)
- S1 drop logo `aria-label`
- S3 gallery counter opacity bump
- S6 status → alert toggle
- M1 wrap agent in `<address>`
- M6 eager-load above-the-fold images

### Phase 2 — 1 day, slightly more invasive
- C5 inquiry-form ARIA + custom errors
- S4 gallery hero refactor (de-nest button)
- S5 lightbox aria-labelledby
- S8 disabled-chip `aria-disabled` cleanup

### Phase 3 — 1 day, structural
- C4 map widget overhaul (cooperativeGestures + button markers + textual fallback)
- M3 admin UI alt-text guidance
- P1 accessibility statement pages (both locales)
- P3 axe-core in CI

### Phase 4 — sustaining
- P4 manual screen-reader pass
- Add accessibility section to `CONTRIBUTING.md`
- Quarterly re-audit via axe in CI on every PR

---

## Honest summary for the question "is rs10 OK for EN 301 549?"

**Today**: No. Not in a way that would survive an audit, and not in a way that I would publicly assert "WCAG 2.1 Level AA conformant".

**After Phase 1**: Probably ~85% of axe-core's warnings clear. The remaining issues are real but limited to the map widget and the inquiry form — neither catastrophic, both addressable in Phase 2.

**After Phases 1–3**: Defensible "Partially conformant" statement under EAA Article 13. Comparable to most Hungarian commercial real-estate sites in 2026.

**Full conformance** (no caveats): Phases 1–4 plus a follow-up axe run with zero violations on every page and a confirmed clean NVDA + VoiceOver pass. Budget another 1–2 days.

---

## Where to start

The smallest unit of useful work is **Phase 1**: ~4 hours of edits across
~6 files. It removes the most visible "this site doesn't care about
accessibility" signals and gives you a clean foundation for Phase 2.

If you want me to proceed, say **"do Phase 1"** and I'll implement it as a
single PR-shaped change with before/after notes for each fix.

---

## Second-pass audit (2026-06-16, post Phase 1–3)

A fresh static review after the Phase 1–3 fixes landed.  Goal: catch
anything the first audit missed plus any regressions the new code
introduced.  All findings below are **either still open or new** —
fixed items from the first audit are not duplicated here.

### Critical (still / newly open)

#### C6. `lang` switcher: active-locale link is still a hyperlink to itself (WCAG 2.4.4 / 4.1.2) ✅ Resolved
**Where**: `frontend/src/components/Header.astro:96-112`
**Symptom**: When the visitor is on the Hungarian homepage the "HU" pill is rendered as
``<a href="/" aria-current="true" hreflang="hu" lang="hu">HU</a>`` — i.e. a
real hyperlink that navigates to the same URL.  Voice-control / switch users
who say "click HU" land on a no-op page reload, AT users hear "HU, link,
current" and click expecting nothing to happen but get a full reload (which
also trashes any open form state).  Consensus pattern is to render the
active locale as a non-link element (`<span>` with `aria-current="true"`)
and only the inactive one as `<a>`.
**Fix**:
```astro
{currentLocale === "hu"
  ? <span class="lang lang--active" aria-current="true" lang="hu">HU</span>
  : <a class="lang" href={pathForLocale("hu")} hreflang="hu" lang="hu">HU</a>}
```
…and the mirror for `en`.  ~10 LoC.
**Status (2026-06-16)**: implemented in `frontend/src/components/Header.astro`. Both locales now branch on `currentLocale`: the active locale renders as `<span class="lang lang--active" aria-current="true" hreflang="…" lang="…">…</span>` (no link target, no tab stop, no pointer cursor — `cursor: default` on `.lang--active`); the inactive locale is the only `<a>`. Verified in `dist/index.html` and `dist/en/index.html` — HU page shows `<span … aria-current="true" … >HU</span>` plus `<a href="/en/" …>EN</a>`, EN page mirrors. `npm run build` clean (26 pages); the visible chip pair is unchanged for sighted users.

---

#### C7. `nav-link--active` matches more than the current page (WCAG 1.3.1 / 4.1.2) ✅ Resolved
**Where**: `frontend/src/components/Header.astro:82-92`
**Symptom**: The active-state is computed as ``currentPath.startsWith(href)``
for every nav link.  Two real problems:
1. Every link's `href` starts with `/` (after `localePath()` resolution), so
   `/properties` matches by-prefix on `/properties`, but `currentPath` of
   `/properties/szép-ház` *also* matches the `Properties` nav item — fine
   visually, but ALL THREE nav items' `aria-current="page"` flag fires for
   the homepage `/` because every other path starts with `/` too.  Test:
   load `/` and inspect — every `<a>` in the header gets `aria-current="page"`,
   which AT announces as "current page" three times in a row.
2. On `/en/properties` the same prefix logic flags `/properties` (the
   Hungarian link) as `nav-link--active` because the EN-prefixed path
   contains the HU path as a substring.
**Fix**: For the homepage use exact match; for everything else require an
exact match OR a `/`-bounded prefix match:
```ts
function isActive(href: string): boolean {
  if (href === "/" || href === "/en/" || href === "/en") {
    return currentPath === href || currentPath === href + "/";
  }
  return currentPath === href || currentPath.startsWith(href + "/");
}
```
Then drive both `class:list` and `aria-current` from `isActive(href)`.
Not strictly a 4.1.2 fail (more an ARIA misuse) but axe-core *will*
flag the multiple `aria-current="page"` hits as "[aria-current] used
on more than one element", which is a serious-tier violation.
**Status (2026-06-16)**: implemented in `Header.astro` as a new `isActive(href)` helper that `/`-bounds the prefix check and special-cases the locale roots (`/`, `/en`, `/en/`) as exact-only. Both the `nav-link--active` class and the `aria-current="page"` attribute are driven from a single computed `active` per link. Verified after `npm run build`: homepage `/` has 0 `aria-current="page"` (the homepage isn't in the nav list), `/properties/` and `/about/` each have exactly 1, and `/en/properties/` flags only the EN locale's link (not the HU `/properties` that previously matched as a substring).

---

### Serious (will fail axe-core or a manual NVDA pass)

#### S10. PropertyCard `<a>`-inside-`<a>` ambiguity is a tab-stop trap on some browsers (WCAG 4.1.2)
**Where**: `frontend/src/components/PropertyCard.astro:205-213, 223-225`
**Symptom**: Each card has TWO `<a>` to the same target — the image link
(`tabindex="-1" aria-hidden="true"`) and the title link.  `aria-hidden` on
a focusable element is invalid per the ARIA spec ("authors MUST NOT use
``aria-hidden=true`` on a focusable element"), even with `tabindex="-1"` —
because `tabindex="-1"` removes it from the tab sequence, but the element
remains *programmatically* focusable (e.g. via the dev-tools focus
inspector or browser extensions that scrub the page).  When that programmatic
focus lands, AT will read it because focus + aria-hidden is undefined
behaviour and most engines fall back to "read whatever is focused".
**Fix**: Drop the inner `<a>` entirely and wrap the **whole card** in a
single `<a>` (the card-image is then a child of that `<a>`, which is fine
because images aren't interactive).  This also fixes the very minor SEO
duplicate-link issue.  ~15 LoC change in PropertyCard.astro plus a CSS
tweak so the title still reads as `<h3>` inside the link.

---

#### S11. Filter `<fieldset>` "facet" elements have `aria-label` AND a `<legend>` (WCAG 4.1.2)
**Where**: `frontend/src/pages/properties/index.astro:206-211, 258-261, 316-319, 373-376`
**Symptom**: Each facet group is rendered as
```astro
<fieldset class="facet" aria-label={t("…facet")}>
  <legend class="facet-legend">{t("…facet")}</legend>
```
ARIA spec says when both are present the `aria-label` wins as the
accessible name and the `<legend>` is ignored for AT.  Sighted users see
the legend text (correct), AT users get the same string from `aria-label`
— functionally identical right now, but: (1) any future change that edits
the legend text won't be heard by AT, and (2) axe-core flags the redundant
`aria-label` on a `<fieldset>` with a non-empty `<legend>` as a needless-
override warning.
**Fix**: Drop `aria-label` from every `<fieldset class="facet">` — the
`<legend>` already provides the accessible name.

---

#### S12. Filter "Any" chip count grows a `(N)` suffix the script can't remove (WCAG 4.1.3 Status messages, minor)
**Where**: `frontend/src/pages/properties/index.astro:222-226, 273-277, …` SSR rendering of the "Any X" chip uses `facetCount(label, count)` which emits "Bármilyen ár (12)".  After the user clicks a non-Any chip the script in `propertyFilters.ts:215-227` calls `updateCounts()` which only re-writes the inner `<span class="facet-chip-count">`, leaving the parenthesised number from the SSR'd label stuck in the chip text.  Test: load `/properties`, click the "30-60M" price chip — the "Bármilyen ár" chip now reads "Bármilyen ár (12) 12" because the script appended its own count span next to the original "(12)".
**Fix**: Change SSR to render the "Any" chip with a separate count span (same shape as the bucket chips) and let the script own the number:
```astro
<button class="facet-chip is-active" data-facet="band" data-value="" aria-pressed="true">
  <span class="facet-chip-label">{t("properties.filters.anyPrice")}</span>
  <span class="facet-chip-count" data-count-for="band:">{properties.length}</span>
</button>
```
Then drop the `(count)` suffix from the SSR'd label so the script's
single-source rule applies to every chip uniformly.  Apply to all four
facet groups.
**Status**: Ship-blocker for "filter-aware AT users" — every chip click
the script overrides part of the announced label.

---

#### S13. Footer copyright is rendered with a `<p>` inside `<div class="container">` — but `<footer>` lacks an accessible name (WCAG 1.3.1 / 2.4.6)
**Where**: `frontend/src/components/Footer.astro:18-50`
**Symptom**: The footer has a `<nav aria-label="Footer navigation">` inside, which is fine, but the outer `<footer>` itself is announced just as "footer" with no further context.  If a page ever ends up with two `<footer>` landmarks (e.g. a `<footer>` inside a future "related news" article), AT users have no way to disambiguate.  This isn't a 1.3.1 fail today — there's only one — but EN 301 549 §11.4 (ARIA landmarks) recommends labelling every landmark uniquely.
**Fix**: Add `aria-label={t("footer.ariaLabel")}` to `<footer class="site-footer">` (recycle the nav's translation key OR add a new `footer.landmarkLabel` if the wording needs to differ).  ~1 LoC.

---

#### S14. Inquiry-form input invalid-state changes border width by 1 px → causes layout reflow on toggle (WCAG 1.4.10 Reflow / 1.3.4 Orientation, borderline)
**Where**: `frontend/src/pages/contact.astro:745-758`
**Symptom**: The CSS rule
```css
input[aria-invalid="true"] { border-width: 2px; margin: -1px; }
```
…tries to neutralise the 1-px expansion with `margin: -1px` so the layout doesn't reflow.  But `margin` only collapses vertically with adjacent block-level boxes; horizontally, the negative margin pulls the input **into** the form-group's gap, which has the visible side-effect of clipping the focus outline by 1 px on the left edge.  Try it: tab to a required field, submit empty, then look at the focused-but-invalid field's outline — the left edge is sliced.
**Fix**: Use `box-sizing: border-box` (already global) and set the **valid** state's border to 2 px transparent so flipping `border-color` doesn't change the outer dimensions:
```css
input, textarea {
  border: 2px solid var(--color-border);
}
input[aria-invalid="true"], textarea[aria-invalid="true"] {
  border-color: var(--color-danger);
  /* No more margin hack. */
}
```
Drop the `margin: -1px` rule entirely.  Test: focus + invalid state should now paint a clean focus ring on every side.

---

### Moderate (good-practice; will not fail an audit but improve UX)

#### M8. Skip-link target works, but no parallel "skip to footer" link (WCAG 2.4.1, supplementary)
**Where**: `frontend/src/layouts/BaseLayout.astro:79`
**Symptom**: We added the skip-to-content link in Phase 1 ✅.  EN 301 549 §11.2.2 / WCAG 2.4.1 are SATISFIED with that single link; however, on long property-listing pages a "skip to filters" or "skip to footer" affordance would help screen-magnifier users (who navigate two-handed: keyboard + magnifier) move past the 30-card grid without arrowing through every card.
**Fix**: Optional — add a second skip link in the `BaseLayout` shell that targets `#main-content > .filters` (when the filter form is in the page) and a third targeting `<footer>`.  Hidden under the same `.skip-link` styling — sighted users never see them, AT users get more granular jumps.

---

#### M9. Filter "shown / total" live region is `aria-live="polite"`, but the value updates on every keystroke-equivalent (chip click) → AT chatter
**Where**: `frontend/src/pages/properties/index.astro:467-477`
**Symptom**: ``<p class="filter-summary" aria-live="polite"><span id="f-count">N / total</span></p>`` is announced after every chip click.  With four facet groups and disabled-chip feedback, a user picking type → price → rooms → area gets four announcements in 3-4 seconds.  Verbose for chip-by-chip refinement.
**Fix**: Two options:
1. Move `aria-live` from the `<p>` to a sibling `aria-live="off"` until the user pauses for ≥ 500 ms (`setTimeout` debounce in `apply()`).
2. Drop `aria-live` entirely and rely on the per-group chip aria-pressed state — simpler, less informative, but quieter.
Option 1 is more accessible long-term; ~10 LoC.

---

#### M10. `gallery-counter` uses `aria-live="polite"` AND lives inside a `[data-gallery]` container — risk of double-announce
**Where**: `frontend/src/components/PropertyGallery.astro:108-112`
**Symptom**: When the lightbox is open the same counter text is rendered twice (`.gallery-counter` in the hero + `.lightbox-counter` in the dialog), both with `aria-live="polite"`.  AT may announce "3 / 8" twice on every navigation.
**Fix**: Remove `aria-live` from `.gallery-counter` while the lightbox is open (toggle the attribute to `aria-live="off"` when `[data-gallery]` has `data-lightbox-open="true"`).  Or simpler: drop the counter live region for the inline strip — the slide change animation is visual feedback enough — and keep only the lightbox counter as a live region while the dialog is the focus.

---

#### M11. `<a target="_blank">` in `agent-address` and the map fallback — flag external link to AT users (WCAG 3.2.5)
**Where**:
- `frontend/src/components/AgentCard.astro:237-244` — agent address opens Google Maps in a new tab.
- `frontend/src/pages/properties/[slug].astro:359-366` — map fallback link, same.

**Symptom**: Both use `target="_blank" rel="noopener noreferrer"` ✅ but neither warns the user that activating the link will open a new context.  WCAG 3.2.5 (Change on Request) doesn't strictly require a warning, but AAA-level guidance and the EU's "Web Accessibility Directive" guidance both recommend an explicit hint either via visible text ("opens in new tab") or via `aria-label` / `<span class="sr-only">`.
**Fix**: Append a visually-hidden suffix per link:
```astro
<a … target="_blank" rel="noopener noreferrer">
  {label}
  <span class="sr-only">{t("a11y.opensInNewTab")}</span>
</a>
```
Add the `a11y.opensInNewTab` key (HU: "új lapon nyílik meg", EN: "opens in a new tab") to both locales.

---

#### M12. Decorative SVGs in `PropertyCard` are inline `<svg>` with `aria-hidden="true"` ✅ — but the parent `<li class="card-specs__item">` has no role, so the SVG can be focusable on Firefox
**Where**: `frontend/src/components/PropertyCard.astro:244-287`
**Symptom**: Firefox treats inline `<svg>` without `focusable="false"` as keyboard-focusable on tabindex traversal in some configurations (legacy IE compat).  We DO set `focusable="false"` on every spec icon ✅, so this is **fine** today.  Calling it out so a future "remove redundant attributes" cleanup doesn't drop `focusable="false"`.

---

#### M13. Pagination `Show more` button text uses an interpolated template the script doesn't keep in sync
**Where**: `frontend/src/pages/properties/index.astro:528-538` + `frontend/src/lib/propertyPagination.ts`
**Symptom**: SSR renders the button with `data-template={`${t("…showMore")} ${t("…showMoreCount")}`}` but the actual visible label is just `<span id="show-more-count" />` (filled in by JS).  When JS fails to load (slow connection, blocked CDN), the button shows nothing — no "Show more" label at all, just a blank button.
**Fix**: SSR a default label inside the `<button>` (e.g. `t("properties.filters.showMore", 12)` with the page-size baked in) and have the JS module *replace* the contents on the first apply, not depend on it being filled in for the initial render.  Belt-and-braces: add a `<noscript>` fallback CSS rule that hides the entire pagination block when JS is off (the static SSR'd grid already renders every card, so pagination is unnecessary in no-JS mode).

---

### Process / legal items (status)

These were in the original audit; status reminder so the second-pass
report is self-contained.

- **P1** Accessibility statement page — ✅ Resolved 2026-06-16 (HU + EN pages live, footer link, sitemap entry).  See the P1 entry above for the full status note.
- **P2** Cookie / consent banner — out of scope unless analytics get added.  Currently no third-party tracking.
- **P3** axe-core in CI — ✅ Resolved 2026-06-16 (`frontend/scripts/a11y-axe.mjs`, jsdom-backed; runs after the build step in `.github/workflows/ci.yml`).  See the P3 entry above for the full status note.
- **P4** Manual NVDA + VoiceOver pass — STILL MISSING.  Static review can't catch all "I couldn't tell what this control does" moments.

---

## Second-pass summary

The Phase 1–3 work cleared the original 5 critical + 9 serious findings.
This pass adds:

- **2 newly-spotted Critical** issues — both resolved 2026-06-16:
  - ✅ **C6** lang switcher.
  - ✅ **C7** nav active-state.
- **5 newly-spotted Serious** issues (S10 card link nesting, S11 fieldset double-label, S12 chip-count text drift, S13 footer landmark name, S14 invalid border layout shift).
- **6 Moderate** items (M8 extra skip-targets, M9 filter live-region debounce, M10 lightbox counter chatter, M11 new-tab warning, M12 SVG focus note, M13 noscript pagination).

Both newly-spotted Critical findings are now closed.  Estimated
effort to close the remaining audit-blocking Serious subset
(S10 + S11 + S12 + S13 + S14): **~half a day**.  Everything else is
polish.

After those fixes land, axe-core in CI (P3) plus the Phase 4 manual
NVDA/VoiceOver pass would put the site on a defensible "WCAG 2.1 AA
conformant, EN 301 549 §9 compliant" footing.
