From fe00bcf2473edff285d2127ea08c752a6be5c6e1 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Mon, 5 Oct 2026 14:20:42 -0400 Subject: [PATCH 1/6] Shared motion tokens + editorial rhythm polish (#164, #165) - Introduce :root motion tokens (--motion-press/ui/reveal/hero, --ease-standard) and point all existing animation classes at them so timing and easing are defined once. - New primitive (components/reveal.tsx) is the site's single scroll-triggered motion pattern; IntroSection now composes it instead of bespoke IntersectionObserver logic. - Homepage hero gets a light stagger (wordmark, tagline, CTAs); teaser sections convert from page-load fades to scroll reveals. - Beer detail pages get the full-width banner the hero assets were designed for (aspect 4:3 mobile, 2:1 desktop) with an editorial two-column body. - Beers page pairs the Food Pairing + For Partners panels side-by-side on desktop to break the repeated full-width stacking. - Klaro consent buttons get the same press acknowledgement as site controls. - THEME_AND_BRANDING.md documents the tokens, primitives, and hero image banner behavior. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- THEME_AND_BRANDING.md | 38 +++++++++++++++- app/(pages)/beers/[slug]/page.tsx | 76 ++++++++++++++++--------------- app/(pages)/beers/page.tsx | 64 +++++++++++++------------- app/globals.css | 33 +++++++++++--- app/page.tsx | 17 +++---- components/home/IntroSection.tsx | 44 +++--------------- components/reveal.tsx | 71 +++++++++++++++++++++++++++++ 7 files changed, 223 insertions(+), 120 deletions(-) create mode 100644 components/reveal.tsx diff --git a/THEME_AND_BRANDING.md b/THEME_AND_BRANDING.md index c5ba435..0aede55 100644 --- a/THEME_AND_BRANDING.md +++ b/THEME_AND_BRANDING.md @@ -222,8 +222,12 @@ gs://deepdivebrewing-web.firebasestorage.app/beers/ * **Purpose**: Full-width hero image on individual beer detail pages (e.g., `/beers/neipa`) * **Format**: JPG, optimized for web * **File size target**: Under 400KB -* **Behavior**: Grows with screen width, maintains aspect ratio +* **Behavior**: Rendered as a full-width page banner — `aspect-[4/3]` on + mobile (keeps the can in frame) easing to `aspect-[2/1]` on wider + screens, `object-cover` centered * **Responsive**: Will be served at different sizes based on viewport +* **Composition**: Cans/subjects should sit near the horizontal center and + survive modest top/bottom crops at the 2:1 banner ratio ### Naming Convention @@ -303,6 +307,38 @@ When in doubt, add more space rather than less. * Page transitions: None (or instant) * Scroll-triggered reveals: Simple fade-in (600ms ease) +### Shared motion tokens + +All motion derives from the tokens in `:root` (`app/globals.css`) so timing +and easing stay consistent site-wide: + +* `--motion-press` (150ms) — control press/hover acknowledgement +* `--motion-ui` (200ms) — small UI transitions (menus, chips, borders) +* `--motion-reveal` (600ms) — fade/rise reveals and page-load fades +* `--motion-hero` (800ms) — the homepage hero reveal only +* `--ease-standard` — single shared easing curve for anything that moves + +Do not introduce per-component one-off durations or easings; pick the +nearest token instead. + +### Motion primitives + +* `components/reveal.tsx` (``) is the single scroll-triggered + pattern: content fades up once it enters the viewport via + IntersectionObserver (one-shot, no re-triggering). Use it for + editorial section reveals — not for content that must be instantly + scannable (forms, dense listings, FAQs). Reduced-motion sessions get + instant visibility with no movement. +* `animate-hero-fade` + `animate-delay-*` classes handle page-load reveals; + stagger is used sparingly on the homepage hero only. +* Dialogs, menus, and disclosure motion come from `tw-animate-css` + utilities already wired into shadcn/Radix primitives — use the shared + components rather than hand-rolled keyframes. +* All motion must respect `prefers-reduced-motion`: transforms are gated + by `motion-safe:`/`motion-reduce:` Tailwind variants or an explicit + `matchMedia("(prefers-reduced-motion: reduce)")` check, and the global + media query in `globals.css` collapses durations to near-instant. + ### Interactive feedback * Every interactive control (buttons, links styled as buttons, clickable diff --git a/app/(pages)/beers/[slug]/page.tsx b/app/(pages)/beers/[slug]/page.tsx index 74ce6b9..a8a72a9 100644 --- a/app/(pages)/beers/[slug]/page.tsx +++ b/app/(pages)/beers/[slug]/page.tsx @@ -107,47 +107,49 @@ export default async function BeerDetailPage({ params }: BeerDetailPageProps) { -
- {/* Beer image */} -
-
- {beer.name} -
-
- - {/* Beer details */} -
-

- {beer.name} -

- -

- {beer.style} -

+ {/* Title block */} +

+ {beer.name} +

+ +

+ {beer.style} +

+ + {/* Specs */} +
+ {beer.abv}% ABV + {beer.status} + {beer.ibu && {beer.ibu} IBU} + {beer.srm && SRM {beer.srm}} +
- {/* Specs */} -
- {beer.abv}% ABV - {beer.status} - {beer.ibu && {beer.ibu} IBU} - {beer.srm && SRM {beer.srm}} -
+ {/* Full-width hero image — the 16:9 hero assets are composed for + banner crops: a modest 4:3 on mobile keeps the can visible, 2:1 + on wider screens gives an editorial page header. */} +
+ {beer.name} +
+ {/* Beer details — editorial two-column body */} +
+
{/* Description */} -

+

{beer.descriptionShort}

{/* Tasting notes */} {beer.tastingNotes.length > 0 && ( -
+

Tasting Notes

@@ -160,9 +162,11 @@ export default async function BeerDetailPage({ params }: BeerDetailPageProps) {
)} +
- {/* Where to find */} -
+ {/* Where to find */} +
+
); diff --git a/app/(pages)/beers/page.tsx b/app/(pages)/beers/page.tsx index e0de7a9..5df9a00 100644 --- a/app/(pages)/beers/page.tsx +++ b/app/(pages)/beers/page.tsx @@ -74,38 +74,40 @@ export default async function BeersPage() { -
-

Food Pairing Ideas

-
    -
  • - NEIPA: spicy dishes, - grilled fish, and rich cheeses. -
  • -
  • - American Amber: - burgers, barbecue, roasted chicken, and aged gouda. -
  • -
  • - Pale Lager: shellfish, - ceviche, salads, and light fried foods. -
  • -
  • - Tropical Wheat: - citrus-forward dishes, seafood, and island-style lunches. -
  • -
-
+
+
+

Food Pairing Ideas

+
    +
  • + NEIPA: spicy dishes, + grilled fish, and rich cheeses. +
  • +
  • + American Amber: + burgers, barbecue, roasted chicken, and aged gouda. +
  • +
  • + Pale Lager: shellfish, + ceviche, salads, and light fried foods. +
  • +
  • + Tropical Wheat: + citrus-forward dishes, seafood, and island-style lunches. +
  • +
+
-
-

For Partners

-

- Interested in carrying Deep Dive Brewing Co? Start with our trade - inquiry form and we’ll help you find the right mix for your venue. -

- - Submit a trade inquiry → - -
+
+

For Partners

+

+ Interested in carrying Deep Dive Brewing Co? Start with our trade + inquiry form and we’ll help you find the right mix for your venue. +

+ + Submit a trade inquiry → + +
+
); } diff --git a/app/globals.css b/app/globals.css index e79b87e..5b413e2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -102,6 +102,16 @@ --sidebar-accent-foreground: #0B0F14; --sidebar-border: #E6E7E3; --sidebar-ring: #334E68; + + /* Motion tokens — the shared DDB motion language + (THEME_AND_BRANDING.md → Motion & Animation). Durations stay calm: + press feedback is the fastest thing on the site, reveals the slowest. + One easing curve for everything that moves. */ + --motion-press: 150ms; + --motion-ui: 200ms; + --motion-reveal: 600ms; + --motion-hero: 800ms; + --ease-standard: cubic-bezier(0.22, 0.61, 0.36, 1); } @layer base { @@ -145,7 +155,7 @@ } .animate-fade-in { - animation: fade-in-up 600ms ease both; + animation: fade-in-up var(--motion-reveal) var(--ease-standard) both; } .animate-delay-1 { @@ -177,14 +187,17 @@ } .animate-hero-fade { - animation: hero-fade 800ms ease 200ms both; + animation: hero-fade var(--motion-hero) var(--ease-standard) 200ms both; } -/* Scroll-triggered fade-in animation */ +/* Scroll-triggered fade-in — toggled by components/reveal.tsx, the shared + primitive for scroll reveals (Issue #164). */ .scroll-fade-in { opacity: 0; transform: translateY(20px); - transition: opacity 600ms ease, transform 600ms ease; + transition: + opacity var(--motion-reveal) var(--ease-standard), + transform var(--motion-reveal) var(--ease-standard); } .scroll-fade-in.fade-in-visible { @@ -376,8 +389,16 @@ margin-right: 0; padding: 0.55em 1em; transition: - background-color 150ms ease, - border-color 150ms ease; + background-color var(--motion-press) ease, + border-color var(--motion-press) ease, + transform var(--motion-press) ease; +} + +/* Same press acknowledgement as site controls (scale 0.98, motion-safe). */ +@media (prefers-reduced-motion: no-preference) { + #klaro .cm-btn:active { + transform: scale(0.98); + } } #klaro .cm-btn:hover { diff --git a/app/page.tsx b/app/page.tsx index 5ffea6e..750068d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,6 +4,7 @@ import type { Metadata } from "next"; import { Button } from "@/components/ui/button"; import { SiteNav } from "@/components/site-nav"; import IntroSection from "@/components/home/IntroSection"; +import { Reveal } from "@/components/reveal"; import { BeerCarousel } from "@/components/beer-carousel"; import { HeroVideo } from "@/components/hero-video"; import { TourInquiryCta } from "@/components/tour-inquiry-cta"; @@ -76,9 +77,9 @@ export default async function Home() { className="object-cover object-[30%_50%]" />
-
+

Brewing Co

-

+

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 @@ -151,7 +152,7 @@ export default async function Home() { {/* Featured beers teaser */} -
+

Our Beers

@@ -166,10 +167,10 @@ export default async function Home() {
-
+ {/* Where to find us teaser */} -
+

@@ -198,7 +199,7 @@ export default async function Home() {

- + ); diff --git a/components/home/IntroSection.tsx b/components/home/IntroSection.tsx index 025ab25..d7fafde 100644 --- a/components/home/IntroSection.tsx +++ b/components/home/IntroSection.tsx @@ -1,48 +1,16 @@ -"use client"; - -import { useEffect, useRef } from "react"; +import { Reveal } from "@/components/reveal"; interface IntroSectionProps { children: React.ReactNode; } export default function IntroSection({ children }: IntroSectionProps) { - const introRef = useRef(null); - - useEffect(() => { - // Check for reduced motion preference - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - // Immediately apply visible state for reduced motion - if (introRef.current) { - introRef.current.classList.add("fade-in-visible"); - } - return; - } - - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - entry.target.classList.add("fade-in-visible"); - } - }, - { threshold: 0.1 } - ); - - const currentRef = introRef.current; - if (currentRef) { - observer.observe(currentRef); - } - - return () => { - if (currentRef) { - observer.unobserve(currentRef); - } - }; - }, []); - return ( -
+ {children} -
+ ); } diff --git a/components/reveal.tsx b/components/reveal.tsx new file mode 100644 index 0000000..aab56e5 --- /dev/null +++ b/components/reveal.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; + +type RevealTag = + | "div" + | "section" + | "article" + | "aside" + | "header" + | "footer" + | "ul" + | "li" + | "p"; + +interface RevealProps extends React.HTMLAttributes { + /** + * Rendered element — semantic tag of the block being revealed. + * Defaults to `div`. + */ + as?: RevealTag; +} + +/** + * Shared scroll-reveal primitive (Issue #164) — the site's single + * scroll-triggered motion pattern. Content fades/rises into place once it + * enters the viewport (`scroll-fade-in` → `fade-in-visible` in globals.css). + * + * Reveals are one-shot and never block interaction: the element stays in the + * SSR'd document from first paint, and reduced-motion sessions skip straight + * to the visible state (no movement, no wait). + */ +export function Reveal({ + as = "div", + className, + children, + ...rest +}: RevealProps) { + // Typed as "div" for JSX/ref typing; renders the `as` tag at runtime. + const Tag = as as "div"; + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + el.classList.add("fade-in-visible"); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + el.classList.add("fade-in-visible"); + observer.disconnect(); + } + }, + { threshold: 0.1, rootMargin: "0px 0px -8% 0px" } + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( + + {children} + + ); +} From f57b12511479a8330ec34bd747a077d577317a87 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Mon, 5 Oct 2026 15:15:45 -0400 Subject: [PATCH 2/6] Render scroll-reveal content visible when JavaScript does not run Per CodeRabbit review on #189: .scroll-fade-in started at opacity 0 unconditionally, so no-JS sessions (or a failed hydration) left revealed sections permanently invisible. The hidden start state now applies only under html.js, set by a pre-paint inline script in the root layout. Reduced-motion media query specificity raised to match so those sessions still get instant visibility. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/globals.css | 13 +++++++++---- app/layout.tsx | 11 ++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/globals.css b/app/globals.css index 5b413e2..4e8b89a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -191,15 +191,20 @@ } /* Scroll-triggered fade-in — toggled by components/reveal.tsx, the shared - primitive for scroll reveals (Issue #164). */ + primitive for scroll reveals (Issue #164). The hidden start state only + applies under html.js (set by the inline script in app/layout): sessions + without JS render the content visible rather than permanently faded out. */ .scroll-fade-in { - opacity: 0; - transform: translateY(20px); transition: opacity var(--motion-reveal) var(--ease-standard), transform var(--motion-reveal) var(--ease-standard); } +html.js .scroll-fade-in { + opacity: 0; + transform: translateY(20px); +} + .scroll-fade-in.fade-in-visible { opacity: 1; transform: translateY(0); @@ -218,7 +223,7 @@ animation-delay: 0s; } - .scroll-fade-in { + html.js .scroll-fade-in { opacity: 1; transform: none; transition: none; diff --git a/app/layout.tsx b/app/layout.tsx index f4442d4..6f8d9d3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -151,8 +151,17 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + + {/* Sets the html.js flag before first paint. CSS scopes + JS-dependent start states (e.g. .scroll-fade-in's hidden state) + under html.js, so no-JS sessions render content visible instead + of permanently faded out. */} +