diff --git a/.gitignore b/.gitignore index 32fc500..71bacfc 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,8 @@ next-env.d.ts # verification screenshots generated locally /screenshots +/audit-shots +/review-shots # playwright smoke-test artifacts /test-results/ diff --git a/THEME_AND_BRANDING.md b/THEME_AND_BRANDING.md index e8c8707..c5ba435 100644 --- a/THEME_AND_BRANDING.md +++ b/THEME_AND_BRANDING.md @@ -119,7 +119,9 @@ Use ONLY for: **Sizes:** * Homepage hero: 56px / 64px line-height (desktop), 40px / 48px (mobile) -* Navigation logo: 24px / 28px line-height +* Navigation logo: 24px / 28px line-height (20px below the `sm` + breakpoint, scaling down fluidly on very narrow viewports so the full + wordmark never clips) --- diff --git a/app/(pages)/layout.tsx b/app/(pages)/layout.tsx index b1f7e23..e6b210d 100644 --- a/app/(pages)/layout.tsx +++ b/app/(pages)/layout.tsx @@ -1,4 +1,4 @@ -import { SiteHeaderDefault } from "@/components/site-header-default"; +import { SiteNav } from "@/components/site-nav"; export default function PagesLayout({ children, @@ -7,8 +7,13 @@ export default function PagesLayout({ }) { return ( <> - -
{children}
+ + {/* The nav is a fixed floating pill (~72px incl. its top offset and + any safe-area inset); the padding clears it with the breathing + room the floating treatment calls for. */} +
+ {children} +
); } diff --git a/app/error.tsx b/app/error.tsx index 2c7c677..b153ace 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -3,7 +3,7 @@ import * as Sentry from "@sentry/nextjs"; import { useEffect } from "react"; import Link from "next/link"; -import { SiteHeaderDefault } from "@/components/site-header-default"; +import { SiteNav } from "@/components/site-nav"; import { Button } from "@/components/ui/button"; // Root error boundary: an unhandled render error in any page lands here with @@ -33,11 +33,11 @@ export default function Error({ return ( <> - +

Something went wrong diff --git a/app/not-found.tsx b/app/not-found.tsx index c53be5a..82174eb 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { SiteHeaderDefault } from "@/components/site-header-default"; +import { SiteNav } from "@/components/site-nav"; import { Button } from "@/components/ui/button"; export const metadata: Metadata = { @@ -14,11 +14,11 @@ export const metadata: Metadata = { export default function NotFound() { return ( <> - +

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.

- - - {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 ( -
-
- - - Deep Dive Brewing Co - - - -
-
- ); -} 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 ( -
-
- - - Deep Dive Brewing Co - - - -
-
- ); -} 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`. */} + +
+ + {/* 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. */} + + + {/* 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(