diff --git a/.claude/launch.json b/.claude/launch.json index 7e34cf155..b20a1f5da 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,7 +5,8 @@ "name": "web", "runtimeExecutable": "pnpm", "runtimeArgs": ["dev"], - "port": 3000 + "port": 3000, + "autoPort": true }, { "name": "db-studio", diff --git a/src/components/ds/ds-nav.ts b/src/components/ds/ds-nav.ts index f36c263d2..89eb60768 100644 --- a/src/components/ds/ds-nav.ts +++ b/src/components/ds/ds-nav.ts @@ -13,13 +13,15 @@ export interface DsNavSection { * * This is the single source of truth for the sidebar. Adding a new page is a * two-step change: drop a `ds..tsx` route in `src/routes` and add an - * entry here. Keep `Styles` (design tokens) above `Components` (rendered UI). + * entry here. Keep `Brand & Styles` (tokens & assets) above `Components` + * (rendered UI). */ export const dsNav: Array = [ { - title: 'Styles', + title: 'Brand & Styles', items: [ { label: 'Overview', to: '/ds' }, + { label: 'Logos', to: '/ds/logos' }, { label: 'Colors', to: '/ds/colors' }, { label: 'Typography', to: '/ds/typography' }, { label: 'Iconography', to: '/ds/iconography' }, @@ -40,6 +42,7 @@ export const dsNav: Array = [ items: [ { label: 'Buttons', to: '/ds/buttons' }, { label: 'Badges', to: '/ds/badges' }, + { label: 'Eyebrow', to: '/ds/eyebrow' }, { label: 'Inputs', to: '/ds/inputs' }, { label: 'Dropdown', to: '/ds/dropdown' }, { label: 'Avatar', to: '/ds/avatar' }, diff --git a/src/components/ds/ui/index.tsx b/src/components/ds/ui/index.tsx index 8184a2dd4..898b4ce37 100644 --- a/src/components/ds/ui/index.tsx +++ b/src/components/ds/ui/index.tsx @@ -4,6 +4,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import { Link } from '@tanstack/react-router' import { CaretDown, CircleNotch, User } from '@phosphor-icons/react' import type { MarkdownHeading } from '~/utils/markdown' +import type { LibraryId } from '~/libraries/ids' /** * New-system DS components — built on the Figma semantic tokens (action-*, @@ -31,13 +32,29 @@ type ButtonColor = type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon-sm' | 'icon-md' type ButtonRounded = 'none' | 'md' | 'lg' | 'full' -interface ButtonProps extends React.ButtonHTMLAttributes { +// Polymorphic: defaults to - ) - }, -) + children, + ) +}) as unknown as ButtonComponent /* ------------------------------------------------------------------ Badge -- */ @@ -207,6 +225,83 @@ export function Badge({ ) } +/* ---------------------------------------------------------------- Eyebrow -- */ + +type EyebrowTone = 'muted' | 'secondary' | 'accent' + +const eyebrowToneStyles: Record = { + muted: 'text-text-muted', + secondary: 'text-text-secondary', + accent: 'text-text-accent', +} + +// Libraries that ship a `--color-lib-*` brand token. Written as literal classes +// so Tailwind's JIT emits each `text-lib-*` utility. Libraries without a brand +// token (e.g. ranger, config) simply fall back to the neutral tone. +const eyebrowLibraryStyles: Partial> = { + start: 'text-lib-start', + router: 'text-lib-router', + query: 'text-lib-query', + table: 'text-lib-table', + db: 'text-lib-db', + ai: 'text-lib-ai', + form: 'text-lib-form', + virtual: 'text-lib-virtual', + pacer: 'text-lib-pacer', + hotkeys: 'text-lib-hotkeys', + store: 'text-lib-store', + devtools: 'text-lib-devtools', + cli: 'text-lib-cli', + intent: 'text-lib-intent', +} + +/** + * Section eyebrow / kicker — the small uppercase label that sits above a + * heading. Locks in the DS `mono-caps` role (IBM Plex Mono, 12px, +1.5px + * tracking) plus the inline icon layout, so every eyebrow across the site stays + * on-system instead of hand-stacking `text-xs font-black uppercase`. + * + * Color resolves in priority order: `library` (brand the eyebrow by the + * category it sits within, via that library's `--color-lib-*` token) → + * `className` override → `tone` (neutral default). Pass a `library` to brand it, + * omit it for a neutral eyebrow. + */ +export function Eyebrow({ + children, + icon, + tone = 'secondary', + library, + className, +}: { + children: React.ReactNode + icon?: React.ReactNode + tone?: EyebrowTone + /** Brand the eyebrow by the library/category it's nested within. Falls back + * to `tone` when unset or when the library has no brand token. */ + library?: LibraryId + className?: string +}) { + const color = + (library && eyebrowLibraryStyles[library]) ?? eyebrowToneStyles[tone] + + // The `mono-caps` type role is a fixed base — never merged away — so the DS + // font/size/tracking can't be overridden. Only color (library / tone / + // className) goes through twMerge, which handles standard color utilities + // cleanly. This also sidesteps twMerge dropping the custom `text-ds-*` size + // when a caller passes a text color. + return ( +

+ {icon} + {children} +

+ ) +} + /* -------------------------------------------------------------- FormInput -- */ type FormInputProps = React.InputHTMLAttributes & { diff --git a/src/components/landing/LibraryLanding.tsx b/src/components/landing/LibraryLanding.tsx new file mode 100644 index 000000000..c3ccd1f03 --- /dev/null +++ b/src/components/landing/LibraryLanding.tsx @@ -0,0 +1,418 @@ +import * as React from 'react' +import { Link, useParams } from '@tanstack/react-router' +import { ArrowRight, BookOpen } from '@phosphor-icons/react' + +import { BottomCTA } from '~/components/BottomCTA' +import { Footer } from '~/components/Footer' +import { GithubIcon } from '~/components/icons/GithubIcon' +import LandingPageGad from '~/components/LandingPageGad' +import { LandingCommunitySection } from '~/components/LandingCommunitySection' +import { SponsorSection } from '~/components/SponsorSection' +import { LibraryDownloadsMicro } from '~/components/LibraryDownloadsMicro' +import { LibraryTestimonials } from '~/components/LibraryTestimonials' +import { LibraryWordmark } from '~/components/LibraryWordmark' +import { LandingCopyPromptButton } from '~/components/landing/LandingCopyPromptButton' +import { Eyebrow } from '~/components/ds/ui' +import { getLibrary } from '~/libraries' +import type { LibraryId, Testimonial } from '~/libraries/types' + +/** + * Shared, config-driven landing page for every TanStack library. + * + * The page structure is identical across libraries; only the copy, the + * interactive demo panels, and a small brand accent change. Pass those in via + * `LibraryLandingConfig` and let this component own the scaffold, spacing, + * neutral theme, and de-noised defaults (one CTA, no superlatives). + */ + +type Kicker = { icon: React.ReactNode; text: string } + +/** + * The handful of spots where a library's brand color shows through. Everything + * else stays neutral zinc so the page reads calm and consistent. Defaults to a + * neutral accent when omitted. + */ +export type LandingAccent = { + /** Kicker eyebrow text color. */ + kicker: string + /** Feature-card / step icon chip background + text. */ + chip: string + /** Proof-pill left border. */ + pill: string + /** Bottom CTA button colors. */ + cta: string +} + +const NEUTRAL_ACCENT: LandingAccent = { + kicker: 'text-zinc-700 dark:text-zinc-300', + chip: 'bg-zinc-950 text-white dark:bg-white dark:text-zinc-950', + pill: 'border-zinc-950 dark:border-white', + cta: 'border-zinc-950 bg-zinc-950 text-white hover:bg-zinc-800 dark:border-white dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200', +} + +export type LibraryFeature = { + title: string + body: string + icon: React.ReactNode +} + +export type LibrarySplitSection = { + kicker: Kicker + heading: string + body: string + /** The bespoke demo/illustration panel — the one truly per-library asset. */ + panel: React.ReactNode + /** Which side the panel sits on at lg+. Defaults to `right`. */ + side?: 'left' | 'right' + /** Optional content rendered under the body (e.g. framework pills). */ + belowBody?: React.ReactNode +} + +export type LibraryLandingConfig = { + libraryId: LibraryId + accent?: LandingAccent + hero: { + kicker: Kicker + /** Bold one-liner. Keep it plain — no superlatives, no exclamations. */ + tagline: string + description: string + /** Agent prompt for the "Copy prompt" button. */ + prompt: string + promptLabel: string + proof: Array<{ label: string; value: string }> + panel: React.ReactNode + /** Optional block under the proof pills (e.g. ecosystem proof strip). */ + belowProof?: React.ReactNode + } + why: { + kicker: Kicker + heading: string + intro: string + features: Array + } + sections: Array + /** Optional full-bleed block rendered after the split sections. */ + interlude?: React.ReactNode + testimonials?: { + kicker: Kicker + heading: string + body: string + items: Array + } + ecosystem: { + kicker?: Kicker + heading: string + body: string + /** Optional extra block inside the ecosystem section (e.g. a banner). */ + extra?: React.ReactNode + } +} + +// Horizontal gutter: 32px minimum, flexing up to 40px at sm+, so section +// content never sits closer than 32px to the viewport edge. +const SHELL = 'mx-auto w-full max-w-[80rem] px-6 sm:px-10 xl:max-w-[92rem]' +const GRID = + 'mx-auto grid w-full min-w-0 max-w-full gap-10 px-8 py-16 sm:px-10 lg:max-w-[80rem] xl:max-w-[92rem]' + +export function LibraryLanding({ config }: { config: LibraryLandingConfig }) { + const { libraryId, hero, why, sections, interlude, testimonials, ecosystem } = + config + const accent = config.accent ?? NEUTRAL_ACCENT + const library = getLibrary(libraryId) + const { version } = useParams({ strict: false }) + const resolvedVersion = version ?? library.latestVersion + + return ( +
+ {/* Hero */} +
+
+
+ + + {/* Small TanStack mark stacked over a large, dominant product name. + The mark stays understated — the navbar already carries the brand; + here the library is the headline. */} +
+ TanStack + +

+ +

+
+ +

+ {hero.tagline} +

+ +

+ {hero.description} +

+ + + +
+
+ +
+ {hero.proof.map((proof) => ( + + ))} +
+ + {hero.belowProof} +
+ + {hero.panel} +
+
+ + {/* Why */} +
+
+
+ +

{why.heading}

+

+ {why.intro} +

+
+ +
+ {why.features.map((feature) => ( + + ))} +
+
+
+ + {/* Split feature sections */} + {sections.map((section, index) => ( + + ))} + + {interlude} + + {/* Testimonials */} + {testimonials ? ( +
+
+
+ +

{testimonials.heading}

+

+ {testimonials.body} +

+
+
+
+ +
+
+ ) : null} + + {/* Open source ecosystem */} +
+
+
+ + } + text={ecosystem.kicker?.text ?? 'Open source ecosystem'} + /> +

{ecosystem.heading}

+

+ {ecosystem.body} +

+
+
+ +
+ + {ecosystem.extra} + +
+
+ + + +
+
+ ) +} + +function SplitSection({ + accent, + alt, + section, +}: { + accent: LandingAccent + alt: boolean + section: LibrarySplitSection +}) { + const panelLeft = section.side === 'left' + const cols = panelLeft + ? 'lg:grid-cols-[1.08fr_0.92fr]' + : 'lg:grid-cols-[0.82fr_1.18fr]' + const text = ( +
+ +

{section.heading}

+

+ {section.body} +

+ {section.belowBody} +
+ ) + + return ( +
+
+ {panelLeft ? ( + <> + {section.panel} + {text} + + ) : ( + <> + {text} + {section.panel} + + )} +
+
+ ) +} + +// Thin adapter over the DS : maps the template's {icon, text} + brand +// accent onto the shared component so every library page renders the same +// on-system eyebrow. Brand color rides in via className until the color pass +// moves accents onto DS tokens. +function SectionKicker({ + accent, + icon, + text, +}: { + accent: LandingAccent + icon: React.ReactNode + text: React.ReactNode +}) { + return ( + + {text} + + ) +} + +function FeatureCard({ + accent, + body, + icon, + title, +}: LibraryFeature & { accent: LandingAccent }) { + return ( +
+ + {icon} + +

{title}

+

+ {body} +

+
+ ) +} + +function ProofPill({ + accent, + label, + value, +}: { + accent: LandingAccent + label: string + value: string +}) { + return ( +
+

{label}

+

+ {value} +

+
+ ) +} + +function PrimaryLink({ + icon, + label, + params, + to, +}: { + icon: React.ReactNode + label: string + params: Record + to: string +}) { + return ( + + {icon} + {label} +
+Server-state manager +Server-state manager`} + > + {TONES.map((tone) => ( + + Server-state manager + + ))} + + + + + }>Why Query +}>Cache lifecycle`} + > + }>Why Query + }>Cache lifecycle + + + + + } className="text-lib-cli"> + Server-state manager +`} + > + } className="text-lib-cli"> + Server-state manager + + + + + ) +} diff --git a/src/routes/ds.logos.tsx b/src/routes/ds.logos.tsx new file mode 100644 index 000000000..ebf50f187 --- /dev/null +++ b/src/routes/ds.logos.tsx @@ -0,0 +1,129 @@ +import { createFileRoute } from '@tanstack/react-router' +import { DownloadSimple } from '@phosphor-icons/react' +import { seo } from '~/utils/seo' +import { DsPage, DsSection } from '~/components/ds/DsKit' + +export const Route = createFileRoute('/ds/logos')({ + component: LogosPage, + head: () => ({ + meta: seo({ + title: 'Logos | TanStack Design System', + description: + 'Download the TanStack logo lockups — stacked and landscape, in every brand color.', + }), + }), +}) + +type LogoTone = 'black' | 'charcoal' | 'cream' | 'white' + +interface LogoAsset { + tone: LogoTone + file: string + // Light marks (cream/white) need a dark backdrop to stay visible. + onDark: boolean +} + +const TONE_LABEL: Record = { + black: 'Black', + charcoal: 'Charcoal', + cream: 'Cream', + white: 'White', +} + +const STACKED: Array = [ + { tone: 'black', file: 'tanstack-stacked-black.svg', onDark: false }, + { tone: 'charcoal', file: 'tanstack-stacked-charcoal.svg', onDark: false }, + { tone: 'cream', file: 'tanstack-stacked-cream.svg', onDark: true }, + { tone: 'white', file: 'tanstack-stacked-white.svg', onDark: true }, +] + +const LANDSCAPE: Array = [ + { tone: 'black', file: 'tanstack-landscape-black.svg', onDark: false }, + { tone: 'charcoal', file: 'tanstack-landscape-charcoal.svg', onDark: false }, + { tone: 'white', file: 'tanstack-landscape-white.svg', onDark: true }, +] + +function LogoCard({ + asset, + lockup, + imgClass, +}: { + asset: LogoAsset + lockup: string + imgClass: string +}) { + const src = `/images/brand/${asset.file}` + + return ( +
+ {/* Fixed backdrops — a mark's color is fixed, so its preview surface must + not flip with the page theme. Light marks on neutral-500, dark marks on + neutral-100. */} +
+ {`TanStack +
+
+ + {TONE_LABEL[asset.tone]} + + + + SVG + +
+
+ ) +} + +function LogosPage() { + return ( + + +
+ {STACKED.map((asset) => ( + + ))} +
+
+ + +
+ {LANDSCAPE.map((asset) => ( + + ))} +
+
+
+ ) +} diff --git a/src/routes/ds.typography.tsx b/src/routes/ds.typography.tsx index ad40c5cec..a50793571 100644 --- a/src/routes/ds.typography.tsx +++ b/src/routes/ds.typography.tsx @@ -77,9 +77,9 @@ const GROUPS: Array = [ font: 'font-sans', sample: 'Headless, type-safe tools for building modern web apps.', items: [ - { name: 'body/xl', cls: 'text-ds-body-xl', spec: '20 / 32 · Regular' }, - { name: 'body/lg', cls: 'text-ds-body-lg', spec: '18 / 28 · Regular' }, - { name: 'body/md', cls: 'text-ds-body-md', spec: '16 / 24 · Regular' }, + { name: 'body/xl', cls: 'text-ds-body-xl', spec: '20 / 32 · Light' }, + { name: 'body/lg', cls: 'text-ds-body-lg', spec: '18 / 28 · Light' }, + { name: 'body/md', cls: 'text-ds-body-md', spec: '16 / 24 · Light' }, { name: 'body/sm', cls: 'text-ds-body-sm', spec: '14 / 20 · Regular' }, { name: 'body/xs', cls: 'text-ds-body-xs', spec: '12 / 17 · Regular' }, ], diff --git a/src/styles/app.css b/src/styles/app.css index 6122c2b66..a00ec6ea3 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -292,13 +292,13 @@ html.theme-switching *::after { --text-ds-body-xl: 20px; --text-ds-body-xl--line-height: 32px; - --text-ds-body-xl--font-weight: 400; + --text-ds-body-xl--font-weight: 300; --text-ds-body-lg: 18px; --text-ds-body-lg--line-height: 28px; - --text-ds-body-lg--font-weight: 400; + --text-ds-body-lg--font-weight: 300; --text-ds-body-md: 16px; --text-ds-body-md--line-height: 24px; - --text-ds-body-md--font-weight: 400; + --text-ds-body-md--font-weight: 300; --text-ds-body-sm: 14px; --text-ds-body-sm--line-height: 20px; --text-ds-body-sm--font-weight: 400;