404
diff --git a/app/page.tsx b/app/page.tsx
index 09fc535..5ffea6e 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -2,7 +2,7 @@ import Image from "next/image";
import Link from "next/link";
import type { Metadata } from "next";
import { Button } from "@/components/ui/button";
-import { SiteHeader } from "@/components/site-header";
+import { SiteNav } from "@/components/site-nav";
import IntroSection from "@/components/home/IntroSection";
import { BeerCarousel } from "@/components/beer-carousel";
import { HeroVideo } from "@/components/hero-video";
@@ -59,18 +59,21 @@ export default async function Home() {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(breweryJsonLd) }}
/>
-
+
- {/* Hero — full viewport, grain photo, extends behind header */}
+ {/* Hero — full viewport, brewhouse photo, extends behind header.
+ The focal point sits left-of-center: the row of tanks and the
+ Alpha plate stay in frame while the busiest hose run drifts
+ right, away from the centered headline. */}
@@ -90,13 +93,20 @@ export default async function Home() {
Craft beer, brewed on Saba.
-
+ {/* Primary stays the heaviest surface: 85% ink glass over the
+ secondaries' 40% — a hint of the brewhouse shows through
+ without competing with the label. Hover still solidifies
+ to primary/90 via the default variant. */}
+
Explore Our Beers
Book a Brewery Tour
diff --git a/components/mobile-menu.tsx b/components/mobile-menu.tsx
deleted file mode 100644
index cf4c189..0000000
--- a/components/mobile-menu.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-"use client";
-
-import { useEffect, useId, useState } from "react";
-import Link from "next/link";
-import { cn, pressableClasses } from "@/lib/utils";
-
-const navLinks = [
- { href: "/beers", label: "Beers" },
- { href: "/where-to-buy", label: "Where to Buy" },
- { href: "/about", label: "About" },
- { href: "/contact", label: "Contact" },
- { href: "/trade", label: "Trade" },
-];
-
-interface MobileMenuProps {
- variant?: "light" | "dark";
-}
-
-export function MobileMenu({ variant = "dark" }: MobileMenuProps) {
- const [open, setOpen] = useState(false);
- const menuId = useId();
-
- const iconColor = variant === "light" ? "text-paper" : "text-ink";
- const focusRing =
- variant === "light"
- ? "focus-visible:ring-paper/70 focus-visible:outline-paper"
- : "focus-visible:ring-ocean/50";
-
- useEffect(() => {
- if (!open) return;
-
- function handleKeyDown(event: KeyboardEvent) {
- if (event.key === "Escape") {
- setOpen(false);
- }
- }
-
- document.addEventListener("keydown", handleKeyDown);
- return () => document.removeEventListener("keydown", handleKeyDown);
- }, [open]);
-
- return (
-
- setOpen(!open)}
- className={cn(
- `flex h-11 w-11 min-h-[44px] min-w-[44px] items-center justify-center rounded-md ${iconColor} focus-visible:ring-2 ${focusRing}`,
- pressableClasses
- )}
- aria-label={open ? "Close menu" : "Open menu"}
- aria-expanded={open}
- aria-controls={menuId}
- type="button"
- >
- {open ? (
-
-
-
-
- ) : (
-
-
-
-
-
- )}
-
-
- {open && (
-
- )}
-
- );
-}
diff --git a/components/site-header-default.tsx b/components/site-header-default.tsx
deleted file mode 100644
index afd9ce4..0000000
--- a/components/site-header-default.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import Link from "next/link";
-import { BrandMark } from "@/components/brand-mark";
-import { MobileMenu } from "@/components/mobile-menu";
-
-const navLinks = [
- { href: "/beers", label: "Beers" },
- { href: "/where-to-buy", label: "Where to Buy" },
- { href: "/about", label: "About" },
- { href: "/contact", label: "Contact" },
- { href: "/trade", label: "Trade" },
-];
-
-export function SiteHeaderDefault() {
- return (
-
- );
-}
diff --git a/components/site-header.tsx b/components/site-header.tsx
deleted file mode 100644
index ba61175..0000000
--- a/components/site-header.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import Link from "next/link";
-import { BrandMark } from "@/components/brand-mark";
-import { MobileMenu } from "@/components/mobile-menu";
-
-const navLinks = [
- { href: "/beers", label: "Beers" },
- { href: "/where-to-buy", label: "Where to Buy" },
- { href: "/about", label: "About" },
- { href: "/contact", label: "Contact" },
- { href: "/trade", label: "Trade" },
-];
-
-export function SiteHeader() {
- return (
-
- );
-}
diff --git a/components/site-nav.tsx b/components/site-nav.tsx
new file mode 100644
index 0000000..b95c87e
--- /dev/null
+++ b/components/site-nav.tsx
@@ -0,0 +1,333 @@
+"use client";
+
+import { useEffect, useId, useRef, useState } from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { Menu, X } from "lucide-react";
+import { BrandMark } from "@/components/brand-mark";
+import { cn, pressableClasses } from "@/lib/utils";
+
+const navLinks = [
+ { href: "/beers", label: "Beers" },
+ { href: "/where-to-buy", label: "Where to Buy" },
+ { href: "/about", label: "About" },
+ { href: "/contact", label: "Contact" },
+ { href: "/trade", label: "Trade" },
+];
+
+// How far down the page the nav must be before it may retreat, and how much
+// scroll-delta counts as an intentional direction change. The delta gate is
+// the hysteresis that keeps touch/elastic scrolling from flickering the bar.
+const RETREAT_SCROLL_THRESHOLD = 120;
+const SCROLL_DELTA_THRESHOLD = 8;
+
+/**
+ * Floating pill navigation (Issue #162) — one shared dark-ink pill on every
+ * route. It detaches from the viewport edge so hero photography shows around
+ * it, and treats the hoppy turtle as the brand anchor.
+ *
+ * Scroll behavior (Issue #163): the pill retreats above the viewport on a
+ * downward scroll past a threshold and returns on the first upward scroll.
+ * An open mobile menu pins the pill in place so menu state and header state
+ * never fight, and keyboard focus anywhere inside the header forces it back
+ * into view. Transforms are the only animated property and run under
+ * `motion-safe:`, so reduced-motion sessions get an instant snap instead.
+ *
+ * The mobile menu is a floating panel below the pill, not an expansion of
+ * it: the shell keeps its geometry while the panel reveals and dismisses
+ * as one object. Escape closes and returns focus to the toggle, outside
+ * presses dismiss it, and navigation closes it.
+ */
+export function SiteNav() {
+ const pathname = usePathname();
+ const [open, setOpen] = useState(false);
+ const [hidden, setHidden] = useState(false);
+ const openRef = useRef(false);
+ const containerRef = useRef(null);
+ const menuButtonRef = useRef(null);
+ const menuId = useId();
+
+ // The scroll listener reads menu state via a ref so it can be registered
+ // once instead of re-attaching on every menu toggle.
+ useEffect(() => {
+ openRef.current = open;
+ }, [open]);
+
+ function isActive(href: string) {
+ return pathname === href || pathname.startsWith(`${href}/`);
+ }
+
+ // A client-side navigation while the menu is open closes it — derived
+ // during render (the adjust-state-when-a-value-changes pattern) rather
+ // than in an effect.
+ const [lastPathname, setLastPathname] = useState(pathname);
+ if (pathname !== lastPathname) {
+ setLastPathname(pathname);
+ setOpen(false);
+ }
+
+ // Scroll-aware retreat/restore. rAF-batched, passive listener, hysteresis
+ // via a minimum direction-change delta so direction reversals at the same
+ // spot (rubber-banding) never flicker the bar.
+ useEffect(() => {
+ let lastY = window.scrollY;
+ let frame = 0;
+
+ function onScroll() {
+ if (frame) return;
+ frame = requestAnimationFrame(() => {
+ frame = 0;
+ const y = window.scrollY;
+ const dy = y - lastY;
+ // Only commit the baseline once the accumulated movement crosses
+ // the threshold — otherwise dy is a per-frame delta and a slow
+ // 1-2px/frame scroll never registers at all.
+ if (Math.abs(dy) > SCROLL_DELTA_THRESHOLD) lastY = y;
+ // A control focused inside the header keeps it revealed — retreating
+ // here would move the focus-visible outline off-screen.
+ if (containerRef.current?.contains(document.activeElement)) {
+ setHidden(false);
+ return;
+ }
+ setHidden((prev) => {
+ if (openRef.current) return false;
+ if (y <= RETREAT_SCROLL_THRESHOLD) return false;
+ if (dy > SCROLL_DELTA_THRESHOLD) return true;
+ if (dy < -SCROLL_DELTA_THRESHOLD) return false;
+ return prev;
+ });
+ });
+ }
+
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => {
+ window.removeEventListener("scroll", onScroll);
+ if (frame) cancelAnimationFrame(frame);
+ };
+ }, []);
+
+ // While the menu is open: Escape dismisses and restores focus to the
+ // toggle; Tab/Shift+Tab cycle within the pill so focus cannot escape to
+ // page content behind the open menu; a press outside the pill dismisses it.
+ useEffect(() => {
+ if (!open) return;
+
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ setOpen(false);
+ menuButtonRef.current?.focus();
+ return;
+ }
+ if (event.key !== "Tab" || !containerRef.current) return;
+
+ const focusables = Array.from(
+ containerRef.current.querySelectorAll(
+ 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
+ )
+ ).filter((el) => el.getClientRects().length > 0);
+ if (focusables.length < 2) return;
+
+ const first = focusables[0];
+ const last = focusables[focusables.length - 1];
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ }
+
+ function onPointerDown(event: PointerEvent) {
+ if (!containerRef.current?.contains(event.target as Node)) {
+ setOpen(false);
+ }
+ }
+
+ // The mobile menu unrenders at the lg breakpoint; an open state that
+ // survives into desktop widths would keep trapping Tab inside the nav
+ // with no menu visible, so close it as the viewport widens.
+ const desktopMedia = window.matchMedia("(min-width: 64rem)");
+ function onDesktopChange(event: MediaQueryListEvent) {
+ if (event.matches) setOpen(false);
+ }
+
+ document.addEventListener("keydown", onKeyDown);
+ document.addEventListener("pointerdown", onPointerDown);
+ desktopMedia.addEventListener("change", onDesktopChange);
+ return () => {
+ document.removeEventListener("keydown", onKeyDown);
+ document.removeEventListener("pointerdown", onPointerDown);
+ desktopMedia.removeEventListener("change", onDesktopChange);
+ };
+ }, [open]);
+
+ return (
+ setHidden(false)}
+ className={cn(
+ "fixed inset-x-0 top-0 z-50 px-2 pt-[calc(1rem+env(safe-area-inset-top))] min-[400px]:px-3 sm:px-5",
+ "motion-safe:transition-transform motion-safe:duration-300 motion-safe:ease-out",
+ hidden && "-translate-y-[calc(100%+1.5rem)]"
+ )}
+ >
+ {/* Anchors the floating mobile panel to the pill's box so the shell
+ itself never has to grow to host the open menu. */}
+
+ {/* Narrow padding scale below 400px keeps the pill's inset and the
+ brand/toggle row from colliding. At lg the row itself becomes
+ the floating brand pill; the link list floats separately. On
+ desktop the hero already carries the full wordmark, so the
+ brand pill compacts to the turtle mark alone — the text stays
+ mounted as sr-only so the link keeps its accessible name. */}
+
+ setOpen(false)}
+ className="flex h-11 items-center gap-2 whitespace-nowrap rounded-full pr-2 font-festival text-[clamp(0.9rem,calc(6vw-0.3rem),1.25rem)] leading-7 tracking-wide text-paper focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-paper/80 sm:text-2xl lg:w-11 lg:justify-center lg:pr-0"
+ >
+
+ {/* Festival's metrics sit the baseline high in the 28px line
+ box, so the glyph ink lands ~4.5px above box-center —
+ 0.25em lowers the wordmark's ink onto the mark's optical
+ center. The span hides at lg where the pill is mark-only. */}
+
+ Deep Dive Brewing Co
+
+
+
+ {/* The button keeps a 44px target and focus ring; the visible
+ circle is a smaller, lighter nested disc so the control reads
+ as a disclosure affordance instead of a button inside a
+ button. Hover/active styling lives on the disc via `group`. */}
+ setOpen((value) => !value)}
+ aria-label={open ? "Close menu" : "Open menu"}
+ aria-expanded={open}
+ aria-controls={menuId}
+ className={cn(
+ "group flex h-11 w-11 min-h-[44px] min-w-[44px] items-center justify-center rounded-full text-paper focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-paper/80 lg:hidden",
+ pressableClasses
+ )}
+ >
+
+
+
+
+
+
+
+
+
+ {/* Desktop nav floats as its own pill on the opposite end of the
+ content column, so the wordmark and links stop competing for
+ one long strip. */}
+
+ {navLinks.map((link) => (
+
+ {link.label}
+
+ ))}
+
+
+ {/* Mobile nav — a separate floating surface below the pill, not an
+ expansion of it. The shell keeps its geometry; the panel reveals
+ as one object with a 2px settle while opacity only softens the
+ move (0.92→1 in, 1→0.92 out) — it never fades from nothing, and
+ the close reads as a dismissal rather than a retreat upward. The
+ material itself (ink/border/shadow/radius) is constant in both
+ states so nothing appears to develop mid-transition;
+ `visibility` joins the transition list so the panel hides
+ exactly when the exit finishes (discrete flip at the end on
+ close, at the start on open) instead of fading to transparent.
+ The body is solid ink (vs the pill's 85%): translucency
+ reads attractively over the hero photo, but an open menu
+ over a light interior page kept the page's own headings
+ legible through the panel even at 98-99% ink — solid is
+ the only value that fully silences the copy behind it.
+ Always mounted so the same transition runs in
+ reverse on close;
+ inert + pointer-events-none keep the hidden panel out of the
+ tab order and out from under taps. The scrollable region is
+ bounded by the viewport minus the pill's top offset + bar so
+ the last link stays reachable on very short viewports. */}
+
+
+
+ );
+}
diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md
index c3f9381..dbb3e3c 100644
--- a/docs/TECHNICAL.md
+++ b/docs/TECHNICAL.md
@@ -56,8 +56,8 @@ client SDK writes (content management) or through Admin-SDK-backed API routes
required: `admin-dashboard.tsx`, `admin-access.tsx`, `admin-trade-*.tsx`,
`trade-inquiry-form.tsx`,
`beer-carousel.tsx`, `beers-filter-grid.tsx`, the analytics trackers,
- `mobile-menu.tsx`, `site-header-default.tsx`, and the home-page intro/CTA
- components.
+ `site-nav.tsx` (the shared floating-pill navigation), and the home-page
+ intro/CTA components.
- **Rendering model.** The production build output marks every page static
(`○`) — including `/admin` and `/sitemap.xml` — except all `/api/*`
routes, which are dynamic (`ƒ`). Beer detail pages are generated at build
@@ -121,7 +121,7 @@ client SDK writes (content management) or through Admin-SDK-backed API routes
| Path | Responsibility |
| --- | --- |
| `app/` | App Router routes. Root `layout.tsx` (header/footer shell, SEO defaults, favicon metadata pointing at `public/`, analytics wiring), `globals.css` (Tailwind v4 theme tokens), `robots.ts`, `sitemap.ts`, `page.tsx` (home). |
-| `app/(pages)/` | Route group for all content pages — `about` (MDX), `admin`, `beers` (+`[slug]`), `contact`, `privacy`, `terms`, `trade` (+ `login`/`order`/`orders` "coming soon" placeholders), `where-to-buy` — sharing a `SiteHeaderDefault` layout. Pages are `.tsx`; `about` is authored as `page.mdx` — see §4. |
+| `app/(pages)/` | Route group for all content pages — `about` (MDX), `admin`, `beers` (+`[slug]`), `contact`, `privacy`, `terms`, `trade` (+ `login`/`order`/`orders` "coming soon" placeholders), `where-to-buy` — sharing the floating `SiteNav` pill layout. Pages are `.tsx`; `about` is authored as `page.mdx` — see §4. |
| `app/api/` | Server API routes: `admin/bootstrap`, `admin/invitations/accept`, `admin/invitations/[id]/resend`, `admin/me`, `admin/rebuild`, `admin/users` (GET list + POST create-invitation), `admin/users/[uid]` (PATCH/DELETE), `admin/trade-leads` (GET list + POST create), `admin/trade-leads/[id]` (GET + PATCH), `admin/trade-leads/[id]/activities` (POST note), `admin/trade-leads/[id]/messages` (POST send email), `webhooks/resend` (POST — signature-verified provider events), and `trade-inquiry`. All are Admin-SDK-protected except `trade-inquiry` and `webhooks/resend` (which trusts the Resend webhook signature instead). |
| `components/` | App components: header/footer, home sections, cards, carousel/filter grid, analytics trackers, `admin-dashboard.tsx` (auth + data orchestration), `admin-workspace.tsx` (props-driven authenticated view shared with `/admin-fixture`), `admin-access.tsx`, `admin-fixture.tsx` (test-only data), `admin-trade-page.tsx`/`admin-trade-workspace.tsx`/`admin-trade-summary.tsx`/`admin-trade-fixture.tsx` (lead pipeline + test fixture), `trade-inquiry-form.tsx`, `mdx-layout.tsx`. |
| `components/ui/` | shadcn/ui primitives (Radix-based) configured by `components.json`. |
diff --git a/docs/operations/performance.md b/docs/operations/performance.md
index 2ce912b..ad6e22f 100644
--- a/docs/operations/performance.md
+++ b/docs/operations/performance.md
@@ -106,7 +106,7 @@ re-creates the contention it solves.
The video only loads when the section scrolls into view on desktop
without reduced-motion — it never competes with initial load. The
static poster is `photos/video-still.jpg` (approved brewery still,
- distinct from the `herograin.jpg` hero image above it).
+ distinct from the `herobrewhouse.jpg` hero image above it).
- **`/beers/[slug]` is now static** (Issue #104): `generateStaticParams`
enumerates build-time slugs with `dynamicParams = false`, reading via
`getDocsFromServer` so a backend failure throws and fails the build
diff --git a/next.config.ts b/next.config.ts
index 4ca0733..23ff88c 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -153,6 +153,10 @@ const nextConfig: NextConfig = {
source: "/photos/herograin.jpg",
headers: mediaCacheHeaders,
},
+ {
+ source: "/photos/herobrewhouse.jpg",
+ headers: mediaCacheHeaders,
+ },
{
source: "/photos/og-default.jpg",
headers: mediaCacheHeaders,
diff --git a/public/photos/PXL_20261006_121628797.jpg b/public/photos/PXL_20261006_121628797.jpg
new file mode 100644
index 0000000..73f545c
Binary files /dev/null and b/public/photos/PXL_20261006_121628797.jpg differ
diff --git a/public/photos/PXL_20261006_121633967.jpg b/public/photos/PXL_20261006_121633967.jpg
new file mode 100644
index 0000000..81b12f8
Binary files /dev/null and b/public/photos/PXL_20261006_121633967.jpg differ
diff --git a/public/photos/PXL_20261006_121652949.jpg b/public/photos/PXL_20261006_121652949.jpg
new file mode 100644
index 0000000..8341748
Binary files /dev/null and b/public/photos/PXL_20261006_121652949.jpg differ
diff --git a/public/photos/PXL_20261006_121718700.jpg b/public/photos/PXL_20261006_121718700.jpg
new file mode 100644
index 0000000..2229789
Binary files /dev/null and b/public/photos/PXL_20261006_121718700.jpg differ
diff --git a/public/photos/PXL_20261006_121738963.jpg b/public/photos/PXL_20261006_121738963.jpg
new file mode 100644
index 0000000..979e2e1
Binary files /dev/null and b/public/photos/PXL_20261006_121738963.jpg differ
diff --git a/public/photos/herobrewhouse.jpg b/public/photos/herobrewhouse.jpg
new file mode 100644
index 0000000..5836ff5
Binary files /dev/null and b/public/photos/herobrewhouse.jpg differ
diff --git a/scripts/hero-video-check.mjs b/scripts/hero-video-check.mjs
index 7d1f644..0c306e7 100644
--- a/scripts/hero-video-check.mjs
+++ b/scripts/hero-video-check.mjs
@@ -15,8 +15,8 @@ async function check(contextOptions, label) {
const videoCount = await page.locator("video").count();
const stillCount = await page.locator('img[src*="video-still"]').count();
- // The brewery section poster is the video still (Issue #135); the grain
- // hero image belongs to the hero section above it.
+ // The brewery section poster is the video still (Issue #135); the
+ // brewhouse hero image belongs to the hero section above it.
console.log(`${label}: video elements=${videoCount}, video-still images=${stillCount}`);
await context.close();
}
diff --git a/scripts/menu-animation-capture.mjs b/scripts/menu-animation-capture.mjs
new file mode 100644
index 0000000..17bc905
--- /dev/null
+++ b/scripts/menu-animation-capture.mjs
@@ -0,0 +1,79 @@
+import { chromium } from "playwright";
+import { mkdir, writeFile } from "fs/promises";
+import { fileURLToPath } from "url";
+import { dirname, join } from "path";
+
+// Local diagnostic (not CI): captures the mobile menu open/close animation
+// frame-by-frame via a CDP screencast, plus a session video per viewport.
+// Run against a production server:
+// npx next start -p 3100
+// node scripts/menu-animation-capture.mjs
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const outDir = join(__dirname, "..", "review-shots", "menu-anim");
+const baseUrl = process.env.BASE_URL ?? "http://localhost:3100";
+const widths = [320, 390, 430];
+
+const browser = await chromium.launch({ headless: true });
+
+for (const width of widths) {
+ const dir = join(outDir, `w${width}`);
+ await mkdir(dir, { recursive: true });
+ const context = await browser.newContext({
+ viewport: { width, height: 800 },
+ recordVideo: { dir, size: { width, height: 800 } },
+ });
+ const page = await context.newPage();
+ const cdp = await context.newCDPSession(page);
+ await page.goto(`${baseUrl}/beers`, { waitUntil: "networkidle" });
+
+ const toggle = page.locator("button[aria-controls]");
+
+ // --- capture helper: N screencast frames during an action ---
+ async function captureDuring(label, action, settleMs = 600) {
+ const frames = [];
+ cdp.on("Page.screencastFrame", onFrame);
+ async function onFrame({ data, metadata }) {
+ frames.push({ data, ts: metadata.timestamp });
+ cdp.send("Page.screencastFrameAck", { sessionId: metadata.sessionId }).catch(() => {});
+ }
+ await cdp.send("Page.startScreencast", {
+ format: "png",
+ everyNthFrame: 1,
+ });
+ await action();
+ await page.waitForTimeout(settleMs);
+ await cdp.send("Page.stopScreencast");
+ cdp.off("Page.screencastFrame", onFrame);
+ for (let i = 0; i < frames.length; i++) {
+ await writeFile(
+ join(dir, `${label}-f${String(i).padStart(3, "0")}.png`),
+ Buffer.from(frames[i].data, "base64")
+ );
+ }
+ console.log(`w${width} ${label}: ${frames.length} frames`);
+ }
+
+ // open via toggle
+ await captureDuring("open", () => toggle.click());
+ // close via toggle
+ await captureDuring("close-toggle", () => toggle.click());
+ // open again, close via Escape
+ await captureDuring("open2", () => toggle.click());
+ await captureDuring("close-escape", () => page.keyboard.press("Escape"));
+ // open, close via link click (route change)
+ await captureDuring("open3", () => toggle.click());
+ await captureDuring("close-link", () =>
+ page.locator('nav[aria-label="Mobile"] a[href="/about"]').click()
+ );
+ await page.goto(`${baseUrl}/beers`, { waitUntil: "domcontentloaded" });
+ // rapid open -> close
+ await captureDuring("rapid", async () => {
+ await toggle.click();
+ await page.waitForTimeout(120);
+ await toggle.click();
+ });
+
+ await context.close(); // flushes video
+}
+await browser.close();
+console.log("done");
diff --git a/scripts/menu-animation-frames.mjs b/scripts/menu-animation-frames.mjs
new file mode 100644
index 0000000..ebc4900
--- /dev/null
+++ b/scripts/menu-animation-frames.mjs
@@ -0,0 +1,55 @@
+import { chromium } from "playwright";
+import { mkdir } from "fs/promises";
+import { fileURLToPath } from "url";
+import { dirname, join } from "path";
+
+// Local diagnostic (not CI): slows the mobile menu transition ~8x via
+// injected CSS so intermediate frames can be inspected, then captures a
+// real-time burst for actual pacing. Run against a production server:
+// npx next start -p 3100
+// node scripts/menu-animation-frames.mjs
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const outDir = join(__dirname, "..", "review-shots", "menu-anim");
+const baseUrl = process.env.BASE_URL ?? "http://localhost:3100";
+const widths = [320, 390, 430];
+
+const browser = await chromium.launch({ headless: true });
+
+for (const width of widths) {
+ const dir = join(outDir, `w${width}`);
+ await mkdir(dir, { recursive: true });
+ const context = await browser.newContext({
+ viewport: { width, height: 800 },
+ });
+ const page = await context.newPage();
+ await page.goto(`${baseUrl}/beers`, { waitUntil: "networkidle" });
+ const toggle = page.locator("button[aria-controls]");
+
+ async function burst(label, action, count, gapMs) {
+ await action();
+ for (let i = 0; i < count; i++) {
+ await page.screenshot({
+ path: join(dir, `${label}-f${String(i).padStart(2, "0")}.png`),
+ });
+ if (gapMs) await page.waitForTimeout(gapMs);
+ }
+ }
+
+ // --- slowed transition: reveal the motion path ---
+ await page.addStyleTag({
+ content:
+ 'nav[aria-label="Mobile"] { transition-duration: 1.4s !important; }',
+ });
+ await burst("slow-open", () => toggle.click(), 12, 130);
+ await burst("slow-close", () => toggle.click(), 12, 130);
+
+ // --- real-time burst (reload drops the injected override) ---
+ await page.reload({ waitUntil: "networkidle" });
+ await burst("rt-open", () => toggle.click(), 6, 40);
+ await burst("rt-close", () => toggle.click(), 6, 40);
+
+ await context.close();
+ console.log(`w${width} done`);
+}
+await browser.close();
+console.log("done");
diff --git a/scripts/optimize-assets.mjs b/scripts/optimize-assets.mjs
index bcba8db..4a2e5ef 100644
--- a/scripts/optimize-assets.mjs
+++ b/scripts/optimize-assets.mjs
@@ -12,6 +12,10 @@ const PHOTO_DIR = path.join(__dirname, "..", "public", "photos");
const OG_PATH = path.join(PHOTO_DIR, "og-default.jpg");
const HERO_PATH = path.join(PHOTO_DIR, "herograin.jpg");
const OPTIMIZED_HERO_PATH = HERO_PATH.replace(".jpg", "-optimized.jpg");
+// Brewhouse hero: the multi-megabyte phone original stays in the repo as
+// the source of truth; the served asset is a 1920px-wide recompress.
+const BREWHOUSE_SOURCE_PATH = path.join(PHOTO_DIR, "PXL_20261006_121738963.jpg");
+const BREWHOUSE_HERO_PATH = path.join(PHOTO_DIR, "herobrewhouse.jpg");
const hero = sharp(HERO_PATH);
const meta = await hero.metadata();
@@ -28,8 +32,16 @@ await sharp(HERO_PATH)
.jpeg({ quality: 85, progressive: true, mozjpeg: true })
.toFile(OG_PATH);
+const brewhouse = sharp(BREWHOUSE_SOURCE_PATH);
+const brewhouseMeta = await brewhouse.metadata();
+await brewhouse
+ .resize(1920, undefined, { withoutEnlargement: true })
+ .jpeg({ quality: 75, progressive: true, mozjpeg: true })
+ .toFile(BREWHOUSE_HERO_PATH);
+
const optimizedStats = fs.statSync(OPTIMIZED_HERO_PATH);
const ogStats = fs.statSync(OG_PATH);
+const brewhouseStats = fs.statSync(BREWHOUSE_HERO_PATH);
console.log(
`Original hero: ${meta.width}x${meta.height} ~${(
@@ -40,3 +52,9 @@ console.log(
`Optimized hero: ${optimizedStats ? (optimizedStats.size / 1024).toFixed(1) : "unknown"} KB`
);
console.log(`OG image: ${(ogStats.size / 1024).toFixed(1)} KB`);
+console.log(
+ `Brewhouse source: ${brewhouseMeta.width}x${brewhouseMeta.height} ~${(
+ fs.statSync(BREWHOUSE_SOURCE_PATH).size / 1024 / 1024
+ ).toFixed(2)} MB`
+);
+console.log(`Brewhouse hero: ${(brewhouseStats.size / 1024).toFixed(1)} KB`);
diff --git a/scripts/pr188-visual-shots.mjs b/scripts/pr188-visual-shots.mjs
new file mode 100644
index 0000000..86adca5
--- /dev/null
+++ b/scripts/pr188-visual-shots.mjs
@@ -0,0 +1,145 @@
+// Local diagnostic (not CI): captures the PR #188 visual-review artifacts —
+// grain vs brewhouse hero at three widths, desktop brand-pill variants, and
+// the mobile menu's expanded-body opacity over photo and light pages.
+// Run against a production server:
+// npx next start -p 3100
+// node scripts/pr188-visual-shots.mjs
+import { chromium } from "playwright";
+import { mkdir } from "fs/promises";
+import { fileURLToPath } from "url";
+import { dirname, join } from "path";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const outDir = join(__dirname, "..", "review-shots", "pr188");
+const baseUrl = process.env.BASE_URL ?? "http://localhost:3100";
+
+await mkdir(outDir, { recursive: true });
+
+const browser = await chromium.launch({ headless: true });
+
+async function shot(page, name) {
+ const file = join(outDir, `${name}.png`);
+ await page.screenshot({ path: file });
+ console.log(`shot: ${name}.png`);
+}
+
+// Swap the brewhouse hero for the grain asset and recenter it so the
+// capture matches the pre-change hero exactly (same layout, real image).
+async function routeGrainHero(page) {
+ await page.route("**/_next/image*", (route) => {
+ const url = new URL(route.request().url());
+ const inner = url.searchParams.get("url") ?? "";
+ if (inner.includes("herobrewhouse.jpg")) {
+ url.searchParams.set("url", inner.replace("herobrewhouse.jpg", "herograin.jpg"));
+ return route.continue({ url: url.toString() });
+ }
+ return route.continue();
+ });
+}
+
+async function settleHero(page, width, height, { grain = false } = {}) {
+ if (grain) await routeGrainHero(page);
+ await page.setViewportSize({ width, height });
+ await page.goto(`${baseUrl}/`, { waitUntil: "networkidle" });
+ await page.waitForTimeout(700);
+ if (grain) {
+ await page.evaluate(() => {
+ const img = document.querySelector("section img");
+ if (img) img.style.objectPosition = "50% 50%";
+ });
+ await page.waitForTimeout(200);
+ }
+}
+
+// ---------- homepage hero: grain vs brewhouse ----------
+for (const { width, height, tag } of [
+ { width: 390, height: 844, tag: "390" },
+ { width: 1280, height: 900, tag: "1280" },
+ { width: 1600, height: 900, tag: "1600" },
+]) {
+ const context = await browser.newContext({
+ viewport: { width, height },
+ deviceScaleFactor: 1,
+ });
+ const page = await context.newPage();
+
+ await settleHero(page, width, height);
+ await shot(page, `home-${tag}-brewhouse`);
+
+ await settleHero(page, width, height, { grain: true });
+ await shot(page, `home-${tag}-grain`);
+
+ await context.close();
+}
+
+// ---------- desktop brand pill ----------
+{
+ const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const page = await context.newPage();
+ await page.goto(`${baseUrl}/`, { waitUntil: "networkidle" });
+ await page.waitForTimeout(700);
+ await shot(page, "nav-1280-logo-pill");
+
+ // Restore the prior full-wordmark composition for comparison: undo the
+ // lg-only classes added in this pass. Computed result is identical to
+ // the previous build's markup.
+ await page.evaluate(() => {
+ const span = document.querySelector("header a[href='/'] span");
+ const link = document.querySelector("header a[href='/']");
+ const pill = link?.parentElement;
+ span?.classList.remove("lg:sr-only");
+ link?.classList.remove("lg:w-11", "lg:justify-center", "lg:pr-0");
+ pill?.classList.remove("lg:px-4");
+ pill?.classList.add("lg:pl-4", "lg:pr-5");
+ });
+ await page.waitForTimeout(300);
+ await shot(page, "nav-1280-wordmark-pill");
+ await context.close();
+}
+{
+ const context = await browser.newContext({ viewport: { width: 1600, height: 900 } });
+ const page = await context.newPage();
+ await page.goto(`${baseUrl}/`, { waitUntil: "networkidle" });
+ await page.waitForTimeout(700);
+ await shot(page, "nav-1600-logo-pill");
+ await context.close();
+}
+
+// ---------- mobile menu opacity ----------
+{
+ const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
+ const page = await context.newPage();
+ const toggle = page.locator("button[aria-controls]");
+ // Dispatch the click on the DOM node: actionability checks scroll the
+ // button into view, which can trigger the pill's scroll retreat and put
+ // the click point off-screen under page content.
+ const openMenu = () => toggle.dispatchEvent("click");
+
+ await page.goto(`${baseUrl}/`, { waitUntil: "networkidle" });
+ await page.waitForTimeout(700);
+ await openMenu();
+ await page.waitForTimeout(400);
+ await shot(page, "menu-390-home-open");
+ await page.keyboard.press("Escape");
+ await page.waitForTimeout(300);
+
+ await page.goto(`${baseUrl}/where-to-buy`, { waitUntil: "networkidle" });
+ await page.waitForTimeout(700);
+ await openMenu();
+ await page.waitForTimeout(400);
+ await shot(page, "menu-390-where-to-buy-open");
+
+ // before-state comparison: drop the panel back to 85% ink
+ await page.evaluate(() => {
+ const panel = document.querySelector('nav[aria-label="Mobile"]');
+ panel?.classList.remove("bg-ink");
+ panel?.classList.add("bg-ink/85");
+ });
+ await page.waitForTimeout(300);
+ await shot(page, "menu-390-where-to-buy-open-85");
+
+ await context.close();
+}
+
+await browser.close();
+console.log("done -> " + outDir);
diff --git a/smoke-tests/accessibility.spec.ts b/smoke-tests/accessibility.spec.ts
index c0be278..59a71be 100644
--- a/smoke-tests/accessibility.spec.ts
+++ b/smoke-tests/accessibility.spec.ts
@@ -164,6 +164,17 @@ test("mobile menu opens, keyboard-navigates, and closes on Escape", async ({
// menu"), so locate it by its stable aria-controls hook instead.
const toggle = page.locator("button[aria-controls]");
await expect(toggle).toHaveAttribute("aria-expanded", "false");
+
+ // The menu is a floating panel below the pill, not an expansion of it —
+ // opening must not move or resize the shell (no accordion growth). The
+ // panel stays mounted and inert when closed, so locate it by attribute:
+ // inert subtrees leave the accessibility tree, which role queries read.
+ const shell = page.locator("header > div").first();
+ const panel = page.locator('nav[aria-label="Mobile"]');
+ const closedBox = await shell.boundingBox();
+ await expect(panel).toHaveAttribute("inert", "");
+ await expect(panel).not.toBeVisible();
+
await toggle.press("Enter");
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(toggle).toHaveAccessibleName("Close menu");
@@ -171,10 +182,203 @@ test("mobile menu opens, keyboard-navigates, and closes on Escape", async ({
const menu = page.getByRole("navigation", { name: "Mobile" });
await expect(menu).toBeVisible();
await expect(menu.getByRole("link", { name: "Beers" })).toBeVisible();
+ await expect(panel).not.toHaveAttribute("inert", "");
+
+ const openBox = await shell.boundingBox();
+ expect(
+ openBox,
+ "shell geometry must not change when the menu opens"
+ ).toEqual(closedBox);
+ const panelBox = await panel.boundingBox();
+ expect(
+ panelBox!.y,
+ "menu panel must sit below the pill"
+ ).toBeGreaterThanOrEqual(openBox!.y + openBox!.height);
+
+ await page.keyboard.press("Escape");
+ await expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await expect(panel).not.toBeVisible();
+ await expect(panel).toHaveAttribute("inert", "");
+
+ // The closed panel's links are inert: Tab from the toggle must not land
+ // inside the hidden menu.
+ await page.keyboard.press("Tab");
+ expect(
+ await panel.evaluate((el) => el.contains(document.activeElement))
+ ).toBe(false);
+
+ // Selecting the current route's link never changes pathname, but the
+ // menu must still close — otherwise it stays open over the same page.
+ await toggle.press("Enter");
+ await expect(menu).toBeVisible();
+ await menu.getByRole("link", { name: "Beers" }).click();
+ await expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await expect(panel).not.toBeVisible();
+});
+
+test("mobile menu opens and closes instantly under reduced motion", async ({
+ page,
+}) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.setViewportSize({ width: 375, height: 812 });
+ await page.goto("/beers");
+
+ const toggle = page.locator("button[aria-controls]");
+ const panel = page.locator('nav[aria-label="Mobile"]');
+
+ // No decorative transition runs: the panel switches states promptly.
+ expect(
+ await panel.evaluate((el) => getComputedStyle(el).transitionDuration)
+ ).toBe("0s");
+
+ await toggle.press("Enter");
+ await expect(panel).toBeVisible();
+ await expect(
+ panel.getByRole("link", { name: "Beers" })
+ ).toBeVisible();
await page.keyboard.press("Escape");
+ await expect(panel).not.toBeVisible();
+ // Escape still returns focus to the toggle under reduced motion.
+ await expect(toggle).toBeFocused();
+});
+
+test("nav pill keeps compact geometry and 44px targets across widths", async ({
+ page,
+}) => {
+ const pill = page.locator("header > div");
+ const toggle = page.locator("button[aria-controls]");
+ const brand = page.getByRole("link", { name: "Deep Dive Brewing Co" });
+
+ // Desktop: the brand and link list float as two separate pills with
+ // open space between them rather than one stretched bar — the wrapper
+ // is transparent at lg, so assert on the two pill surfaces themselves.
+ // Bounds are fractions of the viewport, not pixel snapshots.
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await page.goto("/beers");
+
+ const brandPill = page
+ .getByRole("link", { name: "Deep Dive Brewing Co" })
+ .locator("..");
+ const navPill = page.getByRole("navigation", { name: "Main" });
+ const brandPillBox = await brandPill.boundingBox();
+ const navPillBox = await navPill.boundingBox();
+ expect(brandPillBox!.width).toBeLessThan(0.35 * 1280);
+ expect(navPillBox!.width).toBeLessThan(0.4 * 1280);
+ expect(
+ navPillBox!.x - (brandPillBox!.x + brandPillBox!.width)
+ ).toBeGreaterThan(80);
+ expect(
+ Math.abs(brandPillBox!.height - navPillBox!.height)
+ ).toBeLessThanOrEqual(2);
+
+ const desktopLink = navPill.getByRole("link", { name: "Where to Buy" });
+ const linkBox = await desktopLink.boundingBox();
+ expect(linkBox!.height).toBeGreaterThanOrEqual(44);
+
+ // Mobile: the pill floats clear of the viewport edges, the toggle keeps
+ // its 44px target inside the pill, and the wordmark never collides with
+ // the toggle (360px and below used to clip it).
+ for (const width of [390, 360]) {
+ await page.setViewportSize({ width, height: 812 });
+ await page.goto("/beers");
+
+ const box = await pill.boundingBox();
+ expect(box!.y).toBeGreaterThanOrEqual(12);
+ expect(box!.x).toBeGreaterThanOrEqual(8);
+ expect(box!.x + box!.width).toBeLessThanOrEqual(width - 8);
+
+ const toggleBox = await toggle.boundingBox();
+ expect(toggleBox!.width).toBeGreaterThanOrEqual(44);
+ expect(toggleBox!.height).toBeGreaterThanOrEqual(44);
+ expect(toggleBox!.x + toggleBox!.width).toBeLessThanOrEqual(
+ box!.x + box!.width
+ );
+
+ const brandBox = await brand.boundingBox();
+ expect(brandBox!.x + brandBox!.width).toBeLessThanOrEqual(toggleBox!.x);
+ }
+
+ // Narrowest supported width: the wordmark shrinks instead of clipping.
+ await page.setViewportSize({ width: 320, height: 812 });
+ await page.goto("/beers");
+ const brandBox = await brand.boundingBox();
+ const toggleBox = await toggle.boundingBox();
+ expect(brandBox!.x + brandBox!.width).toBeLessThanOrEqual(
+ toggleBox!.x + 2
+ );
+});
+
+test("nav pill retreats on slow downward scroll and restores on upward", async ({
+ page,
+}) => {
+ // /privacy is long enough to scroll well past the retreat threshold.
+ await page.goto("/privacy");
+ const header = page.locator("header");
+ const headerTop = () =>
+ header.evaluate((el) => el.getBoundingClientRect().top);
+
+ // Sub-threshold increments: each scroll event moves less than the
+ // direction-change delta, so only accumulated movement may retreat the
+ // pill — a per-frame baseline never fires here.
+ for (let y = 0; y <= 400; y += 4) {
+ await page.evaluate((v) => window.scrollTo(0, v), y);
+ }
+ await expect.poll(headerTop).toBeLessThan(0);
+
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await expect.poll(headerTop).toBeGreaterThanOrEqual(0);
+
+ // Keyboard focus inside the header pins it: retreating while focused
+ // would move the focus-visible outline off-screen. Wait past the rAF
+ // batching and retreat transition before asserting the pin held.
+ await page
+ .getByRole("link", { name: "Deep Dive Brewing Co" })
+ .focus();
+ await page.evaluate(() => window.scrollTo(0, 600));
+ await page.waitForTimeout(500);
+ expect(await headerTop()).toBeGreaterThanOrEqual(0);
+});
+
+test("mobile menu traps Tab focus while open", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 812 });
+ // /privacy is text-only: resizing mid-test can't abort in-flight image
+ // requests the way the beer-card grid on /beers does.
+ await page.goto("/privacy");
+
+ const toggle = page.locator("button[aria-controls]");
+ await toggle.press("Enter");
+
+ const menu = page.getByRole("navigation", { name: "Mobile" });
+ const lastLink = menu.getByRole("link", { name: "Trade" });
+ const brandLink = page.getByRole("link", {
+ name: "Deep Dive Brewing Co",
+ });
+
+ // Tab past the final link must wrap to the first nav control instead of
+ // escaping to page content behind the open menu.
+ await lastLink.focus();
+ await page.keyboard.press("Tab");
+ await expect(brandLink).toBeFocused();
+
+ // Shift+Tab from the first control wraps back to the final link.
+ await page.keyboard.press("Shift+Tab");
+ await expect(lastLink).toBeFocused();
+
+ // Crossing into the lg breakpoint closes the menu so the trap can never
+ // apply to a menu that is no longer rendered. The panel stays mounted
+ // but inert — hidden from both the accessibility tree and the tab order.
+ const panel = page.locator('nav[aria-label="Mobile"]');
+ await page.setViewportSize({ width: 1024, height: 800 });
+ await expect(panel).toHaveAttribute("inert", "");
await expect(toggle).toHaveAttribute("aria-expanded", "false");
- await expect(menu).not.toBeAttached();
+
+ await page.setViewportSize({ width: 375, height: 812 });
+ await toggle.press("Enter");
+ await page.keyboard.press("Escape");
+ await expect(panel).not.toBeVisible();
+ await expect(panel).toHaveAttribute("inert", "");
+ await expect(toggle).toBeFocused();
});
test("trade form exposes labels, autocomplete, and required state", async ({
diff --git a/smoke-tests/hero-video.spec.ts b/smoke-tests/hero-video.spec.ts
index a0f00fd..32f798e 100644
--- a/smoke-tests/hero-video.spec.ts
+++ b/smoke-tests/hero-video.spec.ts
@@ -15,7 +15,7 @@ const brewerySection = (page: import("playwright").Page) =>
has: page.getByRole("heading", { name: "The Brewery", exact: true }),
});
-test("brewery section uses the approved still, not the grain hero image", async ({
+test("brewery section uses the approved still, not the hero image", async ({
page,
}) => {
await page.goto("/");
@@ -24,12 +24,12 @@ test("brewery section uses the approved still, not the grain hero image", async
// The section poster is the distinct brewery still…
await expect(section.locator('img[src*="video-still"]')).toBeVisible();
- // …and never the duplicated grain hero photo.
- await expect(section.locator('img[src*="herograin"]')).toHaveCount(0);
+ // …and never the duplicated brewhouse hero photo.
+ await expect(section.locator('img[src*="herobrewhouse"]')).toHaveCount(0);
- // The main hero above keeps the grain image.
+ // The main hero above keeps the brewhouse image.
const hero = page.locator("section").first();
- await expect(hero.locator('img[src*="herograin"]')).toBeVisible();
+ await expect(hero.locator('img[src*="herobrewhouse"]')).toBeVisible();
// Section remains a complete, readable design.
await expect(