Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Story 3.5: Keyboard Navigation for All Features

Status: done

## Story

As a **user with motor disabilities or keyboard preference**,
I want to navigate the entire site using only keyboard,
So that I can access all features without a mouse.

## Acceptance Criteria

1. **Tab order**: All links, buttons, and inputs are reachable via Tab/Shift+Tab in a logical order
2. **Activation**: All buttons and links are activatable with Enter; checkboxes with Space
3. **Filter panel keyboard**: Escape closes the filter panel; focus returns to the toggle button on close; focus moves to first filter on open (mobile)
4. **No keyboard traps**: User can always Tab away from any element
5. **No hover-only features**: All functionality accessible without mouse
6. **Language switcher**: Language switch UI buttons are present and keyboard-accessible in nav
7. **Comparison page (compare.html)**: Skip link present; `<nav>` has `aria-label`; `<main>` landmark present; `lang` attribute dynamic
8. **Focus visible**: All interactive elements show clear focus indicators (`:focus-visible` already defined)

## Tasks / Subtasks

- [x] Task 1 — Language switcher buttons in `templates/base.html` (AC: #6)
- [x] 1.1 Add `<div class="language-switcher">` with FR/EN `<button class="lang-btn">` elements inside `<nav>` in `base.html`
- [x] 1.2 Set `aria-current="true"` on active lang button; `aria-label="Switch to English"` / `"Passer en français"` on each button
- [x] 1.3 Wire buttons to call `switchLanguage()` from `lang.js` via inline `onclick` or event listener
- [x] 1.4 Mark active lang via `.lang-btn.active` class based on current `{{ lang }}` variable in Tera

- [x] Task 2 — Filter panel keyboard improvements in `static/mobile.js` (AC: #3)
- [x] 2.1 Add `keydown` listener on `document`: Escape key closes filter panel if open, returns focus to toggle button
- [x] 2.2 On panel open (mobile): move focus to first `<input class="filter-checkbox">` inside `#filters-content`
- [x] 2.3 On panel close: return focus to `.filters-toggle` button
- [x] 2.4 Ensure `aria-expanded` state is correctly toggled on the button (already done, verify)

- [x] Task 3 — Fix `static/compare.html` accessibility (AC: #7)
- [x] 3.1 Add `<a href="#main-content" class="skip-link">Skip to main content</a>` as first child of `<body>`
- [x] 3.2 Add `aria-label="Main navigation"` to `<nav>` element
- [x] 3.3 Wrap main content in `<main id="main-content" tabindex="-1">` landmark
- [x] 3.4 Change `<html lang="fr">` to read lang from localStorage (`cloudlandscape_lang`) via inline `<script>` in `<head>` to set correct lang attribute dynamically

- [x] Task 4 — Audit and verify full tab order on provider listing page (AC: #1, #4, #5)
- [x] 4.1 Verify tab order: skip link → nav logo → providers link → lang buttons → search input → filter toggle → filter checkboxes → reset button → provider cards (title link → add to comparison)
- [x] 4.2 Confirm no `tabindex` values above 0 exist anywhere (would break natural order) — confirmed: none found
- [x] 4.3 Verify filter reset button is reachable by Tab when filter panel is open

- [x] Task 5 — Verify tests pass (AC: all)
- [x] 5.1 Run `mise run build` — ✅ 26 pages, 0 errors
- [x] 5.2 Run `mise run check` (zola check) — ✅ 26 pages, 0 errors
- [x] 5.3 Run `mise run a11y` (axe-core) — ✅ 0 violations on 4 pages (/, /providers/, /providers/scaleway/, /compare.html)

## Dev Notes

### Current State — What Already Works ✅

- **Skip link**: `<a href="#main-content" class="skip-link">` in `base.html` — CSS positions it off-screen, reveals on `:focus` ✅
- **Skip link CSS**: `.skip-link:focus { top: 0; }` in `style.css` ✅
- **Focus indicators**: `:focus-visible { outline: 3px solid var(--aurora); outline-offset: 4px; }` in `style.css` ✅
- **Filter form elements**: Native `<input type="checkbox">` + `<label>` = keyboard-accessible by default ✅
- **Filter toggle**: `aria-expanded` is updated by `mobile.js` ✅
- **Reset button**: Native `<button>` = keyboard-accessible ✅
- **Result count**: `aria-live="polite"` on `.result-count` ✅
- **Nav landmark**: `<nav aria-label="Main navigation">` in `base.html` ✅
- **Main landmark**: `<main id="main-content" tabindex="-1" role="main">` in `base.html` ✅
- **Provider title links**: `<h2><a href="...">` = keyboard-accessible ✅
- **Add to comparison buttons**: `<button>` with `aria-label` ✅

### Gaps to Fix 🔧

1. **Language switcher UI missing**: `style.css` defines `.lang-btn` and `.language-switcher` but no HTML in `base.html` — `lang.js`'s `switchLanguage()` is never triggered. Must add buttons to nav.

2. **Filter panel — Escape key**: No Escape key handler in `mobile.js` or `filter.js`. Required by WCAG 2.1 SC 1.4.13 for components that open on interaction.

3. **Filter panel — focus management**: Opening the panel should trap/guide focus on mobile; closing should return focus.

4. **compare.html** is in `static/` (not Zola template), so it doesn't inherit `base.html`. Missing: skip link, proper `<main>` landmark, `<nav aria-label>`.

### Key File Locations

- `templates/base.html` — global nav layout, skip link, lang attribute
- `static/mobile.js` — filter toggle logic (lines ~30-45 for toggle click handler)
- `static/lang.js` — `switchLanguage(lang)` function (line ~37) — already implemented, just needs to be called from buttons
- `static/compare.html` — standalone static HTML (not Zola template)
- `static/style.css` — `.language-switcher`, `.lang-btn`, `.skip-link`, `:focus-visible` already defined

### Implementation Guidance

**Task 1 (lang buttons) in `base.html`:**
```html
<div class="language-switcher" role="navigation" aria-label="Language selection">
<button class="lang-btn{% if lang == 'fr' %} active{% endif %}"
onclick="switchLanguage('fr')"
aria-label="Passer en français"
{% if lang == 'fr' %}aria-current="true"{% endif %}>FR</button>
<button class="lang-btn{% if lang == 'en' %} active{% endif %}"
onclick="switchLanguage('en')"
aria-label="Switch to English"
{% if lang == 'en' %}aria-current="true"{% endif %}>EN</button>
</div>
```
Place inside the existing `<nav aria-label="Main navigation">` after the Providers link.

**Task 2 (Escape key) in `mobile.js`:**
```js
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const isOpen = content.classList.contains('open');
if (isOpen) {
content.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
toggleText.textContent = toggle.dataset.showText || 'Show Filters';
toggle.focus(); // return focus
}
}
});
```
Move focus to first checkbox on open:
```js
// Inside the click handler, after toggling open:
if (isOpen) {
const firstCheckbox = content.querySelector('.filter-checkbox');
if (firstCheckbox) firstCheckbox.focus();
}
```

**Task 3 (compare.html lang):**
Add before `</head>`:
```html
<script>
(function(){
var lang = localStorage.getItem('cloudlandscape_lang') || 'fr';
document.documentElement.lang = lang;
})();
</script>
```

### Architecture References

- [Source: project-context.md#Critical Implementation Rules — Accessibility]
- [Source: project-context.md#JavaScript Rules] — vanilla JS only, no framework
- [Source: project-context.md#CSS Rules] — `.lang-btn`, `.language-switcher` already in `style.css`
- WCAG 2.1 SC 2.1.1 (Keyboard), SC 2.1.2 (No Keyboard Trap), SC 2.4.3 (Focus Order)

### Testing

- `zola build` must pass with no errors
- Manual test: Tab through entire providers page without mouse; confirm all elements reachable
- Accessibility CI: `accessibility.yml` runs axe-core on the deployed site (GitHub Actions)
- No new JS dependencies allowed (vanilla JS only)
- JS must stay under 100KB gzipped budget

## Dev Agent Record

### Agent Model Used

claude-sonnet-4-6

### Debug Log References

### Completion Notes List

- ✅ Task 1: Language switcher FR/EN buttons added to `base.html` nav using `trans()` i18n native Zola. New keys added to both `[languages.en.translations]` and `[languages.fr.translations]` in `zola.toml`.
- ✅ Task 2: Escape key closes filter panel + focus management (move focus to first checkbox on open, return focus to toggle on close) added in `mobile.js`.
- ✅ Task 3: `compare.html` — skip link added, `<nav aria-label>`, `<main id="main-content" tabindex="-1">`, dynamic `lang` attribute via inline `<script>`.
- ✅ Task 4: No `tabindex > 0` found; natural tab order verified.
- ✅ Task 5: `mise run build` ✅, `mise run check` ✅, `mise run a11y` ✅ (0 axe-core violations on 4 pages).

### File List

- `templates/base.html` — Added language switcher nav buttons with i18n
- `static/mobile.js` — Added Escape key handler + focus management for filter panel
- `static/compare.html` — Added skip link, nav aria-label, main landmark, dynamic lang script
- `zola.toml` — Added `lang_nav_label`, `lang_switch_fr`, `lang_switch_en` translation keys (EN + FR)
- `_bmad-output/implementation-artifacts/3-5-keyboard-navigation-for-all-features.md` — This story file
141 changes: 141 additions & 0 deletions _bmad-output/implementation-artifacts/sprint-status.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# generated: 2026-03-11
# project: cloudlandscape
# project_key: cloudlandscape
# tracking_system: file-system
# story_location: _bmad-output/implementation-artifacts

# STATUS DEFINITIONS:
# ==================
# Epic Status:
# - backlog: Epic not yet started
# - in-progress: Epic actively being worked on
# - done: All stories in epic completed
#
# Epic Status Transitions:
# - backlog → in-progress: Automatically when first story is created (via create-story)
# - in-progress → done: Manually when all stories reach 'done' status
#
# Story Status:
# - backlog: Story only exists in epic file
# - ready-for-dev: Story file created in stories folder
# - in-progress: Developer actively working on implementation
# - review: Ready for code review (via Dev's code-review workflow)
# - done: Story completed
#
# Retrospective Status:
# - optional: Can be completed but not required
# - done: Retrospective has been completed
#
# WORKFLOW NOTES:
# ===============
# - Epic transitions to 'in-progress' automatically when first story is created
# - Stories can be worked in parallel if team capacity allows
# - SM typically creates next story after previous one is 'done' to incorporate learnings
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)

generated: 2026-03-11
project: cloudlandscape
project_key: cloudlandscape
tracking_system: file-system
story_location: _bmad-output/implementation-artifacts

development_status:
# ─── Epic 0: Foundation & Setup ─── COMPLETE ✅ (Sprint 1: 2026-02-28 to 2026-03-03)
epic-0: done
0-1-initialize-zola-project: done
0-2-configure-zola-for-taxonomies-i18n: done
0-3-set-up-github-actions-ci-cd-pipeline: done
0-4-seed-initial-provider-dataset: done
epic-0-retrospective: optional

# ─── Epic 1: Provider Discovery Foundation ─── COMPLETE ✅ (Sprint 2)
epic-1: done
1-1-display-complete-provider-list: done
1-2-filter-providers-by-service-type: done
1-3-filter-providers-by-certification: done
1-4-filter-providers-by-geographic-location: done
1-5-combine-multiple-filters: done
1-6-reset-filters-to-show-all-providers: done
1-7-search-providers-by-keyword: done
1-8-see-result-count-and-filter-state: done
epic-1-retrospective: optional

# ─── Epic 2: Provider Intelligence & Comparison ─── COMPLETE ✅
epic-2: done
2-1-display-provider-detail-page: done
2-2-view-unified-service-taxonomy: done
2-3-view-certifications-and-verify-attestations: done
2-4-view-provider-datacenters-and-geographic-coverage: done
2-5-access-direct-links-to-provider-websites-and-attestations: done
2-6-select-providers-for-comparison: done
2-7-view-comparison-table-with-service-alignment: done
2-8-view-certification-coverage-matrix: done
2-9-view-geographic-coverage-comparison: done
2-10-share-comparison-url: done
epic-2-retrospective: optional

# ─── Epic 3: Multilingual & Accessible Platform ─── IN PROGRESS 🔄
epic-3: in-progress
3-1-switch-language-between-french-and-english: done
3-2-display-all-provider-listings-in-both-languages: done
3-3-display-ui-navigation-and-elements-in-both-languages: done
3-4-persist-user-language-preference: done
3-5-keyboard-navigation-for-all-features: done
3-6-screen-reader-support-for-all-content: backlog
3-7-sufficient-color-contrast-wcag-2-1-aa: done # Merged via PR #7 (brand identity)
3-8-provide-skip-navigation-links: backlog
3-9-use-semantic-html-structure: backlog
3-10-provide-clear-focus-indicators: backlog
epic-3-retrospective: optional

# ─── Epic 4: Content Generation & Static Site Architecture ─── IN PROGRESS 🔄
epic-4: in-progress
4-1-generate-provider-listing-page-from-yaml-data: done
4-2-generate-individual-provider-detail-pages: done
4-3-generate-comparison-pages-with-dynamic-urls: done
4-4-generate-certification-explainer-pages: backlog
4-5-generate-xml-sitemap-for-search-engines: done
4-6-generate-multilingual-links-with-hreflang-tags: done
4-7-optimize-build-performance-for-incremental-changes: backlog
epic-4-retrospective: optional

# ─── Epic 5: Responsive & Progressive Design ───
epic-5: backlog
5-1-display-responsive-provider-listing-on-mobile: backlog
5-2-display-responsive-provider-listing-on-tablet: backlog
5-3-display-responsive-provider-listing-on-desktop: backlog
5-4-responsive-provider-comparison-table: backlog
5-5-progressive-enhancement-filter-works-without-javascript: backlog
5-6-images-optimized-for-different-viewports: backlog
5-7-no-layout-shift-during-page-load: backlog
5-8-progressive-content-loading-and-skeleton-states: backlog
epic-5-retrospective: optional

# ─── Epic 6: Data Quality & Community Contribution Workflow ───
epic-6: backlog
6-1-provide-contribution-guidelines: backlog
6-2-provide-yaml-template-for-provider-submissions: backlog
6-3-validate-provider-yaml-against-schema: backlog
6-4-detect-broken-external-links-in-provider-data: backlog
6-5-validate-certification-attestation-urls: backlog
6-6-prevent-duplicate-provider-entries: backlog
6-7-show-validation-feedback-in-pr: backlog
6-8-provide-clear-error-messages-for-contributions: backlog
6-9-show-contributor-their-published-contribution: backlog
6-10-validate-provider-schema-in-ci: backlog
epic-6-retrospective: optional

# ─── Epic 7: Maintainer Operations & Reliability ───
epic-7: backlog
7-1-review-pull-requests-with-validation-status: backlog
7-2-approve-provider-submissions-and-request-changes: backlog
7-3-merge-approved-pull-requests: backlog
7-4-trigger-site-regeneration-after-updates: backlog
7-5-monitor-ci-cd-pipeline-status: backlog
7-6-rollback-failed-deployments: backlog
7-7-monitor-uptime-and-performance-metrics: backlog
7-8-ensure-security-headers-are-configured: backlog
7-9-validate-supply-chain-security: backlog
7-10-maintain-high-lighthouse-scores: backlog
7-11-integrate-axe-core-accessibility-testing: backlog
epic-7-retrospective: optional
16 changes: 13 additions & 3 deletions static/compare.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Compare Providers - Cloud Landscape</title>
<script>
(function() {
var lang = localStorage.getItem('cloudlandscape_lang') || 'fr';
document.documentElement.lang = lang;
})();
</script>
<link rel="stylesheet" href="/style.css">
<style>
.comparison-page {
Expand Down Expand Up @@ -95,12 +101,13 @@
</style>
</head>
<body>
<nav>
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav aria-label="Main navigation">
<a href="/">Home</a>
<a href="/providers/">Providers</a>
</nav>

<main>
<main id="main-content" tabindex="-1">
<div class="comparison-page">
<h1>Provider Comparison</h1>

Expand All @@ -110,11 +117,12 @@ <h1>Provider Comparison</h1>

<div class="comparison-actions">
<a href="/providers/" class="btn btn-secondary">← Back to Providers</a>
<button class="btn btn-primary" onclick="shareComparison()">Share Comparison</button>
<button id="share-btn" class="btn btn-primary">Share Comparison</button>
</div>
</div>
</main>

<script src="/lang.js"></script>
<script>
// Get provider data from URL
function getProvidersFromUrl() {
Expand Down Expand Up @@ -287,6 +295,8 @@ <h2>No providers selected</h2>
}

init();

document.getElementById('share-btn').addEventListener('click', shareComparison);
</script>
</body>
</html>
14 changes: 7 additions & 7 deletions static/lang.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,21 +72,21 @@

const switcher = document.createElement('div');
switcher.className = 'language-switcher';
switcher.setAttribute('role', 'navigation');
switcher.setAttribute('aria-label', 'Language switcher');
switcher.setAttribute('role', 'group');
switcher.setAttribute('aria-label', 'Language selection');

switcher.innerHTML = `
<button class="lang-btn ${currentLang === 'fr' ? 'active' : ''}"
data-lang="fr"
aria-label="Switch to French"
${currentLang === 'fr' ? 'aria-current="true"' : ''}>
aria-label="Français"
aria-pressed="${currentLang === 'fr' ? 'true' : 'false'}">
FR
</button>
<span class="lang-separator">|</span>
<span class="lang-separator" aria-hidden="true">|</span>
<button class="lang-btn ${currentLang === 'en' ? 'active' : ''}"
data-lang="en"
aria-label="Switch to English"
${currentLang === 'en' ? 'aria-current="true"' : ''}>
aria-label="English"
aria-pressed="${currentLang === 'en' ? 'true' : 'false'}">
EN
</button>
`;
Expand Down
Loading
Loading