Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions THEME_AND_BRANDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,27 @@ When in doubt, add more space rather than less.

### Allowed Animations
* Hover states: Subtle opacity shift (1.0 → 0.85) or slight color change (200ms ease)
* Pressed states: Brief scale-down (~2%) on activation, ~150ms — tactile
acknowledgement, never playful
* Loading states: Simple fade-in (300ms ease)
* Page transitions: None (or instant)
* Scroll-triggered reveals: Simple fade-in (600ms ease)

### Interactive feedback

* Every interactive control (buttons, links styled as buttons, clickable
rows/cards, filter chips, disclosure controls) must show a distinct
hover, focus-visible, and pressed state so a click or tap never feels
unacknowledged.
* Use the shared primitives rather than bespoke per-component motion:
`buttonVariants` in `components/ui/button.tsx` for `Button` instances and
`pressableClasses` in `lib/utils.ts` for other clickable surfaces.
* The pressed state is a small scale-down (scale 0.98, ~150ms) — no bounce,
spring, ripples, or large transforms, and no layout shift.
* Press transforms are gated by `motion-safe:` so `prefers-reduced-motion`
sessions keep the instant color/border acknowledgement without movement.
* Text-style links use the opacity hover only — they do not scale.

### Forbidden Animations
* Parallax scrolling
* Animated SVG decorations
Expand Down
9 changes: 6 additions & 3 deletions components/admin-trade-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { formatAdminDate, formatAdminDateTime } from "@/lib/admin-format";
import { cn, pressableClasses } from "@/lib/utils";
import type { AdminPanelUser } from "@/components/admin-access";
import {
normalizePhoneNumber,
Expand Down Expand Up @@ -916,11 +917,13 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) {
type="button"
aria-current={selectedId === lead.id}
onClick={() => void openLead(lead.id)}
className={`w-full rounded-md border px-2.5 py-2 text-left text-sm ${
className={cn(
"w-full rounded-md border px-2.5 py-2 text-left text-sm",
selectedId === lead.id
? "border-ocean/60 bg-ocean/[0.07]"
: "border-transparent hover:border-stone hover:bg-stone/25"
}`}
: "border-transparent hover:border-stone hover:bg-stone/25",
pressableClasses
)}
>
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-medium">
Expand Down
27 changes: 18 additions & 9 deletions components/admin-workspace.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import type { Dispatch, SetStateAction } from "react";
import { cn, pressableClasses } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Expand Down Expand Up @@ -236,9 +237,13 @@ export function AdminWorkspace({
type="button"
aria-current={selectedBeerSlug === beer.slug}
onClick={() => onSelectBeer(beer.slug)}
className={`w-full rounded-md px-2 py-1.5 text-left text-sm ${
selectedBeerSlug === beer.slug ? "bg-stone/40" : "hover:bg-stone/20"
}`}
className={cn(
"w-full rounded-md px-2 py-1.5 text-left text-sm",
selectedBeerSlug === beer.slug
? "bg-stone/40"
: "hover:bg-stone/20",
pressableClasses
)}
>
{beer.name}
</button>
Expand Down Expand Up @@ -352,9 +357,13 @@ export function AdminWorkspace({
type="button"
aria-current={selectedVenueSlug === venue.slug}
onClick={() => onSelectVenue(venue.slug)}
className={`w-full rounded-md px-2 py-1.5 text-left text-sm ${
selectedVenueSlug === venue.slug ? "bg-stone/40" : "hover:bg-stone/20"
}`}
className={cn(
"w-full rounded-md px-2 py-1.5 text-left text-sm",
selectedVenueSlug === venue.slug
? "bg-stone/40"
: "hover:bg-stone/20",
pressableClasses
)}
>
{venue.name}
</button>
Expand Down Expand Up @@ -414,7 +423,7 @@ export function AdminWorkspace({

<div className="mt-4 grid gap-4 sm:grid-cols-3">
<details className="text-sm">
<summary className="cursor-pointer rounded-md border border-ink/50 px-3 py-2 font-medium">
<summary className={cn("cursor-pointer rounded-md border border-ink/50 px-3 py-2 font-medium hover:bg-stone/20", pressableClasses)}>
Carries Beers
</summary>
<p className="mt-2 text-xs text-muted-foreground">
Expand All @@ -441,7 +450,7 @@ export function AdminWorkspace({
</details>

<details className="text-sm">
<summary className="cursor-pointer rounded-md border border-ink/50 px-3 py-2 font-medium">
<summary className={cn("cursor-pointer rounded-md border border-ink/50 px-3 py-2 font-medium hover:bg-stone/20", pressableClasses)}>
On Tap
</summary>
<p className="mt-2 text-xs text-muted-foreground">
Expand All @@ -468,7 +477,7 @@ export function AdminWorkspace({
</details>

<details className="text-sm">
<summary className="cursor-pointer rounded-md border border-ink/50 px-3 py-2 font-medium">
<summary className={cn("cursor-pointer rounded-md border border-ink/50 px-3 py-2 font-medium hover:bg-stone/20", pressableClasses)}>
In Can
</summary>
<p className="mt-2 text-xs text-muted-foreground">
Expand Down
7 changes: 5 additions & 2 deletions components/beer-card.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Image from "next/image";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { beerImageUrl } from "@/lib/utils";
import { beerImageUrl, cn, pressableClasses } from "@/lib/utils";
import type { Beer } from "@/lib/types";

interface BeerCardProps {
Expand All @@ -15,7 +15,10 @@ export function BeerCard({ beer, imageUrl, priority }: BeerCardProps) {
return (
<Link
href={`/beers/${beer.slug}`}
className="group block rounded-lg border border-stone bg-paper transition-opacity duration-200 hover:opacity-85"
className={cn(
"group block rounded-lg border border-stone bg-paper hover:opacity-85",
pressableClasses
)}
>
<div className="relative aspect-4/5 w-full overflow-hidden rounded-t-lg">
<Image
Expand Down
11 changes: 9 additions & 2 deletions components/beer-carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import useEmblaCarousel from "embla-carousel-react";
import { useCallback } from "react";
import { BeerCard } from "@/components/beer-card";
import { cn, pressableClasses } from "@/lib/utils";
import type { Beer } from "@/lib/types";

interface BeerCarouselProps {
Expand Down Expand Up @@ -60,7 +61,10 @@ export function BeerCarousel({ beers, imageUrls }: BeerCarouselProps) {
<button
onClick={scrollPrev}
aria-label="Previous beer"
className="absolute -left-4 top-1/3 z-10 flex h-11 w-11 min-h-[44px] min-w-[44px] items-center justify-center rounded-full border border-stone bg-paper shadow-sm transition-opacity hover:opacity-75 focus-visible:ring-2 focus-visible:ring-ocean/50"
className={cn(
"absolute -left-4 top-1/3 z-10 flex h-11 w-11 min-h-[44px] min-w-[44px] items-center justify-center rounded-full border border-stone bg-paper shadow-sm hover:opacity-75 focus-visible:ring-2 focus-visible:ring-ocean/50",
pressableClasses
)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M15 18l-6-6 6-6" />
Expand All @@ -69,7 +73,10 @@ export function BeerCarousel({ beers, imageUrls }: BeerCarouselProps) {
<button
onClick={scrollNext}
aria-label="Next beer"
className="absolute -right-4 top-1/3 z-10 flex h-11 w-11 min-h-[44px] min-w-[44px] items-center justify-center rounded-full border border-stone bg-paper shadow-sm transition-opacity hover:opacity-75 focus-visible:ring-2 focus-visible:ring-ocean/50"
className={cn(
"absolute -right-4 top-1/3 z-10 flex h-11 w-11 min-h-[44px] min-w-[44px] items-center justify-center rounded-full border border-stone bg-paper shadow-sm hover:opacity-75 focus-visible:ring-2 focus-visible:ring-ocean/50",
pressableClasses
)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 18l6-6-6-6" />
Expand Down
6 changes: 5 additions & 1 deletion components/beers-filter-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { cn, pressableClasses } from "@/lib/utils";
import { trackEvent } from "@/lib/analytics";
import { BeerCard } from "@/components/beer-card";
import type { Beer } from "@/lib/types";
Expand Down Expand Up @@ -42,7 +43,10 @@ export function BeersFilterGrid({ beers }: BeersFilterGridProps) {
cta_location: "beers_page",
});
}}
className="min-h-[44px] min-w-[44px] cursor-pointer rounded-md focus-visible:ring-2 focus-visible:ring-ocean/50"
className={cn(
"min-h-[44px] min-w-[44px] cursor-pointer rounded-md hover:opacity-85 focus-visible:ring-2 focus-visible:ring-ocean/50",
pressableClasses
)}
aria-pressed={activeFilter === option.value}
>
<Badge
Expand Down
7 changes: 5 additions & 2 deletions components/contact-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useState } from "react";
import { MapPin } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn, pressableClasses } from "@/lib/utils";
import { BUSINESS_ADDRESS } from "@/lib/site";

// Contextual consent boundary (Issue #77): the Google Maps embed is never
Expand Down Expand Up @@ -57,7 +57,10 @@ export function ContactMap({ className }: ContactMapProps) {
<button
type="button"
onClick={() => setMapRequested(true)}
className="mt-2 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md bg-ink px-4 py-2 text-sm font-medium text-paper transition-opacity duration-200 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ink/50"
className={cn(
"mt-2 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md bg-ink px-4 py-2 text-sm font-medium text-paper hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ink/50",
pressableClasses
)}
>
Load map
</button>
Expand Down
6 changes: 5 additions & 1 deletion components/hero-video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import Image from "next/image";
import { cn, pressableClasses } from "@/lib/utils";

// Issue #135: the brewery still replaces the grain hero photo so the static
// experience looks intentional instead of duplicating the hero above it.
Expand Down Expand Up @@ -145,7 +146,10 @@ export function HeroVideo() {
<div className="mt-8">
<Link
href="/about"
className="inline-flex h-11 min-w-[44px] items-center justify-center rounded-md border border-paper/30 bg-transparent px-6 text-sm font-medium text-paper transition-colors hover:bg-paper/10 focus-visible:ring-2 focus-visible:ring-paper/50"
className={cn(
"inline-flex h-11 min-w-[44px] items-center justify-center rounded-md border border-paper/30 bg-transparent px-6 text-sm font-medium text-paper hover:bg-paper/10 focus-visible:ring-2 focus-visible:ring-paper/50",
pressableClasses
)}
>
Learn More
</Link>
Expand Down
6 changes: 5 additions & 1 deletion components/mobile-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useId, useState } from "react";
import Link from "next/link";
import { cn, pressableClasses } from "@/lib/utils";

const navLinks = [
{ href: "/beers", label: "Beers" },
Expand Down Expand Up @@ -42,7 +43,10 @@ export function MobileMenu({ variant = "dark" }: MobileMenuProps) {
<div className="md:hidden">
<button
onClick={() => setOpen(!open)}
className={`flex h-11 w-11 min-h-[44px] min-w-[44px] items-center justify-center rounded-md ${iconColor} focus-visible:ring-2 ${focusRing}`}
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}
Expand Down
17 changes: 13 additions & 4 deletions components/tour-date-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useEffect, useRef, useState } from "react";
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn, pressableClasses } from "@/lib/utils";
import {
addCalendarDays,
compareCalendarDates,
Expand Down Expand Up @@ -180,7 +180,10 @@ export function TourDatePicker({
: { year: v.year, month: v.month - 1 }
)
}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-ink/70 transition-colors hover:bg-stone disabled:pointer-events-none disabled:opacity-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ocean/50"
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-md text-ink/70 hover:bg-stone disabled:pointer-events-none disabled:opacity-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ocean/50",
pressableClasses
)}
>
<ChevronLeft className="size-5" aria-hidden="true" />
</button>
Expand All @@ -200,7 +203,10 @@ export function TourDatePicker({
: { year: v.year, month: v.month + 1 }
)
}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-ink/70 transition-colors hover:bg-stone focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ocean/50"
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-md text-ink/70 hover:bg-stone focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ocean/50",
pressableClasses
)}
>
<ChevronRight className="size-5" aria-hidden="true" />
</button>
Expand Down Expand Up @@ -245,7 +251,10 @@ export function TourDatePicker({
tabIndex={toIsoDate(day) === focusedIso ? 0 : -1}
onClick={() => select(day)}
onKeyDown={(e) => handleDayKeyDown(e, day)}
className="mx-auto flex h-8 w-full max-w-8 items-center justify-center rounded-full text-sm text-ink transition-colors hover:bg-stone focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ocean/50 aria-[disabled=true]:text-ink/30 aria-[disabled=true]:hover:bg-transparent data-[selected=true]:bg-ink data-[selected=true]:text-paper"
className={cn(
"mx-auto flex h-8 w-full max-w-8 items-center justify-center rounded-full text-sm text-ink hover:bg-stone focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ocean/50 aria-[disabled=true]:text-ink/30 aria-[disabled=true]:hover:bg-transparent data-[selected=true]:bg-ink data-[selected=true]:text-paper",
pressableClasses
)}
>
{day.day}
</button>
Expand Down
6 changes: 5 additions & 1 deletion components/tour-inquiry-cta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { trackEvent } from "@/lib/analytics";
import { cn, pressableClasses } from "@/lib/utils";
import { TourDatePicker } from "@/components/tour-date-picker";
import { toIsoDate } from "@/lib/calendar";
import {
Expand Down Expand Up @@ -157,7 +158,10 @@ export function TourInquiryCta({
return (
<label
key={key}
className="cursor-pointer rounded-md border border-stone bg-paper px-3 py-1.5 transition-colors has-checked:border-ink has-checked:bg-stone/40 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ocean/50"
className={cn(
"cursor-pointer rounded-md border border-stone bg-paper px-3 py-1.5 has-checked:border-ink has-checked:bg-stone/40 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ocean/50",
pressableClasses
)}
>
<input
type="radio"
Expand Down
12 changes: 11 additions & 1 deletion components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all motion-reduce:duration-0 disabled:pointer-events-none disabled:opacity-50 disabled:saturate-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
Expand All @@ -31,6 +31,16 @@ const buttonVariants = cva(
"icon-lg": "size-10",
},
},
compoundVariants: [
// Shared press feedback for every button-style variant: a brief ~2%
// scale-down on activation (THEME_AND_BRANDING.md — Interactive
// feedback). The text-only link variant stays still — scaling inline
// text reads as a layout shift, not a press.
{
variant: ["default", "destructive", "outline", "secondary", "ghost"],
class: "motion-safe:active:scale-[0.98]",
},
],
defaultVariants: {
variant: "default",
size: "default",
Expand Down
7 changes: 5 additions & 2 deletions components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { X } from "lucide-react";

import { cn } from "@/lib/utils";
import { cn, pressableClasses } from "@/lib/utils";

const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
Expand Down Expand Up @@ -33,7 +33,10 @@ function DialogContent({
{children}
<DialogPrimitive.Close
aria-label="Close"
className="absolute right-3 top-3 inline-flex h-11 w-11 min-h-[44px] min-w-[44px] items-center justify-center rounded-md text-ink/70 transition-colors hover:bg-stone hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ocean/50"
className={cn(
"absolute right-3 top-3 inline-flex h-11 w-11 min-h-[44px] min-w-[44px] items-center justify-center rounded-md text-ink/70 hover:bg-stone hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ocean/50",
pressableClasses
)}
>
<X className="size-5" aria-hidden="true" />
</DialogPrimitive.Close>
Expand Down
Loading
Loading