diff --git a/THEME_AND_BRANDING.md b/THEME_AND_BRANDING.md index c5ba435..8939f0e 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,40 @@ 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) or sits above the fold, since + the hidden state is armed only when the component's effect runs + (fail-visible if JS never hydrates). 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..1dc6be2 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,17 +187,27 @@ } .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). Content renders visible in the + SSR'd document; the hidden start state is applied by the Reveal effect + itself via `reveal-armed`, so sessions without JS — or where hydration + never establishes the observer — keep the content visible. */ .scroll-fade-in { + transition: + opacity var(--motion-reveal) var(--ease-standard), + transform var(--motion-reveal) var(--ease-standard); +} + +.scroll-fade-in.reveal-armed { opacity: 0; transform: translateY(20px); - transition: opacity 600ms ease, transform 600ms ease; } -.scroll-fade-in.fade-in-visible { +/* Must out-specify `.scroll-fade-in.reveal-armed` (0,2,0). */ +.scroll-fade-in.reveal-armed.fade-in-visible { opacity: 1; transform: translateY(0); } @@ -205,7 +225,7 @@ animation-delay: 0s; } - .scroll-fade-in { + .scroll-fade-in.reveal-armed { opacity: 1; transform: none; transition: none; @@ -376,8 +396,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..0518fe5 --- /dev/null +++ b/components/reveal.tsx @@ -0,0 +1,83 @@ +"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` → `reveal-armed` → `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, the hidden state is only armed once this + * effect runs (JS-disabled or failed hydration leaves content visible), and + * reduced-motion sessions skip straight to the visible state. + */ +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; + } + + // Arm the hidden start state only once this effect is live. The SSR'd + // element renders visible, so sessions where JS never runs or hydration + // fails before this effect never reach the hidden state — they stay + // visible. Suppressing the transition while arming prevents the style + // flip itself animating a fade-out for elements already in view. + el.style.transition = "none"; + el.classList.add("reveal-armed"); + el.getBoundingClientRect(); + el.style.transition = ""; + + 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} + + ); +} diff --git a/components/site-nav.tsx b/components/site-nav.tsx index b95c87e..ab7a4f3 100644 --- a/components/site-nav.tsx +++ b/components/site-nav.tsx @@ -206,10 +206,13 @@ export function SiteNav() { - {/* 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`. */} + {/* The button keeps a 44px target and focus ring while the icon + sits bare on the pill — a persistent disc surface read as a + button-in-a-button and its lighter fill mismatched the pill. + The span only paints as interaction feedback (press, and + hover on devices that can hover — plain group-hover is not + gated to hover-capable devices, so on touch a tap would + leave :hover stuck on the element and keep the fill lit). */}