| @@ -0,0 +1,86 @@ | ||
| + | # Stepping: Design System - "ON BEAT" | |
| + | ||
| + | A bespoke visual system for the Divine Nine stepping app. Built from first | |
| + | principles, grounded in step-show culture. It deliberately does NOT use the | |
| + | `ui-ux-pro-max` skill or any generic component-kit look. Every choice is | |
| + | sourced from (a) Zion's reference portfolios and (b) license-clean open-source | |
| + | technique, then bent toward the culture. | |
| + | ||
| + | ## Sourcing (provenance, so this stays defensible and original) | |
| + | ||
| + | - **Design DNA** distilled from Zion's references: monolithstudio.com, | |
| + | airborne.studio, donprod.uk, ning-h.com, tenbinlabs.xyz, wildyriftian.com, | |
| + | oscarpecher.com. Common laws we adopt: two-tone discipline, warm-black / | |
| + | warm-paper (never pure #000/#fff), type-as-art, mono micro-type for metadata, | |
| + | one signature interactive object, velocity-driven motion, custom cursor as a | |
| + | discovery mechanic, a named concept spine, negative space as luxury. | |
| + | - **Technique** lifted only from MIT/CC0 repos (security-scanned, semgrep clean): | |
| + | `studio-freight/lenis` (MIT) for smooth scroll; structural patterns from | |
| + | `brunosimon/folio-2019` and `ektogamat/threejs-andy-boilerplate` (MIT). The | |
| + | `olivierlarose` staggered-reveal / zoom-parallax repos are no-license, so we | |
| + | REIMPLEMENT their technique from scratch, never copy their files. | |
| + | ||
| + | ## Concept spine: ON BEAT | |
| + | ||
| + | The site runs like a step show that never stops. Stepping is body-as-instrument: | |
| + | stomps, claps, spoken word, synchronized lines. So the interface keeps time. The | |
| + | page is a show with a run of acts: | |
| + | ||
| + | 1. **STAGE** - the hero. Blackout, spotlight, the name hits on a beat. | |
| + | 2. **THE LINE-UP** - events (step shows, strolls, competitions). | |
| + | 3. **THE YARD** - community: chapters, step teams, DJs, promoters, classes. | |
| + | 4. **THE ROUTE** - per-city travel for traveling to a show. | |
| + | ||
| + | ## Color - two-tone + one heritage accent (warm) | |
| + | ||
| + | | Token | Hex | Role | | |
| + | |---|---|---| | |
| + | | `--stage` | `#0F0E0C` | ground (warm near-black, a stage blackout / brick at night) | | |
| + | | `--bone` | `#F2ECE0` | ink (warm spotlight bone, never pure white) | | |
| + | | `--gold` | `#C8A24C` | the only accent: heritage prestige gold, neutral across all nine orgs | | |
| + | | `--gold-bright` | `#E4C572` | gold hover / active | | |
| + | | `--ash` | `#8A8275` | warm grey for secondary mono metadata | | |
| + | | `--line` | `rgba(242,236,224,0.14)` | hairlines | | |
| + | ||
| + | Discipline: no other hues. We never adopt a single fraternity/sorority's colors | |
| + | (the app serves all nine); gold is the shared dignifier. | |
| + | ||
| + | ## Type - percussive + editorial duality (call-and-response) | |
| + | ||
| + | - **Display / impact:** `Bricolage Grotesque` - oversized, tight, declarative. | |
| + | The "stomp." Used huge for act titles and the name. | |
| + | - **Editorial accent:** `Instrument Serif` - high-contrast serif for the | |
| + | storytelling lines (the definition of stepping is literally storytelling). | |
| + | - **Metadata:** `Space Mono` - every label, index, year, set time, and city | |
| + | coordinate. Maps the "art-tier mono micro-type" law onto real step data: | |
| + | `EST. 1906`, `ΑΦΑ`, `ATL · 33.749, -84.388`, `01 / 06`. | |
| + | ||
| + | Type rule: on any screen, either the display type is loud or the imagery is. | |
| + | Never both. | |
| + | ||
| + | ## Motion - "on beat" | |
| + | ||
| + | - **Cadence:** reveals are staggered like a stomp-clap, not a uniform fade. | |
| + | Short, snappy, slightly syncopated timing. | |
| + | - **Velocity-reactive:** display type reacts to scroll speed (skew/weight), | |
| + | reimplemented with Lenis + transform mapping (not copied). | |
| + | - **Smooth scroll:** Lenis (MIT). | |
| + | - **Signature object / cursor:** a custom cursor that leaves a clap-ripple on | |
| + | click and a soft trailing dot - percussion you can feel. This is our "one | |
| + | signature interactive object," lightweight (canvas/DOM), no heavy WebGL. | |
| + | - **Reduced motion:** all of the above gate on `prefers-reduced-motion`. | |
| + | ||
| + | ## Layout | |
| + | ||
| + | Editorial and asymmetric. Generous negative space. Numbered/lettered systems | |
| + | everywhere (founding years, chapter designations, `01/06` indices). Hairline | |
| + | rules and mono kickers frame each act. Mobile-first; the show reflows to a single | |
| + | column without losing the beat. | |
| + | ||
| + | ## Components (first set) | |
| + | ||
| + | - `BeatCursor` - custom cursor + clap-ripple on click. | |
| + | - `SmoothScroll` - Lenis provider. | |
| + | - `Marquee` - a running mono ticker of founding years / city names (the "roll"). | |
| + | - Act section shell - mono kicker + huge display title + content. | |
| + | - Event card, profile card - bone-on-stage, gold for the one thing that matters. |
| @@ -20,6 +20,7 @@ | ||
| "@trpc/client": "^11.0.0", | ||
| "@trpc/react-query": "^11.0.0", | ||
| "@trpc/server": "^11.0.0", | ||
| + | "lenis": "^1.3.24", | |
| "next": "16.2.9", | ||
| "react": "19.2.4", | ||
| "react-dom": "19.2.4", |
| @@ -15,15 +15,17 @@ const TYPE_LABEL: Record<string, string> = { | ||
| special: "Special", | ||
| }; | ||
| - | function fmtDate(d: Date | null) { | |
| - | if (!d) return "Date TBD"; | |
| - | return new Date(d).toLocaleString("en-US", { | |
| - | weekday: "short", | |
| - | month: "short", | |
| - | day: "numeric", | |
| - | hour: "numeric", | |
| - | minute: "2-digit", | |
| - | }); | |
| + | function fmt(d: Date | null) { | |
| + | if (!d) return "DATE TBD"; | |
| + | return new Date(d) | |
| + | .toLocaleString("en-US", { | |
| + | weekday: "short", | |
| + | month: "short", | |
| + | day: "numeric", | |
| + | hour: "numeric", | |
| + | minute: "2-digit", | |
| + | }) | |
| + | .toUpperCase(); | |
| } | ||
| export default async function CityPage({ | ||
| @@ -54,76 +56,83 @@ export default async function CityPage({ | ||
| } | ||
| return ( | ||
| - | <main className="mx-auto w-full max-w-2xl flex-1 px-6 py-12"> | |
| - | <Link | |
| - | href="/" | |
| - | className="text-sm text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200" | |
| - | > | |
| - | ← All cities | |
| + | <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8"> | |
| + | <Link href="/" className="kicker hover:text-gold"> | |
| + | ← Stepping | |
| </Link> | ||
| - | <h1 className="mt-3 text-3xl font-semibold tracking-tight text-black dark:text-zinc-50"> | |
| - | {city.name} | |
| - | </h1> | |
| - | <p className="text-zinc-500 dark:text-zinc-400">{city.metroLabel}</p> | |
| + | <p className="kicker mt-10 text-ash"> | |
| + | {city.metroLabel} · {city.lat?.toFixed(3)}, {city.lng?.toFixed(3)} | |
| + | </p> | |
| + | <h1 className="display mt-3 text-7xl sm:text-8xl">{city.name}</h1> | |
| - | <section className="mt-8"> | |
| - | <h2 className="text-sm font-medium uppercase tracking-widest text-amber-600"> | |
| - | Add an event | |
| - | </h2> | |
| - | <form action={addEvent} className="mt-3 flex flex-col gap-3"> | |
| + | {/* Add an event */} | |
| + | <section className="mt-14 border-t border-[var(--line)] pt-10"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">Post a show</h2> | |
| + | <span className="kicker">Add to the line-up</span> | |
| + | </div> | |
| + | <form action={addEvent} className="mt-6 flex flex-col gap-3"> | |
| <input | ||
| name="title" | ||
| required | ||
| placeholder="Event name (e.g. Homecoming Step Show)" | ||
| - | className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-black dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" | |
| + | className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold" | |
| /> | ||
| - | <div className="flex gap-3"> | |
| + | <div className="mt-2 flex flex-wrap gap-4"> | |
| <select | ||
| name="type" | ||
| - | className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-black dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" | |
| + | className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold" | |
| > | ||
| {EVENT_TYPE.map((t) => ( | ||
| - | <option key={t} value={t}> | |
| - | {TYPE_LABEL[t] ?? t} | |
| + | <option key={t} value={t} className="bg-stage"> | |
| + | {(TYPE_LABEL[t] ?? t).toUpperCase()} | |
| </option> | ||
| ))} | ||
| </select> | ||
| <input | ||
| type="datetime-local" | ||
| name="startsAt" | ||
| - | className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-black dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" | |
| + | className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold" | |
| /> | ||
| + | <button | |
| + | type="submit" | |
| + | className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright" | |
| + | > | |
| + | Post event | |
| + | </button> | |
| </div> | ||
| - | <button | |
| - | type="submit" | |
| - | className="self-start rounded-full bg-amber-600 px-5 py-2 font-medium text-white hover:bg-amber-700" | |
| - | > | |
| - | Post event | |
| - | </button> | |
| </form> | ||
| </section> | ||
| - | <section className="mt-10"> | |
| - | <h2 className="text-sm font-medium uppercase tracking-widest text-zinc-400"> | |
| - | Events | |
| - | </h2> | |
| + | {/* The line-up */} | |
| + | <section className="mt-16 border-t border-[var(--line)] pt-10"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">The Line-Up</h2> | |
| + | <span className="kicker text-ash"> | |
| + | {String(events.length).padStart(2, "0")} shows | |
| + | </span> | |
| + | </div> | |
| {events.length === 0 ? ( | ||
| - | <p className="mt-3 text-zinc-500"> | |
| - | No events yet. Add the first one above. | |
| + | <p className="serif mt-8 text-xl text-ash"> | |
| + | No shows yet. Post the first one above. | |
| </p> | ||
| ) : ( | ||
| - | <ul className="mt-3 divide-y divide-zinc-200 dark:divide-zinc-800"> | |
| - | {events.map((e) => ( | |
| - | <li key={e.id} className="flex items-center justify-between py-4"> | |
| - | <div> | |
| - | <p className="font-medium text-black dark:text-zinc-50"> | |
| - | {e.title} | |
| - | </p> | |
| - | <p className="text-sm text-zinc-500">{fmtDate(e.startsAt)}</p> | |
| + | <ul className="mt-6"> | |
| + | {events.map((e, i) => ( | |
| + | <li | |
| + | key={e.id} | |
| + | className="flex items-center gap-5 border-b border-[var(--line)] py-6" | |
| + | > | |
| + | <span className="kicker w-8 text-gold"> | |
| + | {String(i + 1).padStart(2, "0")} | |
| + | </span> | |
| + | <div className="flex-1"> | |
| + | <p className="display text-2xl sm:text-3xl">{e.title}</p> | |
| + | <p className="kicker mt-1 text-ash">{fmt(e.startsAt)}</p> | |
| </div> | ||
| - | <span className="rounded-full bg-zinc-100 px-3 py-1 text-xs font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"> | |
| - | {TYPE_LABEL[e.type] ?? e.type} | |
| + | <span className="kicker border border-[var(--line)] px-3 py-1"> | |
| + | {(TYPE_LABEL[e.type] ?? e.type).toUpperCase()} | |
| </span> | ||
| </li> | ||
| ))} |
| @@ -1,26 +1,149 @@ | ||
| @import "tailwindcss"; | ||
| :root { | ||
| - | --background: #ffffff; | |
| - | --foreground: #171717; | |
| + | --stage: #0f0e0c; | |
| + | --bone: #f2ece0; | |
| + | --gold: #c8a24c; | |
| + | --gold-bright: #e4c572; | |
| + | --ash: #8a8275; | |
| + | --line: rgba(242, 236, 224, 0.14); | |
| } | ||
| @theme inline { | ||
| - | --color-background: var(--background); | |
| - | --color-foreground: var(--foreground); | |
| - | --font-sans: var(--font-geist-sans); | |
| - | --font-mono: var(--font-geist-mono); | |
| + | --color-stage: var(--stage); | |
| + | --color-bone: var(--bone); | |
| + | --color-gold: var(--gold); | |
| + | --color-gold-bright: var(--gold-bright); | |
| + | --color-ash: var(--ash); | |
| + | --font-display: var(--font-bricolage); | |
| + | --font-serif: var(--font-instrument); | |
| + | --font-mono: var(--font-space-mono); | |
| } | ||
| - | @media (prefers-color-scheme: dark) { | |
| - | :root { | |
| - | --background: #0a0a0a; | |
| - | --foreground: #ededed; | |
| - | } | |
| + | * { | |
| + | box-sizing: border-box; | |
| + | } | |
| + | ||
| + | html { | |
| + | -webkit-font-smoothing: antialiased; | |
| + | text-rendering: optimizeLegibility; | |
| } | ||
| body { | ||
| - | background: var(--background); | |
| - | color: var(--foreground); | |
| - | font-family: Arial, Helvetica, sans-serif; | |
| + | background: var(--stage); | |
| + | color: var(--bone); | |
| + | font-family: var(--font-bricolage), system-ui, sans-serif; | |
| + | overflow-x: hidden; | |
| + | } | |
| + | ||
| + | ::selection { | |
| + | background: var(--gold); | |
| + | color: var(--stage); | |
| + | } | |
| + | ||
| + | @media (pointer: fine) { | |
| + | .beat-cursor-on, | |
| + | .beat-cursor-on a, | |
| + | .beat-cursor-on button, | |
| + | .beat-cursor-on * { | |
| + | cursor: none; | |
| + | } | |
| + | } | |
| + | ||
| + | .kicker { | |
| + | font-family: var(--font-space-mono), monospace; | |
| + | font-size: 0.72rem; | |
| + | letter-spacing: 0.28em; | |
| + | text-transform: uppercase; | |
| + | color: var(--ash); | |
| + | } | |
| + | ||
| + | .display { | |
| + | font-family: var(--font-bricolage), sans-serif; | |
| + | font-weight: 800; | |
| + | line-height: 0.86; | |
| + | letter-spacing: -0.03em; | |
| + | } | |
| + | ||
| + | .serif { | |
| + | font-family: var(--font-instrument), Georgia, serif; | |
| + | } | |
| + | ||
| + | .beat-dot { | |
| + | position: fixed; | |
| + | top: 0; | |
| + | left: 0; | |
| + | width: 11px; | |
| + | height: 11px; | |
| + | border-radius: 9999px; | |
| + | background: var(--gold); | |
| + | pointer-events: none; | |
| + | z-index: 60; | |
| + | will-change: transform; | |
| + | } | |
| + | ||
| + | .clap-ripple { | |
| + | position: fixed; | |
| + | width: 10px; | |
| + | height: 10px; | |
| + | border: 1.5px solid var(--gold); | |
| + | border-radius: 9999px; | |
| + | transform: translate(-50%, -50%); | |
| + | pointer-events: none; | |
| + | z-index: 59; | |
| + | animation: clap 0.6s ease-out forwards; | |
| + | } | |
| + | ||
| + | @keyframes clap { | |
| + | 0% { | |
| + | opacity: 0.9; | |
| + | width: 10px; | |
| + | height: 10px; | |
| + | } | |
| + | 100% { | |
| + | opacity: 0; | |
| + | width: 96px; | |
| + | height: 96px; | |
| + | } | |
| + | } | |
| + | ||
| + | @keyframes marquee { | |
| + | from { | |
| + | transform: translateX(0); | |
| + | } | |
| + | to { | |
| + | transform: translateX(-50%); | |
| + | } | |
| + | } | |
| + | ||
| + | .marquee-track { | |
| + | animation: marquee 38s linear infinite; | |
| + | } | |
| + | ||
| + | @keyframes beatIn { | |
| + | 0% { | |
| + | opacity: 0; | |
| + | transform: translateY(20px); | |
| + | } | |
| + | 100% { | |
| + | opacity: 1; | |
| + | transform: translateY(0); | |
| + | } | |
| + | } | |
| + | ||
| + | .beat-in { | |
| + | animation: beatIn 0.55s cubic-bezier(0.2, 0.85, 0.2, 1) both; | |
| + | } | |
| + | ||
| + | .b1 { animation-delay: 0.02s; } | |
| + | .b2 { animation-delay: 0.1s; } | |
| + | .b3 { animation-delay: 0.26s; } | |
| + | .b4 { animation-delay: 0.34s; } | |
| + | .b5 { animation-delay: 0.46s; } | |
| + | ||
| + | @media (prefers-reduced-motion: reduce) { | |
| + | .marquee-track, | |
| + | .beat-in { | |
| + | animation: none; | |
| + | } | |
| } |
| @@ -1,22 +1,35 @@ | ||
| import type { Metadata } from "next"; | ||
| - | import { Geist, Geist_Mono } from "next/font/google"; | |
| + | import { Bricolage_Grotesque, Instrument_Serif, Space_Mono } from "next/font/google"; | |
| import "./globals.css"; | ||
| import { TRPCProvider } from "@/lib/trpc/provider"; | ||
| + | import { SmoothScroll } from "@/components/smooth-scroll"; | |
| + | import { BeatCursor } from "@/components/beat-cursor"; | |
| - | const geistSans = Geist({ | |
| - | variable: "--font-geist-sans", | |
| + | const bricolage = Bricolage_Grotesque({ | |
| subsets: ["latin"], | ||
| + | variable: "--font-bricolage", | |
| + | display: "swap", | |
| }); | ||
| - | const geistMono = Geist_Mono({ | |
| - | variable: "--font-geist-mono", | |
| + | const instrument = Instrument_Serif({ | |
| subsets: ["latin"], | ||
| + | weight: "400", | |
| + | style: ["normal", "italic"], | |
| + | variable: "--font-instrument", | |
| + | display: "swap", | |
| + | }); | |
| + | ||
| + | const spaceMono = Space_Mono({ | |
| + | subsets: ["latin"], | |
| + | weight: ["400", "700"], | |
| + | variable: "--font-space-mono", | |
| + | display: "swap", | |
| }); | ||
| export const metadata: Metadata = { | ||
| - | title: "Stepping", | |
| + | title: "Stepping - On Beat", | |
| description: | ||
| - | "Discover step shows, strolls, classes, DJs and the culture around them, city by city.", | |
| + | "The home for Divine Nine step show culture. Step shows, strolls, classes, DJs and the people, city by city.", | |
| }; | ||
| export default function RootLayout({ | ||
| @@ -27,10 +40,13 @@ export default function RootLayout({ | ||
| return ( | ||
| <html | ||
| lang="en" | ||
| - | className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} | |
| + | className={`${bricolage.variable} ${instrument.variable} ${spaceMono.variable} beat-cursor-on h-full`} | |
| > | ||
| - | <body className="min-h-full flex flex-col"> | |
| - | <TRPCProvider>{children}</TRPCProvider> | |
| + | <body className="min-h-full bg-stage text-bone"> | |
| + | <BeatCursor /> | |
| + | <SmoothScroll> | |
| + | <TRPCProvider>{children}</TRPCProvider> | |
| + | </SmoothScroll> | |
| </body> | ||
| </html> | ||
| ); |
| @@ -1,62 +1,193 @@ | ||
| import Link from "next/link"; | ||
| + | import { serverCaller } from "@/lib/trpc/server"; | |
| + | import { Marquee } from "@/components/marquee"; | |
| - | const LAUNCH_CITIES = [ | |
| - | { slug: "atlanta", name: "Atlanta" }, | |
| - | { slug: "washington-dc", name: "Washington, DC" }, | |
| - | ]; | |
| + | export const dynamic = "force-dynamic"; | |
| + | ||
| + | const NINE = ["ΑΦΑ", "ΑΚΑ", "ΚΑΨ", "ΩΨΦ", "ΔΣΘ", "ΦΒΣ", "ΖΦΒ", "ΣΓΡ", "ΙΦΘ"]; | |
| - | const FEATURES = [ | |
| - | "Step shows, strolls, and competitions near you", | |
| - | "Classes, DJs, and promoters in each city", | |
| - | "Watch and live-stream performances", | |
| - | "Chapters and step teams across the Divine Nine", | |
| - | "Where to stay, eat, and go when you travel for a show", | |
| + | const YARD = [ | |
| + | { k: "Chapters", d: "Fraternity and sorority chapters in each city." }, | |
| + | { k: "Step teams", d: "The crews putting in work for the next show." }, | |
| + | { k: "DJs", d: "Who is spinning the set, and where." }, | |
| + | { k: "Promoters", d: "The people who throw the shows and strolls." }, | |
| + | { k: "Classes", d: "Learn the craft. Stomp, clap, spoken word." }, | |
| ]; | ||
| - | export default function Home() { | |
| + | function fmt(d: Date | null) { | |
| + | if (!d) return "DATE TBD"; | |
| + | return new Date(d) | |
| + | .toLocaleString("en-US", { | |
| + | month: "short", | |
| + | day: "numeric", | |
| + | hour: "numeric", | |
| + | minute: "2-digit", | |
| + | }) | |
| + | .toUpperCase(); | |
| + | } | |
| + | ||
| + | export default async function Home() { | |
| + | const caller = serverCaller(); | |
| + | const cities = await caller.city.listLaunched(); | |
| + | const byCity = new Map(cities.map((c) => [c.id, c])); | |
| + | const lists = await Promise.all( | |
| + | cities.map((c) => caller.event.list({ cityId: c.id })), | |
| + | ); | |
| + | const lineup = lists | |
| + | .flat() | |
| + | .sort( | |
| + | (a, b) => | |
| + | (a.startsAt ? +new Date(a.startsAt) : Infinity) - | |
| + | (b.startsAt ? +new Date(b.startsAt) : Infinity), | |
| + | ) | |
| + | .slice(0, 6); | |
| + | ||
| return ( | ||
| - | <main className="flex flex-1 flex-col items-center justify-center bg-zinc-50 px-6 py-24 dark:bg-black"> | |
| - | <div className="w-full max-w-2xl"> | |
| - | <p className="text-sm font-medium uppercase tracking-widest text-amber-600"> | |
| - | Divine Nine stepping | |
| - | </p> | |
| - | <h1 className="mt-3 text-4xl font-semibold tracking-tight text-black sm:text-5xl dark:text-zinc-50"> | |
| - | Stepping | |
| + | <div className="relative"> | |
| + | <header className="fixed top-0 z-50 flex w-full items-center justify-between px-5 py-4 sm:px-8"> | |
| + | <Link href="/" className="display text-lg tracking-tight text-bone"> | |
| + | STEPPING | |
| + | </Link> | |
| + | <nav className="hidden gap-7 sm:flex"> | |
| + | <a href="#lineup" className="kicker hover:text-bone"> | |
| + | The Line-Up | |
| + | </a> | |
| + | <a href="#yard" className="kicker hover:text-bone"> | |
| + | The Yard | |
| + | </a> | |
| + | <a href="#route" className="kicker hover:text-bone"> | |
| + | The Route | |
| + | </a> | |
| + | </nav> | |
| + | </header> | |
| + | ||
| + | {/* STAGE */} | |
| + | <section className="flex min-h-[100svh] flex-col justify-center px-5 pt-24 sm:px-8"> | |
| + | <p className="kicker beat-in b1">On Beat / Divine Nine Stepping</p> | |
| + | <h1 className="display mt-6 text-[20vw] leading-[0.82] sm:text-[16vw]"> | |
| + | <span className="block beat-in b1">STOMP.</span> | |
| + | <span className="block text-gold beat-in b2">CLAP.</span> | |
| + | <span className="serif block italic font-normal beat-in b3">speak.</span> | |
| </h1> | ||
| - | <p className="mt-4 text-lg leading-8 text-zinc-600 dark:text-zinc-400"> | |
| - | The home for step show culture. Find the shows, the teams, the DJs, and | |
| - | the people, then plan the trip around them. | |
| + | <p className="serif mt-8 max-w-xl text-2xl leading-snug text-bone/80 beat-in b4"> | |
| + | The home for step show culture. The body is the instrument. Find the | |
| + | shows, the lines, the people, and the road to every set. | |
| </p> | ||
| - | ||
| - | <ul className="mt-8 space-y-3"> | |
| - | {FEATURES.map((f) => ( | |
| - | <li | |
| - | key={f} | |
| - | className="flex items-start gap-3 text-zinc-700 dark:text-zinc-300" | |
| + | <div className="mt-10 flex flex-wrap items-center gap-x-6 gap-y-2 beat-in b5"> | |
| + | {cities.map((c) => ( | |
| + | <Link | |
| + | key={c.id} | |
| + | href={`/city/${c.slug}`} | |
| + | className="kicker text-bone hover:text-gold" | |
| > | ||
| - | <span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-600" /> | |
| - | {f} | |
| - | </li> | |
| + | {c.name} · {c.lat?.toFixed(3)}, {c.lng?.toFixed(3)} | |
| + | </Link> | |
| ))} | ||
| - | </ul> | |
| + | <span className="kicker text-ash">More cities loading</span> | |
| + | </div> | |
| + | </section> | |
| + | ||
| + | <Marquee /> | |
| + | ||
| + | {/* THE NINE */} | |
| + | <section className="flex flex-wrap items-baseline justify-between gap-4 px-5 py-10 sm:px-8"> | |
| + | {NINE.map((g) => ( | |
| + | <span key={g} className="display text-3xl text-bone/70 sm:text-4xl"> | |
| + | {g} | |
| + | </span> | |
| + | ))} | |
| + | </section> | |
| - | <div className="mt-10 rounded-xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950"> | |
| - | <p className="text-sm font-medium text-zinc-500 dark:text-zinc-400"> | |
| - | Launching first in | |
| - | </p> | |
| - | <div className="mt-2 flex flex-wrap gap-2"> | |
| - | {LAUNCH_CITIES.map((c) => ( | |
| + | {/* THE LINE-UP */} | |
| + | <section | |
| + | id="lineup" | |
| + | className="border-t border-[var(--line)] px-5 py-16 sm:px-8" | |
| + | > | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-5xl sm:text-7xl">The Line-Up</h2> | |
| + | <span className="kicker">01 / Shows</span> | |
| + | </div> | |
| + | <ul className="mt-10"> | |
| + | {lineup.length === 0 && ( | |
| + | <li className="kicker text-ash">No shows posted yet.</li> | |
| + | )} | |
| + | {lineup.map((e, i) => ( | |
| + | <li key={e.id}> | |
| <Link | ||
| - | key={c.slug} | |
| - | href={`/city/${c.slug}`} | |
| - | className="rounded-full bg-amber-100 px-3 py-1 text-sm font-medium text-amber-900 hover:bg-amber-200 dark:bg-amber-950 dark:text-amber-200 dark:hover:bg-amber-900" | |
| + | href={`/city/${byCity.get(e.cityId)?.slug ?? ""}`} | |
| + | className="group flex items-center gap-5 border-b border-[var(--line)] py-6" | |
| > | ||
| - | {c.name} | |
| + | <span className="kicker w-10 text-gold"> | |
| + | {String(i + 1).padStart(2, "0")} | |
| + | </span> | |
| + | <span className="display flex-1 text-2xl text-bone group-hover:text-gold sm:text-4xl"> | |
| + | {e.title} | |
| + | </span> | |
| + | <span className="kicker hidden sm:block"> | |
| + | {byCity.get(e.cityId)?.name} | |
| + | </span> | |
| + | <span className="kicker w-28 text-right text-ash"> | |
| + | {fmt(e.startsAt)} | |
| + | </span> | |
| </Link> | ||
| - | ))} | |
| - | </div> | |
| + | </li> | |
| + | ))} | |
| + | </ul> | |
| + | </section> | |
| + | ||
| + | {/* THE YARD */} | |
| + | <section | |
| + | id="yard" | |
| + | className="border-t border-[var(--line)] px-5 py-16 sm:px-8" | |
| + | > | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-5xl sm:text-7xl">The Yard</h2> | |
| + | <span className="kicker">02 / Community</span> | |
| + | </div> | |
| + | <div className="mt-10 grid gap-px sm:grid-cols-2 lg:grid-cols-3"> | |
| + | {YARD.map((y) => ( | |
| + | <div key={y.k} className="py-6 pr-6"> | |
| + | <h3 className="display text-3xl">{y.k}</h3> | |
| + | <p className="serif mt-2 text-lg text-bone/70">{y.d}</p> | |
| + | </div> | |
| + | ))} | |
| </div> | ||
| - | </div> | |
| - | </main> | |
| + | </section> | |
| + | ||
| + | {/* THE ROUTE */} | |
| + | <section | |
| + | id="route" | |
| + | className="border-t border-[var(--line)] px-5 py-16 sm:px-8" | |
| + | > | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-5xl sm:text-7xl">The Route</h2> | |
| + | <span className="kicker">03 / Cities</span> | |
| + | </div> | |
| + | <div className="mt-10 grid gap-px sm:grid-cols-2"> | |
| + | {cities.map((c) => ( | |
| + | <Link | |
| + | key={c.id} | |
| + | href={`/city/${c.slug}`} | |
| + | className="group flex items-end justify-between border-t border-[var(--line)] py-10" | |
| + | > | |
| + | <div> | |
| + | <span className="kicker text-ash">{c.metroLabel}</span> | |
| + | <h3 className="display mt-2 text-5xl group-hover:text-gold sm:text-6xl"> | |
| + | {c.name} | |
| + | </h3> | |
| + | </div> | |
| + | <span className="kicker text-gold">Enter →</span> | |
| + | </Link> | |
| + | ))} | |
| + | </div> | |
| + | </section> | |
| + | ||
| + | <footer className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--line)] px-5 py-8 sm:px-8"> | |
| + | <span className="display text-xl">STEPPING</span> | |
| + | <span className="kicker text-ash"> | |
| + | On Beat · Built for the culture | |
| + | </span> | |
| + | </footer> | |
| + | </div> | |
| ); | ||
| } |
| @@ -0,0 +1,57 @@ | ||
| + | "use client"; | |
| + | ||
| + | import { useEffect, useRef, useState } from "react"; | |
| + | ||
| + | // Our one signature interactive object: a gold cursor dot that trails the | |
| + | // pointer and leaves a clap-ripple on click. Percussion you can feel. | |
| + | // Disabled on touch and when reduced motion is requested. | |
| + | export function BeatCursor() { | |
| + | const dot = useRef<HTMLDivElement>(null); | |
| + | const [enabled, setEnabled] = useState(false); | |
| + | ||
| + | useEffect(() => { | |
| + | const fine = window.matchMedia("(pointer: fine)").matches; | |
| + | const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; | |
| + | if (!fine || reduce) return; | |
| + | setEnabled(true); | |
| + | ||
| + | let x = window.innerWidth / 2; | |
| + | let y = window.innerHeight / 2; | |
| + | let tx = x; | |
| + | let ty = y; | |
| + | let raf = 0; | |
| + | ||
| + | const loop = () => { | |
| + | x += (tx - x) * 0.2; | |
| + | y += (ty - y) * 0.2; | |
| + | const d = dot.current; | |
| + | if (d) d.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%)`; | |
| + | raf = requestAnimationFrame(loop); | |
| + | }; | |
| + | raf = requestAnimationFrame(loop); | |
| + | ||
| + | const move = (e: MouseEvent) => { | |
| + | tx = e.clientX; | |
| + | ty = e.clientY; | |
| + | }; | |
| + | const clap = (e: MouseEvent) => { | |
| + | const r = document.createElement("span"); | |
| + | r.className = "clap-ripple"; | |
| + | r.style.left = `${e.clientX}px`; | |
| + | r.style.top = `${e.clientY}px`; | |
| + | document.body.appendChild(r); | |
| + | window.setTimeout(() => r.remove(), 600); | |
| + | }; | |
| + | ||
| + | window.addEventListener("mousemove", move); | |
| + | window.addEventListener("click", clap); | |
| + | return () => { | |
| + | cancelAnimationFrame(raf); | |
| + | window.removeEventListener("mousemove", move); | |
| + | window.removeEventListener("click", clap); | |
| + | }; | |
| + | }, []); | |
| + | ||
| + | if (!enabled) return null; | |
| + | return <div ref={dot} className="beat-dot" aria-hidden="true" />; | |
| + | } |
| @@ -0,0 +1,34 @@ | ||
| + | // A running mono ticker (the "roll"): founding years and launch cities. | |
| + | const ROLL = [ | |
| + | "EST. 1906", | |
| + | "ATLANTA", | |
| + | "EST. 1908", | |
| + | "WASHINGTON DC", | |
| + | "EST. 1911", | |
| + | "STEP SHOWS", | |
| + | "EST. 1913", | |
| + | "STROLLS", | |
| + | "EST. 1914", | |
| + | "THE YARD", | |
| + | "EST. 1920", | |
| + | "ON BEAT", | |
| + | "EST. 1922", | |
| + | "DIVINE NINE", | |
| + | "EST. 1963", | |
| + | ]; | |
| + | ||
| + | export function Marquee() { | |
| + | const items = [...ROLL, ...ROLL]; | |
| + | return ( | |
| + | <div className="overflow-hidden border-y border-[var(--line)] py-3"> | |
| + | <div className="marquee-track flex w-max gap-8 whitespace-nowrap"> | |
| + | {items.map((t, i) => ( | |
| + | <span key={i} className="kicker flex items-center gap-8"> | |
| + | {t} | |
| + | <span className="text-gold">/</span> | |
| + | </span> | |
| + | ))} | |
| + | </div> | |
| + | </div> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,23 @@ | ||
| + | "use client"; | |
| + | ||
| + | import { useEffect, type ReactNode } from "react"; | |
| + | import Lenis from "lenis"; | |
| + | ||
| + | export function SmoothScroll({ children }: { children: ReactNode }) { | |
| + | useEffect(() => { | |
| + | if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; | |
| + | const lenis = new Lenis({ duration: 1.1, smoothWheel: true }); | |
| + | let raf = 0; | |
| + | const loop = (t: number) => { | |
| + | lenis.raf(t); | |
| + | raf = requestAnimationFrame(loop); | |
| + | }; | |
| + | raf = requestAnimationFrame(loop); | |
| + | return () => { | |
| + | cancelAnimationFrame(raf); | |
| + | lenis.destroy(); | |
| + | }; | |
| + | }, []); | |
| + | ||
| + | return <>{children}</>; | |
| + | } |
| @@ -53,6 +53,9 @@ importers: | ||
| '@trpc/server': | ||
| specifier: ^11.0.0 | ||
| version: 11.18.0(typescript@5.9.3) | ||
| + | lenis: | |
| + | specifier: ^1.3.24 | |
| + | version: 1.3.24(react@19.2.4) | |
| next: | ||
| specifier: 16.2.9 | ||
| version: 16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) | ||
| @@ -2231,6 +2234,20 @@ packages: | ||
| resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} | ||
| engines: {node: '>=0.10'} | ||
| + | lenis@1.3.24: | |
| + | resolution: {integrity: sha512-UdWz0Bx/Z9dDgRdWe+N1dPmPWXGCF4VOTX3mugyX7lRpdjXG6ACfLI/cVmDOEMeQXf+j5Km/uKrvvf1X0k+u6g==} | |
| + | peerDependencies: | |
| + | '@nuxt/kit': '>=3.0.0' | |
| + | react: '>=17.0.0' | |
| + | vue: '>=3.0.0' | |
| + | peerDependenciesMeta: | |
| + | '@nuxt/kit': | |
| + | optional: true | |
| + | react: | |
| + | optional: true | |
| + | vue: | |
| + | optional: true | |
| + | ||
| levn@0.4.1: | ||
| resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} | ||
| engines: {node: '>= 0.8.0'} | ||
| @@ -4752,6 +4769,10 @@ snapshots: | ||
| dependencies: | ||
| language-subtag-registry: 0.3.23 | ||
| + | lenis@1.3.24(react@19.2.4): | |
| + | optionalDependencies: | |
| + | react: 19.2.4 | |
| + | ||
| levn@0.4.1: | ||
| dependencies: | ||
| prelude-ls: 1.2.1 |