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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
run: npm test

- name: Set up Java (Firebase emulators require JDK 21+)
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: "21"
Expand Down
45 changes: 45 additions & 0 deletions app/(pages)/carousel-fixture/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { BeerCarousel } from "@/components/beer-carousel";
import {
CAROUSEL_FIXTURE_BEERS,
CAROUSEL_FIXTURE_IMAGE,
} from "@/lib/carousel-fixture";

// Test-only rendering of the homepage beer carousel for the Playwright smoke
// suite. The env var is a server-only check evaluated per request
// (force-dynamic): it is set exclusively by the Playwright webServer config,
// so in any normal deployment — including Vercel builds — this route returns
// 404 and serves nothing. It grants no access to real data: the carousel
// renders hardcoded fixture records and never reaches Firebase; the real
// homepage flow is unchanged.

export const dynamic = "force-dynamic";

export const metadata: Metadata = {
title: "Carousel fixture",
robots: { index: false, follow: false },
};

export default function CarouselFixturePage() {
if (process.env.CAROUSEL_FIXTURE !== "1") {
notFound();
}

const imageUrls = Object.fromEntries(
CAROUSEL_FIXTURE_BEERS.map((beer) => [beer.slug, CAROUSEL_FIXTURE_IMAGE])
);

return (
<main
id="main-content"
tabIndex={-1}
className="mx-auto max-w-300 px-6 pb-20 md:pb-30"
>
<h1 className="mb-12 text-3xl font-bold tracking-tight md:text-4xl">
Our Beers
</h1>
<BeerCarousel beers={CAROUSEL_FIXTURE_BEERS} imageUrls={imageUrls} />
</main>
);
}
1 change: 1 addition & 0 deletions app/robots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default function robots(): MetadataRoute.Robots {
disallow: [
"/admin",
"/admin-fixture",
"/carousel-fixture",
"/where-to-buy-fixture",
"/trade/login",
"/trade/order",
Expand Down
11 changes: 10 additions & 1 deletion components/beer-carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,16 @@ export function BeerCarousel({ beers, imageUrls }: BeerCarouselProps) {
role="group"
aria-roledescription="slide"
aria-label={`${index + 1} of ${beers.length}`}
onFocus={() => emblaApi?.scrollTo(index)}
// Keyboard accessibility: a tabbed-to slide that is partly or
// fully off-screen is scrolled into view. The :focus-visible
// gate limits that to keyboard focus — a pointer click/tap on
// the card link must not re-align the carousel ahead of
// navigation.
onFocus={(event) => {
if ((event.target as HTMLElement).matches(":focus-visible")) {
emblaApi?.scrollTo(index);
}
}}
>
<BeerCard beer={beer} imageUrl={imageUrls[beer.slug]} />
</div>
Expand Down
8 changes: 6 additions & 2 deletions docs/TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ moving it into `lib/` or API routes without an explicit issue.
| `/admin` | `app/(pages)/admin/page.tsx` | Server wrapper (`robots: noindex`) rendering the client `AdminDashboard` | Auth state, `beers`, `venues`, `meta/siteRebuild`, Storage | Admin dashboard. Auth checks happen client-side; real enforcement is in rules + APIs. |
| `/api/admin/*` | `app/api/admin/**` | Server (dynamic) | Admin SDK: Auth, Firestore | Bootstrap, `me`, users list/create-invitation, user patch/delete, invitation accept/resend, rebuild trigger. |
| `/api/trade-inquiry` | `app/api/trade-inquiry/route.ts` | Server (dynamic) | Resend | Validates the form payload and emails it; see §11. |
| `/sitemap.xml`, `/robots.txt` | `app/sitemap.ts`, `app/robots.ts` | Server (static — generated at build) | `beers` | SEO metadata routes; sitemap enumerates beer slugs at build time (empty without credentials — deterministic). robots.txt disallows `/admin`, `/admin-fixture`, `/where-to-buy-fixture`, `/trade/*` placeholders, and `/api/`; see `docs/operations/seo.md`. Favicons are static files in `public/` referenced from `app/layout.tsx` metadata. |
| `/sitemap.xml`, `/robots.txt` | `app/sitemap.ts`, `app/robots.ts` | Server (static — generated at build) | `beers` | SEO metadata routes; sitemap enumerates beer slugs at build time (empty without credentials — deterministic). robots.txt disallows `/admin`, `/admin-fixture`, `/carousel-fixture`, `/where-to-buy-fixture`, `/trade/*` placeholders, and `/api/`; see `docs/operations/seo.md`. Favicons are static files in `public/` referenced from `app/layout.tsx` metadata. |

### The `/trade` route: single canonical `page.tsx`

Expand Down Expand Up @@ -724,7 +724,11 @@ either. No secrets or placeholder values exist anywhere in CI.
dialogs, and per-row accessible names. `smoke-tests/where-to-buy-filters.spec.ts`
covers the venue filters via `/where-to-buy-fixture` — the same env-gated
pattern (`WHERE_TO_BUY_FIXTURE`) rendering `VenueDirectory` against
deterministic records from `lib/where-to-buy-fixture.ts`. See
deterministic records from `lib/where-to-buy-fixture.ts`.
`smoke-tests/carousel.spec.ts` covers homepage carousel interaction via
`/carousel-fixture` (`CAROUSEL_FIXTURE`, `lib/carousel-fixture.ts`): pointer
clicks must not re-align slides (only `:focus-visible` keyboard focus may
scroll a slide into view), plus drag and arrow navigation. See
[`docs/operations/accessibility.md`](./operations/accessibility.md).
- **Local-only scripts (`scripts/`):** Playwright-based manual diagnostics
(`screenshot-check`, `overflow-check`, `hero-video-*`),
Expand Down
9 changes: 7 additions & 2 deletions docs/operations/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ is WCAG 2.2 AA — this is a practical baseline, not a certification.
| Surface | Coverage |
| --- | --- |
| `/`, `/beers`, `/about`, `/trade`, `/contact`, `/where-to-buy`, `/admin`, `/beers/[slug]` (404 path) | axe scan (serious/critical block) + keyboard checks |
| Homepage beer carousel (`/carousel-fixture`) | pointer-vs-keyboard focus, drag, and arrow assertions (`smoke-tests/carousel.spec.ts`) |
| Authenticated admin dashboard (beers/venues tabs, forms, rebuild controls, access/invitation management) | axe scan + keyboard/status/focus assertions via `/admin-fixture` |
| `/admin` sign-in shell and unauthorized states | axe scan + live-region assertions (public-route spec) |

Expand Down Expand Up @@ -97,8 +98,12 @@ fail — stop your server first or start it with `ADMIN_A11Y_FIXTURE=1`.
- **Decorative media is hidden from AT** (`alt=""`, `aria-hidden` on the hero
video). Content images get concise, non-duplicative alt.
- **Nothing auto-plays.** The carousel advances only via its arrow buttons
(also per `THEME_AND_BRANDING.md`). The hero video is muted, decorative,
and swaps to a static poster under `prefers-reduced-motion`.
and pointer drag (also per `THEME_AND_BRANDING.md`). It additionally
scrolls a slide into view when that slide's card link receives
**keyboard** focus — the slide `onFocus` handler is gated on
`:focus-visible` so pointer clicks/taps never re-align the carousel.
The hero video is muted, decorative, and swaps to a static poster under
`prefers-reduced-motion`.
- **`prefers-reduced-motion`** is honored in `globals.css` for the site's
fade/reveal animations and in JS (`hero-video`, `IntroSection`) for the
video swap.
Expand Down
1 change: 1 addition & 0 deletions docs/operations/seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ policy, metadata conventions, sitemap/robots behavior, and how to verify.
| `/beers/[slug]` unknown slug | No | `generateMetadata` returns `robots: { index: false }` + 404 |
| `/admin` | No | `robots` meta `noindex,nofollow` + `Disallow` in robots.txt; real protection is auth, not robots |
| `/admin-fixture` | No | `noindex,nofollow` meta + `Disallow`; also returns 404 unless the server-only test flag is set |
| `/carousel-fixture`, `/where-to-buy-fixture` | No | Same env-gated test-fixture pattern: `noindex,nofollow` meta + `Disallow`, 404 without the flag |
| `/trade/login`, `/trade/order`, `/trade/orders` | No | Reserved placeholders — `noindex,nofollow` meta + `Disallow` |
| `/api/*` | No | `Disallow: /api/` (API routes produce no indexable content) |
| `/_not-found` (404) | No | `noindex` meta + 404 status |
Expand Down
33 changes: 33 additions & 0 deletions lib/carousel-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Beer } from "@/lib/types";

// Deterministic fixture records for the test-only /carousel-fixture route
// used by the Playwright carousel suite. Six slides mirror the homepage's
// featured slice (beers.slice(0, 6)) so pointer/keyboard/drag coverage runs
// against the real BeerCarousel even when the Firestore catalog is empty in
// CI. Not real business data — names are placeholders.
export const CAROUSEL_FIXTURE_BEERS: Beer[] = [
"Suds Ridge Pale",
"Fort Bay Porter",
"Ladder Bay Lager",
"Windward Wit",
"Cove Side Stout",
"Spring Bay Saison",
].map((name, i) => ({
name,
slug: name.toLowerCase().replace(/\s+/g, "-"),
style: "Fixture Ale",
abv: 4 + i * 0.5,
ibu: 20 + i * 5,
srm: 5 + i,
status: "core",
descriptionShort: `Fixture beer ${i + 1}.`,
tastingNotes: [],
images: { cardPath: "", heroPath: "" },
isPublic: true,
sortOrder: i + 1,
}));

// Every card shares one real static asset so next/image resolves a real
// same-origin file (an empty cardPath would build a Firebase URL that the
// smoke suite's cross-origin aborts would leave broken).
export const CAROUSEL_FIXTURE_IMAGE = "/photos/og-default.jpg";
Loading
Loading