From 13bf6bdaae5306a0c3569bc47d83e7b057a475eb Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Thu, 2 Jul 2026 12:27:23 -0400 Subject: [PATCH 01/11] =?UTF-8?q?feat(landing):=20First=20Light=20foundati?= =?UTF-8?q?on=20=E2=80=94=20scaffold,=20glass=20grammar,=20scroll=20primit?= =?UTF-8?q?ives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the shared First Light landing scaffold (docs/LANDING_ENTRY_WORLD_DESIGN.md) every downstream lane composes on: palette + 3-grade glass grammar, Lenis scroll root, scroll-progress/pinned-scrub/masked-reveal primitives, and nine M1–M9 moment stubs wired into a coherent draft page. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CHFRrZkRsAHWTwvxhySkfe --- .../marketing/first-light/photos/baseball.jpg | Bin 0 -> 31144 bytes public/marketing/first-light/photos/golf.jpg | Bin 0 -> 31823 bytes public/marketing/first-light/photos/hero.jpg | Bin 0 -> 30741 bytes public/marketing/first-light/photos/mist.jpg | Bin 0 -> 32455 bytes src/app/page.tsx | 44 +++- .../marketing/first-light/CONTRACTS.md | 249 ++++++++++++++++++ .../marketing/first-light/first-light.css | 181 +++++++++++++ src/components/marketing/first-light/fonts.ts | 25 ++ src/components/marketing/first-light/index.ts | 43 +++ .../marketing/first-light/lib/photoBg.ts | 37 +++ .../marketing/first-light/moments/M1Hero.tsx | 151 +++++++++++ .../first-light/moments/M2Clarity.tsx | 70 +++++ .../first-light/moments/M3ProductCinema.tsx | 184 +++++++++++++ .../first-light/moments/M4TwoFields.tsx | 94 +++++++ .../first-light/moments/M5Intelligence.tsx | 77 ++++++ .../first-light/moments/M6ForThePlayer.tsx | 69 +++++ .../first-light/moments/M7Honesty.tsx | 74 ++++++ .../first-light/moments/M8FinalCTA.tsx | 62 +++++ .../first-light/moments/M9Footer.tsx | 114 ++++++++ .../first-light/scroll/LenisRoot.tsx | 72 +++++ .../first-light/scroll/MaskedReveal.tsx | 174 ++++++++++++ .../first-light/scroll/PinnedScrub.tsx | 67 +++++ .../scroll/usePrefersReducedMotion.ts | 47 ++++ .../first-light/scroll/useScrollProgress.ts | 138 ++++++++++ 24 files changed, 1963 insertions(+), 9 deletions(-) create mode 100644 public/marketing/first-light/photos/baseball.jpg create mode 100644 public/marketing/first-light/photos/golf.jpg create mode 100644 public/marketing/first-light/photos/hero.jpg create mode 100644 public/marketing/first-light/photos/mist.jpg create mode 100644 src/components/marketing/first-light/CONTRACTS.md create mode 100644 src/components/marketing/first-light/first-light.css create mode 100644 src/components/marketing/first-light/fonts.ts create mode 100644 src/components/marketing/first-light/index.ts create mode 100644 src/components/marketing/first-light/lib/photoBg.ts create mode 100644 src/components/marketing/first-light/moments/M1Hero.tsx create mode 100644 src/components/marketing/first-light/moments/M2Clarity.tsx create mode 100644 src/components/marketing/first-light/moments/M3ProductCinema.tsx create mode 100644 src/components/marketing/first-light/moments/M4TwoFields.tsx create mode 100644 src/components/marketing/first-light/moments/M5Intelligence.tsx create mode 100644 src/components/marketing/first-light/moments/M6ForThePlayer.tsx create mode 100644 src/components/marketing/first-light/moments/M7Honesty.tsx create mode 100644 src/components/marketing/first-light/moments/M8FinalCTA.tsx create mode 100644 src/components/marketing/first-light/moments/M9Footer.tsx create mode 100644 src/components/marketing/first-light/scroll/LenisRoot.tsx create mode 100644 src/components/marketing/first-light/scroll/MaskedReveal.tsx create mode 100644 src/components/marketing/first-light/scroll/PinnedScrub.tsx create mode 100644 src/components/marketing/first-light/scroll/usePrefersReducedMotion.ts create mode 100644 src/components/marketing/first-light/scroll/useScrollProgress.ts diff --git a/public/marketing/first-light/photos/baseball.jpg b/public/marketing/first-light/photos/baseball.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e98363e2c89a2e9bce252c5bc6501d97a2b63bf5 GIT binary patch literal 31144 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 HrRV++wW@x3 literal 0 HcmV?d00001 diff --git a/public/marketing/first-light/photos/hero.jpg b/public/marketing/first-light/photos/hero.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e0f6c51f4feba43b3c2980c087d7649da96cadbc GIT binary patch literal 30741 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 Qef=A}KaVlk@#^>f7kjq-cmMzZ literal 0 HcmV?d00001 diff --git a/public/marketing/first-light/photos/mist.jpg b/public/marketing/first-light/photos/mist.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4651135f38efe63753ded2831bb5ca57c283fb87 GIT binary patch literal 32455 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 literal 0 HcmV?d00001 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..81a2755ef --- /dev/null +++ b/src/components/marketing/first-light/CONTRACTS.md @@ -0,0 +1,249 @@ +# 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 (CSS custom properties, `first-light.css` `:root`) + +| Var | Hex | Use | +|---|---|---| +| `--fl-ecru` | `#F5F1E6` | Ecru/bone field | +| `--fl-pine` | `#143527` | Deep pine bands | +| `--fl-clay` | `#B0703C` | Warm clay accent (baseball-adjacent) | +| `--fl-green` | `#16A34A` | The ONLY saturated accent — CTAs + live signals. Matches Tailwind `primary-600`. | +| `--fl-brass` | `#B08D57` | Glass-edge hairlines | + +Each also has a `-rgb` sibling (e.g. `--fl-pine-rgb: 20, 53, 39`) for +`rgba(var(--fl-pine-rgb), 0.5)` compositing. + +## 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"** (kelly green, primary) → `/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/first-light.css b/src/components/marketing/first-light/first-light.css new file mode 100644 index 000000000..fde039b26 --- /dev/null +++ b/src/components/marketing/first-light/first-light.css @@ -0,0 +1,181 @@ +/** + * 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. + */ + +:root { + /* ── Palette (the bridge — spec #2) ───────────────────────────────── */ + --fl-ecru: #f5f1e6; + --fl-ecru-rgb: 245, 241, 230; + --fl-pine: #143527; + --fl-pine-rgb: 20, 53, 39; + --fl-clay: #b0703c; + --fl-clay-rgb: 176, 112, 60; + /* Kelly green — the ONLY saturated accent (CTAs + live signals). Mirrors + Tailwind's primary-600 (#16A34A) so first-light CTAs and app chrome + read as the same green. */ + --fl-green: #16a34a; + --fl-green-rgb: 22, 163, 74; + --fl-brass: #b08d57; + --fl-brass-rgb: 176, 141, 87; +} + +/* --------------------------------------------------------------------- + * 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; + mix-blend-mode: overlay; + background-repeat: repeat; + background-image: url("data:image/svg+xml;utf8,"); +} + +@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,"); +} + +/* 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; + } +} 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..dba278f28 --- /dev/null +++ b/src/components/marketing/first-light/index.ts @@ -0,0 +1,43 @@ +/** + * 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'; + +// 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 ``/`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({ + 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 ( +
+ {/* Background register — graded photo over a pine gradient fallback */} +
+ ); +} 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..62a1b18f0 --- /dev/null +++ b/src/components/marketing/first-light/moments/M2Clarity.tsx @@ -0,0 +1,70 @@ +'use client'; + +/** + * M2 · CLARITY — the what-is-it moment. docs/LANDING_ENTRY_WORLD_DESIGN.md + * M2. Background register: ecru editorial (flat `var(--fl-ecru)`). + * + * One serif statement (masked line write-in, viewport-triggered) + three + * hairline-ruled ledger lines — not cards. Graphite numerals / green rules, + * the app's own idiom, pre-echoed on the marketing surface. + */ +import { cn } from '@/lib/utils'; +import { MaskedReveal } from '../scroll/MaskedReveal'; +import { flFraunces } from '../fonts'; +import { m } from 'framer-motion'; + +export interface M2ClarityProps { + className?: string; +} + +const LEDGER_LINES = [ + { label: 'Run the program', detail: 'Roster, calendar, tasks, travel — one shared source of truth.' }, + { label: 'See every number', detail: 'Strokes-gained, live stat lines, and readiness tracked automatically.' }, + { label: 'Know what matters', detail: 'CoachHelm surfaces the one thing worth a conversation this week.' }, +] as const; + +export function M2Clarity({ className }: M2ClarityProps) { + return ( +
+
+ + +
+ {LEDGER_LINES.map((line, i) => ( + 0 ? { borderTop: '1px solid rgba(var(--fl-brass-rgb), 0.4)' } : undefined} + > +
+
+ + {line.label} + + {line.detail} +
+ + ))} +
+
+
+ ); +} 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 ( +
+
+ ); +} + +function ProductFrame({ progress }: { progress: MotionValue }) { + const total = SCREENS.length; + + return ( +
+
+ + Inside Helm + +
+ + {/* G2 glass desktop frame — holds center for the whole scrub */} +
+
+ {SCREENS.map((screen, i) => ( + + ))} +
+
+ + {/* Captions — scrub-linked, one visible per screen window */} +
+ {SCREENS.map((screen, i) => ( + + ))} +
+ + {/* Phone frame — arcs in from the right at the midpoint (Lift Lab) */} + +
+ ); +} + +function ScreenPanel({ + index, + total, + progress, + screen, +}: { + index: number; + total: number; + progress: MotionValue; + 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 ( + + + {screen.label} + + + ); +} + +function CaptionLine({ + index, + total, + progress, + caption, +}: { + index: number; + total: number; + progress: MotionValue; + 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 ( + + + {caption} + + + ); +} + +function PhoneArc({ progress }: { progress: MotionValue }) { + // 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 ( + + ); +} 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(null); + + return ( +
+
+ + One Helm. Two fields. + +
+
+ {FIELDS.map((field) => { + const dimmed = hovered !== null && hovered !== field.key; + return ( + 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)' }} + > + +
+
+

+ {field.title} +

+

{field.line}

+ + Enter + + +
+
+ + ); + })} +
+
+ ); +} 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..2e143aa29 --- /dev/null +++ b/src/components/marketing/first-light/moments/M5Intelligence.tsx @@ -0,0 +1,77 @@ +'use client'; + +/** + * M5 · THE INTELLIGENCE — CoachHelm/signals showcase. + * docs/LANDING_ENTRY_WORLD_DESIGN.md M5. Background register: + * ecru → pine gradient. One real signal card rendered large in glass — + * source drawer, confidence score, limitation. The honesty IS the flex. + */ +import { m } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import { flFraunces } from '../fonts'; + +export interface M5IntelligenceProps { + className?: string; +} + +export function M5Intelligence({ className }: M5IntelligenceProps) { + return ( +
+
+ + The Intelligence + +

+ No signal ships without its source. +

+
+ + +
+
+
+ + Coach Insight + +

+ Approach proximity from 175–200yd has drifted 4.2ft long over the last 3 rounds. +

+
+ + High confidence + +
+ +
+
+ + Source + +

3 rounds · strokes-gained approach, 175–200yd bucket

+
+
+ + Limitation + +

Small sample — 3 rounds. Confidence rises after 5+.

+
+
+
+
+
+ ); +} 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..d083167a3 --- /dev/null +++ b/src/components/marketing/first-light/moments/M6ForThePlayer.tsx @@ -0,0 +1,69 @@ +'use client'; + +/** + * M6 · FOR THE PLAYER — the player as audience, not afterthought. + * docs/LANDING_ENTRY_WORLD_DESIGN.md M6. Background register: warm (clay). + * Three small glass vignettes: passport completeness, Lift Lab check-in, + * readiness. Copy speaks to the athlete, not the buyer. + */ +import { m } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import { flFraunces } from '../fonts'; + +export interface M6ForThePlayerProps { + className?: string; +} + +const VIGNETTES = [ + { + title: 'Your passport', + line: 'Stats, highlights, and your story — always current, always yours to share.', + }, + { + title: 'Lift Lab check-in', + line: 'Log the work. The bar tracks your progress so you don’t have to.', + }, + { + title: 'Readiness', + line: 'A daily read on how you’re showing up — for you and your coach.', + }, +]; + +export function M6ForThePlayer({ className }: M6ForThePlayerProps) { + return ( +
+
+ + For the Player + +

+ You’re not just on the roster. +

+
+ +
+ {VIGNETTES.map((v, i) => ( + +
+

{v.title}

+

{v.line}

+
+
+ ))} +
+
+ ); +} 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..2ec73d381 --- /dev/null +++ b/src/components/marketing/first-light/moments/M7Honesty.tsx @@ -0,0 +1,74 @@ +'use client'; + +/** + * M7 · HONESTY BAND — pre-revenue social proof, done honestly. + * docs/LANDING_ENTRY_WORLD_DESIGN.md M7. Background register: ecru. + * No fake logos. The founder line, what Helm refuses to claim, a live- + * product stat or two (StatRoll on first entry via `AnimatedNumber`). + */ +import { m } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import { flFraunces } from '../fonts'; +import { AnimatedNumber } from '@/components/ui/animated-number'; + +export interface M7HonestyProps { + className?: string; +} + +const STATS = [{ value: 2, suffix: '', label: 'Sports live today: golf + baseball' }] as const; + +export function M7Honesty({ className }: M7HonestyProps) { + return ( +
+
+ + Built by players + + + Built by two former collegiate athletes who got tired of running a program on spreadsheets and group texts. + + + 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. + +
+ +
+ {STATS.map((stat, i) => ( + + +

{stat.label}

+
+ ))} +
+ Every insight cites its source, confidence, and limitation — no exceptions. +
+
+
+ ); +} 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 ( +
+
+ ); +} 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 ( +
+
+
+
+
+
+ Helm Sports Labs + + Helm Sports Labs + +
+

+ One Helm. Two fields. The operating system for college programs — golf and baseball. +

+
+ +
+

+ Products +

+
    + {FOOTER_LINKS.products.map((link) => ( +
  • + + {link.name} + +
  • + ))} +
+
+ +
+

+ Account +

+
    + {[...FOOTER_LINKS.login, ...FOOTER_LINKS.signUp].map((link) => ( +
  • + + {link.name} + +
  • + ))} +
+
+
+ +
+

© {new Date().getFullYear()} Helm Sports Labs. All rights reserved.

+
+ + Privacy + + + Terms + +
+
+
+
+ ); +} 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 ? ( + + {children} + + ) : ( + children + ); + + return ( + + {content} + + ); +} 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; + /** 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 ( + + {lines.map((line, i) => ( + + {line} + + ))} + + ); + } + + return ( + + {lines.map((line, i) => + progress ? ( + + {line} + + ) : ( + + {line} + + ), + )} + + ); +} + +interface MaskedLineBaseProps { + children: ReactNode; + className?: string; +} + +function MaskedLineScrub({ + children, + index, + total, + progress, + revealBy, + className, +}: MaskedLineBaseProps & { + index: number; + total: number; + progress: MotionValue; + 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 ( + + + {children} + + + ); +} + +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 ( + + + {children} + + + ); +} 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) => 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({ + offset: ['start start', 'end end'], + smooth: false, + }); + const staticProgress = useMotionValue(1); + + if (reduced) { + return ( +
+
{children(staticProgress)}
+
+ ); + } + + return ( +
+
+ {children(progress)} +
+
+ ); +} 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 `[' ', ' ']` + * 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 { + /** Attach to the section/container whose progress you want. */ + ref: RefObject; + /** + * 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; + /** The unsmoothed 0→1 value, in case a consumer needs exact scrub math. */ + rawProgress: MotionValue; +} + +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( + options: ScrollProgressOptions = {}, +): ScrollProgressResult { + const { offset = ['start end', 'end start'], smooth = true } = options; + const ref = useRef(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, + }; +} From ad25e8c53a85fb4e9df63afaafef5206c1657679 Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Thu, 2 Jul 2026 14:07:13 -0400 Subject: [PATCH 02/11] =?UTF-8?q?feat(landing):=20sage=20&=20cream=20palet?= =?UTF-8?q?te=20respec=20=E2=80=94=20token=20repoint=20+=20sage=20family?= =?UTF-8?q?=20(Nick=202026-07-02)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repoints --fl-pine -> #2E3A2C (sage-ink) and --fl-green -> #5C6E58 (sage-deep) so in-flight lane work recolors automatically, and adds first-class --fl-cream/--fl-cream-high/--fl-sage-mist/--fl-sage/ --fl-sage-deep/--fl-sage-ink tokens (+ rgb twins) per the SAGE & CREAM amendment in docs/LANDING_ENTRY_WORLD_DESIGN.md. Kelly #16A34A is demoted to product-only (real screenshots + M5 signal-card replica) — never on landing/auth chrome. Updates CONTRACTS.md's palette section to the v2 table, the pine->sage-ink / kelly->sage-deep read-mapping, and the daylight-mood sentence. Swept the foundation's shared files (fonts.ts, lib/, scroll/*, index.ts, page.tsx) for hardcoded #143527/#16A34A: none found, nothing to move. One hardcoded #143527 remains in moments/M3ProductCinema.tsx (the landing-cinema lane's owned stub, out of this lane's ownership) — left in place for that lane to pick up when it replaces the stub. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RVnwXMnsgREVfjpGSAJPvT --- .../marketing/first-light/CONTRACTS.md | 63 +++++++++++++++---- .../marketing/first-light/first-light.css | 57 ++++++++++++++--- 2 files changed, 101 insertions(+), 19 deletions(-) diff --git a/src/components/marketing/first-light/CONTRACTS.md b/src/components/marketing/first-light/CONTRACTS.md index 81a2755ef..e8c461afc 100644 --- a/src/components/marketing/first-light/CONTRACTS.md +++ b/src/components/marketing/first-light/CONTRACTS.md @@ -83,18 +83,58 @@ second one; it's a cheap layer but doesn't need duplicating). `background` inline for a specific ink (M2 does this for its green ledger rules). -## Palette (CSS custom properties, `first-light.css` `:root`) +## 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` | `#F5F1E6` | Ecru/bone field | -| `--fl-pine` | `#143527` | Deep pine bands | -| `--fl-clay` | `#B0703C` | Warm clay accent (baseball-adjacent) | -| `--fl-green` | `#16A34A` | The ONLY saturated accent — CTAs + live signals. Matches Tailwind `primary-600`. | -| `--fl-brass` | `#B08D57` | Glass-edge hairlines | - -Each also has a `-rgb` sibling (e.g. `--fl-pine-rgb: 20, 53, 39`) for -`rgba(var(--fl-pine-rgb), 0.5)` compositing. +| `--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. ## Fonts (`fonts.ts`) @@ -225,8 +265,9 @@ container, `object-cover`). ## CTA architecture (already wired in the stubs — keep these targets or update deliberately) -- **"See it in action"** (kelly green, primary) → `/golf/demo` (the real, - live self-serve product demo — not a dead button). +- **"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`. diff --git a/src/components/marketing/first-light/first-light.css b/src/components/marketing/first-light/first-light.css index fde039b26..7aa90be6e 100644 --- a/src/components/marketing/first-light/first-light.css +++ b/src/components/marketing/first-light/first-light.css @@ -22,21 +22,62 @@ * `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) ───────────────────────────────── */ + /* ── Palette (the bridge — spec #2, sage & cream respec above) ─────── */ --fl-ecru: #f5f1e6; --fl-ecru-rgb: 245, 241, 230; - --fl-pine: #143527; - --fl-pine-rgb: 20, 53, 39; + /* 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; - /* Kelly green — the ONLY saturated accent (CTAs + live signals). Mirrors - Tailwind's primary-600 (#16A34A) so first-light CTAs and app chrome - read as the same green. */ - --fl-green: #16a34a; - --fl-green-rgb: 22, 163, 74; + /* 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; } /* --------------------------------------------------------------------- From efe080385b6c088dca01f4d861145d6780a918ca Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Thu, 2 Jul 2026 14:08:14 -0400 Subject: [PATCH 03/11] wip: inherited partial work (pre-sage) --- next-env.d.ts | 2 +- .../first-light/moments/M2Clarity.tsx | 52 ++++++-- .../first-light/moments/M5Intelligence.tsx | 125 ++++++++++++++---- .../first-light/moments/M6ForThePlayer.tsx | 69 +++++++++- .../first-light/moments/M7Honesty.tsx | 60 +++++---- 5 files changed, 242 insertions(+), 66 deletions(-) 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/src/components/marketing/first-light/moments/M2Clarity.tsx b/src/components/marketing/first-light/moments/M2Clarity.tsx index 62a1b18f0..91349caf4 100644 --- a/src/components/marketing/first-light/moments/M2Clarity.tsx +++ b/src/components/marketing/first-light/moments/M2Clarity.tsx @@ -4,26 +4,48 @@ * M2 · CLARITY — the what-is-it moment. docs/LANDING_ENTRY_WORLD_DESIGN.md * M2. Background register: ecru editorial (flat `var(--fl-ecru)`). * - * One serif statement (masked line write-in, viewport-triggered) + three - * hairline-ruled ledger lines — not cards. Graphite numerals / green rules, - * the app's own idiom, pre-echoed on the marketing surface. + * 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. + * Graphite ordinal numerals (font-annual/Space Grotesk, the app's UI + * register) + green rules, the app's own idiom, pre-echoed on the + * marketing surface. */ import { cn } from '@/lib/utils'; import { MaskedReveal } from '../scroll/MaskedReveal'; import { flFraunces } from '../fonts'; -import { m } from 'framer-motion'; +import { m, useReducedMotion } from 'framer-motion'; export interface M2ClarityProps { className?: string; } const LEDGER_LINES = [ - { label: 'Run the program', detail: 'Roster, calendar, tasks, travel — one shared source of truth.' }, - { label: 'See every number', detail: 'Strokes-gained, live stat lines, and readiness tracked automatically.' }, - { label: 'Know what matters', detail: 'CoachHelm surfaces the one thing worth a conversation this week.' }, + { + 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 (
0 ? { borderTop: '1px solid rgba(var(--fl-brass-rgb), 0.4)' } : undefined} > -
+
+ +
+
{line.label} diff --git a/src/components/marketing/first-light/moments/M5Intelligence.tsx b/src/components/marketing/first-light/moments/M5Intelligence.tsx index 2e143aa29..c53c74449 100644 --- a/src/components/marketing/first-light/moments/M5Intelligence.tsx +++ b/src/components/marketing/first-light/moments/M5Intelligence.tsx @@ -5,16 +5,36 @@ * docs/LANDING_ENTRY_WORLD_DESIGN.md M5. Background register: * ecru → pine gradient. One real signal card rendered large in glass — * source drawer, confidence score, limitation. The honesty IS the flex. + * + * A faithful static replica of the real CoachHelm insight idiom (see + * `src/components/golf/coachhelm/insight-card/InsightCard.tsx`'s "hero" + * density + `src/components/golf/coachhelm/insights/EvidencePanel.tsx`'s + * expanded grid): tone accent bar, confidence badge + progress bar + * (green ≥70% — mirrors `EvidencePanel`'s `confidenceColor` thresholds), + * a Source line and a Limitation line sitting open (not behind a click — + * the whole point is nothing is hidden). The confidence bar and the + * strokes-off number roll in via `AnimatedNumber` on first viewport entry. */ -import { m } from 'framer-motion'; +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 { 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(null); + const inView = useInView(cardRef, { once: true, amount: 0.5 }); + const confPct = Math.round(CONFIDENCE * 100); + return (
- + The Intelligence

@@ -32,42 +52,93 @@ export function M5Intelligence({ className }: M5IntelligenceProps) {

-
-
- - Coach Insight +