Accessibility fixes.
Some checks are pending
Test / vitest (push) Waiting to run
Test / playwright (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions

This commit is contained in:
Jennie Robinson Faber 2026-04-05 16:03:10 +01:00
parent 4aacb26c4b
commit 88c94aaaf4
12 changed files with 276 additions and 260 deletions

View file

@ -21,13 +21,13 @@
--border: #b8a880; --border: #b8a880;
--border-d: #a89470; --border-d: #a89470;
--candle: #7a5a10; --candle: #7a5a10;
--candle-dim: #9a7420; --candle-dim: #7a5a10;
--candle-faint: #c4a448; --candle-faint: #c4a448;
--ember: #8a4420; --ember: #8a4420;
--text: #2a2015; --text: #2a2015;
--text-bright: #1a1008; --text-bright: #1a1008;
--text-dim: #5a5040; --text-dim: #5a5040;
--text-faint: #8a7e6a; --text-faint: #6a5e4a;
--parch: #2a2015; --parch: #2a2015;
--parch-hover: #3a3025; --parch-hover: #3a3025;
--parch-text: #ede4d0; --parch-text: #ede4d0;
@ -110,6 +110,11 @@ a:hover {
text-decoration: underline; text-decoration: underline;
} }
p a {
text-decoration: underline;
text-underline-offset: 2px;
}
/* ---- SECTION LABELS ---- */ /* ---- SECTION LABELS ---- */
.section-label { .section-label {
font-size: 10px; font-size: 10px;

View file

@ -4,7 +4,9 @@
<div class="dev-actions"> <div class="dev-actions">
<div class="dev-buttons"> <div class="dev-buttons">
<a href="/api/dev/test-login" class="dev-button">Admin</a> <a href="/api/dev/test-login" class="dev-button">Admin</a>
<button class="dev-button dev-logout" @click="handleLogout">Log out</button> <button class="dev-button dev-logout" @click="handleLogout">
Log out
</button>
</div> </div>
<USelectMenu <USelectMenu
v-model="selectedEmail" v-model="selectedEmail"
@ -22,22 +24,24 @@
</template> </template>
<script setup> <script setup>
const selectedEmail = ref(null) const selectedEmail = ref(null);
const { logout } = useAuth() const { logout } = useAuth();
const { data: members } = await useFetch('/api/dev/members', { const { data: members } = await useFetch("/api/dev/members", {
default: () => [] default: () => [],
}) });
const loginAsEmail = (email) => { const loginAsEmail = (email) => {
if (email) { if (email) {
navigateTo(`/api/dev/member-login?email=${encodeURIComponent(email)}`, { external: true }) navigateTo(`/api/dev/member-login?email=${encodeURIComponent(email)}`, {
} external: true,
});
} }
};
const handleLogout = async () => { const handleLogout = async () => {
await logout() await logout();
} };
</script> </script>
<style scoped> <style scoped>
@ -86,8 +90,16 @@ const handleLogout = async () => {
color: var(--bg); color: var(--bg);
} }
.dev-select { .dev-select {
width: 100%; width: 100%;
} }
:deep([data-slot="base"]) {
background: var(--bg);
border-color: var(--border);
}
:deep([data-slot="placeholder"]) {
color: var(--text-dim);
}
</style> </style>

View file

@ -1,36 +1,38 @@
export default defineNuxtRouteMiddleware(async (to, from) => { export default defineNuxtRouteMiddleware(async (to, from) => {
// Skip on server-side rendering // Skip on server-side rendering
if (process.server) { if (process.server) {
console.log('🛡️ Auth middleware - skipping on server') console.log("🛡️ Auth middleware - skipping on server");
return return;
} }
const { memberData, checkMemberStatus } = useAuth() const { memberData, checkMemberStatus } = useAuth();
const { openLoginModal } = useLoginModal() const { openLoginModal } = useLoginModal();
console.log('🛡️ Auth middleware (CLIENT) - route:', to.path) console.log("🛡️ Auth middleware (CLIENT) - route:", to.path);
console.log(' - memberData exists:', !!memberData.value) console.log(" - memberData exists:", !!memberData.value);
console.log(' - Running on:', process.server ? 'SERVER' : 'CLIENT') console.log(" - Running on:", process.server ? "SERVER" : "CLIENT");
// If no member data, try to check authentication // If no member data, try to check authentication
if (!memberData.value) { if (!memberData.value) {
console.log(' - No member data, checking authentication...') console.log(" - No member data, checking authentication...");
const isAuthenticated = await checkMemberStatus() const isAuthenticated = await checkMemberStatus();
console.log(' - Authentication result:', isAuthenticated) console.log(" - Authentication result:", isAuthenticated);
if (!isAuthenticated) { if (!isAuthenticated) {
console.log(' - ❌ Authentication failed, showing login modal') console.log(" - ❌ Authentication failed, showing login modal");
// Open login modal instead of redirecting // Open login modal instead of redirecting
openLoginModal({ openLoginModal({
title: 'Sign in to continue', title: "Sign in to continue",
description: 'You need to be signed in to access this page', description: "You need to be signed in to access this page",
dismissible: true, dismissible: true,
redirectTo: to.fullPath, redirectTo: to.fullPath,
}) });
// Abort navigation - stay on current page with modal open // Let navigation proceed — the page renders its own unauthenticated
return abortNavigation() // fallback, and the modal opens on top. abortNavigation() on an initial
// page load resets client state, which closes the modal before it shows.
return;
} }
} }
console.log(' - ✅ Authentication successful for:', memberData.value?.email) console.log(" - ✅ Authentication successful for:", memberData.value?.email);
}) });

View file

@ -3,7 +3,7 @@
<div class="page-header"> <div class="page-header">
<div class="header-row"> <div class="header-row">
<NuxtLink to="/admin/events" class="back-link">&larr; Events</NuxtLink> <NuxtLink to="/admin/events" class="back-link">&larr; Events</NuxtLink>
<h1>{{ editingEvent ? 'Edit Event' : 'Create Event' }}</h1> <h1>{{ editingEvent ? "Edit Event" : "Create Event" }}</h1>
<p>Fill out the form below to create or update an event</p> <p>Fill out the form below to create or update an event</p>
</div> </div>
</div> </div>
@ -32,9 +32,7 @@
<h2 class="section-heading">Basic Information</h2> <h2 class="section-heading">Basic Information</h2>
<div class="field"> <div class="field">
<label> <label> Event Title <span class="required">*</span> </label>
Event Title <span class="required">*</span>
</label>
<UInput <UInput
v-model="eventForm.title" v-model="eventForm.title"
placeholder="Enter a clear, descriptive event title" placeholder="Enter a clear, descriptive event title"
@ -51,15 +49,13 @@
<label>Feature Image</label> <label>Feature Image</label>
<ImageUpload v-model="eventForm.featureImage" /> <ImageUpload v-model="eventForm.featureImage" />
<p class="help-text"> <p class="help-text">
Upload a high-quality image (1200x630px recommended) to Upload a high-quality image (1200x630px recommended) to represent
represent your event your event
</p> </p>
</div> </div>
<div class="field"> <div class="field">
<label> <label> Event Description <span class="required">*</span> </label>
Event Description <span class="required">*</span>
</label>
<UTextarea <UTextarea
v-model="eventForm.description" v-model="eventForm.description"
placeholder="Provide a clear description of what attendees can expect from this event" placeholder="Provide a clear description of what attendees can expect from this event"
@ -97,11 +93,10 @@
<div class="form-grid"> <div class="form-grid">
<div class="field"> <div class="field">
<label> <label> Event Type <span class="required">*</span> </label>
Event Type <span class="required">*</span>
</label>
<USelect <USelect
v-model="eventForm.eventType" v-model="eventForm.eventType"
aria-label="Event Type"
:items="[ :items="[
{ label: 'Community Meetup', value: 'community' }, { label: 'Community Meetup', value: 'community' },
{ label: 'Workshop', value: 'workshop' }, { label: 'Workshop', value: 'workshop' },
@ -116,9 +111,7 @@
</div> </div>
<div class="field"> <div class="field">
<label> <label> Location <span class="required">*</span> </label>
Location <span class="required">*</span>
</label>
<UInput <UInput
v-model="eventForm.location" v-model="eventForm.location"
placeholder="e.g., https://zoom.us/j/123... or #channel-name" placeholder="e.g., https://zoom.us/j/123... or #channel-name"
@ -135,9 +128,7 @@
</div> </div>
<div class="field"> <div class="field">
<label> <label> Start Date & Time <span class="required">*</span> </label>
Start Date & Time <span class="required">*</span>
</label>
<NaturalDateInput <NaturalDateInput
v-model="eventForm.startDate" v-model="eventForm.startDate"
placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'" placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
@ -149,9 +140,7 @@
</div> </div>
<div class="field"> <div class="field">
<label> <label> End Date & Time <span class="required">*</span> </label>
End Date & Time <span class="required">*</span>
</label>
<NaturalDateInput <NaturalDateInput
v-model="eventForm.endDate" v-model="eventForm.endDate"
placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'" placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
@ -248,22 +237,14 @@
<h2 class="section-heading">Ticketing</h2> <h2 class="section-heading">Ticketing</h2>
<label class="check-label"> <label class="check-label">
<input <input v-model="eventForm.tickets.enabled" type="checkbox" />
v-model="eventForm.tickets.enabled"
type="checkbox"
/>
<div> <div>
<strong>Enable Ticketing</strong> <strong>Enable Ticketing</strong>
<span class="help-text"> <span class="help-text"> Allow ticket sales for this event </span>
Allow ticket sales for this event
</span>
</div> </div>
</label> </label>
<div <div v-if="eventForm.tickets.enabled" class="nested-section">
v-if="eventForm.tickets.enabled"
class="nested-section"
>
<label class="check-label"> <label class="check-label">
<input <input
v-model="eventForm.tickets.public.available" v-model="eventForm.tickets.public.available"
@ -298,9 +279,7 @@
placeholder="0.00" placeholder="0.00"
class="w-full" class="w-full"
/> />
<p class="help-text"> <p class="help-text">Set to 0 for free public events</p>
Set to 0 for free public events
</p>
</div> </div>
</div> </div>
@ -339,7 +318,10 @@
</div> </div>
</div> </div>
<div v-if="eventForm.tickets.public.earlyBirdPrice > 0" class="field"> <div
v-if="eventForm.tickets.public.earlyBirdPrice > 0"
class="field"
>
<label>Early Bird Deadline</label> <label>Early Bird Deadline</label>
<NaturalDateInput <NaturalDateInput
v-model="eventForm.tickets.public.earlyBirdDeadline" v-model="eventForm.tickets.public.earlyBirdDeadline"
@ -353,8 +335,8 @@
</div> </div>
<div class="note-box"> <div class="note-box">
<strong>Note:</strong> Members always get free access to all <strong>Note:</strong> Members always get free access to all events
events regardless of ticket settings. regardless of ticket settings.
</div> </div>
</div> </div>
@ -363,10 +345,7 @@
<h2 class="section-heading">Series Management</h2> <h2 class="section-heading">Series Management</h2>
<label class="check-label"> <label class="check-label">
<input <input v-model="eventForm.series.isSeriesEvent" type="checkbox" />
v-model="eventForm.series.isSeriesEvent"
type="checkbox"
/>
<div> <div>
<strong>Part of Event Series</strong> <strong>Part of Event Series</strong>
<span class="help-text"> <span class="help-text">
@ -375,17 +354,13 @@
</div> </div>
</label> </label>
<div <div v-if="eventForm.series.isSeriesEvent" class="nested-section">
v-if="eventForm.series.isSeriesEvent"
class="nested-section"
>
<div class="field"> <div class="field">
<label> <label> Select Series <span class="required">*</span> </label>
Select Series <span class="required">*</span>
</label>
<div class="series-select-row"> <div class="series-select-row">
<USelect <USelect
v-model="selectedSeriesId" v-model="selectedSeriesId"
aria-label="Select Series"
@update:model-value="onSeriesSelect" @update:model-value="onSeriesSelect"
:items=" :items="
availableSeries.map((series) => ({ availableSeries.map((series) => ({
@ -397,10 +372,7 @@
value-key="value" value-key="value"
class="w-full" class="w-full"
/> />
<NuxtLink <NuxtLink to="/admin/series/create" class="btn btn-primary">
to="/admin/series/create"
class="btn btn-primary"
>
New Series New Series
</NuxtLink> </NuxtLink>
</div> </div>
@ -409,13 +381,9 @@
</p> </p>
</div> </div>
<div <div v-if="selectedSeriesId || eventForm.series.id">
v-if="selectedSeriesId || eventForm.series.id"
>
<div class="field"> <div class="field">
<label> <label> Series Title <span class="required">*</span> </label>
Series Title <span class="required">*</span>
</label>
<UInput <UInput
v-model="eventForm.series.title" v-model="eventForm.series.title"
placeholder="e.g., Cooperative Game Development Fundamentals" placeholder="e.g., Cooperative Game Development Fundamentals"
@ -454,8 +422,8 @@
</div> </div>
<div v-if="selectedSeriesId" class="note-box"> <div v-if="selectedSeriesId" class="note-box">
<strong>Note:</strong> This event will be added to the <strong>Note:</strong> This event will be added to the existing
existing "{{ eventForm.series.title }}" series. "{{ eventForm.series.title }}" series.
</div> </div>
</div> </div>
</div> </div>
@ -507,10 +475,7 @@
<div class="form-grid"> <div class="form-grid">
<div class="check-group"> <div class="check-group">
<label class="check-label"> <label class="check-label">
<input <input v-model="eventForm.isOnline" type="checkbox" />
v-model="eventForm.isOnline"
type="checkbox"
/>
<div> <div>
<strong>Online Event</strong> <strong>Online Event</strong>
<span class="help-text"> <span class="help-text">
@ -535,10 +500,7 @@
<div class="check-group"> <div class="check-group">
<label class="check-label"> <label class="check-label">
<input <input v-model="eventForm.isVisible" type="checkbox" />
v-model="eventForm.isVisible"
type="checkbox"
/>
<div> <div>
<strong>Visible on Public Calendar</strong> <strong>Visible on Public Calendar</strong>
<span class="help-text"> <span class="help-text">
@ -548,15 +510,10 @@
</label> </label>
<label class="check-label"> <label class="check-label">
<input <input v-model="eventForm.isCancelled" type="checkbox" />
v-model="eventForm.isCancelled"
type="checkbox"
/>
<div> <div>
<strong>Event Cancelled</strong> <strong>Event Cancelled</strong>
<span class="help-text"> <span class="help-text"> Mark this event as cancelled </span>
Mark this event as cancelled
</span>
</div> </div>
</label> </label>
</div> </div>
@ -582,9 +539,7 @@
<!-- Form Actions --> <!-- Form Actions -->
<div class="form-actions"> <div class="form-actions">
<NuxtLink to="/admin/events" class="btn"> <NuxtLink to="/admin/events" class="btn"> Cancel </NuxtLink>
Cancel
</NuxtLink>
<div class="form-actions-right"> <div class="form-actions-right">
<button <button
@ -597,11 +552,7 @@
{{ creating ? "Saving..." : "Save & Create Another" }} {{ creating ? "Saving..." : "Save & Create Another" }}
</button> </button>
<button <button type="submit" :disabled="creating" class="btn btn-primary">
type="submit"
:disabled="creating"
class="btn btn-primary"
>
{{ {{
creating creating
? "Saving..." ? "Saving..."
@ -1007,7 +958,7 @@ const saveAndCreateAnother = async () => {
} }
.page-header h1 { .page-header h1 {
font-family: 'Brygada 1918', serif; font-family: "Brygada 1918", serif;
font-size: 24px; font-size: 24px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);
@ -1037,7 +988,7 @@ const saveAndCreateAnother = async () => {
} }
.section-heading { .section-heading {
font-family: 'Brygada 1918', serif; font-family: "Brygada 1918", serif;
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);

View file

@ -31,7 +31,7 @@
<input v-model="searchQuery" placeholder="Search members..." /> <input v-model="searchQuery" placeholder="Search members..." />
</div> </div>
<div class="field" style="margin-bottom: 0"> <div class="field" style="margin-bottom: 0">
<select v-model="circleFilter"> <select v-model="circleFilter" aria-label="Filter by circle">
<option value="">All Circles</option> <option value="">All Circles</option>
<option value="community">Community</option> <option value="community">Community</option>
<option value="founder">Founder</option> <option value="founder">Founder</option>
@ -56,6 +56,8 @@
<tr> <tr>
<th class="col-check"> <th class="col-check">
<UCheckbox <UCheckbox
label="Select all members"
:ui="{ label: 'sr-only' }"
:model-value=" :model-value="
allVisibleSelected allVisibleSelected
? true ? true
@ -80,6 +82,8 @@
<tr v-for="member in filteredMembers" :key="member._id"> <tr v-for="member in filteredMembers" :key="member._id">
<td class="col-check"> <td class="col-check">
<UCheckbox <UCheckbox
:label="`Select ${member.name}`"
:ui="{ label: 'sr-only' }"
:model-value="selectedMemberIds.includes(member._id)" :model-value="selectedMemberIds.includes(member._id)"
@update:model-value="toggleSelect(member._id)" @update:model-value="toggleSelect(member._id)"
/> />

View file

@ -403,13 +403,12 @@ const isAlmostFull = (event) => {
color: var(--candle); color: var(--candle);
} }
.cta-soon { .cta-soon {
color: var(--text-faint); color: var(--text-dim);
cursor: default; cursor: default;
} }
.cta-soon em { .cta-soon em {
font-style: normal; font-style: normal;
font-size: 10px; font-size: 10px;
opacity: 0.65;
} }
.filter-toggle { .filter-toggle {

View file

@ -256,22 +256,21 @@ const copyCalendarLink = async () => {
const { openLoginModal } = useLoginModal(); const { openLoginModal } = useLoginModal();
// Handle authentication check on page load // Handle authentication check on page load
// server: false ensures this always runs on the client, even on a hard page load.
// The auth middleware only fires for client-side navigations in Nuxt 4, so we
// can't rely on it to open the modal when the user lands directly on this URL.
const { pending: authPending } = await useLazyAsyncData( const { pending: authPending } = await useLazyAsyncData(
"dashboard-auth", "dashboard-auth",
async () => { async () => {
// Only check authentication on client side
if (process.server) return null;
// If no member data, try to authenticate
if (!memberData.value) { if (!memberData.value) {
const isAuthenticated = await checkMemberStatus(); const isAuthenticated = await checkMemberStatus();
if (!isAuthenticated) { if (!isAuthenticated) {
// Show login modal instead of redirecting
openLoginModal({ openLoginModal({
title: "Sign in to your dashboard", title: "Sign in to continue",
description: "Enter your email to access your member dashboard", description: "You need to be signed in to access this page",
dismissible: true, dismissible: true,
redirectTo: "/member/dashboard",
}); });
return null; return null;
} }
@ -279,6 +278,7 @@ const { pending: authPending } = await useLazyAsyncData(
return memberData.value; return memberData.value;
}, },
{ server: false },
); );
// Load registered events // Load registered events

View file

@ -188,7 +188,10 @@
<div class="section-label">Visibility</div> <div class="section-label">Visibility</div>
<div class="toggle-field"> <div class="toggle-field">
<USwitch v-model="formData.showInDirectory" /> <USwitch
v-model="formData.showInDirectory"
aria-label="Show in Member Directory"
/>
<div class="toggle-label"> <div class="toggle-label">
Show in Member Directory Show in Member Directory
<span class="toggle-sub" <span class="toggle-sub"
@ -206,7 +209,10 @@
<div class="section-label">Peer Support</div> <div class="section-label">Peer Support</div>
<div class="toggle-field"> <div class="toggle-field">
<USwitch v-model="formData.peerSupportEnabled" /> <USwitch
v-model="formData.peerSupportEnabled"
aria-label="Offer Peer Support"
/>
<div class="toggle-label"> <div class="toggle-label">
Offer Peer Support Offer Peer Support
<span class="toggle-sub" <span class="toggle-sub"
@ -291,7 +297,10 @@
<div class="section-label">Notifications</div> <div class="section-label">Notifications</div>
<div class="toggle-field"> <div class="toggle-field">
<USwitch v-model="formData.notifyEvents" /> <USwitch
v-model="formData.notifyEvents"
aria-label="Event reminders"
/>
<div class="toggle-label"> <div class="toggle-label">
Event reminders Event reminders
<span class="toggle-sub" <span class="toggle-sub"
@ -301,7 +310,10 @@
</div> </div>
<div class="toggle-field"> <div class="toggle-field">
<USwitch v-model="formData.notifyUpdates" /> <USwitch
v-model="formData.notifyUpdates"
aria-label="Community updates"
/>
<div class="toggle-label"> <div class="toggle-label">
Community updates Community updates
<span class="toggle-sub" <span class="toggle-sub"
@ -311,7 +323,10 @@
</div> </div>
<div class="toggle-field"> <div class="toggle-field">
<USwitch v-model="formData.notifyPeerRequests" /> <USwitch
v-model="formData.notifyPeerRequests"
aria-label="Peer support requests"
/>
<div class="toggle-label"> <div class="toggle-label">
Peer support requests Peer support requests
<span class="toggle-sub" <span class="toggle-sub"

View file

@ -1,112 +1,127 @@
import { test, expect } from '@playwright/test' import { test, expect } from "@playwright/test";
import AxeBuilder from '@axe-core/playwright' import AxeBuilder from "@axe-core/playwright";
import { loginAsAdmin } from './helpers/auth.js' import { loginAsAdmin } from "./helpers/auth.js";
const publicPages = [ const publicPages = [
{ name: 'Home', path: '/' }, { name: "Home", path: "/" },
{ name: 'Join', path: '/join' }, { name: "Join", path: "/join" },
{ name: 'Events', path: '/events' }, { name: "Events", path: "/events" },
{ name: 'Coming Soon', path: '/coming-soon' }, { name: "Coming Soon", path: "/coming-soon" },
] ];
const memberPages = [ const memberPages = [
{ name: 'Member Dashboard', path: '/member/dashboard' }, { name: "Member Dashboard", path: "/member/dashboard" },
{ name: 'Member Profile', path: '/member/profile' }, { name: "Member Profile", path: "/member/profile" },
] ];
const adminPages = [ const adminPages = [
{ name: 'Admin Members', path: '/admin/members' }, { name: "Admin Members", path: "/admin/members" },
{ name: 'Admin Events Create', path: '/admin/events/create' }, { name: "Admin Events Create", path: "/admin/events/create" },
] ];
test.describe('accessibility — public pages', () => { test.describe("accessibility — public pages", () => {
for (const { name, path } of publicPages) { for (const { name, path } of publicPages) {
test(`${name} has no critical a11y violations`, async ({ page }) => { test(`${name} has no critical a11y violations`, async ({ page }) => {
await page.goto(path) await page.goto(path);
await page.waitForLoadState('networkidle') await page.waitForLoadState("networkidle");
const results = await new AxeBuilder({ page }) const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa']) .withTags(["wcag2a", "wcag2aa"])
.analyze() .analyze();
const critical = results.violations.filter( const critical = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious' (v) => v.impact === "critical" || v.impact === "serious",
) );
expect(critical, `${name} has critical/serious a11y issues`).toEqual([]) expect(critical, `${name} has critical/serious a11y issues`).toEqual([]);
}) });
} }
}) });
test.describe('accessibility — member pages', () => { test.describe("accessibility — member pages", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await loginAsAdmin(page) await loginAsAdmin(page);
}) });
for (const { name, path } of memberPages) { for (const { name, path } of memberPages) {
test(`${name} has no critical a11y violations`, async ({ page }) => { test(`${name} has no critical a11y violations`, async ({ page }) => {
await page.goto(path) await page.goto(path);
await page.waitForLoadState('networkidle') await page.waitForLoadState("networkidle");
const results = await new AxeBuilder({ page }) const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa']) .withTags(["wcag2a", "wcag2aa"])
.analyze() .analyze();
const critical = results.violations.filter( const critical = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious' (v) => v.impact === "critical" || v.impact === "serious",
) );
expect(critical, `${name} has critical/serious a11y issues`).toEqual([]) expect(critical, `${name} has critical/serious a11y issues`).toEqual([]);
}) });
} }
}) });
test.describe('accessibility — admin pages', () => { test.describe("accessibility — admin pages", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await loginAsAdmin(page) await loginAsAdmin(page);
}) });
for (const { name, path } of adminPages) { for (const { name, path } of adminPages) {
test(`${name} has no critical a11y violations`, async ({ page }) => { test(`${name} has no critical a11y violations`, async ({ page }) => {
await page.goto(path) await page.goto(path);
await page.waitForLoadState('networkidle') await page.waitForLoadState("networkidle");
const results = await new AxeBuilder({ page }) const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa']) .withTags(["wcag2a", "wcag2aa"])
.analyze() .analyze();
const critical = results.violations.filter( const critical = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious' (v) => v.impact === "critical" || v.impact === "serious",
) );
expect(critical, `${name} has critical/serious a11y issues`).toEqual([]) expect(critical, `${name} has critical/serious a11y issues`).toEqual([]);
}) });
} }
}) });
test.describe('keyboard navigation', () => { test.describe("keyboard navigation", () => {
test('tab through join form fields in order', async ({ page }) => { test("tab through join form fields in order", async ({ page }) => {
await page.goto('/join') await page.goto("/join");
await page.waitForLoadState('networkidle') await page.waitForLoadState("networkidle");
// Focus the name field and tab through // Focus the name field and tab through
await page.locator('#join-name').focus() await page.locator("#join-name").focus();
expect(await page.locator('#join-name').evaluate((el) => el === document.activeElement)).toBe(true) expect(
await page
.locator("#join-name")
.evaluate((el) => el === document.activeElement),
).toBe(true);
await page.keyboard.press('Tab') await page.keyboard.press("Tab");
// Email field should receive focus next // Email field should receive focus next
expect(await page.locator('#join-email').evaluate((el) => el === document.activeElement)).toBe(true) expect(
}) await page
.locator("#join-email")
.evaluate((el) => el === document.activeElement),
).toBe(true);
});
test('escape closes login modal', async ({ page }) => { test("escape closes login modal", async ({ page }) => {
await page.goto('/member/dashboard') await page.goto("/member/dashboard");
// Wait for login modal to appear
const modal = page.locator('text=Sign in to continue').or(page.locator('text=Sign in to your dashboard'))
await expect(modal.first()).toBeVisible({ timeout: 10000 })
await page.keyboard.press('Escape') // The page renders an inline "sign in required" wall for unauthenticated users
const signInBlock = page.locator("h2", { hasText: "Sign in required" });
await expect(signInBlock).toBeVisible({ timeout: 10000 });
// Click the Sign In button to open the login modal overlay
await page.locator("button", { hasText: "Sign In" }).click();
const modal = page.locator("text=Sign in to your dashboard");
await expect(modal.first()).toBeVisible({ timeout: 5000 });
await page.keyboard.press("Escape");
// Modal should close // Modal should close
await expect(modal.first()).not.toBeVisible({ timeout: 5000 }) await expect(modal.first()).not.toBeVisible({ timeout: 5000 });
}) });
}) });

View file

@ -1,48 +1,53 @@
import { test, expect } from './helpers/fixtures.js' import { test, expect } from "./helpers/fixtures.js";
test.describe('Admin members page', () => { test.describe("Admin members page", () => {
test('members list loads for admin', async ({ adminPage }) => { test("members list loads for admin", async ({ adminPage }) => {
await adminPage.goto('/admin/members') await adminPage.goto("/admin/members");
await expect(adminPage.locator('h1')).toHaveText('Members') await expect(adminPage.locator("h1")).toHaveText("Members");
await expect(adminPage.getByText('Manage members, contributions, and access')).toBeVisible() await expect(
}) adminPage.getByText("Manage members, contributions, and access"),
).toBeVisible();
});
test('search bar works', async ({ adminPage }) => { test("search bar works", async ({ adminPage }) => {
await adminPage.goto('/admin/members') await adminPage.goto("/admin/members");
const searchInput = adminPage.getByPlaceholder('Search members...') const searchInput = adminPage.getByPlaceholder("Search members...");
await expect(searchInput).toBeVisible() await expect(searchInput).toBeVisible();
await searchInput.fill('nonexistent-query-xyz') await searchInput.fill("nonexistent-query-xyz");
// Page should not crash -- either shows filtered results or the empty state // Page should not crash -- either shows filtered results or the empty state
await expect( await expect(
adminPage.locator('table').or(adminPage.getByText('No members found matching your criteria')) adminPage
).toBeVisible() .locator("table")
}) .or(adminPage.getByText("No members found matching your criteria")),
).toBeVisible();
});
test('non-admin redirect', async ({ browser }) => { test("non-admin redirect", async ({ browser }) => {
const context = await browser.newContext() const context = await browser.newContext();
const page = await context.newPage() const page = await context.newPage();
await page.goto('/admin/members') await page.goto("/admin/members");
// Admin middleware redirects non-admin users to / or /members // Admin middleware redirects non-admin users to / or /members
await page.waitForURL((url) => !url.pathname.startsWith('/admin')) await page.waitForURL((url) => !url.pathname.startsWith("/admin"));
expect(page.url()).not.toContain('/admin/members') expect(page.url()).not.toContain("/admin/members");
await context.close() await context.close();
}) });
test('add member button opens modal', async ({ adminPage }) => { test("add member button opens modal", async ({ adminPage }) => {
await adminPage.goto('/admin/members') await adminPage.goto("/admin/members");
await adminPage.waitForLoadState("networkidle"); // ensure Vue hydration is complete
await adminPage.getByRole('button', { name: 'Add Member' }).click() await adminPage.getByRole("button", { name: "Add Member" }).click();
// Modal should appear with the form heading and fields // Modal should appear with the form heading and fields
await expect(adminPage.getByText('Add New Member')).toBeVisible() await expect(adminPage.getByText("Add New Member")).toBeVisible();
await expect(adminPage.getByPlaceholder('Full name')).toBeVisible() await expect(adminPage.getByPlaceholder("Full name")).toBeVisible();
await expect(adminPage.getByPlaceholder('email@example.com')).toBeVisible() await expect(adminPage.getByPlaceholder("email@example.com")).toBeVisible();
}) });
}) });

View file

@ -1,41 +1,45 @@
import { test, expect } from '@playwright/test' import { test, expect } from "@playwright/test";
test.describe('coming-soon page', () => { test.describe("coming-soon page", () => {
test('renders with heading and login form', async ({ page }) => { test("renders with heading and login form", async ({ page }) => {
await page.goto('/coming-soon') await page.goto("/coming-soon");
await expect(page.locator('h1')).toContainText('Ghost Guild') await expect(page.locator("h1")).toContainText("Ghost Guild");
await expect(page.locator('input[type="email"]')).toBeVisible() await expect(page.locator('input[type="email"]')).toBeVisible();
await expect(page.getByRole('button', { name: 'Send Magic Link' })).toBeVisible() await expect(
}) page.getByRole("button", { name: "Send Magic Link" }),
).toBeVisible();
});
test('shows "Coming Soon" text for unauthenticated visitors', async ({ page }) => { test('shows "Coming Soon" text for unauthenticated visitors', async ({
await page.goto('/coming-soon') page,
}) => {
await page.goto("/coming-soon");
await expect(page.getByText('Coming Soon')).toBeVisible() await expect(page.getByText("Coming Soon")).toBeVisible();
}) });
}) });
test.describe('public routes accessible when gate is off', () => { test.describe("public routes accessible when gate is off", () => {
test('home page loads', async ({ page }) => { test("home page loads", async ({ page }) => {
await page.goto('/') await page.goto("/");
// Should not redirect to /coming-soon // Should not redirect to /coming-soon
expect(page.url()).not.toContain('/coming-soon') expect(page.url()).not.toContain("/coming-soon");
await expect(page.getByText('Ghost Guild')).toBeVisible() await expect(page.locator("h1")).toContainText("Ghost Guild");
}) });
test('events page loads', async ({ page }) => { test("events page loads", async ({ page }) => {
await page.goto('/events') await page.goto("/events");
expect(page.url()).not.toContain('/coming-soon') expect(page.url()).not.toContain("/coming-soon");
await expect(page.locator('h1')).toContainText('Events') await expect(page.locator("h1")).toContainText("Events");
}) });
test('join page loads', async ({ page }) => { test("join page loads", async ({ page }) => {
await page.goto('/join') await page.goto("/join");
expect(page.url()).not.toContain('/coming-soon') expect(page.url()).not.toContain("/coming-soon");
await expect(page.locator('h1')).toContainText('Join Ghost Guild') await expect(page.locator("h1")).toContainText("Join Ghost Guild");
}) });
}) });

View file

@ -9,7 +9,11 @@ export default defineNuxtConfig({
classSuffix: "", classSuffix: "",
}, },
app: { app: {
head: {}, head: {
htmlAttrs: { lang: "en" },
title: "Ghost Guild",
titleTemplate: "%s · Ghost Guild",
},
}, },
build: { build: {
transpile: ["vue-cal"], transpile: ["vue-cal"],