diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/public/marketing/first-light/photos/baseball.jpg b/public/marketing/first-light/photos/baseball.jpg new file mode 100644 index 000000000..e98363e2c Binary files /dev/null and b/public/marketing/first-light/photos/baseball.jpg differ diff --git a/public/marketing/first-light/photos/golf.jpg b/public/marketing/first-light/photos/golf.jpg new file mode 100644 index 000000000..81f650609 Binary files /dev/null and b/public/marketing/first-light/photos/golf.jpg differ diff --git a/public/marketing/first-light/photos/hero.jpg b/public/marketing/first-light/photos/hero.jpg new file mode 100644 index 000000000..e0f6c51f4 Binary files /dev/null and b/public/marketing/first-light/photos/hero.jpg differ diff --git a/public/marketing/first-light/photos/mist.jpg b/public/marketing/first-light/photos/mist.jpg new file mode 100644 index 000000000..4651135f3 Binary files /dev/null and b/public/marketing/first-light/photos/mist.jpg differ diff --git a/src/app/page.tsx b/src/app/page.tsx index b67baa61f..e252946b5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,8 +1,16 @@ import type { Metadata } from 'next' -import { Hero } from '@/components/landing/Hero' -import { Footer } from '@/components/landing/Footer' -import { SmoothScroll } from '@/components/landing/SmoothScroll' import { NativeRedirect } from '@/components/NativeRedirect' +import { LenisRoot } from '@/components/marketing/first-light/scroll/LenisRoot' +import { M1Hero } from '@/components/marketing/first-light/moments/M1Hero' +import { M2Clarity } from '@/components/marketing/first-light/moments/M2Clarity' +import { M3ProductCinema } from '@/components/marketing/first-light/moments/M3ProductCinema' +import { M4TwoFields } from '@/components/marketing/first-light/moments/M4TwoFields' +import { M5Intelligence } from '@/components/marketing/first-light/moments/M5Intelligence' +import { M6ForThePlayer } from '@/components/marketing/first-light/moments/M6ForThePlayer' +import { M7Honesty } from '@/components/marketing/first-light/moments/M7Honesty' +import { M8FinalCTA } from '@/components/marketing/first-light/moments/M8FinalCTA' +import { M9Footer } from '@/components/marketing/first-light/moments/M9Footer' +import '@/components/marketing/first-light/first-light.css' export const metadata: Metadata = { title: 'Helm Sports Labs', @@ -29,18 +37,36 @@ export const metadata: Metadata = { }, } +// `overflow-x-clip`, NOT `overflow-x-hidden`, below is load-bearing (see +// the same documented gotcha in src/app/globals.css ~L144): the CSS +// Overflow spec promotes overflow-y to `auto` whenever overflow-x is a +// {hidden,scroll,auto} value and overflow-y is a {visible,clip} value — +// turning
into its own scroll container. That breaks every +// `position: sticky` descendant (PinnedScrub/M3) — sticky then pins +// relative to
's own (never-scrolling) frame instead of the real +// window scroll. `clip` pairs with `visible` (both in the exempt group), +// so no promotion happens and sticky works, while still clipping the +// horizontal overflow the M1/M4/M8 full-bleed sections can produce. export default function LandingPage() { return ( -
+
Skip to main content - -
- -
-
+ +
+ + + + + + + + +
+ +
) } diff --git a/src/components/marketing/first-light/CONTRACTS.md b/src/components/marketing/first-light/CONTRACTS.md new file mode 100644 index 000000000..375757a82 --- /dev/null +++ b/src/components/marketing/first-light/CONTRACTS.md @@ -0,0 +1,415 @@ +# First Light — build contracts + +Scaffold for the "First Light" landing redesign +(`docs/LANDING_ENTRY_WORLD_DESIGN.md`). Built by the `foundation` lane +(`feat/entry-world-foundation`). Every other lane builds ON this scaffold +— read this file before touching anything under +`src/components/marketing/first-light/`. + +## Ownership map + +| File | Owner (per the design doc's build plan) | Status after this PR | +|---|---|---| +| `first-light.css` | foundation (this lane) — shared, edit-with-care | LIVE — palette vars + 3-grade glass grammar | +| `fonts.ts` | foundation (this lane) — shared, edit-with-care | LIVE — Fraunces (400/500/600/700, no italic) | +| `scroll/LenisRoot.tsx` | foundation (this lane) — shared, edit-with-care | LIVE — the ONE Lenis instance | +| `scroll/useScrollProgress.ts` | foundation (this lane) — shared, edit-with-care | LIVE | +| `scroll/PinnedScrub.tsx` | foundation (this lane) — shared, edit-with-care | LIVE | +| `scroll/MaskedReveal.tsx` | foundation (this lane) — shared, edit-with-care | LIVE | +| `lib/photoBg.ts` | foundation (this lane) — shared, edit-with-care | LIVE | +| `moments/M1Hero.tsx` | `landing-hero+nav` | STUB — replace in place | +| `moments/M2Clarity.tsx` | `landing-editorial` | STUB — replace in place | +| `moments/M3ProductCinema.tsx` | `landing-cinema` | STUB — replace in place | +| `moments/M4TwoFields.tsx` | `landing-portals+cta` | STUB — replace in place | +| `moments/M5Intelligence.tsx` | `landing-editorial` | STUB — replace in place | +| `moments/M6ForThePlayer.tsx` | `landing-editorial` | STUB — replace in place | +| `moments/M7Honesty.tsx` | `landing-editorial` | STUB — replace in place | +| `moments/M8FinalCTA.tsx` | `landing-portals+cta` | STUB — replace in place | +| `moments/M9Footer.tsx` | `landing-portals+cta` | STUB — replace in place | +| `src/app/page.tsx` | shared — composition only, keep diffs small | LIVE — composes `LenisRoot` + M1..M9 | + +**Rule for downstream lanes:** replace a `moments/M*.tsx` file's internals +freely, but keep its **filename, default-vs-named export, and top-level +props interface name** — `src/app/page.tsx` and the barrel (`index.ts`) +import by those exact names. If a prop needs to change, update `index.ts`'s +re-export and this table in the same PR. + +## The "shared" files — edit-with-care + +`first-light.css`, `fonts.ts`, and everything under `scroll/` + `lib/` +are infrastructure every moment leans on. If your lane needs a change +here (a new glass grade, a different Lenis tuning, a new scroll +primitive), make the smallest possible addition — don't restyle existing +classes/exports other moments already depend on without checking who +else uses them (`grep -rn "fl-glass-\|MaskedReveal\|PinnedScrub\|useScrollProgress" src/components/marketing/first-light`). + +## Glass grammar (from `first-light.css`) + +Three grades, all: `backdrop-filter: blur+saturate`, a 1px brass top +edge-light (`box-shadow: inset 0 1px 0 0 rgba(brass, 0.35)`), a faint +grain `::before` layer, and an `@supports not (backdrop-filter)` solid +fallback. + +- **`.fl-glass-1`** — hairline nav pill. Lightest touch, always visible + chrome (M1's nav). `blur(16px) saturate(1.4)`. +- **`.fl-glass-2`** — floating card over photography. Product proof, sport + portals (M3's frame, M4's portal cards, M6's vignettes). `blur(20px) + saturate(1.3)`. +- **`.fl-glass-3`** — deep panel. Auth forms, dense product-cinema + content, the M5 signal card. `blur(24px) saturate(1.2) brightness(0.92)`. + +**Position:** `fl-glass-*` defaults to `position: relative` via a +zero-specificity `:where()` selector (NOT plain `.fl-glass-2 { position: +relative }`, and NOT `@layer components` — Next.js processes this file +through its own independent PostCSS pass, so `@layer` throws without a +matching `@tailwind components` directive in the SAME file). This means a +consumer that needs `absolute`/`fixed`/`sticky` instead just adds that +Tailwind utility alongside the `fl-glass-*` class and it wins (`PhoneArc` +in M3ProductCinema does this — `className="fl-glass-2 ... absolute ..."`). +Don't reintroduce a plain (non-`:where()`) `position` rule on `.fl-glass-*` +— confirmed 2026-07-02 that broke exactly this case. + +**Budget:** at most 2 `fl-glass-*` layers visible in a single viewport +(CLAUDE.md motion rules). Content placed inside a glass surface needs +`position: relative` (or `z-10`+) to render above the `::before` grain +layer — every moment in this scaffold wraps its content in a `relative +z-10` div for this reason; copy that pattern. + +`.fl-grain` — fixed, full-bleed, `pointer-events: none`, `aria-hidden`. +Mount once per page (M1 and M3 currently do — check before adding a +second one; it's a cheap layer but doesn't need duplicating). + +`.fl-rule` — a 1px brass hairline gradient, `height: 1px`. Override +`background` inline for a specific ink (M2 does this for its green +ledger rules). + +## Palette v2 — SAGE & CREAM (CSS custom properties, `first-light.css` `:root`) + +**⚠ Respec 2026-07-02 (Nick):** the landing moved from dusk-pine drama to +morning sage-and-cream calm — see the ⚠ AMENDMENT at the top of +`docs/LANDING_ENTRY_WORLD_DESIGN.md` (THE contract; overrides the older +"deep pine bands / kelly-as-only-accent" language anywhere else in that +doc or in this file's history). + +**The mood is daylight.** The page lives in morning light now — if any +frame reads dark, moody, or murky, it's off-spec; fix it before shipping. + +| Var | Hex | Use | +|---|---|---| +| `--fl-ecru` / `--fl-cream` | `#F5F1E6` | Cream field (unchanged) — `--fl-cream` is the first-class name, `--fl-ecru` an equal-value alias | +| `--fl-cream-high` | `#FBF9F2` | Cream high — glass highlights, replaces white | +| `--fl-sage-mist` | `#E4E9DD` | Sage mist — section tints, glass tint | +| `--fl-sage` | `#94A38A` | Sage — rules, icons, decorative/large type ONLY (never body text) | +| `--fl-sage-deep` | `#5C6E58` | Sage deep — CTAs + emphasis, cream text | +| `--fl-sage-ink` | `#2E3A2C` | Sage ink — dark bands + ALL body/display ink | +| `--fl-clay` | `#B0703C` | Warm clay accent — demoted to trace only (M4 baseball-diptych grade) | +| `--fl-brass` | `#B08D57` | Glass-edge hairlines (kept) | + +**Legacy aliases (repointed, not removed):** `--fl-pine` and `--fl-green` +keep their names so in-flight lane work referencing them recolors +automatically — no find/replace needed in moment files that already +shipped. + +| Legacy var | Old value | Repointed to (2026-07-02) | +|---|---|---| +| `--fl-pine` | `#143527` (deep pine) | `#2E3A2C` (`--fl-sage-ink`) | +| `--fl-green` | `#16A34A` (kelly) | `#5C6E58` (`--fl-sage-deep`) | + +Each var also has a `-rgb` sibling (e.g. `--fl-sage-ink-rgb: 46, 58, 44`) +for `rgba(var(--fl-sage-ink-rgb), 0.5)` compositing. + +**Read-mapping — wherever older spec text or code comments say:** +- **deep pine `#143527`** → read **sage-ink `#2E3A2C`** (`--fl-pine` / + `--fl-sage-ink`, same value now). +- **kelly `#16A34A` CTA / accent on landing or auth chrome** → read + **sage-deep `#5C6E58`** (`--fl-green` / `--fl-sage-deep`, same value + now), cream text. **Kelly is demoted to product-only** — it appears + exclusively *inside* real app screenshots and the M5 signal-card + replica (that's the product's own chrome, i.e. content — honest). + Landing/auth chrome never uses kelly directly. +- **clay `#B0703C`** → trace only (the baseball half of the M4 diptych + grade); nothing structural. +- **photo grade "pine→amber duotone"** → **sage→cream grade**: shadows + toward sage-ink, mids toward sage, highlights lifted to cream-high. + Airy morning, never murky. + +**Never hardcode** `#143527` or `#16A34A` (or any other retired hex) — +always reach for the token. + +## IMMACULATE utilities (⚠ AMENDMENT 2, `first-light.css`) + +Added by the foundation lane 2026-07-02 PM per the ⚠ AMENDMENT 2 — +IMMACULATE section of `docs/LANDING_ENTRY_WORLD_DESIGN.md` (Nick: "I +don't want basic cards and basic components… we did research on this, +we need to use it"). Translates +`docs/redesign/marketing-overhaul-2026-06-18/research/04_premium_aesthetic_system.md` +to sage & cream. Same edit-with-care rule as the rest of this file — +grep `fl-aurora\|fl-light-pool\|fl-cta-glow\|fl-card\|fl-gradient-ring\|fl-link-underline` +before restyling. + +| Utility | Recipe | Consumer (per Amendment 2's M-moment map) | +|---|---|---| +| `.fl-aurora` | Cream field + 4 low-chroma sage radial blobs (the brightest blob is cream, not cream-high — §D2), optional 32s drift | Page/section background behind **M2, M5, M6, M7** (§A.1) | +| `.fl-light-pool` | Radial **cream** pool (§D2 — was cream-high), blurred, `position: absolute` default | Behind hero copy + section anchors: **M2** statement, **M5** signal card, **M7** stats (§A.2) | +| `.fl-cta-glow` | Sage bloom `::after`, opacity 0→1 on hover/focus-visible | Primary CTAs only: **M1** + **M8** (§A.3, §B.8) | +| `--fl-shadow-xs/sm/md/lg` + `--fl-specular` | Stacked warm shadows + 1px cream-high top highlight (the ONE cream-high fill §D2 still allows — a hairline, not a fill) | Every elevated light surface sitewide (§B.5) | +| `.fl-card` + `.fl-card-lift` | Top-edge cream-high → cream gradient (18% stop, §D2 — was a flat cream-high fill), specular + ring, hover swell | **M6**'s three vignettes (converted from `.fl-glass-2`, §B.6) + any other solid card | +| `.fl-gradient-ring` | Masked 1px conic ring (sage-deep → sage → brass → sage-deep) | **M5**'s signal card ONLY — the page's single gradient-ring moment (§B.7) | +| `.fl-link-underline` | Sage-deep 2px wipe on hover/focus-visible | Footer + nav text links (§B.10) | +| `.fl-grain` (flipped) | `mix-blend-mode` overlay → multiply | Sitewide standalone grain plate (§A.4) — glass-baked grain (`.fl-glass-*::before`) unchanged | + +**Budgets (Amendment 2 §E final gate — Fable judges rendered frames +against this, code review alone does not pass a moment):** +- ≤2 `.fl-glass-*` layers per viewport (unchanged, existing rule above). +- ≤2 sage glows per viewport — every `.fl-cta-glow` instance counts. +- Exactly ONE `.fl-gradient-ring` per page (M5's signal card). +- Exactly one hero moment per viewport-story. +- Grayscale test, effects-off test, squint test, 8px audit, + reduced-motion audit all pass. + +### §D2 — MORE CREAM, NO WHITE (2026-07-02, Nick directive — overrides +anything lighter elsewhere in this doc or the design doc) + +Nothing larger than a 1px specular/hairline may render at or near white +(`#FFFFFF` or `--fl-cream-high` `#FBF9F2` as a FILL). `--fl-cream-high` is +demoted to exactly three uses: **1px specular lips** (`--fl-specular`, +`.fl-glass-*`'s brass top edge-light is unaffected — that's brass, not +cream-high), **text-on-dark** (e.g. button labels on `--fl-sage-deep`), +and **≤2px accents**. Every other light surface uses `--fl-cream` +(`#F5F1E6`) or warmer — never cream-high, never white. + +Fixed in this pass (foundation lane, `first-light.css`): +- `.fl-card` background — was a flat `var(--fl-cream-high)` fill; now + `linear-gradient(180deg, rgba(var(--fl-cream-high-rgb),0.55) 0%, + var(--fl-cream) 18%)` — only the top edge catches light, body reads + true cream. +- `.fl-light-pool` — was `rgba(var(--fl-cream-high-rgb), 0.9)`; now + `rgba(var(--fl-cream-rgb), 0.9)`. +- `.fl-aurora`'s brightest blob (bottom-left, §A.1) — was + `rgba(var(--fl-cream-high-rgb), 0.9)`; now `rgba(var(--fl-cream-rgb), + 0.9)`. + +Left as-is (both are the hairline/text-on-dark carve-outs §D2 exempts): +- `--fl-specular` (`inset 0 1px 0 rgba(var(--fl-cream-high-rgb), 0.9)`) + — a literal 1px inset highlight, the "80% move" recipe (§B.5). Do not + demote this one — a cream specular lip reads as no light at all. +- Any cream-high used for text color on a sage-deep/sage-ink background + (button labels, CTA text) — text-on-dark is explicitly allowed. + +**Rule for every downstream lane:** before adding a new cream-high fill +anywhere under `src/components/marketing/first-light/`, ask "is this +bigger than a 1px lip and not text-on-dark?" — if yes, use `--fl-cream` +instead. Grep `cream-high` in your file before shipping a new surface. + +## Brand components (Amendment 3 §A/§B, `brand/`) + +Added by the foundation lane per the ⚠ AMENDMENT 3 — BRAND & ARCHITECTURE +section of `docs/LANDING_ENTRY_WORLD_DESIGN.md` (Nick: "really work hard on +this… integrate branding, golf and baseball, and the helm"). Three +stroke-based line-art components, all `fill="none"` / `stroke="currentColor"` +on a shared 24×24 viewBox, all re-exported from the top-level `index.ts` — +import `{ HelmMark, HelmRosette, SportGlyph }` from `first-light`, not from +`./brand` directly. + +**The kelly-raster-never-on-chrome rule:** `public/Helm-Logo-New-Main.png`, +`public/helm-golf-logo-transparent.png`, and +`public/helm-baseball-logo-cropped.png` are flat kelly-green rasters — they +NEVER render on landing/auth chrome (Amendment 3 §A). These three components +are the *only* brand-mark rendering path on that chrome; every other lane +should reach for one of them instead of the PNGs. Kelly itself stays +product-only per the sage & cream amendment (real app screenshots, the M5 +signal-card replica) — these components carry no color of their own +(`currentColor`), so a consumer accidentally coloring one kelly is a review +catch, not a contract violation the component can prevent. + +| Component | Default size | Consumers (per team-lead's map) | Typical color | +|---|---|---|---| +| `HelmMark` | 24px (use 18–20px or ~280px per consumer) | **GlassNav lockup** — 18–20px, sage-ink, paired with "Helm Sports Labs" wordmark (§A.3) · **M9 footer watermark** — ~280px, brass at 5–7% opacity, right-anchored behind the link columns, `pointer-events-none` (§A.4) | `--fl-sage-ink` (nav) / `--fl-brass` at low opacity (footer) | +| `HelmRosette` | 12px (10–14px range) | **M2/M5/M7 eyebrow hairlines** — sits at the center where the hairline rule would otherwise just end, brass (§A.2) · **M6 "readiness" vignette** header glyph — neutral/sage, no sport (§B.3) | `--fl-brass` (eyebrows) / `--fl-sage` (M6 readiness) | +| `SportGlyph` | 18px (16–20px range); also usable much larger for background motifs | **M4 portal cards** — compose with `HelmRosette` side-by-side ("wheel-plus-sport", §B.2 — two components, not a fused one) · **M6 vignette headers** — passport→`sport="baseball"`, lift→`sport="lift"` (§B.3) | cream at ~8% opacity over photography (M4) / `--fl-sage` (M6 headers) | + +**Composing "wheel-plus-sport" (M4 portal cards, §B.2):** render `HelmRosette` +and `SportGlyph` as two adjacent small icons (flex row, small gap) rather than +building a fourth fused component — keeps this lane's surface to exactly +three components and lets each portal card size/space them independently. + +**Geometry provenance:** `HelmMark`/`HelmRosette`'s wheel (ring + 8 spokes at +45° from 12 o'clock, `SPOKE_INNER_R`/`SPOKE_OUTER_R`/`RING_R` constants in +each file) is a traced approximation of `Helm-Logo-New-Main.png`, viewed via +the `Read` tool — proportions eyeballed against the PNG, not pixel-extracted. +`HelmMark`'s ship (mast/cabin/hull/waterline) likewise traces that PNG's ship +silhouette in line art instead of flat fill. `SportGlyph`'s `baseball` case +(chalk V + base-path arc) is derived from +`docs/baseball/ENTRY_SCENES_DESIGN.md`'s field-motif description — **not** +from `src/components/baseball/scenes/EntryField.tsx`, which doesn't exist in +this branch yet (belongs to the not-yet-built `entry-scenes` wave). Same for +`lift` (barbell) vs. a not-yet-built `IronRoomField`. If either scene +component lands later with different exact angles/proportions, reconcile +`SportGlyph`'s constants against it rather than assuming they already match. + +**Accessibility:** all three follow the same `role="img"` + `title` (when +given) / `aria-hidden` + `focusable="false"` (decorative default) contract +via the shared `brand/svgA11y.ts` helper — pass `title="Helm"` etc. only when +the mark is the sole content of an interactive element (e.g. a logo link) +and needs an accessible name; leave it off for purely decorative uses (M9 +watermark, M4 motifs). + +**The one-animated-gold budget (§C.2) does not apply here:** these three +components render static line art — the page's single animated-gold moment +is M7's founder-line brass rule glint sweep, built separately by that lane. +Don't add motion to a `HelmMark`/`HelmRosette`/`SportGlyph` instance itself; +a hover-lift on an M4 card *wrapper* is fine (§B.6), the glyph strokes inside +stay still. + +## Fonts (`fonts.ts`) + +`flFraunces` — `next/font/google` Fraunces, weights 400/500/600/700, +`style: ['normal']` only (**no italic** — per +`feedback_helm_no_italic_accent_word`, never italicize an accent word in +a serif headline; use color/weight instead, as M1/M2 do). Import +`flFraunces.className` onto display/serif elements. Deliberately separate +from `src/lib/fonts.ts`'s `fraunces` export (single weight 600, owned by +the BaseballHelm Living Annual kit) — don't cross-import between them. + +Root `layout.tsx` / `tailwind.config.ts` are OUT OF SCOPE for every First +Light lane — this module loads its own font instance instead. + +## Reduced-motion: two valid patterns, pick correctly + +- **Prop-only variation** (e.g. `transition={reduced ? { duration: 0 } : + {...}}` on an otherwise-identical `m.*` element — the repo-wide pattern, + see `/golf/join`, `/golf/(auth)/demo`) → use framer-motion's own + `useReducedMotion()` directly. Safe: it doesn't change DOM structure, so + a first-render mismatch (server assumes not-reduced; a real + reduced-motion user's first client render already knows better) never + surfaces as a hydration error — transition config isn't serialized into + SSR HTML. +- **Structural branching** (rendering an entirely different JSX tree for + reduced vs. not — what `MaskedReveal` and `PinnedScrub` do) → use + `usePrefersReducedMotion` (`scroll/usePrefersReducedMotion.ts`) INSTEAD. + It's a `useSyncExternalStore` hook with `getServerSnapshot` returning + `false`, so the server AND the very first client render always agree + (structurally identical), then React swaps in the real value right + after hydration — zero console errors. Using framer-motion's + `useReducedMotion()` for structural branching throws a real hydration + mismatch for actual reduced-motion users (confirmed 2026-07-02 via + Playwright's `page.emulateMedia({ reducedMotion: 'reduce' })`) since it + resolves `matchMedia` synchronously on the first client render, before + the server's motion-unaware markup has been reconciled. +- **Known gap, left for downstream lanes**: the simple `whileInView` + reveals inside the M1/M2/M5/M6/M7/M8 moment stubs don't check + reduced-motion at all yet (framer-motion's `whileInView`/`initial` + animate unconditionally unless wrapped in `` or checked explicitly) — they're STUBS, and each + owning lane should apply the prop-only pattern above when it replaces + that file. Don't add a blanket `` to + `LenisRoot` without re-testing for hydration mismatches first — it + routes through the same synchronous-`matchMedia`-on-first-render + mechanism as `useReducedMotion()` and would likely spread the same + issue to every `m.*` element on the page, not fix it. + +## Scroll primitives + +- **`LenisRoot`** (`scroll/LenisRoot.tsx`) — the ONE Lenis instance, + mounted once in `src/app/page.tsx`. `lerp: 0.09`. Disabled (native + scroll) on `prefers-reduced-motion` and coarse-pointer/touch. **Never** + nest a second Lenis instance (or the dashboard's `useSmoothScroll`/ + `SmoothScroll`) inside this tree. It ALSO wraps children in the ONE + `` the whole landing tree + needs — every moment uses framer-motion's `m.*` components (not + `motion.*`), and `m.*` silently no-ops every animation prop without a + `LazyMotion` ancestor (see `src/app/golf/admin/_motion-provider.tsx` + for the same pattern elsewhere in the repo). `strict` mode means a + stray `motion.*` import anywhere in this tree throws — use `m.*`. +- **`useScrollProgress(options?)`** (`scroll/useScrollProgress.ts`) — + returns `{ ref, progress, rawProgress }`. `progress` is a + spring-smoothed `MotionValue` (0→1) unless `smooth: false`. + Feed `progress` into `useTransform` — transform/opacity only. +- **`PinnedScrub`** (`scroll/PinnedScrub.tsx`) — `height: Nvh` outer + + `position: sticky` inner, render-prop `children(progress)`. Under + `prefers-reduced-motion`, renders unpinned with `progress` frozen at + `1` — design your `children` render so `progress === 1` is a coherent + final frame (M3 does this: last screen landed, phone in place). +- **`MaskedReveal`** (`scroll/MaskedReveal.tsx`) — serif line write-in. + Pass `lines` (array of `ReactNode`, one per visual line). Omit + `progress` for a `whileInView` one-shot entrance (M1, M2 use this); + pass a `PinnedScrub`-sourced `progress` MotionValue to scrub-link the + write-in to scroll instead (available for a future M3-style caption + paragraph — M3's own captions are scrub-linked inline rather than + through `MaskedReveal` since they're mutually-exclusive per-screen + lines, not an additive paragraph; see that file's comments). + +## Photo asset contract + +`public/marketing/first-light/photos/{hero,golf,baseball.jpg,mist}.jpg` +are the REAL photography paths, ultimately sourced by a parallel lane. +**Never** render photography as a plain ``/`next/image` for these +hero backgrounds — a 404 shows a visibly broken image. Use +`photoLayerStyle({ src, fallbackGradient })` (`lib/photoBg.ts`): it +returns a `backgroundImage` with the gradient UNDER the photo `url(...)` +layer, so a missing file just leaves the graded gradient showing — always +on-brand, never broken. Every moment that uses photography (M1, M4, M8) +already does this; keep the pattern when you replace those files. + +**Placeholder files checked in by this lane:** to keep the dev console +clean (zero 404s) during this PR's live verification, this lane generated +lightweight graded-gradient JPGs at all four paths (`scripts` used: +Pillow, one-off, not committed as a script). They are intentionally +generic — the parallel photo lane should overwrite them in place with +real graded photography; no code changes are needed on either side, it's +a pure asset swap. + +## Screenshot asset contract + +`public/marketing/first-light/screens/` — real Living-Annual product +screenshots for M3's product-cinema sequence, sourced by the +`landing-cinema` lane. Until they land, M3 renders labeled gradient +placeholder panels (`Command Center` / `Stats Center` / `Decision Room`). +Swap in real screenshots the same way `photoBg.ts` is used elsewhere: a +solid/gradient panel behind, an `` on top, sized so a slow load or +missing file never collapses the frame's layout (fixed aspect-ratio +container, `object-cover`). + +## Motion discipline (recap — see CLAUDE.md + this repo's motion rules) + +- Transform/opacity only for anything scroll-linked. +- `position: sticky` for pinning (never JS-measured pin plugins here — + no GSAP dependency in this scaffold). +- `prefers-reduced-motion` → fully static, final-state DOM. Every + primitive in this scaffold (`LenisRoot`, `PinnedScrub`, `MaskedReveal`) + already handles its own reduced-motion branch; moments built on top + should lean on that rather than re-implementing the check, except for + simple `whileInView` reveals (framer-motion's `useReducedMotion` + pattern used throughout M1/M2/M5/M6/M7/M8 — copy that pattern for new + reveals). +- At most 2 `backdrop-blur` layers per viewport. +- Ease: the house `[0.16, 1, 0.3, 1]` cinematic-settle curve (mirrors + `EASE_GLIDE` in `src/components/baseball/living-annual/motion.ts`) or + `cubic-bezier(0.22, 0.7, 0, 1)`. Never invent a new easing curve. + +## CTA architecture (already wired in the stubs — keep these targets or +update deliberately) + +- **"See it in action"** (sage-deep `--fl-sage-deep`, primary — repointed + 2026-07-02, was kelly green) → `/golf/demo` (the real, live self-serve + product demo — not a dead button). +- **"Join your team"** (ghost / `fl-glass-1`) → `/golf/join` (real + invite-code entry page). +- M4 portal cards → `/products#golfhelm`, `/products#baseballhelm`. +- M9 footer → real `/golf/login`, `/baseball/login`, `/golf/signup`, + `/baseball/signup`, `/privacy`, `/terms` — no golf-only bias, no dead + "Request Demo" button. + +If a downstream lane wires a calendar-link demo-request flow instead +(per the design doc's "demo request w/ calendar" CTA architecture note), +update this section and keep both a functioning fallback for JS-disabled +contexts. + +## `src/app/page.tsx` + +Preserves: the `metadata` export, ``, +and the skip-to-content link (`id="main-content"` target). Composes +`LenisRoot` wrapping `M1Hero` → `M9Footer` in order. Imports +`first-light.css` directly (not through `globals.css`). The old +`Hero`/`Footer`/`SmoothScroll` components are left on disk, unused — +don't delete them from this lane (out of scope; another lane's call). diff --git a/src/components/marketing/first-light/brand/HelmMark.tsx b/src/components/marketing/first-light/brand/HelmMark.tsx new file mode 100644 index 000000000..f64e1fc95 --- /dev/null +++ b/src/components/marketing/first-light/brand/HelmMark.tsx @@ -0,0 +1,76 @@ +import { getSvgA11yProps } from './svgA11y'; + +export interface HelmMarkProps { + /** Rendered width/height in px. viewBox is a fixed 24×24 unit square, so + * the 1.5px stroke visually thins as `size` shrinks below 24 — this mark + * is meant for 18px+ use (GlassNav, M9 watermark); for 10–14px punctuation + * use `HelmRosette` instead, which holds its stroke weight at small sizes. */ + size?: number; + className?: string; + /** Accessible name. Omit for a purely decorative mark (aria-hidden). */ + title?: string; +} + +const CX = 12; +const CY = 12; +const RING_R = 7.4; +const SPOKE_INNER_R = 6.6; +const SPOKE_OUTER_R = 10.4; +// 8 spokes, 45° apart, starting at 12 o'clock — matches Helm-Logo-New-Main.png. +const SPOKE_ANGLES_DEG = [0, 45, 90, 135, 180, 225, 270, 315]; + +function pointOnCircle(angleDeg: number, r: number) { + const rad = (angleDeg * Math.PI) / 180; + return { x: CX + r * Math.sin(rad), y: CY - r * Math.cos(rad) }; +} + +/** + * The Helm mark — a faithful line-art tracing of the 8-spoke ship's-helm + * wheel + ship silhouette in `public/Helm-Logo-New-Main.png`. Stroke-based + * (`fill="none"`, `stroke="currentColor"`) so it recolors per surface + * (sage-ink on cream, brass as jewelry, cream on sage-ink bands) instead of + * carrying that PNG's flat kelly fill. + * + * Kelly raster (`Helm-Logo-New-Main.png` and the sport-variant PNGs) NEVER + * renders on landing/auth chrome — this component (plus `HelmRosette` and + * `SportGlyph`) is the only brand-mark rendering path there. See + * CONTRACTS.md "Brand components" for the full consumer map. + * + * Ship silhouette, top to bottom: mast crossbar (small rect) → deckhouse + * cabin (wider rect) → hull bow (triangular path, `strokeLinejoin="round"` + * softens the top corners toward the source PNG's pentagon read) → + * waterline (a shallow arc wider than the hull). + */ +export function HelmMark({ size = 24, className, title }: HelmMarkProps) { + const a11y = getSvgA11yProps({ title }); + return ( + + {title ? {title} : null} + {SPOKE_ANGLES_DEG.map((angle) => { + const inner = pointOnCircle(angle, SPOKE_INNER_R); + const outer = pointOnCircle(angle, SPOKE_OUTER_R); + return ; + })} + + {/* mast crossbar */} + + {/* deckhouse cabin */} + + {/* hull (bow silhouette) */} + + {/* waterline */} + + + ); +} diff --git a/src/components/marketing/first-light/brand/HelmRosette.tsx b/src/components/marketing/first-light/brand/HelmRosette.tsx new file mode 100644 index 000000000..f7e815de0 --- /dev/null +++ b/src/components/marketing/first-light/brand/HelmRosette.tsx @@ -0,0 +1,68 @@ +import { getSvgA11yProps } from './svgA11y'; + +export interface HelmRosetteProps { + size?: number; + className?: string; + title?: string; +} + +const CX = 12; +const CY = 12; +const RING_R = 7.4; +const SPOKE_INNER_R = 6.6; +const SPOKE_OUTER_R = 10.4; +// Same wheel geometry as HelmMark — the two components read as one mark at +// different scales. +const SPOKE_ANGLES_DEG = [0, 45, 90, 135, 180, 225, 270, 315]; + +function pointOnCircle(angleDeg: number, r: number) { + const rad = (angleDeg * Math.PI) / 180; + return { x: CX + r * Math.sin(rad), y: CY - r * Math.cos(rad) }; +} + +/** + * The Helm wheel reduced to ring + 8 spokes — no ship — for 10–14px section + * punctuation (Amendment 3 §A.2): the eyebrow hairlines of M2/M5/M7 meet a + * brass rosette at center instead of bare line ends; M6's "readiness" + * vignette gets a neutral (sage) rosette per §B.3. + * + * Draws with `vector-effect="non-scaling-stroke"` — a fixed screen-pixel + * stroke regardless of the viewBox→viewport scale. At 10–14px a stroke that + * scales down with the icon (HelmMark's default behavior, correct at 18px+) + * would thin into anti-aliasing mush; a rosette this small needs a constant + * hairline instead, so the base `strokeWidth` here (1.25) is intentionally + * lower than HelmMark's 1.5 — it's the literal on-screen weight at every size. + */ +export function HelmRosette({ size = 12, className, title }: HelmRosetteProps) { + const a11y = getSvgA11yProps({ title }); + return ( + + {title ? {title} : null} + {SPOKE_ANGLES_DEG.map((angle) => { + const inner = pointOnCircle(angle, SPOKE_INNER_R); + const outer = pointOnCircle(angle, SPOKE_OUTER_R); + return ( + + ); + })} + + + ); +} diff --git a/src/components/marketing/first-light/brand/SportGlyph.tsx b/src/components/marketing/first-light/brand/SportGlyph.tsx new file mode 100644 index 000000000..1b4c4a1ee --- /dev/null +++ b/src/components/marketing/first-light/brand/SportGlyph.tsx @@ -0,0 +1,84 @@ +import { getSvgA11yProps } from './svgA11y'; + +export type SportGlyphSport = 'golf' | 'baseball' | 'lift'; + +export interface SportGlyphProps { + sport: SportGlyphSport; + size?: number; + className?: string; + title?: string; +} + +/** + * Tiny per-sport line glyphs, 16–20px by default (Amendment 3 §B). Same + * stroke contract as HelmMark/HelmRosette: `fill="none"`, + * `stroke="currentColor"`, shared 24×24 viewBox so all three brand + * components compose at consistent relative scale. + * + * - **golf** — flag-on-horizon: flagstick + pennant + ground line, plus a + * small ringed dot at the base standing in for the golf-ball motif on + * `public/helm-golf-logo-transparent.png`'s wheel center. Echoes M4's + * "horizon + flag + green-contour arcs" background motif language (§B.1) + * at icon scale — that larger background motif is a separate asset the + * `landing-portals+cta` lane builds; this glyph is the small icon variant + * for portal cards / vignette headers (§B.2, §B.3), NOT that background. + * - **baseball** — the chalk foul-line V + base-path arc, the + * "groundskeeper's blueprint" geometry described in + * `docs/baseball/ENTRY_SCENES_DESIGN.md`. That doc's literal + * `EntryField.tsx` scene component doesn't exist in this branch yet (it + * belongs to the not-yet-built `entry-scenes` lane) — this geometry is + * derived from the design doc's description (two foul lines converging at + * home plate + a shallow infield arc), not read from that file. If + * `EntryField.tsx` lands with different exact angles, reconcile the two. + * - **lift** — barbell: bar + two concentric plate circles per sport, echoing + * an `IronRoomField` scene component that also doesn't exist yet in this + * branch; built from Amendment 3 §B's literal recipe instead. + * + * Consumers may also render this at much larger sizes (via `size`) at low + * opacity for a decorative background motif rather than an icon — the paths + * stay proportionate at any scale since everything is defined in the shared + * 24-unit viewBox. + */ +export function SportGlyph({ sport, size = 18, className, title }: SportGlyphProps) { + const a11y = getSvgA11yProps({ title }); + return ( + + {title ? {title} : null} + {sport === 'golf' && ( + <> + + + + + + )} + {sport === 'baseball' && ( + <> + + + + + )} + {sport === 'lift' && ( + <> + + + + + + + )} + + ); +} diff --git a/src/components/marketing/first-light/brand/index.ts b/src/components/marketing/first-light/brand/index.ts new file mode 100644 index 000000000..9747ea3dd --- /dev/null +++ b/src/components/marketing/first-light/brand/index.ts @@ -0,0 +1,11 @@ +/** + * Brand components (Amendment 3 §A/§B) — see CONTRACTS.md "Brand components" + * for the full consumer map. Re-exported from the top-level first-light + * `index.ts`; import from there in moment files, not from this path directly. + */ +export { HelmMark } from './HelmMark'; +export type { HelmMarkProps } from './HelmMark'; +export { HelmRosette } from './HelmRosette'; +export type { HelmRosetteProps } from './HelmRosette'; +export { SportGlyph } from './SportGlyph'; +export type { SportGlyphProps, SportGlyphSport } from './SportGlyph'; diff --git a/src/components/marketing/first-light/brand/svgA11y.ts b/src/components/marketing/first-light/brand/svgA11y.ts new file mode 100644 index 000000000..8fc25f0a9 --- /dev/null +++ b/src/components/marketing/first-light/brand/svgA11y.ts @@ -0,0 +1,25 @@ +/** + * Shared accessibility contract for the brand SVG icon family (HelmMark, + * HelmRosette, SportGlyph). Decorative by default — `aria-hidden` + + * `focusable="false"` (the latter kills the old-Edge/IE11 "SVGs are + * keyboard-focusable by default" quirk) — and becomes a labeled image + * (`role="img"` + `aria-label` + a `` child the caller renders) + * only when a `title` is supplied. + */ +export interface SvgA11yInput { + title?: string; +} + +export interface SvgA11yProps { + role?: 'img'; + 'aria-hidden'?: boolean; + 'aria-label'?: string; + focusable: 'false'; +} + +export function getSvgA11yProps({ title }: SvgA11yInput): SvgA11yProps { + if (title) { + return { role: 'img', 'aria-label': title, focusable: 'false' }; + } + return { 'aria-hidden': true, focusable: 'false' }; +} diff --git a/src/components/marketing/first-light/first-light.css b/src/components/marketing/first-light/first-light.css new file mode 100644 index 000000000..3d705e8ae --- /dev/null +++ b/src/components/marketing/first-light/first-light.css @@ -0,0 +1,460 @@ +/** + * first-light.css — "First Light" landing palette + the 3-grade glass + * grammar (docs/LANDING_ENTRY_WORLD_DESIGN.md #2, #4). + * + * Imported directly by `src/app/page.tsx` — NOT globals.css. Scoped to the + * marketing landing surface (and, later, the login/onboarding scene world + * per docs/baseball/ENTRY_SCENES_DESIGN.md — "same glass tokens across + * landing/login/onboarding"). See CONTRACTS.md for the full ownership map. + * + * NOTE on `.fl-glass-*`'s `position: relative` default: it's wrapped in + * `:where(...)` (zero specificity) instead of `@layer components`. Next.js + * runs each imported `.css` file through its own independent PostCSS + * pass — Tailwind's `@layer components` requires a matching + * `@tailwind components` directive IN THE SAME FILE (globals.css has one, + * this file doesn't), so `@layer` here throws a build error rather than + * reordering anything. `:where()` sidesteps the whole layering question: + * a zero-specificity selector always loses to a normal one, so any + * consumer that also applies a Tailwind position utility (`.absolute`, + * `.fixed`, `.sticky`) wins regardless of file/import order. Confirmed + * the hard way (2026-07-02): un-`:where()`'d `.fl-glass-2 { position: + * relative }` silently beat a consumer's `absolute` utility, collapsing + * `PhoneArc` (M3ProductCinema) back to static-in-flow. + */ + +/** + * ── 2026-07-02 SAGE & CREAM RESPEC ────────────────────────────────────── + * Nick (2026-07-02): "I want sage and cream on the landing page." The + * landing moves from dusk-pine drama to morning sage-and-cream calm — see + * the ⚠ AMENDMENT at the top of docs/LANDING_ENTRY_WORLD_DESIGN.md (THE + * contract; overrides the original palette spec #2 in that doc). + * + * `--fl-pine` and `--fl-green` KEEP THEIR NAMES but are REPOINTED below + * (pine → sage-ink #2E3A2C, green → sage-deep #5C6E58) so every in-flight + * lane still referencing the old var names recolors automatically — no + * find/replace needed in moment files that already shipped. They are now + * LEGACY ALIASES; new work should reach for the first-class `--fl-sage-*` + * / `--fl-cream*` names below instead. Never hardcode the retired hexes + * `#143527` (old pine) or `#16A34A` (kelly, now product-only — it appears + * inside real app screenshots and the M5 signal-card replica, never on + * landing/auth chrome). + * + * Mood: the page lives in daylight now. If a frame reads dark, moody, or + * murky, it's off-spec — fix it before shipping. + * ────────────────────────────────────────────────────────────────────── */ +:root { + /* ── Palette (the bridge — spec #2, sage & cream respec above) ─────── */ + --fl-ecru: #f5f1e6; + --fl-ecru-rgb: 245, 241, 230; + /* Legacy alias — repointed 2026-07-02: was deep pine #143527, now sage-ink. */ + --fl-pine: #2e3a2c; + --fl-pine-rgb: 46, 58, 44; + --fl-clay: #b0703c; + --fl-clay-rgb: 176, 112, 60; + /* Legacy alias — repointed 2026-07-02: was kelly #16A34A, now sage-deep. + Kelly is demoted to product-only (real app screenshots + the M5 + signal-card replica) — landing/auth chrome never uses it directly. */ + --fl-green: #5c6e58; + --fl-green-rgb: 92, 110, 88; + --fl-brass: #b08d57; + --fl-brass-rgb: 176, 141, 87; + + /* ── Sage & cream — first-class tokens (2026-07-02 respec) ──────────── */ + /* Cream field — alias of --fl-ecru, same value, first-class name. */ + --fl-cream: #f5f1e6; + --fl-cream-rgb: 245, 241, 230; + /* Cream high — glass highlights, replaces white. */ + --fl-cream-high: #fbf9f2; + --fl-cream-high-rgb: 251, 249, 242; + /* Sage mist — section tints, glass tint. */ + --fl-sage-mist: #e4e9dd; + --fl-sage-mist-rgb: 228, 233, 221; + /* Sage — rules, icons, decorative/large type ONLY (not body text). */ + --fl-sage: #94a38a; + --fl-sage-rgb: 148, 163, 138; + /* Sage deep — CTAs + emphasis, cream text. Same value as --fl-green. */ + --fl-sage-deep: #5c6e58; + --fl-sage-deep-rgb: 92, 110, 88; + /* Sage ink — dark bands + all body/display ink. Same value as --fl-pine. */ + --fl-sage-ink: #2e3a2c; + --fl-sage-ink-rgb: 46, 58, 44; + + /* ── Shadow tokens (⚠ AMENDMENT 2 — IMMACULATE §B.5, research §0) ─── + Stacked warm shadows tinted with --fl-sage-ink-rgb — never a single + hard rgba(0,0,0,x). Pair with --fl-specular on every elevated light + surface (the "80% move" — research §1.3A). */ + --fl-shadow-xs: 0 1px 2px rgba(var(--fl-sage-ink-rgb), 0.04); + --fl-shadow-sm: 0 1px 2px rgba(var(--fl-sage-ink-rgb), 0.04), + 0 2px 6px rgba(var(--fl-sage-ink-rgb), 0.04); + --fl-shadow-md: 0 2px 4px rgba(var(--fl-sage-ink-rgb), 0.04), + 0 8px 20px rgba(var(--fl-sage-ink-rgb), 0.06); + --fl-shadow-lg: 0 4px 8px rgba(var(--fl-sage-ink-rgb), 0.05), + 0 18px 40px rgba(var(--fl-sage-ink-rgb), 0.08); + --fl-specular: inset 0 1px 0 rgba(var(--fl-cream-high-rgb), 0.9); +} + +/* --------------------------------------------------------------------- + * Grain — one perf-tuned static noise recipe (feTurbulence data-URI, + * numOctaves 1, static — no animation), reused as a fixed full-bleed + * overlay (.fl-grain) AND baked low-opacity into every glass grade below. + * `pointer-events: none` always; consumers add `aria-hidden="true"`. + * ------------------------------------------------------------------- */ +.fl-grain { + position: fixed; + inset: 0; + z-index: 1; + pointer-events: none; + opacity: 0.04; + /* ⚠ AMENDMENT 2 — IMMACULATE §A.4 (research §3.2): flipped from + overlay → multiply on 2026-07-02. Multiply sinks grain INTO the + cream warmly; overlay read digital/screen-like on the sage & cream + field. Scoped to this standalone full-bleed .fl-grain plate only — + the baked-in grain inside .fl-glass-*::before (below) sits over + mixed photo/glass backgrounds and is intentionally left on overlay. */ + mix-blend-mode: multiply; + background-repeat: repeat; + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='1' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>"); +} + +@media (prefers-reduced-motion: reduce) { + /* Grain itself is static (no keyframes) — nothing to disable here, but + keep the hook so a future animated-grain variant can't slip past + reduced-motion review. */ + .fl-grain { + opacity: 0.03; + } +} + +/* --------------------------------------------------------------------- + * Glass grammar — 3 grades (spec #4, "the cookbook"). Every grade shares: + * 1. backdrop-filter blur + saturate + * 2. a 1px top edge-light (inset highlight), brass-tinted + * 3. faint grain baked into the surface via a ::before layer + * 4. an @supports fallback (solid translucent) for engines without + * backdrop-filter (older WKWebView, some Firefox builds) + * + * Budget: at most 2 blurring (fl-glass-*) layers visible in a single + * viewport — see docs/LANDING_ENTRY_WORLD_DESIGN.md #4 + CLAUDE.md motion + * rules. Content placed inside a glass surface needs `position: relative` + * (or `isolate`) and a z-index above 1 to sit above the grain ::before. + * `position: relative` below is only the DEFAULT (zero-specificity via + * `:where()`) — a consumer that needs `absolute`/`fixed`/`sticky` instead + * (e.g. `PhoneArc` in M3ProductCinema) just adds that Tailwind utility + * alongside `fl-glass-*` and it wins — see the file-header note. + * ------------------------------------------------------------------- */ + +:where(.fl-glass-1, .fl-glass-2, .fl-glass-3) { + position: relative; +} + +.fl-glass-1, +.fl-glass-2, +.fl-glass-3 { + isolation: isolate; + /* Top edge-light — 1px inset highlight, brass-tinted, on all grades. */ + box-shadow: inset 0 1px 0 0 rgba(var(--fl-brass-rgb), 0.35); +} + +.fl-glass-1::before, +.fl-glass-2::before, +.fl-glass-3::before { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + border-radius: inherit; + pointer-events: none; + opacity: 0.05; + mix-blend-mode: overlay; + background-repeat: repeat; + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='1' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>"); +} + +/* G1 — hairline glass nav pill (always visible, lightest touch). */ +.fl-glass-1 { + background: rgba(var(--fl-ecru-rgb), 0.55); + backdrop-filter: blur(16px) saturate(1.4); + -webkit-backdrop-filter: blur(16px) saturate(1.4); + border: 1px solid rgba(var(--fl-brass-rgb), 0.22); +} + +@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + .fl-glass-1 { + background: rgba(var(--fl-ecru-rgb), 0.94); + } +} + +/* G2 — floating glass card over photography (product proof, sport portals). */ +.fl-glass-2 { + background: rgba(var(--fl-ecru-rgb), 0.14); + backdrop-filter: blur(20px) saturate(1.3); + -webkit-backdrop-filter: blur(20px) saturate(1.3); + border: 1px solid rgba(var(--fl-brass-rgb), 0.28); +} + +@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + .fl-glass-2 { + background: rgba(var(--fl-pine-rgb), 0.74); + } +} + +/* G3 — deep glass panel (auth forms, dense product-cinema frame). */ +.fl-glass-3 { + background: rgba(var(--fl-pine-rgb), 0.55); + backdrop-filter: blur(24px) saturate(1.2) brightness(0.92); + -webkit-backdrop-filter: blur(24px) saturate(1.2) brightness(0.92); + border: 1px solid rgba(var(--fl-brass-rgb), 0.3); +} + +@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + .fl-glass-3 { + background: rgba(var(--fl-pine-rgb), 0.92); + } +} + +/* --------------------------------------------------------------------- + * Small shared utilities used across moments. + * ------------------------------------------------------------------- */ + +/* A brass hairline rule (ledger lines, glass edges, diptych seam flash). */ +.fl-rule { + height: 1px; + background: linear-gradient( + 90deg, + rgba(var(--fl-brass-rgb), 0.55), + rgba(var(--fl-brass-rgb), 0.12) + ); +} + +/* Line-mask wrapper for MaskedReveal — overflow-hidden per visual line. */ +.fl-line-mask { + display: block; + overflow: hidden; +} + +@media (prefers-reduced-motion: reduce) { + .fl-glass-1, + .fl-glass-2, + .fl-glass-3 { + transition: none !important; + } +} + +/* ===================================================================== + * ⚠ AMENDMENT 2 — IMMACULATE (docs/LANDING_ENTRY_WORLD_DESIGN.md, the + * "⚠ AMENDMENT 2 — IMMACULATE" section, 2026-07-02 PM). Translates + * docs/redesign/marketing-overhaul-2026-06-18/research/ + * 04_premium_aesthetic_system.md (§ numbers below cite THAT doc) to + * sage & cream — helm-green/kelly → sage-deep throughout. Foundation + * lane owns every utility below; moment lanes consume them. See + * CONTRACTS.md → "IMMACULATE utilities" for the per-moment consumer map + * and the §E final-gate checklist. + * ===================================================================== */ + +/* --------------------------------------------------------------------- + * .fl-aurora — cream aurora field (Amendment 2 §A.1, research §3.1). + * The page's cream background is never a flat fill: 4 low-chroma sage + * radial blobs (each fading to transparent at 70-75%) sit under + * whatever moment renders on top. Apply to the section/page background + * layer — content stacks above it in normal flow (background-image + * paints behind box content; no z-index needed). + * ------------------------------------------------------------------- */ +.fl-aurora { + background-color: var(--fl-cream); + background-image: radial-gradient( + 40% 50% at 15% 20%, + rgba(var(--fl-sage-rgb), 0.1), + transparent 70% + ), + radial-gradient(45% 55% at 85% 15%, rgba(var(--fl-sage-mist-rgb), 0.55), transparent 72%), + radial-gradient(50% 60% at 75% 85%, rgba(var(--fl-sage-deep-rgb), 0.07), transparent 75%), + /* ⚠ AMENDMENT 2 §D2 (2026-07-02, Nick — "more cream, no white"): this + blob was cream-high; demoted to cream so no fill larger than a 1px + specular lip reads near-white. */ + radial-gradient(40% 45% at 25% 90%, rgba(var(--fl-cream-rgb), 0.9), transparent 70%); + background-attachment: fixed; + /* Optional 32s drift — killed outright under reduced-motion below. */ + animation: fl-aurora-drift 32s ease-in-out infinite; +} + +@keyframes fl-aurora-drift { + 0%, + 100% { + background-position: 0% 0%, 100% 0%, 100% 100%, 0% 100%; + } + 50% { + background-position: 3% 4%, 96% 5%, 94% 95%, 5% 96%; + } +} + +@media (prefers-reduced-motion: reduce) { + .fl-aurora { + animation: none; + } +} + +/* Mobile: drop background-attachment: fixed (continuous-repaint cost on + scroll) and the drift animation — Amendment 2 §A.1's mobile carve-out. */ +@media (max-width: 767px) { + .fl-aurora { + background-attachment: scroll; + animation: none; + } +} + +/* --------------------------------------------------------------------- + * .fl-light-pool — soft radial light pool (Amendment 2 §A.2, research + * §3.3). Behind hero copy and each section anchor (M2 statement, M5 + * card, M7 stats). Positioned by the CONSUMER: this utility supplies + * only the visual recipe + a `position: absolute` default via :where() + * (zero specificity — same discipline as .fl-glass-* above), so a + * consumer's own Tailwind position utility wins if it needs one. + * Consumer sets size/placement (width, height, inset/top/left). + * Uses `filter: blur`, not `backdrop-filter` — no @supports fallback + * needed (near-universal support). Static gradient, no keyframes — no + * reduced-motion story needed. + * ------------------------------------------------------------------- */ +:where(.fl-light-pool) { + position: absolute; +} + +.fl-light-pool { + z-index: -10; + pointer-events: none; + /* ⚠ AMENDMENT 2 §D2 (2026-07-02, Nick — "more cream, no white"): cream-high + demoted to cream — this pool is a large fill, not a 1px specular lip. */ + background: radial-gradient(ellipse at center, rgba(var(--fl-cream-rgb), 0.9), transparent 60%); + filter: blur(40px); +} + +/* --------------------------------------------------------------------- + * .fl-cta-glow — sage bloom under primary CTAs only (Amendment 2 §A.3 + + * §B.8, research §3.4). M1 + M8 primaries. ≤2 glows per viewport, ever + * (Amendment 2 §E final gate). Parent needs `position: relative` for + * the ::after to anchor against — supplied as a zero-specificity + * :where() default so a consumer's own position utility still wins. + * Uses `filter: blur`, not `backdrop-filter` — no @supports fallback + * needed. + * ------------------------------------------------------------------- */ +:where(.fl-cta-glow) { + position: relative; +} + +.fl-cta-glow::after { + content: ''; + position: absolute; + inset: -40% -20%; + z-index: -1; + border-radius: inherit; + pointer-events: none; + background: radial-gradient(ellipse at center, rgba(var(--fl-sage-deep-rgb), 0.28), transparent 65%); + filter: blur(28px); + opacity: 0; + transition: opacity 420ms cubic-bezier(0.33, 1, 0.68, 1); +} + +.fl-cta-glow:hover::after, +.fl-cta-glow:focus-visible::after { + opacity: 1; +} + +@media (prefers-reduced-motion: reduce) { + .fl-cta-glow::after { + transition: none; + } +} + +/* --------------------------------------------------------------------- + * .fl-card — solid lit card recipe (Amendment 2 §B.5/§B.6, research + * §1.3A + §6.1). Replaces glass on cards-of-record: M6's three + * vignettes convert from .fl-glass-2 to this (Amendment 2 §B.6 — glass + * is chrome-only; content sits on solid lit cream). Pair with + * .fl-card-lift for the hover swell. + * + * ⚠ AMENDMENT 2 §D2 (2026-07-02, Nick — "more cream, no white"): a flat + * cream-high fill reads too pale across a whole card. Background is now a + * short top-to-18% gradient — only the top edge catches cream-high light, + * the card body resolves to true cream by 18%. --fl-specular (the 1px + * inset highlight) is unaffected — it's the hairline lip §D2 exempts. + * ------------------------------------------------------------------- */ +.fl-card { + background: linear-gradient(180deg, rgba(var(--fl-cream-high-rgb), 0.55) 0%, var(--fl-cream) 18%); + border-radius: 20px; + border: 1px solid rgba(var(--fl-sage-mist-rgb), 0.6); + box-shadow: var(--fl-specular), var(--fl-shadow-sm); +} + +/* .fl-card-lift — hover swell (Amendment 2 §B.6, research §5.3): + translateY(-4px) + shadow grows to --fl-shadow-lg. */ +.fl-card-lift { + transition: transform 240ms cubic-bezier(0.33, 1, 0.68, 1), + box-shadow 240ms cubic-bezier(0.33, 1, 0.68, 1); + will-change: transform; +} + +.fl-card-lift:hover { + transform: translateY(-4px); + box-shadow: var(--fl-specular), var(--fl-shadow-lg); +} + +@media (prefers-reduced-motion: reduce) { + .fl-card-lift { + transition: none; + } +} + +/* --------------------------------------------------------------------- + * .fl-gradient-ring — masked 1px conic ring (Amendment 2 §B.7, research + * §5.6). THE page's single gradient-ring moment — used on exactly ONE + * element per page (M5's signal card). Do not reuse elsewhere. + * `border-radius: inherit` on ::before follows whatever radius the + * consumer already set on this same element (e.g. via .fl-card or + * .fl-glass-3) — this utility declares no radius of its own. Static + * conic gradient, no motion — no reduced-motion story needed. + * ------------------------------------------------------------------- */ +:where(.fl-gradient-ring) { + position: relative; +} + +.fl-gradient-ring::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + pointer-events: none; + background: conic-gradient( + from 180deg, + rgba(var(--fl-sage-deep-rgb), 0.5), + rgba(var(--fl-sage-rgb), 0.15), + rgba(var(--fl-brass-rgb), 0.4), + rgba(var(--fl-sage-deep-rgb), 0.5) + ); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; +} + +/* --------------------------------------------------------------------- + * .fl-link-underline — the editorial wipe (Amendment 2 §B.10, research + * §5.5). Footer + nav text links: sage-deep 2px underline, + * background-size 0%→100% on hover/focus-visible. + * ------------------------------------------------------------------- */ +.fl-link-underline { + background-image: linear-gradient(rgba(var(--fl-sage-deep-rgb), 1), rgba(var(--fl-sage-deep-rgb), 1)); + background-size: 0% 2px; + background-position: 0 100%; + background-repeat: no-repeat; + transition: background-size 240ms cubic-bezier(0.32, 0.72, 0, 1); +} + +.fl-link-underline:hover, +.fl-link-underline:focus-visible { + background-size: 100% 2px; +} + +@media (prefers-reduced-motion: reduce) { + .fl-link-underline { + transition: none; + } +} diff --git a/src/components/marketing/first-light/fonts.ts b/src/components/marketing/first-light/fonts.ts new file mode 100644 index 000000000..7e10c7a0e --- /dev/null +++ b/src/components/marketing/first-light/fonts.ts @@ -0,0 +1,25 @@ +/** + * fonts.ts — Fraunces for the First Light landing surface (display / + * serif moments only — docs/LANDING_ENTRY_WORLD_DESIGN.md decision #1). + * + * A dedicated `next/font/google` load, separate from `src/lib/fonts.ts`'s + * single-weight-600 `fraunces` export (used by the BaseballHelm Living + * Annual kit). The landing surface needs a wider weight range across nine + * moments (eyebrow-adjacent serif accents down to a hero display weight), + * so moments import THIS module rather than reaching into `@/lib/fonts` or + * touching the root layout / tailwind.config (out of scope for this lane — + * see CONTRACTS.md). + * + * No italic style is loaded: per feedback_helm_no_italic_accent_word, Helm + * marketing headlines never italicize a single accent word. Moments should + * lean on weight/size/color for emphasis instead of `font-style: italic`. + */ +import { Fraunces } from 'next/font/google'; + +export const flFraunces = Fraunces({ + subsets: ['latin'], + weight: ['400', '500', '600', '700'], + style: ['normal'], + display: 'swap', + variable: '--fl-font-serif', +}); diff --git a/src/components/marketing/first-light/index.ts b/src/components/marketing/first-light/index.ts new file mode 100644 index 000000000..0722938e1 --- /dev/null +++ b/src/components/marketing/first-light/index.ts @@ -0,0 +1,48 @@ +/** + * Barrel — the First Light landing scaffold. See CONTRACTS.md for the + * ownership map, prop contracts, and glass/photo/screenshot asset + * conventions every lane building on this scaffold should follow. + */ + +// Fonts +export { flFraunces } from './fonts'; + +// Scroll primitives +export { LenisRoot } from './scroll/LenisRoot'; +export { usePrefersReducedMotion } from './scroll/usePrefersReducedMotion'; +export { useScrollProgress } from './scroll/useScrollProgress'; +export type { ScrollProgressOptions, ScrollProgressOffset, ScrollProgressResult } from './scroll/useScrollProgress'; +export { PinnedScrub } from './scroll/PinnedScrub'; +export type { PinnedScrubProps } from './scroll/PinnedScrub'; +export { MaskedReveal } from './scroll/MaskedReveal'; +export type { MaskedRevealProps } from './scroll/MaskedReveal'; + +// Shared lib +export { photoLayerStyle } from './lib/photoBg'; +export type { PhotoLayerOptions } from './lib/photoBg'; + +// Brand — Helm mark line-art (Amendment 3 §A/§B). See CONTRACTS.md "Brand +// components" for the consumer map. +export { HelmMark, HelmRosette, SportGlyph } from './brand'; +export type { HelmMarkProps, HelmRosetteProps, SportGlyphProps, SportGlyphSport } from './brand'; + +// Moments — M1..M9 (each lane replaces its own file in place; see +// CONTRACTS.md for the ownership map) +export { M1Hero } from './moments/M1Hero'; +export type { M1HeroProps } from './moments/M1Hero'; +export { M2Clarity } from './moments/M2Clarity'; +export type { M2ClarityProps } from './moments/M2Clarity'; +export { M3ProductCinema } from './moments/M3ProductCinema'; +export type { M3ProductCinemaProps } from './moments/M3ProductCinema'; +export { M4TwoFields } from './moments/M4TwoFields'; +export type { M4TwoFieldsProps } from './moments/M4TwoFields'; +export { M5Intelligence } from './moments/M5Intelligence'; +export type { M5IntelligenceProps } from './moments/M5Intelligence'; +export { M6ForThePlayer } from './moments/M6ForThePlayer'; +export type { M6ForThePlayerProps } from './moments/M6ForThePlayer'; +export { M7Honesty } from './moments/M7Honesty'; +export type { M7HonestyProps } from './moments/M7Honesty'; +export { M8FinalCTA } from './moments/M8FinalCTA'; +export type { M8FinalCTAProps } from './moments/M8FinalCTA'; +export { M9Footer } from './moments/M9Footer'; +export type { M9FooterProps } from './moments/M9Footer'; diff --git a/src/components/marketing/first-light/lib/photoBg.ts b/src/components/marketing/first-light/lib/photoBg.ts new file mode 100644 index 000000000..a23de5b63 --- /dev/null +++ b/src/components/marketing/first-light/lib/photoBg.ts @@ -0,0 +1,37 @@ +/** + * photoBg.ts — graded-photography-over-gradient CSS helper. + * + * Photo assets (`public/marketing/first-light/photos/{hero,golf,baseball,mist}.jpg`) + * are sourced by a parallel lane and may not exist yet at any given point in + * the build. Using a plain `<img>`/`next/image` for hero photography means a + * missing file paints a visibly broken image. CSS `background-image` with a + * layered gradient UNDER the `url(...)` layer degrades gracefully instead — + * if the photo 404s, that background layer simply doesn't paint and the + * gradient underneath (always present) carries the section. See + * CONTRACTS.md "Photo asset contract." + */ +import type { CSSProperties } from 'react'; + +export interface PhotoLayerOptions { + /** Path under `public/`, e.g. `/marketing/first-light/photos/hero.jpg`. */ + src: string; + /** A CSS gradient (or comma-separated gradients) painted BELOW the photo — + * this is what renders if `src` 404s, so it must read as a complete, + * on-brand background by itself (the graded pine/clay/ecru fallback). */ + fallbackGradient: string; + backgroundPosition?: string; +} + +/** Returns an inline `style` object: graded gradient + photo, cover/center. */ +export function photoLayerStyle({ + src, + fallbackGradient, + backgroundPosition = 'center', +}: PhotoLayerOptions): CSSProperties { + return { + backgroundImage: `${fallbackGradient}, url(${src})`, + backgroundSize: 'cover', + backgroundPosition, + backgroundRepeat: 'no-repeat', + }; +} diff --git a/src/components/marketing/first-light/moments/M1Hero.tsx b/src/components/marketing/first-light/moments/M1Hero.tsx new file mode 100644 index 000000000..3633ffab7 --- /dev/null +++ b/src/components/marketing/first-light/moments/M1Hero.tsx @@ -0,0 +1,151 @@ +'use client'; + +/** + * M1 · HERO — full-bleed dawn-field photo cinema + G1 glass nav pill. + * docs/LANDING_ENTRY_WORLD_DESIGN.md M1. Background register: photo-dark + * with a deep-pine gradient fallback layer underneath (graceful when + * `public/marketing/first-light/photos/hero.jpg` isn't shipped yet — see + * CONTRACTS.md "Photo asset contract"). + * + * Motion: near-still — a slow 1.02 scale drift on the photo layer, grain, + * a staggered mount reveal on the headline/CTAs. No parallax circus. + * `prefers-reduced-motion` → the scale drift freezes; reveals render in + * their final state (framer-motion's own reduced-motion path, used + * throughout this file via `whileInView`/mount variants). + */ +import Link from 'next/link'; +import { m, useTransform } from 'framer-motion'; +import { ArrowRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useScrollProgress } from '../scroll/useScrollProgress'; +import { MaskedReveal } from '../scroll/MaskedReveal'; +import { flFraunces } from '../fonts'; +import { photoLayerStyle } from '../lib/photoBg'; + +export interface M1HeroProps { + className?: string; +} + +const HERO_FALLBACK_GRADIENT = + 'linear-gradient(160deg, rgba(20,53,39,0.55) 0%, rgba(20,53,39,0.88) 55%, rgba(10,26,19,0.97) 100%), radial-gradient(ellipse 90% 60% at 30% 15%, rgba(176,141,87,0.14), transparent 60%)'; + +export function M1Hero({ className }: M1HeroProps) { + const { ref, progress } = useScrollProgress<HTMLElement>({ + offset: ['start start', 'end start'], + }); + const photoScale = useTransform(progress, [0, 1], [1, 1.02]); + const contentOpacity = useTransform(progress, [0, 0.7], [1, 0.2]); + + return ( + <section + ref={ref} + className={cn( + 'relative min-h-[100svh] min-h-dvh overflow-hidden', + className, + )} + style={{ backgroundColor: 'var(--fl-pine)' }} + > + {/* Background register — graded photo over a pine gradient fallback */} + <m.div + aria-hidden="true" + className="absolute inset-0" + style={{ ...photoLayerStyle({ src: '/marketing/first-light/photos/hero.jpg', fallbackGradient: HERO_FALLBACK_GRADIENT }), scale: photoScale }} + /> + <div className="fl-grain" aria-hidden="true" /> + + {/* G1 glass nav pill — Products/Log-in collapse below `sm` so the + 390px layout stays a calm brand + single-CTA row instead of + wrapping/overflowing (design bar: "mobile 390px => calm static + layout that still reads perfectly"). */} + <div className="relative z-30 pt-4 sm:pt-5"> + <div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-5 sm:px-8"> + <Link href="/" className="text-sm font-semibold tracking-tight text-[var(--fl-ecru)]"> + Helm Sports Labs + </Link> + <nav + className="fl-glass-1 flex items-center gap-1 rounded-full px-1.5 py-1.5" + aria-label="Primary" + > + <span className="relative z-10 flex items-center gap-1"> + <Link + href="/products" + className="hidden rounded-full px-4 py-2 text-sm font-medium text-[rgba(var(--fl-ecru-rgb),0.85)] transition-colors duration-200 hover:bg-[rgba(var(--fl-ecru-rgb),0.12)] hover:text-[var(--fl-ecru)] sm:inline-flex" + > + Products + </Link> + <Link + href="/golf/login" + className="hidden rounded-full px-4 py-2 text-sm font-medium text-[rgba(var(--fl-ecru-rgb),0.85)] transition-colors duration-200 hover:bg-[rgba(var(--fl-ecru-rgb),0.12)] hover:text-[var(--fl-ecru)] sm:inline-flex" + > + Log in + </Link> + <Link + href="/golf/demo" + className="rounded-full bg-[var(--fl-green)] px-4 py-2 text-sm font-semibold text-white transition-[transform,background-color] duration-200 hover:-translate-y-0.5 hover:bg-primary-500 active:translate-y-0" + > + See it in action + </Link> + </span> + </nav> + </div> + </div> + + {/* Main content */} + <m.div + style={{ opacity: contentOpacity }} + className="relative z-20 mx-auto flex min-h-[calc(100svh-88px)] max-w-5xl flex-col items-center justify-center px-6 pb-16 pt-10 text-center sm:px-8" + > + <div className="mb-6 flex items-center gap-3"> + <span className="h-px w-8 bg-[rgba(var(--fl-brass-rgb),0.7)]" /> + <span className="text-eyebrow font-semibold uppercase tracking-[0.28em] text-[rgba(var(--fl-ecru-rgb),0.6)]"> + Helm Sports Labs + </span> + <span className="h-px w-8 bg-[rgba(var(--fl-brass-rgb),0.7)]" /> + </div> + + <MaskedReveal + as="h1" + lines={[ + 'The program,', + <span key="seen" className="text-[var(--fl-green)]"> + seen clearly. + </span>, + ]} + className={cn(flFraunces.className, 'text-[clamp(2.75rem,6vw,5.25rem)] font-normal leading-[0.98] tracking-tight text-[var(--fl-ecru)]')} + /> + + <m.p + initial={{ opacity: 0, y: 10 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true }} + transition={{ duration: 0.6, delay: 0.35, ease: [0.16, 1, 0.3, 1] }} + className="mt-6 max-w-lg text-body-lg leading-relaxed text-[rgba(var(--fl-ecru-rgb),0.7)]" + > + One Helm. Two fields. Roster, schedule, stats, and an AI that reads the game with you. + </m.p> + + <m.div + initial={{ opacity: 0, y: 10 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true }} + transition={{ duration: 0.6, delay: 0.5, ease: [0.16, 1, 0.3, 1] }} + className="mt-9 flex flex-col items-center gap-3 sm:flex-row" + > + <Link + href="/golf/demo" + className="group inline-flex items-center gap-2 rounded-full bg-[var(--fl-green)] px-7 py-3.5 text-sm font-semibold text-white shadow-lg shadow-black/20 transition-[transform,background-color] duration-200 hover:-translate-y-0.5 hover:bg-primary-500 active:translate-y-0" + > + See it in action + <ArrowRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" /> + </Link> + <Link + href="/golf/join" + className="fl-glass-1 inline-flex items-center gap-2 rounded-full px-7 py-3.5 text-sm font-medium text-[var(--fl-ecru)] transition-transform duration-200 hover:-translate-y-0.5 active:translate-y-0" + > + <span className="relative z-10">Join your team</span> + </Link> + </m.div> + </m.div> + </section> + ); +} diff --git a/src/components/marketing/first-light/moments/M2Clarity.tsx b/src/components/marketing/first-light/moments/M2Clarity.tsx new file mode 100644 index 000000000..b604c411e --- /dev/null +++ b/src/components/marketing/first-light/moments/M2Clarity.tsx @@ -0,0 +1,149 @@ +'use client'; + +/** + * M2 · CLARITY — the what-is-it moment. docs/LANDING_ENTRY_WORLD_DESIGN.md + * M2, SAGE & CREAM amendment, ⚠ AMENDMENT 2 — IMMACULATE, ⚠ AMENDMENT 3 — + * BRAND & ARCHITECTURE §A.2/§D.1. Background register: `.fl-aurora` + * (low-chroma sage radial field over cream) — the pre-amendment flat + * `var(--fl-cream)` fill is retired per Amendment 2 §A.1 ("the cream should + * never be a flat fill"). One `.fl-light-pool` sits behind the eyebrow/ + * statement anchor (§A.2). + * + * Amendment 3 §D.1 breaks the centered axis: an asymmetric editorial split + * (research §6.2's `1.1fr/0.9fr` recipe) — the serif statement holds the + * left column, the 01/02/03 ledger holds the right column with a + * `md:translate-y-8` stagger. A centered eyebrow punctuation row (hairline + * — "Clarity" — brass `HelmRosette` — hairline, §A.2: "the eyebrow's + * flanking hairlines meet a brass rosette at center") sits above the split + * as the section's opening mark; the split itself stays off-axis below it. + * Mobile collapses to a single stacked column (statement, then ledger, no + * translate) — the research doc's explicit mobile carve-out for this + * layout. + * + * This is the landing moment of the M1 exit scrub (spec's scroll + * choreography map: "M1 → M2 exit... M2's serif statement writes itself + * in line-by-line timed to arrival"). The serif statement writes itself in + * via `MaskedReveal`'s `whileInView` one-shot entrance (the same primitive + * M1's headline uses) — no PinnedScrub here, this section stays CALM per + * the build brief so M3's scrubbed set piece lands harder next. + * + * Below: three hairline-ruled ledger lines — explicitly NOT cards. + * Sage-ink ordinal numerals (font-annual/Space Grotesk, the app's UI + * register) + sage rules, the app's own idiom, pre-echoed on the + * marketing surface. + */ +import { cn } from '@/lib/utils'; +import { MaskedReveal } from '../scroll/MaskedReveal'; +import { HelmRosette } from '../brand'; +import { flFraunces } from '../fonts'; +import { m, useReducedMotion } from 'framer-motion'; + +export interface M2ClarityProps { + className?: string; +} + +const LEDGER_LINES = [ + { + n: '01', + label: 'Run the program', + detail: 'Roster, calendar, tasks, travel — one shared source of truth.', + }, + { + n: '02', + label: 'See every number', + detail: 'Strokes-gained, live stat lines, and readiness tracked automatically.', + }, + { + n: '03', + label: 'Know what matters', + detail: 'CoachHelm surfaces the one thing worth a conversation this week.', + }, +] as const; + +export function M2Clarity({ className }: M2ClarityProps) { + const reduced = useReducedMotion(); + + return ( + <section className={cn('fl-aurora relative px-6 py-24 sm:py-32', className)}> + <div + aria-hidden="true" + className="fl-light-pool left-1/2 top-0 h-[28rem] w-[42rem] -translate-x-1/2" + /> + + <div className="relative mx-auto max-w-5xl"> + {/* Centered eyebrow punctuation — the section's opening mark. The + asymmetric split below stays off-axis; only this row earns center + (Amendment 3 §A.2/§D — "set pieces earn center... editorial + moments break the axis"). */} + <div className="mx-auto mb-14 flex max-w-xs items-center justify-center gap-3 sm:mb-16"> + <span aria-hidden="true" className="fl-rule h-px flex-1" /> + <span className="font-annual text-eyebrow font-semibold uppercase tracking-[0.28em] text-[var(--fl-sage-deep)]"> + Clarity + </span> + <HelmRosette size={12} className="flex-shrink-0 text-[var(--fl-brass)]" /> + <span aria-hidden="true" className="fl-rule h-px flex-1" /> + </div> + + <div className="grid gap-12 md:grid-cols-[1.1fr_0.9fr] md:items-start md:gap-14 lg:gap-20"> + <MaskedReveal + as="h2" + lines={[ + 'Helm is the operating system for college programs —', + 'roster, schedule, stats, and an AI that reads', + 'the game with you.', + ]} + className={cn( + flFraunces.className, + 'text-balance text-[clamp(1.75rem,3.6vw,2.75rem)] font-normal leading-[1.15] tracking-tight text-[var(--fl-sage-ink)]', + )} + /> + + <div className="flex flex-col md:translate-y-8"> + {LEDGER_LINES.map((line, i) => ( + <m.div + key={line.label} + initial={{ opacity: 0, y: 12 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true, margin: '-15%' }} + transition={ + reduced + ? { duration: 0 } + : { duration: 0.5, delay: i * 0.08, ease: [0.16, 1, 0.3, 1] } + } + className="py-6 sm:py-7" + style={i > 0 ? { borderTop: '1px solid rgba(var(--fl-brass-rgb), 0.4)' } : undefined} + > + <div className="mb-4 flex items-center gap-3"> + <span + aria-hidden="true" + className="font-annual text-caption font-medium tabular-nums text-[var(--fl-sage-ink)]" + > + {line.n} + </span> + <m.div + className="fl-rule flex-1 origin-left" + style={{ background: 'var(--fl-sage)' }} + initial={{ scaleX: 0 }} + whileInView={{ scaleX: 1 }} + viewport={{ once: true, margin: '-15%' }} + transition={ + reduced + ? { duration: 0 } + : { duration: 0.7, delay: i * 0.08 + 0.1, ease: [0.16, 1, 0.3, 1] } + } + /> + </div> + <div className="flex flex-col gap-1.5"> + <span className={cn(flFraunces.className, 'text-lg font-medium text-[var(--fl-sage-ink)] sm:text-xl')}> + {line.label} + </span> + <span className="text-body-sm leading-[1.65] text-[rgba(var(--fl-sage-ink-rgb),0.7)]">{line.detail}</span> + </div> + </m.div> + ))} + </div> + </div> + </div> + </section> + ); +} diff --git a/src/components/marketing/first-light/moments/M3ProductCinema.tsx b/src/components/marketing/first-light/moments/M3ProductCinema.tsx new file mode 100644 index 000000000..469dc6968 --- /dev/null +++ b/src/components/marketing/first-light/moments/M3ProductCinema.tsx @@ -0,0 +1,184 @@ +'use client'; + +/** + * M3 · PRODUCT CINEMA — THE scroll set piece. docs/LANDING_ENTRY_WORLD_DESIGN.md + * M3. Background register: deep pine band. A `PinnedScrub` (~250vh) holds a + * G2 glass desktop frame center-stage while three product "screens" glide + * through under scroll control, each with a scrub-linked caption. A phone + * frame (Lift Lab) arcs in from the right at the midpoint. + * + * Screens are placeholder gradient panels + labels for now — real + * screenshots land from `public/marketing/first-light/screens/` (see + * CONTRACTS.md "Screenshot asset contract"); the frame + motion contract + * (props, glass grade, scrub windows) is what this lane locks down. + * + * `prefers-reduced-motion`: `PinnedScrub` renders this content unpinned at + * a single static `progress = 1` frame — the LAST screen, landed, with its + * caption visible and the phone frame in place. No scrub, no glide. + */ +import { m, useTransform, type MotionValue } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import { PinnedScrub } from '../scroll/PinnedScrub'; +import { flFraunces } from '../fonts'; + +export interface M3ProductCinemaProps { + className?: string; +} + +interface CinemaScreen { + label: string; + caption: string; + tint: string; +} + +const SCREENS: CinemaScreen[] = [ + { + label: 'Command Center', + caption: 'Roster, calendar, and tasks — the whole program, one screen.', + tint: 'linear-gradient(155deg, #1c4632 0%, #143527 100%)', + }, + { + label: 'Stats Center', + caption: 'Strokes-gained and stat lines that update themselves.', + tint: 'linear-gradient(155deg, #1a3f2c 0%, #0f2a1e 100%)', + }, + { + label: 'Decision Room', + caption: 'CoachHelm surfaces the one thing worth a conversation this week.', + tint: 'linear-gradient(155deg, #173d2b 0%, #0d2318 100%)', + }, +]; + +export function M3ProductCinema({ className }: M3ProductCinemaProps) { + return ( + <section className={cn('relative', className)} style={{ backgroundColor: 'var(--fl-pine)' }}> + <div className="fl-grain" aria-hidden="true" /> + <PinnedScrub heightVh={250} innerClassName="flex items-center justify-center px-6"> + {(progress) => <ProductFrame progress={progress} />} + </PinnedScrub> + </section> + ); +} + +function ProductFrame({ progress }: { progress: MotionValue<number> }) { + const total = SCREENS.length; + + return ( + <div className="relative mx-auto w-full max-w-4xl"> + <div className="mb-8 text-center"> + <span className="text-eyebrow font-semibold uppercase tracking-[0.28em] text-[rgba(var(--fl-ecru-rgb),0.5)]"> + Inside Helm + </span> + </div> + + {/* G2 glass desktop frame — holds center for the whole scrub */} + <div className="fl-glass-2 relative mx-auto aspect-[16/10] w-full overflow-hidden rounded-2xl"> + <div className="relative z-10 h-full w-full overflow-hidden rounded-2xl"> + {SCREENS.map((screen, i) => ( + <ScreenPanel key={screen.label} index={i} total={total} progress={progress} screen={screen} /> + ))} + </div> + </div> + + {/* Captions — scrub-linked, one visible per screen window */} + <div className="relative mt-8 h-14 text-center"> + {SCREENS.map((screen, i) => ( + <CaptionLine key={screen.label} index={i} total={total} progress={progress} caption={screen.caption} /> + ))} + </div> + + {/* Phone frame — arcs in from the right at the midpoint (Lift Lab) */} + <PhoneArc progress={progress} /> + </div> + ); +} + +function ScreenPanel({ + index, + total, + progress, + screen, +}: { + index: number; + total: number; + progress: MotionValue<number>; + screen: CinemaScreen; +}) { + const start = index / total; + const end = (index + 1) / total; + const enterEnd = Math.min(end, start + 0.06); + const exitStart = Math.max(start, end - 0.08); + + const y = useTransform( + progress, + [start, enterEnd, exitStart, end], + ['28%', '0%', '0%', '-28%'], + ); + const opacity = useTransform( + progress, + [start, enterEnd, exitStart, end], + [index === 0 ? 1 : 0, 1, 1, index === total - 1 ? 1 : 0], + ); + + return ( + <m.div + style={{ y, opacity, background: screen.tint }} + className="absolute inset-0 flex items-center justify-center" + > + <span className={cn(flFraunces.className, 'text-2xl font-medium text-[rgba(var(--fl-ecru-rgb),0.9)] sm:text-3xl')}> + {screen.label} + </span> + </m.div> + ); +} + +function CaptionLine({ + index, + total, + progress, + caption, +}: { + index: number; + total: number; + progress: MotionValue<number>; + caption: string; +}) { + const start = index / total; + const end = (index + 1) / total; + const enterEnd = Math.min(end, start + 0.06); + const exitStart = Math.max(start, end - 0.08); + const y = useTransform(progress, [start, enterEnd], ['70%', '0%']); + const opacity = useTransform( + progress, + [start, enterEnd, exitStart, end], + [index === 0 ? 1 : 0, 1, 1, index === total - 1 ? 1 : 0], + ); + + return ( + <span className="fl-line-mask absolute inset-x-0 top-0"> + <m.span style={{ y, opacity }} className="block text-body-lg text-[rgba(var(--fl-ecru-rgb),0.75)]"> + {caption} + </m.span> + </span> + ); +} + +function PhoneArc({ progress }: { progress: MotionValue<number> }) { + // Arrives from the right at the scrub's midpoint, settles, holds. + const x = useTransform(progress, [0.42, 0.62], ['18%', '0%']); + const opacity = useTransform(progress, [0.42, 0.55], [0, 1]); + const rotate = useTransform(progress, [0.42, 0.62], [8, 0]); + + return ( + <m.div + style={{ x, opacity, rotate }} + className="fl-glass-2 pointer-events-none absolute -right-4 top-1/3 hidden h-40 w-20 rounded-[1.4rem] sm:block lg:-right-10 lg:h-48 lg:w-24" + aria-hidden="true" + > + <div className="relative z-10 flex h-full w-full flex-col items-center justify-center gap-1.5 px-3"> + <span className="h-1.5 w-1.5 rounded-full" style={{ background: 'var(--fl-green)' }} /> + <span className="text-eyebrow font-medium uppercase text-[rgba(var(--fl-ecru-rgb),0.7)]">Lift Lab</span> + </div> + </m.div> + ); +} diff --git a/src/components/marketing/first-light/moments/M4TwoFields.tsx b/src/components/marketing/first-light/moments/M4TwoFields.tsx new file mode 100644 index 000000000..8a008448b --- /dev/null +++ b/src/components/marketing/first-light/moments/M4TwoFields.tsx @@ -0,0 +1,94 @@ +'use client'; + +/** + * M4 · TWO FIELDS — the portal fork. docs/LANDING_ENTRY_WORLD_DESIGN.md M4. + * Background register: diptych split — golf dawn (left) / baseball dusk + * (right), graded to one light language, each a G2 glass card. Hover: the + * field brightens, the other dims slightly. Mobile: stacked portals. + */ +import { useState } from 'react'; +import Link from 'next/link'; +import { m } from 'framer-motion'; +import { ArrowUpRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { flFraunces } from '../fonts'; +import { photoLayerStyle } from '../lib/photoBg'; + +export interface M4TwoFieldsProps { + className?: string; +} + +type FieldKey = 'golf' | 'baseball'; + +const FIELDS: Array<{ + key: FieldKey; + title: string; + line: string; + href: string; + fallbackGradient: string; +}> = [ + { + key: 'golf', + title: 'GolfHelm', + line: 'College golf team management + the CoachHelm AI layer.', + href: '/products#golfhelm', + fallbackGradient: + 'linear-gradient(150deg, rgba(20,53,39,0.35) 0%, rgba(20,53,39,0.82) 100%), radial-gradient(ellipse 80% 60% at 30% 20%, rgba(245,241,230,0.18), transparent 60%)', + }, + { + key: 'baseball', + title: 'BaseballHelm', + line: 'College baseball recruiting + team management, coach to player.', + href: '/products#baseballhelm', + fallbackGradient: + 'linear-gradient(150deg, rgba(176,112,60,0.28) 0%, rgba(20,53,39,0.86) 100%), radial-gradient(ellipse 80% 60% at 70% 25%, rgba(176,141,87,0.16), transparent 60%)', + }, +]; + +export function M4TwoFields({ className }: M4TwoFieldsProps) { + const [hovered, setHovered] = useState<FieldKey | null>(null); + + return ( + <section className={cn('relative', className)} style={{ backgroundColor: 'var(--fl-pine)' }}> + <div className="mx-auto max-w-6xl px-6 pb-6 pt-20 text-center sm:pt-24"> + <span className="text-eyebrow font-semibold uppercase tracking-[0.28em] text-[rgba(var(--fl-ecru-rgb),0.5)]"> + One Helm. Two fields. + </span> + </div> + <div className="grid grid-cols-1 sm:grid-cols-2"> + {FIELDS.map((field) => { + const dimmed = hovered !== null && hovered !== field.key; + return ( + <Link + key={field.key} + href={field.href} + onMouseEnter={() => setHovered(field.key)} + onMouseLeave={() => setHovered(null)} + className="group relative flex min-h-[60vh] items-end overflow-hidden p-6 sm:min-h-[70vh] sm:p-10" + style={{ backgroundColor: 'var(--fl-pine)' }} + > + <m.div + animate={{ opacity: dimmed ? 0.55 : 1, scale: hovered === field.key ? 1.02 : 1 }} + transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }} + className="absolute inset-0" + style={photoLayerStyle({ src: `/marketing/first-light/photos/${field.key}.jpg`, fallbackGradient: field.fallbackGradient })} + /> + <div className="fl-glass-2 relative w-full max-w-sm rounded-2xl p-6"> + <div className="relative z-10"> + <h3 className={cn(flFraunces.className, 'text-2xl font-medium text-[var(--fl-ecru)] sm:text-3xl')}> + {field.title} + </h3> + <p className="mt-2 text-body text-[rgba(var(--fl-ecru-rgb),0.7)]">{field.line}</p> + <span className="mt-4 inline-flex items-center gap-1.5 text-sm font-semibold text-[var(--fl-green)]"> + Enter + <ArrowUpRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" /> + </span> + </div> + </div> + </Link> + ); + })} + </div> + </section> + ); +} diff --git a/src/components/marketing/first-light/moments/M5Intelligence.tsx b/src/components/marketing/first-light/moments/M5Intelligence.tsx new file mode 100644 index 000000000..5084c1c88 --- /dev/null +++ b/src/components/marketing/first-light/moments/M5Intelligence.tsx @@ -0,0 +1,218 @@ +'use client'; + +/** + * M5 · THE INTELLIGENCE — CoachHelm/signals showcase. + * docs/LANDING_ENTRY_WORLD_DESIGN.md M5, SAGE & CREAM amendment, ⚠ + * AMENDMENT 2 — IMMACULATE, ⚠ AMENDMENT 3 — BRAND & ARCHITECTURE §D.4. + * Background register: cream → sage-mist → sage, deepening toward + * sage-ink ONLY at the band's very far edge — the section must still + * read light (daylight mood; never murky). + * + * Amendment 3 §D.4 goes off-axis: the signal card moves into a + * right-of-center column (`md:grid-cols-[0.9fr_1.1fr]`, the card's column + * is ~55% of the row) and the line that used to be a centered heading — + * "No signal ships without its source." — becomes a large serif + * margin-note anchored in the LEFT column instead, paired with a + * `HelmRosette` eyebrow (§A.2). The margin note sits at the top of its + * column (`md:items-start`), which keeps it inside the band's + * cream/sage-mist zone (the gradient's light ~0–45% range) regardless of + * how tall the card renders — sage-ink holds the doc's ≈10:1 contrast + * floor there; this is a deliberate placement choice, not an accident of + * layout, so don't switch this row to `items-center` without re-checking + * contrast against whatever the card's height has grown to by then. + * + * The card's replica interior (icon well, top rule, badge, drift number, + * source/limitation, confidence bar) is UNCHANGED from the previous + * centered layout — only its outer wrapper moved into the new grid + * column. See that block's own comments for the interior's design intent + * (confidenceColor() echo, specular/hairline composition, etc). + * + * ⚠ AMENDMENT 2 — IMMACULATE (§B.7): this card is the page's ONE + * `.fl-gradient-ring` element — applied to an outer wrapper (not the + * `.fl-glass-2` element itself), because `.fl-gradient-ring::before` and + * `.fl-glass-2::before` (the baked-in grain layer) would otherwise fight + * over the same pseudo-element on one host. One `.fl-light-pool` sits + * behind the wrapper (§A.2). The glass card itself gains an inline + * `boxShadow` that layers `--fl-specular` (the cream-high top highlight) + * and a faint sage-ink hairline on top of its existing brass top-edge — + * composed via inline style (not a Tailwind `shadow-*`/`ring-*` utility) + * so it can't lose a cascade fight against `.fl-glass-2`'s own box-shadow + * rule; see `.fl-card`'s identical `var(--fl-specular), var(--fl-shadow-*)` + * pattern in first-light.css for precedent. + * + * Per the amendment, kelly (`#16A34A` / Tailwind `primary-*`) is demoted + * to product-only chrome. It appears ONLY on the confidence badge + bar + * below, because that is a literal echo of the product's own + * `confidenceColor()` helper (green at >=70% confidence, exactly this + * card's 82%) — real product content, not landing chrome. Every other + * surface on this card (icon well, top rule, labels, body ink) uses the + * sage/cream tokens like the rest of the landing. + */ +import { useRef } from 'react'; +import { m, useInView, useReducedMotion } from 'framer-motion'; +import { Sparkles } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { flFraunces } from '../fonts'; +import { HelmRosette } from '../brand'; +import { AnimatedNumber } from '@/components/ui/animated-number'; + +export interface M5IntelligenceProps { + className?: string; +} + +const CONFIDENCE = 0.82; +const DRIFT_FEET = 4.2; + +export function M5Intelligence({ className }: M5IntelligenceProps) { + const reduced = useReducedMotion(); + const cardRef = useRef<HTMLDivElement>(null); + const inView = useInView(cardRef, { once: true, amount: 0.5 }); + const confPct = Math.round(CONFIDENCE * 100); + + return ( + <section + className={cn('relative px-6 py-24 sm:py-32', className)} + style={{ + background: + 'linear-gradient(180deg, var(--fl-cream) 0%, var(--fl-sage-mist) 42%, var(--fl-sage) 86%, var(--fl-sage-ink) 100%)', + }} + > + <div className="relative z-10 mx-auto grid max-w-6xl gap-12 md:grid-cols-[0.9fr_1.1fr] md:items-start md:gap-16"> + {/* LEFT — rosette eyebrow + the large serif margin-note. */} + <div> + <div className="flex items-center gap-3"> + <HelmRosette size={12} className="flex-shrink-0 text-[var(--fl-brass)]" /> + <span className="font-annual text-eyebrow font-semibold uppercase tracking-[0.28em] text-[var(--fl-sage-deep)]"> + The Intelligence + </span> + </div> + <m.p + initial={{ opacity: 0, y: 12 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true, margin: '-15%' }} + transition={reduced ? { duration: 0 } : { duration: 0.6, ease: [0.16, 1, 0.3, 1] }} + className={cn( + flFraunces.className, + 'mt-6 text-balance text-[clamp(2rem,4.2vw,3.1rem)] font-normal leading-[1.1] text-[var(--fl-sage-ink)]', + )} + > + No signal ships without its source. + </m.p> + </div> + + {/* RIGHT — the signal card, off-axis (~55% column). Interior unchanged. */} + <div className="relative"> + <div + aria-hidden="true" + className="fl-light-pool left-1/2 top-1/2 h-[30rem] w-[36rem] -translate-x-1/2 -translate-y-1/2" + /> + + <div className="fl-gradient-ring relative mx-auto max-w-xl rounded-2xl md:mx-0"> + <m.div + ref={cardRef} + initial={{ opacity: 0, y: 20 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true, margin: '-15%' }} + transition={reduced ? { duration: 0 } : { duration: 0.6, ease: [0.16, 1, 0.3, 1] }} + className="fl-glass-2 relative z-10 overflow-hidden rounded-2xl" + style={{ + boxShadow: + 'inset 0 1px 0 0 rgba(var(--fl-brass-rgb), 0.35), var(--fl-specular), 0 0 0 1px rgba(var(--fl-sage-ink-rgb), 0.06)', + }} + > + <div className="relative z-10"> + <div className="h-1 w-full" style={{ backgroundColor: 'var(--fl-sage)' }} aria-hidden="true" /> + + <div className="p-7 sm:p-8"> + <div className="flex items-start justify-between gap-4"> + <div className="flex items-center gap-2.5"> + <div + className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl" + style={{ backgroundColor: 'rgba(var(--fl-sage-ink-rgb), 0.08)' }} + > + <Sparkles className="h-4 w-4" style={{ color: 'var(--fl-sage-deep)' }} aria-hidden="true" /> + </div> + <span className="font-annual text-eyebrow font-semibold uppercase tracking-[0.18em] text-[rgba(var(--fl-sage-ink-rgb),0.55)]"> + Coach Insight + </span> + </div> + {/* Kelly, deliberately — see file header: a literal echo of + the product's own confidenceColor() >=70% threshold. */} + <span + data-testid="fl-signal-confidence-badge" + className="flex-shrink-0 rounded-full bg-primary-600 px-3 py-1 text-xs font-semibold text-white" + > + {confPct}% confidence + </span> + </div> + + <div className="mt-5 flex items-end justify-between gap-4"> + <p className="max-w-[15rem] text-body-lg leading-[1.65] text-[var(--fl-sage-ink)]"> + Approach proximity from 175–200yd has drifted long over the last 3 rounds. + </p> + <div className="flex-shrink-0 text-right"> + <div className="font-annual text-3xl font-medium tabular-nums leading-none text-[var(--fl-sage-ink)]"> + <AnimatedNumber + value={inView ? DRIFT_FEET : 0} + decimals={1} + prefix="+" + suffix="ft" + className="tabular-nums" + /> + </div> + <div className="mt-1 text-eyebrow uppercase tracking-[0.14em] text-[rgba(var(--fl-sage-ink-rgb),0.5)]"> + long + </div> + </div> + </div> + + <div className="mt-6 grid grid-cols-1 gap-4 border-t border-[rgba(var(--fl-sage-ink-rgb),0.14)] pt-5 sm:grid-cols-2"> + <div> + <span className="font-annual text-eyebrow font-semibold uppercase tracking-[0.18em] text-[rgba(var(--fl-sage-ink-rgb),0.5)]"> + Source + </span> + <p className="mt-1 text-body leading-[1.65] text-[rgba(var(--fl-sage-ink-rgb),0.78)]"> + 3 rounds · strokes-gained approach, 175–200yd bucket + </p> + </div> + <div> + <span className="font-annual text-eyebrow font-semibold uppercase tracking-[0.18em] text-[rgba(var(--fl-sage-ink-rgb),0.5)]"> + Limitation + </span> + <p className="mt-1 text-body leading-[1.65] text-[rgba(var(--fl-sage-ink-rgb),0.78)]"> + Small sample — 3 rounds. Confidence rises after 5+. + </p> + </div> + </div> + + <div className="mt-5 flex items-center gap-3"> + <div + role="progressbar" + aria-valuenow={confPct} + aria-valuemin={0} + aria-valuemax={100} + aria-label="Confidence" + className="relative h-1.5 flex-1 overflow-hidden rounded-full" + style={{ backgroundColor: 'rgba(var(--fl-sage-ink-rgb), 0.12)' }} + > + <m.div + className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-primary-500" + initial={{ scaleX: 0 }} + whileInView={{ scaleX: confPct / 100 }} + viewport={{ once: true, margin: '-15%' }} + transition={reduced ? { duration: 0 } : { duration: 0.8, delay: 0.15, ease: [0.16, 1, 0.3, 1] }} + /> + </div> + <span className="font-annual text-caption font-medium tabular-nums text-[rgba(var(--fl-sage-ink-rgb),0.6)]"> + {confPct}% + </span> + </div> + </div> + </div> + </m.div> + </div> + </div> + </div> + </section> + ); +} diff --git a/src/components/marketing/first-light/moments/M6ForThePlayer.tsx b/src/components/marketing/first-light/moments/M6ForThePlayer.tsx new file mode 100644 index 000000000..4da6b0066 --- /dev/null +++ b/src/components/marketing/first-light/moments/M6ForThePlayer.tsx @@ -0,0 +1,194 @@ +'use client'; + +/** + * M6 · FOR THE PLAYER — the player as audience, not afterthought. + * docs/LANDING_ENTRY_WORLD_DESIGN.md M6, SAGE & CREAM amendment, ⚠ + * AMENDMENT 2 — IMMACULATE, ⚠ AMENDMENT 3 — BRAND & ARCHITECTURE §B.3/§D.2. + * Background register: `.fl-aurora` (the pre-amendment flat cream→sage-mist + * gradient is retired per §A.1 — "the cream should never be a flat fill"). + * + * Amendment 3 §D.2 replaces the equal-thirds row with an asymmetric 6-col + * bento (research §6.1's "no equal-thirds row" recipe): Readiness is now + * the dominant tile (`md:col-span-4 md:row-span-2`, grown to carry a small + * ledger-style sparkline idiom — 7 thin divs, no chart lib) with the + * Passport and Lift Lab vignettes stacked beside it at `md:col-span-2` + * each. Every tile keeps the `.fl-card fl-card-lift` recipe from Amendment + * 2 §B.6 (glass is chrome-only; these are content cards on cream, not + * floating chrome) and gains a small sage `SportGlyph`/`HelmRosette` in its + * header per §B.3 (passport→baseball, lift→lift, readiness→neutral + * rosette — the design system's literal per-tile glyph assignment, not a + * thematic claim that passport content "is" baseball). No new glow utility + * is added here — the page's `.fl-cta-glow` budget is already spent on + * M1/M8 (§E). Mobile collapses to a single column with generous gaps. + * + * Copy speaks to the athlete, not the buyer. Each vignette carries one + * small real-product detail (a completeness bar, a logged-session line, a + * readiness sparkline + band dot) rather than being pure copy — echoes + * M5's "show, don't just tell" without competing with it for attention + * (calm entry reveals only, no scroll-linked motion here). Accents use + * sage-deep (landing/auth chrome) — kelly stays product-only per the + * amendment, so nothing here reaches for it. + * + * The hover lift uses framer-motion's `whileHover={{ y: -4 }}` rather than + * `.fl-card-lift`'s own CSS `:hover { transform }` rule — this `m.div` + * already animates its entrance via the `y` motion value (`initial`/ + * `whileInView`), and framer keeps that value bound to an inline + * `transform` style for the component's lifetime. An inline style always + * wins over an external stylesheet's `:hover` selector regardless of + * specificity, so a CSS-only lift would be silently dead here; driving the + * same -4px offset through `whileHover` lets framer resolve it correctly + * against its own already-inline-controlled transform. `.fl-card-lift`'s + * CSS `:hover` rule still does useful work for the box-shadow swell, which + * framer doesn't touch. + */ +import { m, useReducedMotion } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import { flFraunces } from '../fonts'; +import { HelmRosette, SportGlyph } from '../brand'; + +export interface M6ForThePlayerProps { + className?: string; +} + +/** Last 7 days, Tailwind height-scale steps (4px grid) — last bar is "today". */ +const SPARKLINE_HEIGHTS = ['h-3', 'h-5', 'h-4', 'h-7', 'h-5', 'h-6', 'h-9'] as const; + +export function M6ForThePlayer({ className }: M6ForThePlayerProps) { + const reduced = useReducedMotion(); + const lift = reduced ? undefined : { y: -4, transition: { duration: 0.24, ease: [0.33, 1, 0.68, 1] as const } }; + + return ( + <section className={cn('fl-aurora relative px-6 py-24 sm:py-32', className)}> + <div className="relative z-10 mx-auto max-w-2xl text-center"> + <span className="font-annual text-eyebrow font-semibold uppercase tracking-[0.28em] text-[var(--fl-sage-deep)]"> + For the Player + </span> + <h2 className={cn(flFraunces.className, 'mt-4 text-balance text-[clamp(1.75rem,3.4vw,2.5rem)] font-normal leading-tight text-[var(--fl-sage-ink)]')}> + You’re not just on the roster. + </h2> + </div> + + <div className="relative z-10 mx-auto mt-14 grid max-w-5xl auto-rows-[minmax(180px,auto)] grid-cols-1 gap-6 md:grid-cols-6 md:gap-5"> + {/* Readiness — the dominant tile. */} + <m.div + initial={{ opacity: 0, y: 16 }} + whileInView={{ opacity: 1, y: 0 }} + whileHover={lift} + viewport={{ once: true, margin: '-10%' }} + transition={reduced ? { duration: 0 } : { duration: 0.5, ease: [0.16, 1, 0.3, 1] }} + className="fl-card fl-card-lift flex flex-col p-7 md:col-span-4 md:row-span-2" + > + <div className="relative z-10 flex h-full flex-col"> + <div className="flex items-center gap-2"> + <HelmRosette size={16} className="flex-shrink-0 text-[var(--fl-sage)]" /> + <h3 className={cn(flFraunces.className, 'text-lg font-medium text-[var(--fl-sage-ink)]')}>Readiness</h3> + </div> + <p className="mt-2 max-w-sm text-body-sm leading-[1.65] text-[rgba(var(--fl-sage-ink-rgb),0.72)]"> + A daily read on how you’re showing up — for you and your coach. + </p> + + <div className="mt-auto flex flex-col gap-5 pt-8 sm:flex-row sm:items-end sm:justify-between"> + <div aria-hidden="true" className="flex items-end gap-2"> + {SPARKLINE_HEIGHTS.map((h, i) => ( + <div + key={i} + className={cn('w-3 rounded-full', h)} + style={{ backgroundColor: i === SPARKLINE_HEIGHTS.length - 1 ? 'var(--fl-sage-deep)' : 'var(--fl-sage)' }} + /> + ))} + </div> + <div className="flex items-center gap-2.5"> + <span className="h-2 w-2 flex-shrink-0 rounded-full" style={{ backgroundColor: 'var(--fl-sage-deep)' }} aria-hidden="true" /> + <span className="font-annual text-body-sm text-[rgba(var(--fl-sage-ink-rgb),0.72)]"> + <span className="tabular-nums text-[var(--fl-sage-ink)]">4.6</span> of 5 — ready to go + </span> + </div> + </div> + </div> + </m.div> + + {/* Passport — stacked, upper. */} + <m.div + initial={{ opacity: 0, y: 16 }} + whileInView={{ opacity: 1, y: 0 }} + whileHover={lift} + viewport={{ once: true, margin: '-10%' }} + transition={reduced ? { duration: 0 } : { duration: 0.5, delay: 0.08, ease: [0.16, 1, 0.3, 1] }} + className="fl-card fl-card-lift p-6 md:col-span-2" + > + <div className="relative z-10"> + <div className="flex items-center gap-2"> + <SportGlyph sport="baseball" size={16} className="flex-shrink-0 text-[var(--fl-sage)]" /> + <h3 className={cn(flFraunces.className, 'text-lg font-medium text-[var(--fl-sage-ink)]')}>Your passport</h3> + </div> + <p className="mt-2 text-body-sm leading-[1.65] text-[rgba(var(--fl-sage-ink-rgb),0.72)]"> + Stats, highlights, and your story — always current, always yours to share. + </p> + <div className="mt-4 border-t border-[rgba(var(--fl-sage-ink-rgb),0.12)] pt-4"> + <PassportDetail /> + </div> + </div> + </m.div> + + {/* Lift Lab — stacked, lower. */} + <m.div + initial={{ opacity: 0, y: 16 }} + whileInView={{ opacity: 1, y: 0 }} + whileHover={lift} + viewport={{ once: true, margin: '-10%' }} + transition={reduced ? { duration: 0 } : { duration: 0.5, delay: 0.16, ease: [0.16, 1, 0.3, 1] }} + className="fl-card fl-card-lift p-6 md:col-span-2" + > + <div className="relative z-10"> + <div className="flex items-center gap-2"> + <SportGlyph sport="lift" size={16} className="flex-shrink-0 text-[var(--fl-sage)]" /> + <h3 className={cn(flFraunces.className, 'text-lg font-medium text-[var(--fl-sage-ink)]')}>Lift Lab check-in</h3> + </div> + <p className="mt-2 text-body-sm leading-[1.65] text-[rgba(var(--fl-sage-ink-rgb),0.72)]"> + Log the work. The bar tracks your progress so you don’t have to. + </p> + <div className="mt-4 border-t border-[rgba(var(--fl-sage-ink-rgb),0.12)] pt-4"> + <LiftLabDetail /> + </div> + </div> + </m.div> + </div> + </section> + ); +} + +function PassportDetail() { + const pct = 92; + return ( + <div> + <div className="flex items-baseline justify-between"> + <span className="font-annual text-caption font-medium uppercase tracking-[0.1em] text-[rgba(var(--fl-sage-ink-rgb),0.55)]"> + Complete + </span> + <span className="font-annual text-body-sm font-medium tabular-nums text-[var(--fl-sage-ink)]">{pct}%</span> + </div> + <div className="mt-2 h-1 overflow-hidden rounded-full" style={{ backgroundColor: 'rgba(var(--fl-sage-ink-rgb), 0.12)' }}> + <div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: 'var(--fl-sage-deep)' }} /> + </div> + </div> + ); +} + +function LiftLabDetail() { + return ( + <div className="flex items-center gap-2"> + <span + className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full" + style={{ backgroundColor: 'var(--fl-sage-deep)' }} + aria-hidden="true" + > + <svg viewBox="0 0 12 12" width={8} height={8} fill="none" stroke="var(--fl-cream-high)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> + <path d="M2 6.5l2.5 2.5L10 3" /> + </svg> + </span> + <span className="font-annual text-body-sm text-[rgba(var(--fl-sage-ink-rgb),0.72)]"> + Today logged <span className="tabular-nums text-[var(--fl-sage-ink)]">· 4 sets</span> + </span> + </div> + ); +} diff --git a/src/components/marketing/first-light/moments/M7Honesty.tsx b/src/components/marketing/first-light/moments/M7Honesty.tsx new file mode 100644 index 000000000..24c20af42 --- /dev/null +++ b/src/components/marketing/first-light/moments/M7Honesty.tsx @@ -0,0 +1,147 @@ +'use client'; + +/** + * M7 · HONESTY BAND — pre-revenue social proof, done honestly. + * docs/LANDING_ENTRY_WORLD_DESIGN.md M7, SAGE & CREAM amendment, ⚠ + * AMENDMENT 2 — IMMACULATE, ⚠ AMENDMENT 3 — BRAND & ARCHITECTURE §C.2/§D.3. + * Background register: `.fl-aurora`'s radial field composited OVER a + * sage-mist base (an inline `backgroundColor` override sits under the + * aurora class's own radial `background-image` layers — the two are + * separate CSS properties, so the tint survives) rather than plain cream, + * preserving this section's deeper "band" feel per Amendment 2 §A.1's + * explicit carve-out for M7 while still killing the flat single-color + * fill. No fake logos. + * + * Amendment 3 §D.3 goes left-weighted: the founder line and what-Helm- + * refuses-to-claim line hold the LEFT column; the two live-product stats + * move into a tabular-nums ledger block (divide-y rows, not the old + * side-by-side row) in the RIGHT column. A rosette eyebrow ("Built by + * players") stays centered above the split as the section's opening mark, + * same convention as M2/M5. + * + * §C.2 — THE ONE GOLD MOMENT: the brass rule under the founder line draws + * in (scaleX, origin-left) and then carries a single slow glint sweep — + * a 1px-tall traveling highlight (a masked gradient span translated via + * `x`, transform-only) that runs ONCE on first entry, then stays still. + * This is the page's ONLY animated gold; nothing else on the page should + * animate a brass/gold value. Reduced-motion renders the rule fully drawn, + * static, with no glint element at all (not just a disabled transition — + * the sweep span isn't mounted). + */ +import { useRef } from 'react'; +import { m, useInView, useReducedMotion } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import { flFraunces } from '../fonts'; +import { HelmRosette } from '../brand'; +import { AnimatedNumber } from '@/components/ui/animated-number'; + +export interface M7HonestyProps { + className?: string; +} + +const STATS = [ + { value: 2, suffix: '', label: 'Sports live today — golf + baseball' }, + { value: 3, suffix: '', label: 'Things every insight must show: source, confidence, limitation' }, +] as const; + +export function M7Honesty({ className }: M7HonestyProps) { + const reduced = useReducedMotion(); + const ruleRef = useRef<HTMLDivElement>(null); + const ruleInView = useInView(ruleRef, { once: true, amount: 0.8 }); + const statsRef = useRef<HTMLDivElement>(null); + const statsInView = useInView(statsRef, { once: true, amount: 0.6 }); + + return ( + <section + className={cn('fl-aurora relative px-6 py-24 sm:py-32', className)} + style={{ backgroundColor: 'var(--fl-sage-mist)' }} + > + <div className="relative mx-auto max-w-5xl"> + <div className="mx-auto mb-14 flex max-w-xs items-center justify-center gap-3 sm:mb-16"> + <span aria-hidden="true" className="fl-rule h-px flex-1" /> + <span className="font-annual text-eyebrow font-semibold uppercase tracking-[0.28em] text-[var(--fl-sage-deep)]"> + Built by players + </span> + <HelmRosette size={12} className="flex-shrink-0 text-[var(--fl-brass)]" /> + <span aria-hidden="true" className="fl-rule h-px flex-1" /> + </div> + + <div className="grid gap-12 md:grid-cols-[1.3fr_1fr] md:items-start md:gap-16"> + {/* LEFT — founder line, the one gold rule, the refusals line. */} + <div className="text-left"> + <m.p + initial={{ opacity: 0, y: 12 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true, margin: '-15%' }} + transition={reduced ? { duration: 0 } : { duration: 0.55, ease: [0.16, 1, 0.3, 1] }} + className={cn(flFraunces.className, 'text-balance text-[clamp(1.5rem,3vw,2.1rem)] font-normal leading-snug text-[var(--fl-sage-ink)]')} + > + Built by two former collegiate athletes who got tired of running a program on spreadsheets and group texts. + </m.p> + + <div ref={ruleRef} className="relative my-6 h-px w-32 overflow-hidden" aria-hidden="true"> + <m.div + className="absolute inset-0 origin-left" + style={{ + background: 'linear-gradient(90deg, rgba(var(--fl-brass-rgb), 0.75), rgba(var(--fl-brass-rgb), 0.25))', + }} + initial={{ scaleX: 0 }} + animate={ruleInView ? { scaleX: 1 } : { scaleX: 0 }} + transition={reduced ? { duration: 0 } : { duration: 0.7, ease: [0.16, 1, 0.3, 1] }} + /> + {!reduced && ( + <m.div + className="absolute inset-y-0 w-10" + style={{ + background: + 'linear-gradient(90deg, transparent, rgba(var(--fl-cream-high-rgb), 0.9), transparent)', + }} + initial={{ x: '-140%' }} + animate={ruleInView ? { x: '240%' } : { x: '-140%' }} + transition={{ duration: 1.1, delay: 0.85, ease: [0.4, 0, 0.2, 1] }} + /> + )} + </div> + + <m.p + initial={{ opacity: 0, y: 12 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true, margin: '-15%' }} + transition={reduced ? { duration: 0 } : { duration: 0.55, delay: 0.1, ease: [0.16, 1, 0.3, 1] }} + className="text-pretty text-body leading-[1.65] text-[rgba(var(--fl-sage-ink-rgb),0.72)]" + > + We won’t claim a client roster we don’t have, or a stat we can’t source. If a signal can’t point to where it came from, it doesn’t ship. + </m.p> + </div> + + {/* RIGHT — the tabular-nums stat ledger. */} + <div className="relative"> + <div + aria-hidden="true" + className="fl-light-pool left-1/2 top-1/2 h-64 w-72 -translate-x-1/2 -translate-y-1/2" + /> + <m.div + ref={statsRef} + initial={{ opacity: 0, y: 12 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true, margin: '-15%' }} + transition={reduced ? { duration: 0 } : { duration: 0.5, delay: 0.16, ease: [0.16, 1, 0.3, 1] }} + className="relative flex flex-col divide-y divide-[rgba(var(--fl-sage-ink-rgb),0.14)] border-t border-[rgba(var(--fl-sage-ink-rgb),0.14)]" + > + {STATS.map((stat) => ( + <div key={stat.label} className="flex items-baseline justify-between gap-6 py-5"> + <AnimatedNumber + value={statsInView ? stat.value : 0} + suffix={stat.suffix} + className={cn(flFraunces.className, 'text-3xl font-medium tabular-nums text-[var(--fl-sage-ink)]')} + /> + <p className="max-w-[11rem] text-right text-caption text-[rgba(var(--fl-sage-ink-rgb),0.6)]">{stat.label}</p> + </div> + ))} + </m.div> + </div> + </div> + </div> + </section> + ); +} diff --git a/src/components/marketing/first-light/moments/M8FinalCTA.tsx b/src/components/marketing/first-light/moments/M8FinalCTA.tsx new file mode 100644 index 000000000..52ad5e53c --- /dev/null +++ b/src/components/marketing/first-light/moments/M8FinalCTA.tsx @@ -0,0 +1,62 @@ +'use client'; + +/** + * M8 · FINAL CTA — full-bleed misty photo inset. + * docs/LANDING_ENTRY_WORLD_DESIGN.md M8. Centered serif ask + the dual CTA + * pair again: demo path = "See it in action" (`/golf/demo`); player path = + * invite-code entry (`/golf/join`). Real handlers, no dead buttons. + */ +import Link from 'next/link'; +import { m } from 'framer-motion'; +import { ArrowRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { flFraunces } from '../fonts'; +import { photoLayerStyle } from '../lib/photoBg'; + +export interface M8FinalCTAProps { + className?: string; +} + +const MIST_FALLBACK_GRADIENT = + 'linear-gradient(180deg, rgba(20,53,39,0.72) 0%, rgba(20,53,39,0.9) 100%), radial-gradient(ellipse 70% 50% at 50% 30%, rgba(245,241,230,0.14), transparent 65%)'; + +export function M8FinalCTA({ className }: M8FinalCTAProps) { + return ( + <section + className={cn('relative overflow-hidden px-6 py-28 sm:py-36', className)} + style={photoLayerStyle({ src: '/marketing/first-light/photos/mist.jpg', fallbackGradient: MIST_FALLBACK_GRADIENT })} + > + <div className="fl-grain" aria-hidden="true" /> + <m.div + initial={{ opacity: 0, y: 16 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true, margin: '-15%' }} + transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }} + className="relative z-10 mx-auto max-w-2xl text-center" + > + <h2 className={cn(flFraunces.className, 'text-[clamp(2rem,4vw,3rem)] font-normal leading-[1.1] text-[var(--fl-ecru)]')}> + See your program, seen clearly. + </h2> + <p className="mt-4 text-body-lg text-[rgba(var(--fl-ecru-rgb),0.7)]"> + A short walkthrough for coaches. An invite code for players. + </p> + + <div className="mt-9 flex flex-col items-center gap-3 sm:flex-row sm:justify-center"> + <Link + href="/golf/demo" + className="group inline-flex items-center gap-2 rounded-full bg-[var(--fl-green)] px-7 py-3.5 text-sm font-semibold text-white shadow-lg shadow-black/20 transition-[transform,background-color] duration-200 hover:-translate-y-0.5 hover:bg-primary-500 active:translate-y-0" + > + See it in action + <ArrowRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" /> + </Link> + <Link + href="/golf/join" + className="fl-glass-1 inline-flex items-center gap-2 rounded-full px-7 py-3.5 text-sm font-medium text-[var(--fl-ecru)] transition-transform duration-200 hover:-translate-y-0.5 active:translate-y-0" + > + <span className="relative z-10">Join your team</span> + </Link> + </div> + </m.div> + </section> + ); +} diff --git a/src/components/marketing/first-light/moments/M9Footer.tsx b/src/components/marketing/first-light/moments/M9Footer.tsx new file mode 100644 index 000000000..de194c147 --- /dev/null +++ b/src/components/marketing/first-light/moments/M9Footer.tsx @@ -0,0 +1,114 @@ +/** + * M9 · FOOTER — pine field, both sports, both logins, real links. + * docs/LANDING_ENTRY_WORLD_DESIGN.md M9. Fixes the legacy footer's + * golf-first-only link set and any dead CTA — every link here resolves to + * a real route. Server component (no interactivity needed). + */ +import Link from 'next/link'; +import Image from 'next/image'; + +export interface M9FooterProps { + className?: string; +} + +const FOOTER_LINKS = { + products: [ + { name: 'GolfHelm', href: '/products#golfhelm' }, + { name: 'BaseballHelm', href: '/products#baseballhelm' }, + ], + login: [ + { name: 'Golf login', href: '/golf/login' }, + { name: 'Baseball login', href: '/baseball/login' }, + ], + signUp: [ + { name: 'Golf sign up', href: '/golf/signup' }, + { name: 'Baseball sign up', href: '/baseball/signup' }, + ], +} as const; + +export function M9Footer({ className }: M9FooterProps) { + return ( + <footer + className={className} + style={{ + backgroundColor: 'var(--fl-pine)', + backgroundImage: + 'radial-gradient(ellipse 80% 60% at 50% 40%, rgba(22,163,74,0.08), transparent)', + }} + > + <div + className="h-px w-full" + style={{ background: 'linear-gradient(90deg, transparent, rgba(var(--fl-brass-rgb), 0.4), transparent)' }} + /> + <div className="mx-auto max-w-6xl px-6 py-12 sm:py-16"> + <div className="grid grid-cols-1 gap-10 sm:grid-cols-[1.5fr_1fr_1fr]"> + <div> + <div className="flex items-center gap-3"> + <Image + src="/Helm-Logo-New-Main.png" + alt="Helm Sports Labs" + width={40} + height={40} + className="h-8 w-8 object-contain" + /> + <span className="text-base font-semibold tracking-tight text-[var(--fl-ecru)]"> + Helm Sports Labs + </span> + </div> + <p className="mt-3 max-w-xs text-body-sm leading-relaxed text-[rgba(var(--fl-ecru-rgb),0.55)]"> + One Helm. Two fields. The operating system for college programs — golf and baseball. + </p> + </div> + + <div> + <h4 className="text-eyebrow font-semibold uppercase tracking-[0.2em] text-[rgba(var(--fl-ecru-rgb),0.45)]"> + Products + </h4> + <ul className="mt-3 space-y-2.5"> + {FOOTER_LINKS.products.map((link) => ( + <li key={link.name}> + <Link + href={link.href} + className="text-body-sm text-[rgba(var(--fl-ecru-rgb),0.7)] transition-colors hover:text-[var(--fl-ecru)]" + > + {link.name} + </Link> + </li> + ))} + </ul> + </div> + + <div> + <h4 className="text-eyebrow font-semibold uppercase tracking-[0.2em] text-[rgba(var(--fl-ecru-rgb),0.45)]"> + Account + </h4> + <ul className="mt-3 space-y-2.5"> + {[...FOOTER_LINKS.login, ...FOOTER_LINKS.signUp].map((link) => ( + <li key={link.name}> + <Link + href={link.href} + className="text-body-sm text-[rgba(var(--fl-ecru-rgb),0.7)] transition-colors hover:text-[var(--fl-ecru)]" + > + {link.name} + </Link> + </li> + ))} + </ul> + </div> + </div> + + <div className="mt-10 flex flex-col items-center justify-between gap-3 border-t border-[rgba(var(--fl-ecru-rgb),0.1)] pt-6 text-caption text-[rgba(var(--fl-ecru-rgb),0.4)] sm:flex-row"> + <p>© {new Date().getFullYear()} Helm Sports Labs. All rights reserved.</p> + <div className="flex items-center gap-5"> + <Link href="/privacy" className="transition-colors hover:text-[rgba(var(--fl-ecru-rgb),0.7)]"> + Privacy + </Link> + <Link href="/terms" className="transition-colors hover:text-[rgba(var(--fl-ecru-rgb),0.7)]"> + Terms + </Link> + </div> + </div> + </div> + </footer> + ); +} diff --git a/src/components/marketing/first-light/scroll/LenisRoot.tsx b/src/components/marketing/first-light/scroll/LenisRoot.tsx new file mode 100644 index 000000000..5ed95b709 --- /dev/null +++ b/src/components/marketing/first-light/scroll/LenisRoot.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useEffect, useState, type ReactNode } from 'react'; +import { ReactLenis } from 'lenis/react'; +import { LazyMotion, domAnimation } from 'framer-motion'; +import 'lenis/dist/lenis.css'; + +export interface LenisRootProps { + children: ReactNode; +} + +/** + * The First Light landing root provider: the ONE Lenis instance + * (docs/LANDING_ENTRY_WORLD_DESIGN.md decision #5) PLUS the ONE + * `LazyMotion` feature bundle every moment's `m.*` components need. + * + * `lerp: 0.09` sits in the spec's 0.08–0.1 sweet spot — the "weighted + * buttery glide." Disabled entirely (native scroll) on: + * - `prefers-reduced-motion: reduce` + * - coarse-pointer / touch devices (native momentum is already excellent + * and battery-friendly — see docs/redesign/.../03_apple_scroll_playbook.md §1.4) + * + * IMPORTANT — single-instance contract: mount this ONCE, at the landing + * page root (`src/app/page.tsx`). Never nest a second Lenis instance (or + * `useSmoothScroll` / `SmoothScroll`, the dashboard's separate hook) inside + * a subtree this wraps — two competing Lenis instances fight over the same + * scroll container and both feel broken. `useScrollProgress` / + * `PinnedScrub` below read the real `window.scrollY` Lenis writes, so they + * "just work" underneath this provider without any additional wiring. + * + * IMPORTANT — `LazyMotion` contract: every moment in this scaffold uses + * framer-motion's lightweight `m.*` components (not `motion.*`) — per the + * repo's own convention (see `src/app/golf/admin/_motion-provider.tsx`), + * `m.*` silently no-ops every animation prop (`whileInView`, `animate`, + * etc.) without a `LazyMotion` ancestor. This provider is that ancestor + * for the whole landing tree — don't add a second, competing + * `LazyMotion` inside an individual moment. + */ +export function LenisRoot({ children }: LenisRootProps) { + // Resolve on the client only — SSR/first paint always renders native + // scroll so there's no possibility of a Lenis flash-then-teardown. + const [lenisEnabled, setLenisEnabled] = useState(false); + + useEffect(() => { + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const coarsePointer = window.matchMedia('(pointer: coarse)').matches; + setLenisEnabled(!reduced && !coarsePointer); + }, []); + + const content = lenisEnabled ? ( + <ReactLenis + root + options={{ + lerp: 0.09, + smoothWheel: true, + syncTouch: false, + touchMultiplier: 1, + anchors: true, + }} + > + {children} + </ReactLenis> + ) : ( + children + ); + + return ( + <LazyMotion features={domAnimation} strict> + {content} + </LazyMotion> + ); +} diff --git a/src/components/marketing/first-light/scroll/MaskedReveal.tsx b/src/components/marketing/first-light/scroll/MaskedReveal.tsx new file mode 100644 index 000000000..af27b4228 --- /dev/null +++ b/src/components/marketing/first-light/scroll/MaskedReveal.tsx @@ -0,0 +1,174 @@ +'use client'; + +import type { ElementType, ReactNode } from 'react'; +import { m, useTransform, type MotionValue } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import { usePrefersReducedMotion } from './usePrefersReducedMotion'; + +/** The house cinematic-settle curve — matches `EASE_GLIDE` in + * `src/components/baseball/living-annual/motion.ts` (the repo's shared + * motion vocabulary; imported by value here to keep this module + * dependency-free of the baseball kit). */ +const EASE_GLIDE: [number, number, number, number] = [0.16, 1, 0.3, 1]; + +export interface MaskedRevealProps { + /** One entry per visual line — each gets its own overflow-hidden mask. */ + lines: ReactNode[]; + /** + * Scrub-link the reveal to a `PinnedScrub`/`useScrollProgress` progress + * value instead of viewport-entry. When provided, each line's write-in + * window is an even slice of `[0, revealBy]` (see that prop). Omit for + * the default `whileInView` one-shot entrance. + */ + progress?: MotionValue<number>; + /** Wrapper element — `h1`/`h2`/`p`/`div`. Default `div`. */ + as?: ElementType; + className?: string; + lineClassName?: string; + /** `whileInView` mode only: base delay in seconds before the first line. */ + delay?: number; + /** `whileInView` mode only: seconds between each line's start. */ + stagger?: number; + /** + * Scrub mode only: fraction of `progress` (0–1) by which every line + * should have finished writing in. Default `0.6` — the write-in + * completes in the first 60% of the scrub window, leaving room for + * whatever comes next in the same pinned sequence. + */ + revealBy?: number; +} + +/** + * Serif line write-in — overflow-hidden line masks, translateY reveal, + * stagger (docs/LANDING_ENTRY_WORLD_DESIGN.md M2/M3 captions). Reads as a + * pen finishing each line in turn. + * + * `prefers-reduced-motion` → every line renders in its final, visible + * position instantly (no mask animation). + */ +export function MaskedReveal({ + lines, + progress, + as: As = 'div', + className, + lineClassName, + delay = 0, + stagger = 0.09, + revealBy = 0.6, +}: MaskedRevealProps) { + const reduced = usePrefersReducedMotion(); + + if (reduced) { + return ( + <As className={className}> + {lines.map((line, i) => ( + <span key={i} className={cn('block', lineClassName)}> + {line} + </span> + ))} + </As> + ); + } + + return ( + <As className={className}> + {lines.map((line, i) => + progress ? ( + <MaskedLineScrub + key={i} + index={i} + total={lines.length} + progress={progress} + revealBy={revealBy} + className={lineClassName} + > + {line} + </MaskedLineScrub> + ) : ( + <MaskedLineInView + key={i} + index={i} + delay={delay} + stagger={stagger} + className={lineClassName} + > + {line} + </MaskedLineInView> + ), + )} + </As> + ); +} + +interface MaskedLineBaseProps { + children: ReactNode; + className?: string; +} + +function MaskedLineScrub({ + children, + index, + total, + progress, + revealBy, + className, +}: MaskedLineBaseProps & { + index: number; + total: number; + progress: MotionValue<number>; + revealBy: number; +}) { + const windowSize = revealBy / Math.max(total, 1); + const start = index * windowSize; + const end = Math.min(revealBy, start + windowSize * 1.5); + const y = useTransform(progress, [start, end], ['110%', '0%']); + const opacity = useTransform(progress, [start, end], [0, 1]); + + return ( + <span className={cn('fl-line-mask', className)}> + <m.span style={{ y, opacity }} className="block will-change-transform"> + {children} + </m.span> + </span> + ); +} + +function MaskedLineInView({ + children, + index, + delay, + stagger, + className, +}: MaskedLineBaseProps & { index: number; delay: number; stagger: number }) { + // IMPORTANT: `whileInView` lives on this OUTER, never-transformed wrapper + // (not the inner span that actually moves) — IntersectionObserver + // computes intersection post-clip by ancestor `overflow: hidden`, so an + // element sitting at its own INITIAL translateY(110%) is self-clipped by + // this wrapper's `overflow: hidden` and would never geometrically + // intersect, deadlocking the reveal (it can only become visible once the + // animation fires, and the animation can only fire once it's visible). + // Observing the stable wrapper and propagating "hidden"/"visible" down + // to the inner span via matching `variants` sidesteps that paradox. + return ( + <m.span + className={cn('fl-line-mask', className)} + initial="hidden" + whileInView="visible" + viewport={{ once: true, margin: '-15%' }} + > + <m.span + variants={{ + hidden: { y: '110%', opacity: 0 }, + visible: { + y: '0%', + opacity: 1, + transition: { duration: 0.7, ease: EASE_GLIDE, delay: delay + index * stagger }, + }, + }} + className="block will-change-transform" + > + {children} + </m.span> + </m.span> + ); +} diff --git a/src/components/marketing/first-light/scroll/PinnedScrub.tsx b/src/components/marketing/first-light/scroll/PinnedScrub.tsx new file mode 100644 index 000000000..dfb111929 --- /dev/null +++ b/src/components/marketing/first-light/scroll/PinnedScrub.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { type ReactNode } from 'react'; +import { useMotionValue, type MotionValue } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import { useScrollProgress } from './useScrollProgress'; +import { usePrefersReducedMotion } from './usePrefersReducedMotion'; + +export interface PinnedScrubProps { + /** + * Total scroll height (as vh) driving the scrub — the spec's M3 uses + * ~250vh (docs/LANDING_ENTRY_WORLD_DESIGN.md M3). Default 250. + */ + heightVh?: number; + /** Classes on the tall outer spacer. */ + className?: string; + /** Classes on the `position: sticky` inner viewport. */ + innerClassName?: string; + /** + * Render-prop — receives the 0→1 scrub progress as a `MotionValue` so + * children stay transform/opacity-only consumers (feed straight into + * `useTransform`). Under `prefers-reduced-motion`, this receives a + * static `MotionValue` pinned at `1` (final state) and the section + * renders unpinned/unscrubbed — see the component doc below. + */ + children: (progress: MotionValue<number>) => ReactNode; +} + +/** + * `height: Nvh` outer spacer + `position: sticky` inner viewport — the + * Motion-native pinned-scrub primitive (docs/redesign/.../ + * 03_apple_scroll_playbook.md §2b / §3i). No GSAP needed for the common + * case: `useScrollProgress`'s `['start start', 'end end']` window scrubs + * `children(progress)` for the full pinned duration. + * + * `prefers-reduced-motion`: renders the section as a normal (non-pinned, + * non-tall) `100dvh` block and calls `children` with a MotionValue frozen + * at `1` — so a scrub sequence's "final state" (all screens landed, full + * lights on, etc.) renders once, statically, with zero motion. This is + * the primitive-level half of the CLAUDE.md motion rule; the moment + * consuming `PinnedScrub` should design its `children` render so `progress + * === 1` reads as a complete, coherent frame — not a mid-scrub glitch. + */ +export function PinnedScrub({ heightVh = 250, className, innerClassName, children }: PinnedScrubProps) { + const reduced = usePrefersReducedMotion(); + const { ref, progress } = useScrollProgress<HTMLDivElement>({ + offset: ['start start', 'end end'], + smooth: false, + }); + const staticProgress = useMotionValue(1); + + if (reduced) { + return ( + <div className={cn('relative', className)}> + <div className={cn('relative h-[100dvh]', innerClassName)}>{children(staticProgress)}</div> + </div> + ); + } + + return ( + <div ref={ref} style={{ height: `${heightVh}vh` }} className={cn('relative', className)}> + <div className={cn('sticky top-0 h-[100dvh] overflow-hidden', innerClassName)}> + {children(progress)} + </div> + </div> + ); +} diff --git a/src/components/marketing/first-light/scroll/usePrefersReducedMotion.ts b/src/components/marketing/first-light/scroll/usePrefersReducedMotion.ts new file mode 100644 index 000000000..43ae24f30 --- /dev/null +++ b/src/components/marketing/first-light/scroll/usePrefersReducedMotion.ts @@ -0,0 +1,47 @@ +'use client'; + +import { useSyncExternalStore } from 'react'; + +const QUERY = '(prefers-reduced-motion: reduce)'; + +function subscribe(callback: () => void): () => void { + const mql = window.matchMedia(QUERY); + mql.addEventListener('change', callback); + return () => mql.removeEventListener('change', callback); +} + +function getSnapshot(): boolean { + return window.matchMedia(QUERY).matches; +} + +function getServerSnapshot(): boolean { + return false; +} + +/** + * SSR-safe `prefers-reduced-motion` read + * (docs/redesign/.../03_apple_scroll_playbook.md §4.8's recommended + * pattern) — use this INSTEAD OF framer-motion's own `useReducedMotion()` + * in any component that branches DOM STRUCTURE (not just an animation + * prop value) based on the result. + * + * Why: framer-motion's `useReducedMotion()` resolves `matchMedia` + * synchronously on the very first render, client-side included — for a + * real user with reduced-motion enabled, that first CLIENT render + * disagrees with the server's motion-unaware render, and if a component + * uses the flag to pick between two DIFFERENT JSX trees (like + * `MaskedReveal`'s reduced-vs-animated branch), React logs a hydration + * mismatch (confirmed 2026-07-02 via Playwright's `reducedMotion: + * 'reduce'` emulation). `useSyncExternalStore` with a `getServerSnapshot` + * that returns `false` is React's own sanctioned fix: the server AND the + * first client render both resolve to `false` (structurally matching), + * then React swaps in the real client value immediately after hydration + * — no console error, and no build-up of the wrong DOM for more than a + * tick. Simple prop-only variation (e.g. `transition={reduced ? {duration:0} + * : {...}}` on an otherwise-identical `m.div`) doesn't have this problem + * and can keep using framer-motion's `useReducedMotion()` directly (see + * the repo-wide convention in `/golf/join`, `/golf/(auth)/demo`, etc.). + */ +export function usePrefersReducedMotion(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/src/components/marketing/first-light/scroll/useScrollProgress.ts b/src/components/marketing/first-light/scroll/useScrollProgress.ts new file mode 100644 index 000000000..8e680715b --- /dev/null +++ b/src/components/marketing/first-light/scroll/useScrollProgress.ts @@ -0,0 +1,138 @@ +'use client'; + +import { useEffect, useRef, type RefObject } from 'react'; +import { useMotionValue, useSpring, type MotionValue } from 'framer-motion'; + +/** + * A `['<target-edge> <viewport-edge>', '<target-edge> <viewport-edge>']` + * pair, e.g. `['start start', 'end end']` (classic pinned-scrub window) or + * `['start end', 'end start']` (classic full-pass parallax window) — + * matches the subset of framer-motion's `useScroll` offset syntax this + * hook supports (§ below explains why it's hand-rolled instead of + * delegating to `useScroll`). + */ +export type ScrollProgressOffset = [string, string]; + +export interface ScrollProgressOptions { + /** Defaults to `['start end', 'end start']` (full-pass window). */ + offset?: ScrollProgressOffset; + /** + * Wrap the raw progress in a `useSpring` so transforms feel weighted + * rather than 1:1 with the scrollbar — matches the Lenis "buttery + * glide" feel per docs/redesign/.../03_apple_scroll_playbook.md §2b. + * Default `true`. Pass `false` for frame-exact 1:1 scrub tracking. + */ + smooth?: boolean; +} + +export interface ScrollProgressResult<T extends HTMLElement> { + /** Attach to the section/container whose progress you want. */ + ref: RefObject<T | null>; + /** + * 0→1 progress, GPU-safe (a framer-motion `MotionValue`) — feed straight + * into `useTransform` for transform/opacity-only consumers. Already + * spring-smoothed unless `smooth: false` was passed. + */ + progress: MotionValue<number>; + /** The unsmoothed 0→1 value, in case a consumer needs exact scrub math. */ + rawProgress: MotionValue<number>; +} + +function parseEdgePair(pair: ScrollProgressOffset): [{ target: number; viewport: number }, { target: number; viewport: number }] { + const parseOne = (s: string) => { + const [targetEdge, viewportEdge] = s.trim().split(/\s+/); + return { + target: targetEdge === 'end' ? 1 : 0, + viewport: viewportEdge === 'end' ? 1 : 0, + }; + }; + return [parseOne(pair[0]), parseOne(pair[1])]; +} + +/** + * Returns 0..1 scroll progress for a ref'd section, reading the real + * scroll position Lenis writes (see {@link LenisRoot}). Built for + * transform-only consumers: pass `progress` into `useTransform`, never + * into a layout-affecting style. + * + * IMPLEMENTATION NOTE — hand-rolled instead of framer-motion's + * `useScroll({ target, offset })`: Motion v12 opportunistically + * "accelerates" `target`+`offset` scroll progress onto the browser's + * native `ScrollTimeline`/`ViewTimeline` when the offset matches one of + * its four presets (`['start start','end end']` — this hook's pinned- + * scrub case — matches the "All"/`contain` preset). That acceleration + * bypasses the normal `MotionValue.set()` flow the raw value's `.get()` + * relies on; verified in this repo (2026-07-02, Chromium via Playwright) + * that a `useTransform` chain consuming that accelerated value THROUGH A + * CHILD COMPONENT (progress passed as a prop, one or more component + * boundaries away from the `useScroll` call — exactly `PinnedScrub`'s + * render-prop shape) freezes at its initial computed value and never + * updates on subsequent scroll, even via real mouse-wheel input. A + * same-component `useTransform` right next to `useScroll` (no prop + * boundary) was NOT observed to freeze, so this may be narrow to the + * cross-component case — but every current and anticipated consumer of + * this hook (`PinnedScrub` above all) passes `progress` down through + * child components, so a plain `window.scroll` listener + `getBoundingClientRect` + * (this implementation) sidesteps the whole acceleration path and is + * the reliable default. If a future framer-motion upgrade fixes the + * underlying issue, this can revert to `useScroll` — re-verify with the + * same repro (PinnedScrub → child `useTransform`, real wheel scroll) + * before doing so. + */ +export function useScrollProgress<T extends HTMLElement = HTMLDivElement>( + options: ScrollProgressOptions = {}, +): ScrollProgressResult<T> { + const { offset = ['start end', 'end start'], smooth = true } = options; + const ref = useRef<T>(null); + const raw = useMotionValue(0); + const smoothed = useSpring(raw, { stiffness: 120, damping: 30, restDelta: 0.001 }); + + useEffect(() => { + const el = ref.current; + if (typeof window === 'undefined' || !el) return; + + const [a, b] = parseEdgePair(offset); + let rafId = 0; + let pending = false; + + function measure() { + pending = false; + const rect = el!.getBoundingClientRect(); + const vh = window.innerHeight; + const docTop = rect.top + window.scrollY; + const height = rect.height; + const yAt = (edge: { target: number; viewport: number }) => + docTop + edge.target * height - edge.viewport * vh; + const startY = yAt(a); + const endY = yAt(b); + const denom = endY - startY; + const p = denom === 0 ? 0 : (window.scrollY - startY) / denom; + raw.set(Math.min(1, Math.max(0, p))); + } + + function schedule() { + if (pending) return; + pending = true; + rafId = requestAnimationFrame(measure); + } + + measure(); + window.addEventListener('scroll', schedule, { passive: true }); + window.addEventListener('resize', schedule); + return () => { + window.removeEventListener('scroll', schedule); + window.removeEventListener('resize', schedule); + if (rafId) cancelAnimationFrame(rafId); + }; + // `offset` is a small literal tuple recreated per-render at most call + // sites — compare by value, not identity, to avoid tearing down/ + // rebuilding the listener every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [offset[0], offset[1], raw]); + + return { + ref, + progress: smooth ? smoothed : raw, + rawProgress: raw, + }; +}