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
30 changes: 30 additions & 0 deletions .claude/agents/accessibility-reviewer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
name: accessibility-reviewer
description: >-
Use to independently review UI changes for accessibility and UX-flow
regressions before a PR is opened or when a reviewer requests an a11y pass.
Reports findings only; does not edit code unless explicitly asked.
tools: Read, Grep, Glob, Bash
model: sonnet
---

You are an accessibility specialist reviewing changes to a Nuxt 3 / Vue 3
site (Tailwind, @nuxt/content, @nuxtjs/i18n).

Process:
1. Determine the changed UI files: `git diff --name-only main...HEAD` filtered
to `components/`, `layouts/`, `pages/`, `assets/css/`.
2. Read each changed file fully and apply the `a11y-review` skill checklist
(WCAG 2.1 AA: structure/semantics, keyboard/focus, names/labels/i18n,
contrast/motion).
3. Run `pnpm lint` and report any `vuejs-accessibility/*` violations.

Output a single report grouped by severity:
- **Blocker** — keyboard trap, missing accessible name, broken landmark,
contrast failure.
- **Should fix** — heading order, redundant landmarks, missing
`aria-expanded`.
- **Nice to have** — minor polish.

Each finding: `file:line`, the problem, and a concrete fix. Be precise and
brief. Do not modify files unless the caller explicitly instructs you to.
19 changes: 19 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(pnpm install)",
"Bash(pnpm dev)",
"Bash(pnpm build)",
"Bash(pnpm generate)",
"Bash(pnpm lint)",
"Bash(pnpm lint:fix)",
"Bash(pnpm exec eslint .)",
"Bash(pnpm seo:lighthouse)",
"Bash(pnpm seo:check)",
"Bash(git diff:*)",
"Bash(git status)",
"Bash(git log:*)"
]
}
}
48 changes: 48 additions & 0 deletions .claude/skills/a11y-review/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
name: a11y-review
description: >-
Audit Vue/Nuxt components and pages in this repo for accessibility (WCAG 2.1
AA) and UX-flow issues. Use whenever adding or changing UI, before opening a
PR with visual changes, or when the user asks for an accessibility check.
---

# Accessibility & UX-Flow Review

Apply this checklist to every changed `.vue` file under `components/`,
`layouts/`, and `pages/`. Report findings as a short list grouped by
severity (blocker / should-fix / nice-to-have) with `file:line` refs.

## Structure & semantics
- One `<h1>` per page; headings increase by one level (no skipped levels).
- Use semantic elements (`<button>`, `<nav>`, `<main>`, `<header>`,
`<footer>`) instead of `div`/`span` with click handlers.
- Exactly one `<main id="main-content">`; landmark roles are not duplicated
with identical `aria-label`s.
- Interactive `<div>`/`<span>` is forbidden — use a real `<button>`/`<a>`.

## Keyboard & focus
- Every interactive element is reachable and operable by keyboard.
- Visible focus style (`focus-visible:ring-*`) on all focusable elements.
- Disclosure/menu toggles expose `aria-expanded` and `aria-controls`.
- Overlays/dialogs trap focus, restore focus on close, close on `Escape`.
- The skip link in `layouts/default.vue` still targets `#main-content`.

## Names, labels, i18n
- Icon-only controls have an `aria-label` sourced from i18n
(`accessibility.*`), never a hard-coded string.
- All `<img>`/`<NuxtImg>` have meaningful `alt` (empty `alt=""` only for
purely decorative images).
- Form fields have associated `<label>`s.
- Add new strings to both `i18n/locales/en.json` and `de.json`.

## Visual & motion
- Text/background contrast meets WCAG AA (4.5:1, or 3:1 for large text);
verify against Tailwind tokens in `assets/css/tokens.css`.
- Animations/transitions respect `prefers-reduced-motion` (covered globally
in `tokens.css` — do not reintroduce unconditional long transitions).
- Tap targets are at least 24x24 px.

## Verification
- Run `pnpm lint` (eslint-plugin-vuejs-accessibility is wired in).
- For page-level changes, run `pnpm seo:lighthouse` and keep the
`categories:accessibility` gate (error, minScore 0.9) green.
28 changes: 28 additions & 0 deletions .claude/skills/component-scaffold/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
name: component-scaffold
description: >-
Scaffold a new feature in this Nuxt repo following the house pattern:
domain types first, then a composable, then section/UI components, then wire
the page. Use when adding any new feature, section, or page.
---

# Feature Scaffold (types -> composables -> sections -> page)

Follow this order strictly; it is the repo convention from `AGENTS.md`.

1. **Domain types** — add to `types/` (e.g. `types/<feature>.ts`). Model the
data the feature renders before touching UI.
2. **Composable** — add `composables/use<Feature>.ts` that loads/derives the
data and exposes a typed, reactive API. Keep data logic out of components.
3. **Section / UI components** — build under
`components/features/<feature>/`, reusing `components/base/*` primitives.
Use `<script setup lang="ts">`, 2-space indent, `PascalCase.vue`.
4. **Page assembly** — the route file in `pages/` only composes sections and
calls the composable; it contains no business logic.

## Constraints
- Do not add new dependencies or config presets without clear justification.
- Respect the existing directory structure; extend, don't rearrange.
- Every new string goes into both `i18n/locales/en.json` and `de.json`.
- Before finishing, run the `a11y-review` skill on the new components and
ensure `pnpm lint` and `pnpm build` pass.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
.nuxt
.nitro
.cache
.wrangler
dist

# Lighthouse CI local run artefacts (the workflow uploads its own copy)
Expand Down
9 changes: 6 additions & 3 deletions .lighthouserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"assertions": {
"categories:seo": ["error", { "minScore": 0.95 }],
"categories:best-practices": ["warn", { "minScore": 0.9 }],
"categories:accessibility": ["warn", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.9 }],
"robots-txt": "error",
"canonical": "error",
"hreflang": "error",
Expand All @@ -32,8 +32,11 @@
"html-lang-valid": "error",
"meta-description": "error",
"document-title": "error",
"image-alt": "warn",
"link-text": "warn"
"image-alt": "error",
"link-text": "error",
"color-contrast": ["warn", { "minScore": 0.9 }],
"tap-targets": "warn",
"heading-order": "warn"
}
},
"upload": {
Expand Down
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,18 @@
- For neue Features zuerst Domain-Typen unter `types/` und Composables unter `composables/` anlegen, dann Sections/UI-Komponenten bauen und die Seite nur noch daraus zusammensetzen.
- Do not introduce new dependencies or configuration presets without clear justification.
- Respect the existing directory structure; extend rather than rearrange where possible.

## Reusable Agents & Skills (`.claude/`)
These are checked in so every CLI/web session gets the same standards. Treat
them as rules, not suggestions:
- **`.claude/skills/component-scaffold`** — MUST be followed when adding any
new feature/section/page (types → composables → sections → page).
- **`.claude/skills/a11y-review`** — MUST be run on every changed
`components/`, `layouts/`, or `pages/` file before finishing UI work.
- **`.claude/agents/accessibility-reviewer`** — delegate an independent
accessibility pass to this subagent before opening a PR with visual changes.
- Accessibility tooling is enforced in CI: `eslint-plugin-vuejs-accessibility`
via `pnpm lint`, and the Lighthouse `accessibility` gate
(error, minScore 0.9) via `pnpm seo:lighthouse`. Keep both green.
- When adding new skills/agents, place them under `.claude/` and document
them here so they remain the single source of truth.
28 changes: 28 additions & 0 deletions assets/css/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ html { scroll-behavior: smooth; }
/* Respect user preference to reduce motion */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

/* Skip link: visually hidden until focused via keyboard */
.skip-link {
position: absolute;
left: 0.5rem;
top: -3rem;
z-index: 100;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 2px 8px rgb(0 0 0 / 25%);
transition: top 0.15s ease-in-out;
}

.skip-link:focus {
top: 0.5rem;
outline: 2px solid var(--color-secondary);
outline-offset: 2px;
}

/* Utility: Gradient-Text auf Basis der Brand-Token */
Expand Down
2 changes: 1 addition & 1 deletion components/content/ProseHr.vue
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<template>
<hr role="separator" class="my-6 border-t border-gray-200 dark:border-gray-700 transition-colors">
<hr class="my-6 border-t border-gray-200 dark:border-gray-700 transition-colors">
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ onClickOutside(dropdown, () => {
class="absolute z-30 mt-2 overflow-hidden origin-top-right bg-white text-gray-900 dark:text-white dark:bg-gray-800 rounded-xl shadow-xxl transition-all transform"
:class="props.mobile ? 'left-0 right-0' : 'right-0 w-48'"
role="menu"
tabindex="-1"
:aria-labelledby="props.mobile ? 'mobile-language-menu-button' : 'language-menu-button'"
@keydown.esc="isOpen = false"
>
Expand Down
2 changes: 1 addition & 1 deletion components/features/footer/Footer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const versionLabel = computed(() =>
</script>

<template>
<footer class="mt-auto bg-[var(--color-surface)] dark:bg-[var(--color-surface)]" role="contentinfo" :aria-label="t('footer.aria_label')">
<footer class="mt-auto bg-[var(--color-surface)] dark:bg-[var(--color-surface)]" :aria-label="t('footer.aria_label')">
<!-- Main section -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 md:gap-8">
Expand Down
18 changes: 14 additions & 4 deletions components/features/home/carousel/Carousel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,10 @@ function withAlpha(rgb: string, a: number) {
</script>

<template>
<!-- carousel container per WAI-ARIA APG; section with a name is implicitly a region and owns the keyboard interaction -->
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<section
class="w-full"
role="region"
:aria-label="ariaLabel"
aria-roledescription="carousel"
@keydown="onKeydown"
Expand All @@ -240,11 +241,15 @@ function withAlpha(rgb: string, a: number) {
<div class="relative">

<!-- Ratio wrapper -->
<!-- focusable swipe surface; pointer/touch gestures have keyboard parity via the section keydown handler -->
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<div
class="group relative z-10 w-full overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] touch-pan-y select-none min-h-[58svh] md:min-h-0"
:style="{ paddingTop: aspectPercent }"
@mouseenter="isHovering = true"
@mouseleave="isHovering = false"
@focusin="isHovering = true"
@focusout="isHovering = false"
@pointerdown.passive="onPointerDown"
@pointerup.passive="onPointerUp"
@touchstart.passive="onPointerDown"
Expand Down Expand Up @@ -309,13 +314,18 @@ function withAlpha(rgb: string, a: number) {
v-for="(_s, i) in normalizedSlides"
:key="i"
type="button"
class="h-3.5 w-3.5 rounded-full transition ring-1 ring-white/60"
:class="i === current ? 'bg-[var(--color-brand-accent,#38bdf8)] ring-2 ring-offset-1 ring-offset-black/20' : 'bg-white/50 hover:bg-white/80'"
class="group grid h-6 w-6 place-items-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
:aria-label="`Show slide ${i + 1}`"
:aria-current="i === current ? 'true' : undefined"
:aria-controls="`carousel-slide-${i}`"
@click.stop="goTo(i)"
/>
>
<span
aria-hidden="true"
class="h-3.5 w-3.5 rounded-full transition ring-1 ring-white/60"
:class="i === current ? 'bg-[var(--color-brand-accent,#38bdf8)] ring-2 ring-offset-1 ring-offset-black/20' : 'bg-white/50 group-hover:bg-white/80'"
/>
</button>
</div>
</div>

Expand Down
4 changes: 2 additions & 2 deletions components/features/home/server-addresses/ServerAddresses.vue
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ const onCopyBedrockPort = async () => {
:title="t('server.connect.java')"
:address="props.javaAddress"
icon="desktop_windows"
iconClass="text-emerald-600 dark:text-emerald-400"
buttonClass="bg-emerald-600"
iconClass="text-emerald-700 dark:text-emerald-400"
buttonClass="bg-emerald-700"
:copied="copiedJava"
:onCopy="onCopyJava"
/>
Expand Down
4 changes: 1 addition & 3 deletions components/features/home/server-concept/ServerConcept.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ const iconFor = (name?: string): IconDefinition => iconMap[name ?? ''] ?? faCirc
<template>
<section
class="relative isolate w-full"
role="region"
:aria-labelledby="headingId"
:aria-describedby="props.subtitle ? descriptionId : undefined"
>
Expand All @@ -43,12 +42,11 @@ const iconFor = (name?: string): IconDefinition => iconMap[name ?? ''] ?? faCirc
</SectionHeading>
</div>

<div class="grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-3" role="list">
<div class="grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-3">
<article
v-for="(p, idx) in points"
:key="p.id ?? idx"
class="h-full rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 p-5 shadow-sm ring-1 ring-zinc-200/70 dark:ring-zinc-800/70 transition hover:shadow-md"
role="listitem"
>
<header class="mb-3 flex items-center gap-3">
<FontAwesomeIcon
Expand Down
2 changes: 1 addition & 1 deletion components/features/home/team/TeamMemberCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const ariaLabel = computed(() => t('team.card_aria', { name: props.name, role: p
</script>

<template>
<li class="snap-start shrink-0 w-72" role="listitem">
<li class="snap-start shrink-0 w-72">
<component
:is="profileHref ? NuxtLink : 'div'"
:to="profileHref"
Expand Down
13 changes: 6 additions & 7 deletions components/features/home/team/TeamMembers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,19 @@ const onWheel = (e: WheelEvent) => {

<div class="mb-4 flex flex-col items-stretch gap-3 sm:flex-row sm:items-end sm:justify-between">
<div class="flex gap-3">
<label class="sr-only" :for="'team-search'">{{ t('team.search_label') }}</label>
<label class="sr-only" for="team-search">{{ t('team.search_label') }}</label>
<input
:id="'team-search'"
id="team-search"
v-model="query"
type="search"
:placeholder="t('team.search_placeholder')"
class="w-64 rounded-lg border border-zinc-300/70 dark:border-zinc-700/80 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-500"
/>

<div class="flex items-center gap-2">
<label class="text-sm text-gray-700 dark:text-gray-300" :for="'team-role'">{{ t('team.filter_role') }}</label>
<label class="text-sm text-gray-700 dark:text-gray-300" for="team-role">{{ t('team.filter_role') }}</label>
<select
:id="'team-role'"
id="team-role"
v-model="selectedRole"
class="rounded-lg border border-zinc-300/70 dark:border-zinc-700/80 bg-white/90 dark:bg-zinc-900/70 px-2 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500"
>
Expand All @@ -88,9 +88,9 @@ const onWheel = (e: WheelEvent) => {
</div>

<div class="flex items-center gap-2">
<label class="text-sm text-gray-700 dark:text-gray-300" :for="'team-limit'">{{ t('team.limit_label') }}</label>
<label class="text-sm text-gray-700 dark:text-gray-300" for="team-limit">{{ t('team.limit_label') }}</label>
<select
:id="'team-limit'"
id="team-limit"
v-model.number="visibleCount"
class="rounded-lg border border-zinc-300/70 dark:border-zinc-700/80 bg-white/90 dark:bg-zinc-900/70 px-2 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500"
>
Expand All @@ -109,7 +109,6 @@ const onWheel = (e: WheelEvent) => {
>
<ul
class="team-scroll flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory [scrollbar-color:theme(colors.zinc.400)_transparent] [scrollbar-width:thin]"
role="list"
:aria-label="t('team.list_label')"
@wheel.prevent="onWheel"
>
Expand Down
2 changes: 1 addition & 1 deletion components/features/home/timeline/VerticalTimeline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ const shouldBlur = (index: number) => {
<section class="relative" :class="lineColorClass">
<!-- Central line (visible from md breakpoint upwards) -->

<ol role="list" :aria-label="ariaLabelComputed" class="relative mx-auto max-w-5xl space-y-10">
<ol :aria-label="ariaLabelComputed" class="relative mx-auto max-w-5xl space-y-10">
<div class="pointer-events-none absolute left-1/2 top-0 hidden h-full w-px -translate-x-1/2 bg-[var(--line)] md:block" aria-hidden="true" />
<template v-for="(ev, i) in displayedEvents" :key="ev.id">
<slot
Expand Down
Loading
Loading