Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8730347
feat(homepage): scaffold homepage and Sign-Up CTA component
May 16, 2026
e3cb3fd
feat(features): add Features section with 4 cards, grid, and tests
May 16, 2026
dd58540
feat(cta-login): add secondary login CTA component with tests
May 16, 2026
ff613c8
feat(footer): add footer component with 4-column layout and standard …
May 16, 2026
193e0a5
feat(navigation): add sticky header with logo, nav links, and mobile …
May 16, 2026
cea74fc
feat(homepage): add responsive breakpoints for mobile/tablet/desktop
May 16, 2026
a6d5510
chore(scenario): backend fallback commit for "Footer with Standard Li…
May 16, 2026
1e3e40c
Merge remote-tracking branch 'origin/feature/product-homepage-design-…
May 16, 2026
f6bbce0
feat(theme-toggle): add dark/light theme toggle with persistence
May 16, 2026
e2abf8c
fix(component-loader): recursively load nested components
May 16, 2026
795a040
feat(social-proof): add scenario 9 social-proof section
May 16, 2026
0be95ea
test(performance): add scenario 11 performance budget tests
May 16, 2026
473e176
chore(scenario): backend fallback commit for "Dark and Light Theme To…
May 16, 2026
c6e4782
Merge remote-tracking branch 'origin/feature/product-homepage-design-…
May 16, 2026
90cc2f3
feat(a11y): add WCAG 2.1 AA accessibility utilities and tests
May 16, 2026
b239f96
test(homepage): add cross-browser Playwright suite (chromium/webkit/f…
May 16, 2026
53d8a4e
chore(scenario): backend fallback commit for "Performance Budgets and…
May 16, 2026
3ac02ae
Merge remote-tracking branch 'origin/feature/product-homepage-design-…
May 16, 2026
348a271
feat(social-proof): wire up renderer, add badge assets, demo preview
May 16, 2026
8ad8fb0
test(social-proof): add axe-core WCAG 2 A/AA audit for dark/light TC4
May 16, 2026
14301f6
chore(scenario): backend fallback commit for "Social Proof Section"
May 16, 2026
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
16 changes: 16 additions & 0 deletions .claude/skills/axe-jsdom-homepage-audit/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
name: axe-jsdom-homepage-audit
description: Run axe-core WCAG 2 A/AA audits in vitest+jsdom against a component-assembled homepage. Use when the project's test pipeline is vitest+jsdom (not Playwright) and you need to validate WCAG compliance across light and dark themes.
---

Run axe-core WCAG audits in the project's vitest+jsdom pipeline against the fully assembled homepage (inlined components + runtime renderers).

See [README.md](references/README.md) for full documentation, including the assembleHomepage() helper pattern, JSDOM-incompatible rules to disable, and source-CSS contract assertions.

Quick scripted use:

```bash
scripts/run_axe_audit.sh homepage/
```

This runs `npm test -- accessibility` from the given directory and surfaces axe violations with file:line targets.
95 changes: 95 additions & 0 deletions .claude/skills/axe-jsdom-homepage-audit/references/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# axe-jsdom-homepage-audit

## What this skill does

Runs axe-core WCAG 2 A/AA audits in **vitest + jsdom** (not Playwright) against a fully assembled, component-based homepage. Tests cover both `light` and `dark` themes.

Use this skill when:

- The project tests with vitest + jsdom (already in `devDependencies`)
- The homepage is built from per-component HTML/CSS/JS files with `data-component="..."` placeholders
- You need WCAG 2.1 AA validation but cannot afford to introduce Playwright just for accessibility

## Project-specific anchors

- Production CSS: `homepage/css/accessibility.css` — skip-link, `:focus-visible`, `prefers-reduced-motion`
- Test suite: `homepage/tests/accessibility/accessibility.test.js`
- Page entry: `homepage/index.html` (skip-link at line 22, `<main id="main" tabindex="-1">` at line 26)

## The assembleHomepage() helper pattern

Every accessibility test assertion runs against the same DOM as a real user. Build it once per test by:

1. Setting `<html data-theme="light|dark" lang="en">`
2. Inserting `<title>` and `<meta charset>` into `<head>` (required for axe's `document-title` rule)
3. Inlining every project stylesheet via a list of paths (use `readFileIfExists` so missing scenarios don't break the audit)
4. Reading `index.html`, extracting `<body>`, and inlining `[data-component]` placeholders with each component's HTML file
5. Running the runtime renderers (`renderFeatures`, `renderSocialProof`, `initFooter`)

Skip components whose HTML hasn't been authored yet — remove the placeholder rather than leaving an "empty section" axe will flag.

## Disabling JSDOM-incompatible axe rules

JSDOM doesn't compute layout, so these axe rules emit false positives and must be disabled:

```js
await axe.run(document, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] },
rules: {
'color-contrast': { enabled: false }, // needs real layout
'target-size': { enabled: false }, // needs real pixel sizes
},
});
```

These rules should still be enforced — by the Playwright visual suite, not here.

## Translating "Playwright" scenarios to JSDOM

When the scenario writeup says "Playwright" but the project ships only vitest:

| Scenario step | JSDOM translation |
|---|---|
| "Tab through the page" | `el.focus()` on each focusable in DOM order |
| "Press Enter on the skip-link" | `dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))` + explicit `main.focus()` (hash-anchor focus isn't auto in JSDOM) |
| "emulateMedia({ reducedMotion: 'reduce' })" | Override `globalThis.matchMedia` to return `matches: true` for the query |
| "getComputedStyle outline" | JSDOM doesn't match `:focus-visible` against computed style — assert the CSS *source* contains the rule via regex instead |

## CSS source-contract assertions

Some WCAG behaviours can't be observed via the JSDOM runtime (CSS variables, `:focus-visible`, `@media`). Assert against the source file:

```js
const css = fs.readFileSync('homepage/css/accessibility.css', 'utf8');
expect(css).toMatch(/:focus-visible\s*\{[^}]*outline\s*:/);
expect(css).toMatch(/@media\s*\(prefers-reduced-motion:\s*reduce\)[\s\S]*animation-duration\s*:\s*0/i);
```

This treats the shipped CSS as the source of truth — a regression in the source will fail the test even though JSDOM can't observe the runtime effect.

## Test cases the suite covers

1. axe-core WCAG 2 A/AA scan (light + dark, color-contrast/target-size disabled)
2. Exactly one `<h1>` on the page
3. Heading hierarchy with no skipped levels
4. Every focusable element reaches focus in DOM order; CSS ships the `:focus-visible` safety net
5. Skip-link is the first focusable; Enter moves focus to `#main`
6. `prefers-reduced-motion: reduce` CSS contract + `matchMedia` listener
7. Every `<img>` has alt / role / aria-hidden / `alt=""`

## Running

```bash
cd homepage
npm test -- accessibility
```

Or use the helper script in `scripts/run_axe_audit.sh`.

## Required dev dependencies

```
axe-core ^4.11.4
vitest (already in project)
jsdom (already in project)
```
39 changes: 39 additions & 0 deletions .claude/skills/axe-jsdom-homepage-audit/scripts/run_axe_audit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Run the accessibility test file against a homepage directory.
#
# Usage:
# run_axe_audit.sh [HOMEPAGE_DIR]
#
# Defaults to the current directory. Expects:
# - package.json with a "test" script that runs vitest
# - tests/accessibility/accessibility.test.js (or another file matching the
# "accessibility" name pattern)
# - axe-core listed as a devDependency
set -euo pipefail

HOMEPAGE_DIR="${1:-.}"

if [[ ! -d "$HOMEPAGE_DIR" ]]; then
echo "error: $HOMEPAGE_DIR is not a directory" >&2
exit 2
fi

if [[ ! -f "$HOMEPAGE_DIR/package.json" ]]; then
echo "error: no package.json at $HOMEPAGE_DIR — is this a JS project?" >&2
exit 2
fi

cd "$HOMEPAGE_DIR"

if ! grep -q '"axe-core"' package.json; then
echo "warning: axe-core not listed in package.json devDependencies." >&2
echo " install with: npm install --save-dev axe-core@^4.11.4" >&2
fi

if [[ ! -d node_modules/axe-core ]]; then
echo "installing dependencies..."
npm install
fi

# Run only the accessibility test file. Vitest pattern matches by path substring.
exec npm test -- accessibility
7 changes: 7 additions & 0 deletions .claude/skills/progressive-cta-anchor/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
name: progressive-cta-anchor
description: Build a homepage call-to-action (Sign-Up, Login, Download) as a progressively-enhanced anchor — anchor-default no-JS navigation, optional history.pushState SPA navigation, bubbling telemetry CustomEvent, idempotent attachment, and WCAG 2.1 AA focus-visible styling. Use when adding any primary/secondary CTA to the MirDB homepage under `homepage/components/cta-*/`.
scope: project
---

See [README.md](references/README.md) for full documentation.
229 changes: 229 additions & 0 deletions .claude/skills/progressive-cta-anchor/references/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
# Progressive CTA Anchor

A pattern for building homepage call-to-action elements (Sign-Up, Login, Download, etc.) on the MirDB homepage so they:

1. Work without JavaScript (semantic `<a href="…">`)
2. Enhance with SPA-style `history.pushState` navigation when JS is available
3. Emit a vendor-neutral telemetry `CustomEvent`
4. Are idempotent — safe to mount in multiple slots (nav strip + hero)
5. Meet WCAG 2.1 AA (`aria-label`, `:focus-visible`, `prefers-reduced-motion`)

## When to Use

Any time you add a CTA button/link under `homepage/components/cta-*/`. Examples:
- Sign-Up → `/register`
- Login → `/login`
- "Download" or "Try it" → `/downloads`
- "View docs" → `/docs`

This is **not** the right pattern for arbitrary in-page buttons that only run JS (use `<button>` for those).

## File Layout

```
homepage/components/cta-<name>/
├── cta-<name>.html # Single anchor, no scripts, no inline handlers
├── cta-<name>.css # Button + :focus-visible + prefers-reduced-motion
└── cta-<name>.js # Exports: createXAnchor, findXAnchor, attachXHandler
homepage/tests/components/cta-<name>.test.js
```

## HTML Template

```html
<a class="btn btn--primary cta-<name>"
href="/<destination>"
data-cta="<name>"
aria-label="<verb the destination, e.g. Sign up for MirDB>">
<Visible label>
</a>
```

**Required attributes**:
- `href` — the static destination (anchor-default works without JS)
- `data-cta` — DOM hook the JS handler / tests select on
- `aria-label` — describes the action + product, not just the visible label

## JS Module Template

```js
export const X_HREF = "/<destination>";
export const X_TELEMETRY_EVENT = "cta:<name>:click";

export function createXAnchor({ href = X_HREF, label = "<Visible>" } = {}) {
const a = document.createElement("a");
a.className = "btn btn--primary cta-<name>";
a.dataset.cta = "<name>";
a.setAttribute("aria-label", "<verb the destination>");
a.href = href && href.length > 0 ? href : X_HREF;
a.textContent = label;
return a;
}

export function findXAnchor(root) {
if (!root) return null;
if (root.matches && root.matches('a[data-cta="<name>"]')) return root;
return root.querySelector('a[data-cta="<name>"]');
}

function emitTelemetry(detail) {
try {
document.dispatchEvent(new CustomEvent(X_TELEMETRY_EVENT, { detail, bubbles: true }));
} catch { /* old test envs */ }
}

function supportsHistoryNavigation() {
return typeof window !== "undefined"
&& typeof window.history !== "undefined"
&& typeof window.history.pushState === "function";
}

export function attachXHandler(root, options = {}) {
const anchor = findXAnchor(root);
if (!anchor) return null;

// Restore the default destination if a consumer wiped it.
if (!anchor.getAttribute("href")) anchor.setAttribute("href", X_HREF);
if (!anchor.textContent || !anchor.textContent.trim()) anchor.textContent = "<Visible>";

// Idempotent — bail if already attached.
if (anchor.dataset.xAttached === "true") return anchor;
anchor.dataset.xAttached = "true";

anchor.addEventListener("click", (event) => {
const href = anchor.getAttribute("href") || X_HREF;
emitTelemetry({ href, source: "cta-<name>" });

if (options.spa !== false && supportsHistoryNavigation()) {
event.preventDefault();
try {
window.history.pushState({ cta: "<name>" }, "", href);
window.dispatchEvent(new PopStateEvent("popstate", { state: { cta: "<name>" } }));
} catch {
window.location.href = href;
}
}
});
return anchor;
}
```

## CSS Template

```css
.cta-<name> {
display: inline-block;
padding: 0.75rem 1.5rem;
/* Primary brand color, or transparent/outline for secondary */
background-color: #2563eb;
color: #ffffff;
font-weight: 600;
border-radius: 6px;
border: 2px solid transparent;
text-decoration: none;
cursor: pointer;
transition: background-color 150ms ease, transform 100ms ease, box-shadow 150ms ease;
}

.cta-<name>:hover,
.cta-<name>:focus-visible { background-color: #1d4ed8; }

/* High-contrast focus ring per WCAG 2.4.7 */
.cta-<name>:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.25);
}

.cta-<name>:active { transform: translateY(1px); }

@media (prefers-reduced-motion: reduce) {
.cta-<name> { transition: none; }
.cta-<name>:active { transform: none; }
}
```

## Test Skeleton (Vitest + jsdom)

```js
import { describe, it, expect, beforeEach, vi } from "vitest";
import { mountComponent } from "../helpers/dom-helpers.js";
import {
attachXHandler, createXAnchor, findXAnchor,
X_HREF, X_TELEMETRY_EVENT,
} from "../../components/cta-<name>/cta-<name>.js";

describe("cta-<name>", () => {
let root;
beforeEach(async () => {
root = document.createElement("div");
root.setAttribute("data-component", "cta-<name>");
document.body.appendChild(root);
await mountComponent("cta-<name>", root);
});

it("renders the anchor with correct href and a non-empty label", () => {
const a = document.querySelector('a[data-cta="<name>"]');
expect(a).not.toBeNull();
expect(a.getAttribute("href")).toBe(X_HREF);
expect(a.textContent.trim()).toMatch(/<label regex>/i);
});

it("emits telemetry and pushState-navigates on click", () => {
attachXHandler(root);
const spy = vi.fn();
document.addEventListener(X_TELEMETRY_EVENT, spy);
findXAnchor(root).click();
expect(spy).toHaveBeenCalledTimes(1);
expect(window.location.pathname).toBe(X_HREF);
});

it("keeps href even with no handler (no-JS fallback)", () => {
expect(findXAnchor(root).getAttribute("href")).toBe(X_HREF);
});

it("supplies default href when constructed with href=''", () => {
expect(createXAnchor({ href: "" }).getAttribute("href")).toBe(X_HREF);
});

it("is idempotent — double-attach yields one listener", () => {
attachXHandler(root); attachXHandler(root);
const spy = vi.fn();
document.addEventListener(X_TELEMETRY_EVENT, spy);
findXAnchor(root).click();
expect(spy).toHaveBeenCalledTimes(1);
});
});
```

## Wiring into the App

`homepage/index.html` places a placeholder:
```html
<div data-component="cta-<name>"></div>
```

`homepage/js/main.js` should call the handler after `loadAllComponents()`:
```js
document.querySelectorAll('[data-component="cta-<name>"]').forEach(slot => {
attachXHandler(slot);
});
```

## Key Design Decisions

| Decision | Why |
|--------------------------------------------|--------------------------------------------------------------------------------------|
| `<a href>` not `<button>` | Works without JS, correctly announced by screen readers, native focus order |
| Telemetry as bubbling `CustomEvent` | No vendor lock-in; tests subscribe via `addEventListener`, no global mocks needed |
| Centralised `X_HREF` constant | Single source of truth; reused by markup default, factory, and restore path |
| `dataset.xAttached` idempotency marker | Cheapest "have we attached?" check; survives multi-slot mounting |
| `:focus-visible` (not `:focus`) | Avoids ring on mouse click; still visible for keyboard / programmatic focus |
| `prefers-reduced-motion: reduce` override | Vestibular-safe; required for WCAG conformance |

## Anti-Patterns

- Do **not** use `<button onclick="…">` — breaks no-JS, fails accessibility tests.
- Do **not** dispatch telemetry through a global SDK call from inside the component — couples it to a vendor.
- Do **not** read the destination from a config object passed at mount time — keep it as a module-level constant so URL changes are a single grep.
- Do **not** use `:focus { outline: none }` to "clean up" the ring — leave `:focus-visible` styled.
6 changes: 6 additions & 0 deletions .claude/skills/vitest-performance-budgets/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: vitest-performance-budgets
description: Validate Lighthouse-style performance budgets and image lazy-loading in vitest+JSDOM without spawning Chromium. Use when adding tests for page-load time, asset-weight ceilings, Lighthouse score prerequisites, or below-the-fold image detection in the MirDB homepage.
---

See [README.md](references/README.md) for full documentation.
Loading