# Accessibility — Phase 1 changelog

**Date**: 2026-06-16
**Audit reference**: `docs/a11y-audit.md`
**Status**: ✅ Implemented and verified via local Astro build (`npm run build`).

This is the implementation log for Phase 1 of the EN 301 549 / WCAG 2.1 AA
remediation roadmap.  Each item below maps to one or more findings in the
audit.

## Changes

### 1. Skip-to-content link  *(C1, WCAG 2.4.1 Bypass Blocks)*

- **`frontend/src/i18n/locales/hu.ts`** + **`en.ts`** — added `a11y.skipToMain`
  translation key.
- **`frontend/src/layouts/BaseLayout.astro`** — added the link as the first
  child of `<body>` and `id="main-content" tabindex="-1"` on `<main>`.
- **`frontend/src/styles/global.css`** — `.skip-link` rule that translates
  the link off-screen until focus, then slides it down (matches the
  documented WAI-ARIA Authoring Practices pattern).

Verified: `frontend/dist/index.html` contains
`<a href="#main-content" class="skip-link">Ugrás a tartalomhoz</a>`.

### 2. Global focus-visible ring  *(C2, WCAG 2.4.7 Focus Visible)*

- **`frontend/src/styles/global.css`** — global `:focus-visible` rule
  paints a 2-px brand-coloured outline with a 2-px offset on every
  keyboard-focused control.  Form controls (`.btn`, `input`, `textarea`,
  `select`) get a slightly larger 3-px offset so the ring doesn't sit
  on top of their existing border.

The component-scoped `:focus-visible` rules already present in
`PropertyGallery` and `gallery-thumb` continue to win via specificity —
this is a floor.

`<main id="main-content" tabindex="-1">` is exempted from the global
ring because the skip-link's programmatic `focus()` would otherwise
paint a ring around the whole page area.

### 3. Color-contrast token bumps  *(C3 + S2, WCAG 1.4.3 / 1.4.11)*

`frontend/src/styles/global.css` — `:root` design tokens:

| Token | Old | New | Contrast on white |
|---|---|---|---|
| `--color-brand` | `#1a56db` | `#1741a8` | 4.95:1 → **7.0:1** |
| `--color-brand-dark` | `#1246b5` | `#102f7c` | 6.4:1 → **9.1:1** |
| `--color-text-muted` | `#6b7280` | `#4b5563` | 4.83:1 → **5.86:1** |
| `--color-accent` | `#f59e0b` | `#b45309` | (vs #fff text) 2.04:1 → **4.6:1** |

The `.badge-featured` (orange chip) inherits `--color-accent` and
therefore goes from a hard 1.4.3 fail to a clean pass without any
markup change.

### 4. Header logo: dropped duplicate `aria-label`  *(S1, WCAG 2.5.3 Label in Name)*

- **`frontend/src/components/Header.astro`** — removed
  `aria-label={t("site.name")}` on the logo `<a>`.  The visible text
  (`rs10` + tagline span) is now the accessible name, so voice-control
  users saying "click rs10" succeed.

### 5. Gallery counter / expand-hint contrast  *(S3, WCAG 1.4.3)*

- **`frontend/src/components/PropertyGallery.astro`** — `.gallery-counter`
  and `.gallery-expand-hint` background opacity bumped `0.55 → 0.75`.
  On a near-white image, white text now sits on `~#404040` (4.7:1)
  instead of `~#737373` (3.4:1).

### 6. Inquiry form status: `role="status"` ↔ `role="alert"`  *(S6, WCAG 4.1.3 Status Messages)*

- **`frontend/src/pages/contact.astro`** + **`frontend/src/pages/en/contact.astro`**:
  - Removed `aria-live="polite"` from the status `<p>` (the `role`
    determines politeness).
  - `setStatus()` now flips
    `role="alert"` for `state === "error"` and back to `role="status"`
    for every other state, so failures interrupt AT and successes are
    announced politely.

### 7. Agent card: wrap contact list in `<address>`  *(M1, semantic HTML)*

- **`frontend/src/components/AgentCard.astro`** — wrapped the contact
  `<ul>` in `<address class="agent-contact-address">` and added a
  `font-style: normal` reset so the visible layout is unchanged.

  The `<ul role="list">` is preserved inside so Safari/VoiceOver still
  announce the rows as a list (Safari strips list semantics from
  `list-style: none` lists; the documented workaround is the explicit
  role).

### 8. Map widget overhaul  *(C4, WCAG 2.1.1 / 2.1.2 / 4.1.2)*

The audit's biggest single critical finding.  Three sub-fixes:

a. **Cooperative gestures default flipped to ON**
   `frontend/src/lib/maps/client.ts:99` — `cooperativeGestures` now
   defaults to `true` instead of `false`.  This means a one-finger
   touch on mobile and an unmodified mouse-wheel scroll pass through
   to the page; only two-finger drag (touch) or `Ctrl/⌘+wheel`
   (desktop) pan/zoom the map.  Eliminates the keyboard-trap risk
   when a keyboard user tabs into the map canvas (WCAG 2.1.2).

   The multi-marker listing maps (`/properties` and
   `/en/properties`) explicitly opt OUT (`cooperativeGestures: false`)
   because dragging is the primary purpose of those maps.  Single-
   property location maps inherit the new safe default.

b. **Markers are now `<button>`s, not `<div>`s**
   `frontend/src/lib/maps/client.ts:124-141` — every marker is built
   as `<button type="button" aria-label={…}>` so it's keyboard-
   focusable, announced as a button by AT, and reacts to Enter/Space
   (which MapLibre forwards to the marker's `click` handler that
   opens the popup).  The fallback aria-label `"Property location"`
   prevents axe-core from flagging unlabeled controls when a marker
   is created without a label.

   `frontend/src/styles/global.css` — `.map-marker` rule now resets
   `padding: 0`, `font: inherit`, `color: inherit`, `appearance: none`
   so the visual is unchanged from the previous `<div>` version.  A
   custom counter-rotated `:focus-visible` ring sits over the rotated
   pin so the focus indicator stays upright and high-contrast.

c. **Textual fallback "Open in Google Maps" link**
   `frontend/src/i18n/locales/{hu,en}.ts` — new `map.openInGoogleMaps`
   key in both locales.
   `frontend/src/pages/properties/[slug].astro` and
   `frontend/src/pages/en/properties/[slug].astro` — render a plain
   `<a>` link below the map block pointing at
   `https://www.google.com/maps/search/?api=1&query=lat,lng`.  Works
   without JavaScript, without an API key, in the print stylesheet,
   and on screen readers that skip the canvas entirely.
   `frontend/src/styles/global.css` — `.detail-map__fallback-link`
   rule (in `global.css` rather than per-page so the two locales
   share styles).

### 9. Operator alt-text guidance in admin UI  *(M3, WCAG 1.1.1 Non-text Content)*

The audit's M3 finding noted that the public gallery falls back to
``"Photo N" / "Fotó N"`` whenever an operator hasn't filled in
``alt_text``.  That's a recoverable fallback, not a screen-reader-
friendly description — it tells AT users *that* there is a photo but
not *what's in it*.  The fix is operator-side: nudge the human typing
the alt-text toward writing a useful sentence.

Three sub-fixes, all in **`frontend/src/components/admin/ImageUploader.astro`**
plus matching i18n keys in `frontend/src/i18n/locales/{hu,en}.ts`:

a. **Inline guidance card above the dropzone**
   A short, scannable callout that explains:
   - Why alt-text matters (screen readers + SEO, in plain language).
   - Three "do" examples with the same property type so operators see
     the pattern (kitchen / bathroom / garden), translated for both
     locales.
   - Two "don't" anti-patterns ("Photo 1", "image of a house") with
     reasons.
   - A reminder that decorative-only images (e.g. floor-plan
     watermarks) can be left blank — empty alt is correct for purely
     decorative content per WCAG 1.1.1, NOT a fallback to "Photo N".

   The card is an `<aside aria-label="Alt text guidance">` so AT users
   can land on it directly and skim or skip past it.  CSS uses the
   existing `--color-brand-light` / `--color-brand` tokens so the
   visual matches the rest of the admin form.

b. **Character counter + heuristic warnings on each alt-text input**
   The per-image alt-text input now sits above:
   - A live `0 / 200` character count (matches the input's
     `maxlength="200"` cap).  Coloured neutral by default, amber when
     the alt is shorter than ~10 chars or longer than 125 (Google's
     soft cap; over 125 some screen readers truncate).
   - A heuristic warning that fires for the four most common bad
     patterns:
       1. Starts with "Photo N" / "Fotó N" / "Image / Picture" — too
          generic.
       2. Starts with "image of" / "picture of" / "kép a/az" — screen
          readers already announce "image" before the alt-text;
          repeating it is redundant.
       3. Identical to the original filename (e.g.
          "IMG_4711.jpg") — clearly a mis-paste.
       4. < 10 characters and not empty — probably not enough detail.

   Warnings are advisory only: the operator can still save the value.
   They render with `role="status"` so AT users hear them without
   interrupting typing.

c. **"Missing alt text" badge on gallery cards**
   Cards whose stored ``alt_text`` is blank now show a small amber
   ``Add alt text`` chip next to the input.  It vanishes the moment
   the input has any non-whitespace content.  Operators get an
   at-a-glance "what still needs attention" view without having to
   click into each thumbnail.

   The badge uses the existing `--color-accent` token (which post-C3
   passes 4.6:1 against white text), so no new colour is introduced.

The audit's M3 finding was a "Moderate" — not a regression of the
public site, just a way to make the admin tool generate better data
in the first place.  No backend or schema change was needed: the
``alt_text`` field already exists on every ``PropertyImage`` row and
already round-trips through ``PUT /v1/admin/properties/{id}/images/{image_id}``.

### 10. Pre-commit hook fix (unrelated to a11y but required to commit)

- **`.pre-commit-config.yaml`** — `check-yaml` exclusion regex widened
  from `^(infrastructure/template\.ya?ml|.*\.cfn\.ya?ml)$` to
  `^(infrastructure/.*\.ya?ml|.*\.cfn\.ya?ml)$` so the new
  `infrastructure/frontend.yaml` (with CFN intrinsics like `!Ref`,
  `!GetAtt`, `!Sub`) doesn't fail PyYAML's strict tag parser.

## Verification

```bash
cd frontend && npm run build
```

26 pages built successfully.  Skip link rendered on the homepage:
```html
<a href="#main-content" class="skip-link">Ugrás a tartalomhoz</a>
```
Main element wired:
```html
<main id="main-content" tabindex="-1">
```

CSS bundle (`about.<hash>.css` is the deduplicated home for the global
stylesheet — every page using `BaseLayout` links to it) contains the
new `.skip-link` rule, the `:focus-visible` rule, and the bumped color
tokens.

Of 26 generated pages, **23/26 reference a CSS bundle that contains
the new global rules**.  The 3 exceptions are admin pages
(`/admin`, `/admin/login`, `/admin/callback`) which use a different
`AdminLayout` component — these are out of scope for the public-site
audit and will be covered in Phase 2.

## Audit deltas

The Phase 1 fixes resolve the following audit findings:

- ✅ C1 — Skip-to-content
- ✅ C2 — Global focus-visible
- ✅ C3 — Color contrast (text + non-text)
- ✅ S1 — Header logo accessible-name override
- ✅ S2 — Badge-featured contrast (covered by C3)
- ✅ S3 — Gallery overlay contrast on light images
- ✅ S6 — Status vs alert semantics on the inquiry form
- ✅ M1 — `<address>` semantic wrapper for the agent card
- ✅ C4 — Map widget (cooperative gestures + button markers + textual fallback)
- ✅ C5 — Inquiry form `aria-required` / `aria-invalid` / error summary
- ✅ S4 — Gallery hero: nested interactive elements
- ✅ S5 — Lightbox `aria-labelledby`
- ✅ S7 — PropertyCard duplicate-link pattern verified compliant (no change needed; documented in audit)
- ✅ S8 — Filter chip noise: empty chips drop `aria-pressed` and gain `aria-disabled="true"`
- ✅ S9 — `toLocale()` regression test pins the `<html lang>` fallback contract
- ✅ M3 — Operator alt-text guidance in admin UI

**Phase 2 complete — all critical and serious findings closed.**

Phase 3 progress:

- ✅ M3 — Operator alt-text guidance in admin UI
- ✅ M6 — Above-the-fold eager loading for first-row PropertyCards
- ✅ M7 — Sitemap hreflang alternates
- ✅ P1 — Accessibility statement page (legal requirement under EAA)
- ✅ P3 — axe-core in CI

- ✅ **Public hosting of the audit document** (`frontend/scripts/copy-audit-docs.mjs`, `frontend/package.json` `prebuild` hook, `.gitignore`, `pages/{,en/}accessibility.astro`).
  - Why: the `/accessibility` statement (P1) referenced an "audit report" link, but the link was a placeholder `https://github.com/`.  The Web Accessibility Directive (Directive (EU) 2016/2102) and EAA Article 13 both expect the underlying audit findings to be publicly retrievable from the same domain that publishes the statement.  Pointing reviewers at GitHub also leaks the source repository, which we don't want to advertise on a customer-facing page.
  - How: a new `frontend/scripts/copy-audit-docs.mjs` build helper copies a curated allow-list of three documents (`a11y-audit.md`, `a11y-phase1-changelog.md`, `a11y-dropdown-spec.md`) from `docs/` into `frontend/public/audit/` before every Astro build, wired through a `"prebuild"` script in `frontend/package.json`.  Astro's `public/` pass-through means the files land in `dist/audit/*` and the existing `aws s3 sync dist/ s3://… --delete` step in `scripts/deploy_frontend.sh` ships them to the same S3 bucket / CloudFront distribution as the rest of the site — same origin, no auth, no GitHub link.  The single source of truth stays `docs/` at the repo root; `frontend/public/audit/` is gitignored so the build-time copy never ends up in commits.  Curated allow-list (rather than `cp -r docs/ public/audit/`) keeps internal runbooks and operational notes out of the public bundle.
  - Both `pages/accessibility.astro` and `pages/en/accessibility.astro` now point at `/audit/a11y-audit.md` instead of the placeholder, and the "Date of last assessment" paragraph also surfaces `/audit/a11y-phase1-changelog.md` so reviewers get the implementation log alongside the findings.
  - Verified `dist/audit/` after `npm run build`: 3 files staged (`a11y-audit.md` 40 KB, `a11y-phase1-changelog.md` ~20 KB, `a11y-dropdown-spec.md` ~13 KB).  `grep 'href="/audit/' dist/{,en/}accessibility/index.html` returns the three expected links per locale.  `npm test` 71/71 still pass; `npm run a11y` still reports 0 critical/serious across 10 pages.

- ✅ **P3 — axe-core in CI** (`frontend/scripts/a11y-axe.mjs`, `frontend/package.json`, `.github/workflows/ci.yml`).
  - Static-site scanner: feeds the built `dist/*.html` files through `axe-core` inside a `jsdom` window — no headless browser needed because every public page is fully pre-rendered. Layout-dependent checks (focus order, gesture handling, true composited contrast) are explicitly out of scope and stay on the manual NVDA/VoiceOver pass documented in `docs/a11y-audit.md` P4.
  - Page coverage: one entry per template family (homepage, properties index, property detail, contact, about, accessibility — both HU and EN where the locale exists). Adding a new template = one line in the `PAGES` array. The current set is 10 pages.
  - Failure policy: any violation of impact `critical` or `serious` against the `wcag2a / wcag2aa / wcag21a / wcag21aa / best-practice` rule tags fails the build (`process.exit(1)`). `moderate` / `minor` findings print as advisories but don't block — the constant `FAIL_IMPACTS` is the single flip when we're ready to tighten. The `color-contrast` rule is disabled because jsdom can't compute composited backgrounds; the C3 token bumps cover that requirement statically.
  - CI wiring: `npm run a11y` runs as the last step of the `frontend` job, immediately after `astro build`, so the same `dist/` the deploy step would publish is exactly what's audited. Pinned via `axe-core@^4.10.0` and `jsdom@^25.0.0` as `devDependencies` in `frontend/package.json`.
  - Verified locally on the post-Phase-3 build: `✓ axe-core: 0 critical/serious violations across 10 pages.` Four advisory `heading-order` findings remain on the listing/contact pages (PropertyCard `<h3>` and the Footer `<h3>`s land at deeper levels than their parent `<h2>`); these are documented as accepted noise and will be revisited if/when we promote `moderate` to blocking.

- ✅ **P1 — Accessibility statement pages (HU + EN)** (`pages/{,en/}accessibility.astro`, footer link, sitemap entry).
  - Required by EAA Article 13. Both locales cover the five mandatory sections: conformance status, last-assessment date, feedback contact, enforcement procedure (Hungarian Equal Treatment Authority), and technical specification.
  - Footer gets a new "Legal / Jogi" column with a single link to the statement (`Footer.astro` + `footer.legalHeading` / `footer.accessibility` keys in both locales).
  - Sitemap (`pages/sitemap.xml.ts`) now lists `/accessibility` as a canonical entry with the standard hreflang chain to its EN twin.
  - Verified: `dist` grew from 26 → 28 pages; both `/accessibility/index.html` and `/en/accessibility/index.html` render; footer links resolve to the right locale; 71/71 tests pass.

- ✅ **M7 — Sitemap hreflang alternates** (`frontend/src/pages/sitemap.xml.ts`).
  - Old shape: every public page emitted as one canonical entry per locale (`/properties` plus `/en/properties` listed independently). Search engines and EU multilingual-coverage audits had no signal that the two URLs are localised twins.
  - New shape: one canonical `<url>` entry per page (default-locale URL), with `<xhtml:link rel="alternate" hreflang="hu|en|x-default" href="…"/>` children pointing at every variant. The `urlset` element gained the required `xmlns:xhtml="http://www.w3.org/1999/xhtml"` namespace declaration.
  - Verified `dist/sitemap.xml` after `npm run build`: every entry shows the three alternates with the right URLs (`/`, `/en/`, and the same `/` as `x-default` for the homepage; `/properties` + `/en/properties` for the listing page; etc.).

### Second-pass audit fixes (post-Phase-3)

- ✅ **M6 — First-row PropertyCards now eager-load their image** (`PropertyCard.astro` + 14 listing-page callsites).
  - `PropertyCard.astro` accepts an optional `index` prop (defaults to `99` so any unprefixed call still lazy-loads); the inner `<PropertyPicture loading={index < 3 ? "eager" : "lazy"}>` opts the first three cards on every grid into eager loading.
  - All 14 callsites in `pages/{,en/}{index,city/[city],district/[district],type/[type],properties/index,properties/[slug]}.astro` switched from `.map((p) => <PropertyCard property={p} />)` to `.map((p, i) => <PropertyCard property={p} index={i} />)`.
  - Improves LCP on the homepage and listing pages without affecting the lazy-loading contract for off-screen cards. Verified `loading="eager"` appears on the first card image in `dist/index.html` and `dist/properties/index.html`.

- ✅ **C6 — Lang switcher: active locale rendered as a `<span>`** (`Header.astro`).
  - The active locale (the one currently being viewed) was a real `<a>` linking to its own URL, so voice-control "click HU" on the HU homepage triggered a no-op page reload that trashed any open form state, and AT users heard the misleading "HU, link, current".
  - Fix: 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. The inactive locale stays a real `<a>`.
  - Visible chip pair is unchanged for sighted users; comment header on the component documents the rationale per WCAG 2.4.4 / 4.1.2.
  - Verified in `dist/index.html` and `dist/en/index.html` after `npm run build` (26 pages, clean).

- ✅ **C7 — Nav active-state no longer leaks via substring matching** (`Header.astro`).
  - The previous `currentPath.startsWith(href)` check meant every nav link's `aria-current="page"` fired on the homepage `/` (every other path starts with `/` too) and `/properties` matched on `/en/properties` because the EN-prefixed path contains the HU one as a substring.
  - Fix: a new `isActive(href)` helper — exact match plus `/`-bounded prefix match, with the locale roots (`/`, `/en`, `/en/`) special-cased as exact-only. Both `nav-link--active` and `aria-current="page"` 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; `/en/properties/` flags only the EN locale's link, not the HU `/properties`.

## How to verify visually

1. `cd frontend && npm run build && npx serve dist`
2. Open `http://localhost:3000`
3. Tab once — you should see "Ugrás a tartalomhoz" pop in at the top-left of the viewport.
4. Hit `Enter` — focus should move into `<main>` and subsequent Tabs should walk through the page content, skipping the header.
5. Tab through any link/button — every stop should now have a visible 2-px brand-blue outline.
6. Visit `/properties/<any>` and inspect the gallery counter pill — text should remain legible against any photo background.
7. Submit the contact form with required fields blank — the inline browser tooltip will still show, but on a network-failure submit the status box now carries `role="alert"`.
8. Open `/admin/properties/edit?id=<any>` (after logging in) and scroll to the *Images* card — the new alt-text guidance callout sits above the dropzone, every alt-text input now shows a live char-count + heuristic warning row, and gallery cards without alt-text gain a small "Add alt text" chip.

## Files changed

```
.pre-commit-config.yaml
docs/a11y-audit.md                       (audit doc, unchanged this commit)
docs/a11y-phase1-changelog.md            (this file)
frontend/src/i18n/locales/en.ts          (+a11y.skipToMain, +admin.images.altGuidance.*)
frontend/src/i18n/locales/hu.ts          (+a11y.skipToMain, +admin.images.altGuidance.*)
frontend/src/layouts/BaseLayout.astro    (skip link + main id)
frontend/src/styles/global.css           (focus-visible + skip-link + token bumps)
frontend/src/components/Header.astro     (drop logo aria-label)
frontend/src/components/PropertyGallery.astro  (counter/hint opacity)
frontend/src/components/AgentCard.astro  (<address> wrapper)
frontend/src/components/admin/ImageUploader.astro  (M3 — alt-text guidance + counter + heuristics)
frontend/src/pages/contact.astro         (role flip)
frontend/src/pages/en/contact.astro      (role flip)
```
