diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index 1e31ccd74..758e3c715 100644 Binary files a/public/android-chrome-192x192.png and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png index f7ddfb213..3d176980e 100644 Binary files a/public/android-chrome-512x512.png and b/public/android-chrome-512x512.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 72d54fbd6..58ec5c76b 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png index 5e83b5e33..6e460d203 100644 Binary files a/public/favicon-16x16.png and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png index a200cfd4f..4adc9cefa 100644 Binary files a/public/favicon-32x32.png and b/public/favicon-32x32.png differ diff --git a/public/favicon-dark.svg b/public/favicon-dark.svg index 6e7345e2f..01f6ad5bc 100644 --- a/public/favicon-dark.svg +++ b/public/favicon-dark.svg @@ -1,3 +1,13 @@ - - + + + + + + + + + + + + diff --git a/public/favicon-light.svg b/public/favicon-light.svg index bbbd2dbb6..01f6ad5bc 100644 --- a/public/favicon-light.svg +++ b/public/favicon-light.svg @@ -1,3 +1,13 @@ - - + + + + + + + + + + + + diff --git a/public/favicon.ico b/public/favicon.ico index 6d61f71a1..efd8b3201 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png index a200cfd4f..4adc9cefa 100644 Binary files a/public/favicon.png and b/public/favicon.png differ diff --git a/public/images/brand/social/mark-green.svg b/public/images/brand/social/mark-green.svg new file mode 100644 index 000000000..8bd0b138d --- /dev/null +++ b/public/images/brand/social/mark-green.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/brand/social/mark-green@2x.png b/public/images/brand/social/mark-green@2x.png new file mode 100644 index 000000000..dd8cd92da Binary files /dev/null and b/public/images/brand/social/mark-green@2x.png differ diff --git a/public/images/brand/social/mark-ocean.svg b/public/images/brand/social/mark-ocean.svg new file mode 100644 index 000000000..607f4edf1 --- /dev/null +++ b/public/images/brand/social/mark-ocean.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/brand/social/mark-ocean@2x.png b/public/images/brand/social/mark-ocean@2x.png new file mode 100644 index 000000000..cdbb4a148 Binary files /dev/null and b/public/images/brand/social/mark-ocean@2x.png differ diff --git a/public/images/brand/social/mark-purple.svg b/public/images/brand/social/mark-purple.svg new file mode 100644 index 000000000..f9d000724 --- /dev/null +++ b/public/images/brand/social/mark-purple.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/brand/social/mark-purple@2x.png b/public/images/brand/social/mark-purple@2x.png new file mode 100644 index 000000000..f502e1064 Binary files /dev/null and b/public/images/brand/social/mark-purple@2x.png differ diff --git a/public/images/brand/social/mark-yellow.svg b/public/images/brand/social/mark-yellow.svg new file mode 100644 index 000000000..0dbbf9aaa --- /dev/null +++ b/public/images/brand/social/mark-yellow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/brand/social/mark-yellow@2x.png b/public/images/brand/social/mark-yellow@2x.png new file mode 100644 index 000000000..273ebad8d Binary files /dev/null and b/public/images/brand/social/mark-yellow@2x.png differ diff --git a/public/images/brand/social/naked-mark-dark.svg b/public/images/brand/social/naked-mark-dark.svg new file mode 100644 index 000000000..6b0c5c61e --- /dev/null +++ b/public/images/brand/social/naked-mark-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/brand/social/naked-mark-dark@2x.png b/public/images/brand/social/naked-mark-dark@2x.png new file mode 100644 index 000000000..20cc2f61c Binary files /dev/null and b/public/images/brand/social/naked-mark-dark@2x.png differ diff --git a/public/images/brand/social/naked-mark-green.svg b/public/images/brand/social/naked-mark-green.svg new file mode 100644 index 000000000..4c5a4243f --- /dev/null +++ b/public/images/brand/social/naked-mark-green.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/brand/social/naked-mark-green@2x.png b/public/images/brand/social/naked-mark-green@2x.png new file mode 100644 index 000000000..6703fc68d Binary files /dev/null and b/public/images/brand/social/naked-mark-green@2x.png differ diff --git a/public/images/brand/social/naked-mark-light.svg b/public/images/brand/social/naked-mark-light.svg new file mode 100644 index 000000000..732de8ae5 --- /dev/null +++ b/public/images/brand/social/naked-mark-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/brand/social/naked-mark-light@2x.png b/public/images/brand/social/naked-mark-light@2x.png new file mode 100644 index 000000000..f09ee9e65 Binary files /dev/null and b/public/images/brand/social/naked-mark-light@2x.png differ diff --git a/public/images/brand/social/naked-mark-ocean.svg b/public/images/brand/social/naked-mark-ocean.svg new file mode 100644 index 000000000..0df2ae412 --- /dev/null +++ b/public/images/brand/social/naked-mark-ocean.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/brand/social/naked-mark-ocean@2x.png b/public/images/brand/social/naked-mark-ocean@2x.png new file mode 100644 index 000000000..b76fde71b Binary files /dev/null and b/public/images/brand/social/naked-mark-ocean@2x.png differ diff --git a/public/images/brand/social/naked-mark-purple.svg b/public/images/brand/social/naked-mark-purple.svg new file mode 100644 index 000000000..14bd370e7 --- /dev/null +++ b/public/images/brand/social/naked-mark-purple.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/brand/social/naked-mark-purple@2x.png b/public/images/brand/social/naked-mark-purple@2x.png new file mode 100644 index 000000000..37434fdf1 Binary files /dev/null and b/public/images/brand/social/naked-mark-purple@2x.png differ diff --git a/public/images/brand/social/naked-mark-yellow.svg b/public/images/brand/social/naked-mark-yellow.svg new file mode 100644 index 000000000..acca602bb --- /dev/null +++ b/public/images/brand/social/naked-mark-yellow.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/brand/social/naked-mark-yellow@2x.png b/public/images/brand/social/naked-mark-yellow@2x.png new file mode 100644 index 000000000..adf6d91e4 Binary files /dev/null and b/public/images/brand/social/naked-mark-yellow@2x.png differ diff --git a/public/images/brand/social/stacked-dark.svg b/public/images/brand/social/stacked-dark.svg index 75d34cd4f..f6a6369d9 100644 --- a/public/images/brand/social/stacked-dark.svg +++ b/public/images/brand/social/stacked-dark.svg @@ -1,18 +1,18 @@ - - - - - - - - - + + + + + + + + + - + diff --git a/public/images/brand/social/stacked-dark@2x.png b/public/images/brand/social/stacked-dark@2x.png index 04a8e21d1..cb22b2d0b 100644 Binary files a/public/images/brand/social/stacked-dark@2x.png and b/public/images/brand/social/stacked-dark@2x.png differ diff --git a/public/images/brand/social/stacked-green.svg b/public/images/brand/social/stacked-green.svg new file mode 100644 index 000000000..08f6884ed --- /dev/null +++ b/public/images/brand/social/stacked-green.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/brand/social/stacked-green@2x.png b/public/images/brand/social/stacked-green@2x.png new file mode 100644 index 000000000..28ce724e2 Binary files /dev/null and b/public/images/brand/social/stacked-green@2x.png differ diff --git a/public/images/brand/social/stacked-light.svg b/public/images/brand/social/stacked-light.svg index 6bbcb7698..ccd7f6889 100644 --- a/public/images/brand/social/stacked-light.svg +++ b/public/images/brand/social/stacked-light.svg @@ -1,18 +1,18 @@ - - - - - - - - - + + + + + + + + + - + diff --git a/public/images/brand/social/stacked-light@2x.png b/public/images/brand/social/stacked-light@2x.png index 4ccc3e8bd..e8d9c0627 100644 Binary files a/public/images/brand/social/stacked-light@2x.png and b/public/images/brand/social/stacked-light@2x.png differ diff --git a/public/images/brand/social/stacked-ocean.svg b/public/images/brand/social/stacked-ocean.svg new file mode 100644 index 000000000..4ddd7d706 --- /dev/null +++ b/public/images/brand/social/stacked-ocean.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/brand/social/stacked-ocean@2x.png b/public/images/brand/social/stacked-ocean@2x.png new file mode 100644 index 000000000..0acdb242e Binary files /dev/null and b/public/images/brand/social/stacked-ocean@2x.png differ diff --git a/public/images/brand/social/stacked-purple.svg b/public/images/brand/social/stacked-purple.svg new file mode 100644 index 000000000..3f7db96a5 --- /dev/null +++ b/public/images/brand/social/stacked-purple.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/brand/social/stacked-purple@2x.png b/public/images/brand/social/stacked-purple@2x.png new file mode 100644 index 000000000..2f58d989f Binary files /dev/null and b/public/images/brand/social/stacked-purple@2x.png differ diff --git a/public/images/brand/social/stacked-yellow.svg b/public/images/brand/social/stacked-yellow.svg new file mode 100644 index 000000000..8928c6af3 --- /dev/null +++ b/public/images/brand/social/stacked-yellow.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/images/brand/social/stacked-yellow@2x.png b/public/images/brand/social/stacked-yellow@2x.png new file mode 100644 index 000000000..c0ce740e7 Binary files /dev/null and b/public/images/brand/social/stacked-yellow@2x.png differ diff --git a/public/images/brand/tanstack-emblem-black@2x.png b/public/images/brand/tanstack-emblem-black@2x.png new file mode 100644 index 000000000..2c1d4b551 Binary files /dev/null and b/public/images/brand/tanstack-emblem-black@2x.png differ diff --git a/public/images/brand/tanstack-emblem-charcoal@2x.png b/public/images/brand/tanstack-emblem-charcoal@2x.png new file mode 100644 index 000000000..90432a699 Binary files /dev/null and b/public/images/brand/tanstack-emblem-charcoal@2x.png differ diff --git a/public/images/brand/tanstack-emblem-cream@2x.png b/public/images/brand/tanstack-emblem-cream@2x.png new file mode 100644 index 000000000..ba12ef148 Binary files /dev/null and b/public/images/brand/tanstack-emblem-cream@2x.png differ diff --git a/public/images/brand/tanstack-emblem-white@2x.png b/public/images/brand/tanstack-emblem-white@2x.png new file mode 100644 index 000000000..0f5faedc5 Binary files /dev/null and b/public/images/brand/tanstack-emblem-white@2x.png differ diff --git a/public/images/brand/tanstack-landscape-black@2x.png b/public/images/brand/tanstack-landscape-black@2x.png new file mode 100644 index 000000000..b16740e02 Binary files /dev/null and b/public/images/brand/tanstack-landscape-black@2x.png differ diff --git a/public/images/brand/tanstack-landscape-charcoal@2x.png b/public/images/brand/tanstack-landscape-charcoal@2x.png new file mode 100644 index 000000000..f345e4610 Binary files /dev/null and b/public/images/brand/tanstack-landscape-charcoal@2x.png differ diff --git a/public/images/brand/tanstack-landscape-white@2x.png b/public/images/brand/tanstack-landscape-white@2x.png new file mode 100644 index 000000000..55b664260 Binary files /dev/null and b/public/images/brand/tanstack-landscape-white@2x.png differ diff --git a/public/images/brand/tanstack-stacked-black@2x.png b/public/images/brand/tanstack-stacked-black@2x.png new file mode 100644 index 000000000..a89e7c9e0 Binary files /dev/null and b/public/images/brand/tanstack-stacked-black@2x.png differ diff --git a/public/images/brand/tanstack-stacked-charcoal@2x.png b/public/images/brand/tanstack-stacked-charcoal@2x.png new file mode 100644 index 000000000..e8c7f44e7 Binary files /dev/null and b/public/images/brand/tanstack-stacked-charcoal@2x.png differ diff --git a/public/images/brand/tanstack-stacked-cream@2x.png b/public/images/brand/tanstack-stacked-cream@2x.png new file mode 100644 index 000000000..0c4a78f82 Binary files /dev/null and b/public/images/brand/tanstack-stacked-cream@2x.png differ diff --git a/public/images/brand/tanstack-stacked-white@2x.png b/public/images/brand/tanstack-stacked-white@2x.png new file mode 100644 index 000000000..db0b8f657 Binary files /dev/null and b/public/images/brand/tanstack-stacked-white@2x.png differ diff --git a/src/components/ButtonGroup.tsx b/src/components/ButtonGroup.tsx index c78be1f88..bd0cc0d01 100644 --- a/src/components/ButtonGroup.tsx +++ b/src/components/ButtonGroup.tsx @@ -15,6 +15,7 @@ export function ButtonGroup({ children, className }: ButtonGroupProps) { 'divide-x divide-border-default', 'bg-background-surface text-text-primary', 'shadow-sm', + '[&>[aria-pressed=true]]:border-transparent [&>[aria-pressed=true]]:bg-text-primary [&>[aria-pressed=true]]:text-background-default [&>[aria-pressed=true]]:shadow-sm', className, )} > diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index c7037b0ee..402ebf2e7 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -122,81 +122,76 @@ export function Footer() { return (
-
-
-
- -
- - {FOOTER_COLUMNS.map((column) => ( - - ))} +
+
+ +
-
-
-

- © {new Date().getFullYear()} TanStack LLC -

- -
-
+ +
+
+

+ © {new Date().getFullYear()} TanStack LLC +

+
+
diff --git a/src/components/LibraryLayout.tsx b/src/components/LibraryLayout.tsx index 25d10194a..bd67a3d5a 100644 --- a/src/components/LibraryLayout.tsx +++ b/src/components/LibraryLayout.tsx @@ -32,7 +32,6 @@ import { type PartnerPlacementContext, } from '~/utils/partner-placement' import { usePartnerPlacementContext } from '~/utils/usePartnerPlacementContext' -import { Footer } from './Footer' import { RecentPostsWidget } from './RecentPostsWidget' import { SearchButton } from './SearchButton' import { FrameworkSelect, useCurrentFramework } from './FrameworkSelect' @@ -1510,11 +1509,6 @@ export function LibraryLayout({ > {children}
- {!isLandingPage && ( -
-
-
- )}
{!isLandingPage && ( e.stopPropagation()} className={ variant === 'card' - ? 'inline-flex size-6 items-center justify-center text-icon-muted transition-colors duration-200 hover:text-icon-default focus-visible:rounded-sm focus-visible:corner-squircle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus motion-reduce:transition-none [&_svg]:size-6' + ? 'inline-flex size-4 items-center justify-center text-icon-muted transition-colors duration-200 hover:text-icon-default focus-visible:rounded-sm focus-visible:corner-squircle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus motion-reduce:transition-none [&_svg]:size-4' : 'p-2 -mb-2 -ml-2 transition-colors hover:scale-110 hover:text-gray-700 hover:grayscale-0 dark:hover:text-gray-200' } > @@ -223,7 +223,7 @@ function MaintainerSocialLinks({
@@ -395,7 +395,7 @@ export function MaintainerRowCard({ export function MaintainerCard({ maintainer }: MaintainerCardProps) { return (
-
+
{maintainer.name} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index aba068c51..68388b894 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -8,6 +8,11 @@ const LazyNavbarAuthControls = React.lazy(() => default: m.NavbarAuthControls, })), ) +const LazyMobileNavbarAuthControls = React.lazy(() => + import('./NavbarAuthControls').then((m) => ({ + default: m.MobileNavbarAuthControls, + })), +) import { NavbarCartButton } from './NavbarCartButton' import { MegaMenuItem } from './MegaMenuItem' import { Link, useLocation } from '@tanstack/react-router' @@ -630,12 +635,13 @@ export function Navbar({ children }: { children: React.ReactNode }) {
{socialLinks}
- +
+ +
-
- +
+
-
@@ -898,7 +904,7 @@ function MobileNavigation({ type="button" tabIndex={activeGroup ? -1 : 0} onClick={() => openUtility('search')} - className="flex w-full items-center gap-3.5 rounded-xl px-3 py-4 text-left font-ds-display text-ds-heading-1 text-[#a3a3a3] transition-colors hover:bg-[#171717] hover:text-white focus-visible:bg-[#171717] focus-visible:text-white focus-visible:outline-none" + className="flex w-full items-center gap-3.5 rounded-xl px-3 py-4 text-left font-ds-display text-ds-heading-3 text-[#a3a3a3] transition-colors hover:bg-[#171717] hover:text-white focus-visible:bg-[#171717] focus-visible:text-white focus-visible:outline-none" > Search @@ -907,20 +913,29 @@ function MobileNavigation({ type="button" tabIndex={activeGroup ? -1 : 0} onClick={() => openUtility('ai')} - className="flex w-full items-center gap-3.5 rounded-xl px-3 py-4 text-left font-ds-display text-ds-heading-1 text-[#a3a3a3] transition-colors hover:bg-[#171717] hover:text-white focus-visible:bg-[#171717] focus-visible:text-white focus-visible:outline-none" + className="flex w-full items-center gap-3.5 rounded-xl px-3 py-4 text-left font-ds-display text-ds-heading-3 text-[#a3a3a3] transition-colors hover:bg-[#171717] hover:text-white focus-visible:bg-[#171717] focus-visible:text-white focus-visible:outline-none" > Ask AI - + + Sign In + + } > - - Sign In - + +
@@ -1700,7 +1715,7 @@ function SocialStack() { - + {SOCIAL_LINKS.map(({ label, href, Icon }) => ( ) } + +type MobileNavbarAuthControlsProps = { + onNavigate: () => void + tabIndex: number +} + +export function MobileNavbarAuthControls({ + onNavigate, + tabIndex, +}: MobileNavbarAuthControlsProps) { + const capabilities = useCapabilities() + const user = useCurrentUser() + const navigate = useNavigate() + const { notify } = useToast() + const canAdmin = capabilities.some((cap) => adminCapabilities.has(cap)) + + const itemClassName = + 'flex w-full items-center gap-3.5 rounded-xl px-3 py-4 text-left font-ds-display text-ds-heading-3 text-[#a3a3a3] transition-colors hover:bg-[#171717] hover:text-white focus-visible:bg-[#171717] focus-visible:text-white focus-visible:outline-none' + + const signOut = async () => { + onNavigate() + await authClient.signOut() + navigate({ to: '/login' }) + notify( +
+
Signed out
+
+ You have been logged out +
+
, + ) + } + + const signIn = ( + + + Sign In + + ) + + return ( + <> + {signIn} + {signIn} + + + + Account + + + + My Showcases + + {user && ( + + + Integrations + + )} + {canAdmin && ( + + + Admin + + )} + + + + ) +} diff --git a/src/components/ds/BrandAssets.tsx b/src/components/ds/BrandAssets.tsx index 0df3f31a2..6a144da60 100644 --- a/src/components/ds/BrandAssets.tsx +++ b/src/components/ds/BrandAssets.tsx @@ -1,14 +1,23 @@ -import { DownloadSimpleIcon } from '@phosphor-icons/react' +import * as React from 'react' +import { + CaretDownIcon as CaretDown, + CheckIcon as Check, + CopyIcon as Copy, + DownloadSimpleIcon as DownloadSimple, +} from '@phosphor-icons/react' import { DsSection } from '~/components/ds/DsKit' +import { useToast } from '~/components/ToastProvider' +import { copyTextToClipboard, useTemporaryFlag } from '~/utils/browser-effects' +import { + Button, + Dropdown, + DropdownContent, + DropdownItem, + DropdownTrigger, +} from '~/components/ds/ui' type LogoTone = 'black' | 'charcoal' | 'cream' | 'white' -interface LogoAsset { - tone: LogoTone - file: string - onDark: boolean -} - interface GalleryAsset { name: string category: string @@ -16,74 +25,511 @@ interface GalleryAsset { alt: string onDark: boolean imgClass: string - downloads: Array<{ format: string; href: string }> + downloads: Array<{ format: 'SVG'; href: string }> } -const TONE_LABEL: Record = { - black: 'Black', - charcoal: 'Charcoal', - cream: 'Cream', - white: 'White', -} +const BRAND_LOGOS = [ + { + value: 'stacked', + label: 'Stacked', + colors: ['black', 'charcoal', 'cream', 'white'], + }, + { + value: 'landscape', + label: 'Landscape', + colors: ['black', 'charcoal', 'white'], + }, + { + value: 'emblem', + label: 'Emblem', + colors: ['black', 'charcoal', 'cream', 'white'], + }, +] as const -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 BRAND_COLORS: Array<{ + value: LogoTone + label: string + swatch: string +}> = [ + { value: 'black', label: 'Black', swatch: '#111111' }, + { value: 'charcoal', label: 'Charcoal', swatch: '#444444' }, + { value: 'cream', label: 'Cream', swatch: '#eeebd4' }, + { value: 'white', label: 'White', swatch: '#ffffff' }, ] -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 }, -] +const BRAND_FORMATS = [ + { value: 'svg', label: 'SVG' }, + { value: 'png', label: 'PNG · 2×' }, +] as const -const EMBLEM: Array = [ - { tone: 'black', file: 'tanstack-emblem-black.svg', onDark: false }, - { tone: 'charcoal', file: 'tanstack-emblem-charcoal.svg', onDark: false }, - { tone: 'cream', file: 'tanstack-emblem-cream.svg', onDark: true }, - { tone: 'white', file: 'tanstack-emblem-white.svg', onDark: true }, -] +type BrandLogo = (typeof BRAND_LOGOS)[number] +type BrandColor = (typeof BRAND_COLORS)[number] +type BrandFormat = (typeof BRAND_FORMATS)[number] + +type DissolveAsset = { + src: string + alt: string +} + +function CopyAssetButton({ + className, + format, + href, + label, +}: { + className: string + format: 'svg' | 'png' + href: string + label: string +}) { + const copied = useTemporaryFlag(1500) + const { notify } = useToast() + + const copyAsset = async () => { + try { + const response = await fetch(href) + if (!response.ok) throw new Error('Asset unavailable') + + if (format === 'svg') { + await copyTextToClipboard(await response.text()) + } else { + const blob = await response.blob() + await navigator.clipboard.write([ + new ClipboardItem({ 'image/png': blob }), + ]) + } -function brandGalleryAssets( - lockup: string, - assets: Array, - imgClass: string, -): Array { - return assets.map((asset) => { - const src = `/images/brand/${asset.file}` - const tone = TONE_LABEL[asset.tone] - - return { - name: `${lockup} · ${tone}`, - category: 'Brand logo', - preview: src, - alt: `TanStack ${lockup.toLowerCase()} logo — ${tone}`, - onDark: asset.onDark, - imgClass, - downloads: [{ format: 'SVG', href: src }], + copied.trigger() + notify(
Copied {label}
) + } catch { + notify(
Could not copy {label}
) } - }) + } + + return ( + + ) } -const SOCIAL_LOGOS: Array = [ - ['Mark · Dark', 'mark-dark'], - ['Mark · Light', 'mark-light'], - ['Stacked · Dark', 'stacked-dark'], - ['Stacked · Light', 'stacked-light'], -].map(([name, file]) => ({ - name, - category: 'Social logo', - preview: `/images/brand/social/${file}.svg`, - alt: `TanStack social logo — ${name}`, - onDark: false, - imgClass: 'h-full w-full', - downloads: [ - { format: 'SVG', href: `/images/brand/social/${file}.svg` }, - { format: 'PNG · 2×', href: `/images/brand/social/${file}@2x.png` }, - ], -})) +function DissolvingLogoPreview({ + src, + alt, + className, +}: DissolveAsset & { className: string }) { + const [current, setCurrent] = React.useState({ src, alt }) + const [incoming, setIncoming] = React.useState(null) + const [incomingVisible, setIncomingVisible] = React.useState(false) + const requestId = React.useRef(0) + + React.useEffect(() => { + if (src === current.src) return + + const activeRequest = ++requestId.current + const image = new Image() + image.onload = () => { + if (activeRequest !== requestId.current) return + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + setCurrent({ src, alt }) + setIncoming(null) + setIncomingVisible(false) + return + } + + setIncoming({ src, alt }) + setIncomingVisible(false) + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + if (activeRequest === requestId.current) setIncomingVisible(true) + }) + }) + } + image.src = src + + return () => { + requestId.current += 1 + } + }, [alt, current.src, src]) + + const finishDissolve = (event: React.TransitionEvent) => { + if (event.propertyName !== 'opacity' || !incoming || !incomingVisible) { + return + } + + setCurrent(incoming) + setIncoming(null) + setIncomingVisible(false) + } + + return ( +
+ {incoming + {incoming ? ( + {incoming.alt} + ) : null} +
+ ) +} + +function BrandLogoPicker() { + const [logo, setLogo] = React.useState(BRAND_LOGOS[0]) + const [color, setColor] = React.useState(BRAND_COLORS[0]) + const [format, setFormat] = React.useState(BRAND_FORMATS[0]) + const availableColors = BRAND_COLORS.filter((option) => + logo.colors.some((value) => value === option.value), + ) + const extension = format.value === 'png' ? '@2x.png' : '.svg' + const assetName = `tanstack-${logo.value}-${color.value}` + const href = `/images/brand/${assetName}${extension}` + const onDark = color.value === 'cream' || color.value === 'white' + const actionClassName = `hover:bg-transparent max-[899px]:bg-transparent ${ + onDark + ? 'text-ds-neutral-100 hover:text-ds-neutral-100/70' + : 'text-ds-neutral-500 hover:text-ds-neutral-500/70' + }` + + const selectLogo = (option: BrandLogo) => { + setLogo(option) + if (!option.colors.some((value) => value === color.value)) { + const nextColor = BRAND_COLORS.find( + (candidate) => candidate.value === option.colors[0], + ) + if (nextColor) setColor(nextColor) + } + } + + return ( +
+
+ + + + + + {BRAND_LOGOS.map((option) => ( + selectLogo(option)} + > + {option.label} + {option.value === logo.value ? ( + + ) : null} + + ))} + + + + + + + + + {availableColors.map((option) => ( + setColor(option)} + > + + ))} + + + + + + + + + {BRAND_FORMATS.map((option) => ( + setFormat(option)} + > + {option.label} + {option.value === format.value ? ( + + ) : null} + + ))} + + + +
+ + +
+
+ +
+ +
+
+
+
+ {logo.label} · {color.label} +
+

+ Brand logo · {format.label} +

+
+ {assetName} +
+
+ ) +} + +const SOCIAL_ORIENTATIONS = [ + { value: 'naked-mark', label: 'Naked mark' }, + { value: 'mark', label: 'Tablet' }, + { value: 'stacked', label: 'Stacked' }, +] as const + +const SOCIAL_COLORS = [ + { value: 'dark', label: 'Dark', swatch: '#111111' }, + { value: 'light', label: 'Light', swatch: '#eeebd4' }, + { + value: 'green', + label: 'Green', + swatch: 'linear-gradient(135deg, #2fbf71, #087f5b)', + }, + { + value: 'ocean', + label: 'Ocean', + swatch: 'linear-gradient(135deg, #38bdf8, #2563eb)', + }, + { + value: 'purple', + label: 'Purple', + swatch: 'linear-gradient(135deg, #c084fc, #7c3aed)', + }, + { value: 'yellow', label: 'Yellow', swatch: '#ffed6a' }, +] as const + +const SOCIAL_FORMATS = [ + { value: 'svg', label: 'SVG' }, + { value: 'png', label: 'PNG · 2×' }, +] as const + +type SocialOrientation = (typeof SOCIAL_ORIENTATIONS)[number] +type SocialColor = (typeof SOCIAL_COLORS)[number] +type SocialFormat = (typeof SOCIAL_FORMATS)[number] + +const socialControlClass = + 'inline-flex h-10 items-center gap-2 rounded-lg border border-border-default bg-background-surface px-3 text-sm text-text-primary transition-colors hover:bg-background-subtle' + +function SocialLogoPicker() { + const [orientation, setOrientation] = React.useState( + SOCIAL_ORIENTATIONS[1], + ) + const [color, setColor] = React.useState(SOCIAL_COLORS[0]) + const [format, setFormat] = React.useState(SOCIAL_FORMATS[0]) + const extension = format.value === 'png' ? '@2x.png' : '.svg' + const assetName = `${orientation.value}-${color.value}` + const href = `/images/brand/social/${assetName}${extension}` + + return ( +
+
+ + + + + + {SOCIAL_ORIENTATIONS.map((option) => ( + setOrientation(option)} + > + {option.label} + {option.value === orientation.value ? ( + + ) : null} + + ))} + + + + + + + + + {SOCIAL_COLORS.map((option) => ( + setColor(option)} + > + + ))} + + + + + + + + + {SOCIAL_FORMATS.map((option) => ( + setFormat(option)} + > + {option.label} + {option.value === format.value ? ( + + ) : null} + + ))} + + + +
+ + +
+
+ +
+ +
+
+
+
+ {orientation.label} · {color.label} +
+

+ Social logo · {format.label} +

+
+ {assetName} +
+
+ ) +} const FAVICONS: Array = [ { @@ -106,21 +552,6 @@ const FAVICONS: Array = [ }, ] -const BRAND_LOGO_FORMATS = [ - { - name: 'Stacked', - assets: brandGalleryAssets('Stacked', STACKED, 'h-20 max-w-full'), - }, - { - name: 'Landscape', - assets: brandGalleryAssets('Landscape', LANDSCAPE, 'h-10 max-w-full'), - }, - { - name: 'Emblem', - assets: brandGalleryAssets('Emblem', EMBLEM, 'h-16 max-w-full'), - }, -] - function AssetCard({ asset }: { asset: GalleryAsset }) { return (
@@ -159,41 +601,26 @@ function AssetCard({ asset }: { asset: GalleryAsset }) { export function BrandAssetGallery() { return ( <> -
- {BRAND_LOGO_FORMATS.map((format) => ( -
-

- {format.name} -

-
- {format.assets.map((asset) => ( - - ))} -
-
- ))} -
- -
- {FAVICONS.map((asset) => ( - - ))} -
+
+ + + +
- {SOCIAL_LOGOS.map((asset) => ( + {FAVICONS.map((asset) => ( ))}
diff --git a/src/components/ds/DsKit.tsx b/src/components/ds/DsKit.tsx index da76917e7..9575918cc 100644 --- a/src/components/ds/DsKit.tsx +++ b/src/components/ds/DsKit.tsx @@ -7,10 +7,11 @@ import { CopyIcon, } from '@phosphor-icons/react' import { copyTextToClipboard } from '~/utils/browser-effects' +import { toDsSectionId } from '~/components/ds/ds-nav' const descriptionStyles = { - page: 'mt-3 max-w-2xl text-ds-body-md text-text-secondary', - section: 'mt-1 max-w-2xl text-ds-body-sm text-text-secondary', + page: 'mt-3 max-w-[716px] text-ds-body-sm text-text-secondary', + section: 'mt-1 max-w-2xl text-ds-body-xs font-extralight text-text-secondary', preview: 'truncate text-ds-body-sm text-text-muted', } as const @@ -72,14 +73,19 @@ export function DsSection({ title, description, children, + className, }: { title: string description?: React.ReactNode children: React.ReactNode + className?: string }) { return ( -
-
+
+

{title}

diff --git a/src/components/ds/ds-nav.ts b/src/components/ds/ds-nav.ts index 45a443bd6..2fc0fcd7b 100644 --- a/src/components/ds/ds-nav.ts +++ b/src/components/ds/ds-nav.ts @@ -1,6 +1,7 @@ export interface DsNavItem { label: string to: string + sections?: Array } export interface DsNavSection { @@ -20,38 +21,184 @@ export const dsNav: Array = [ { 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' }, - { label: 'Shadows', to: '/ds/shadows' }, - { label: 'Effects', to: '/ds/effects' }, + { label: 'Overview', to: '/ds', sections: ['How to use this'] }, + { + label: 'Logos', + to: '/ds/logos', + sections: ['Brand logos', 'Social logos', 'Favicons', 'Usage'], + }, + { + label: 'Colors', + to: '/ds/colors', + sections: [ + 'Brand & neutral ramps', + 'Text', + 'Background', + 'Border', + 'Icon', + 'Action', + 'Status', + 'Accent', + ], + }, + { + label: 'Typography', + to: '/ds/typography', + sections: ['Font families', 'Documentation roles', 'Semantic headings'], + }, + { + label: 'Iconography', + to: '/ds/iconography', + sections: ['Browse'], + }, + { + label: 'Shadows', + to: '/ds/shadows', + sections: ['Elevation scale'], + }, + { + label: 'Effects', + to: '/ds/effects', + sections: ['Animated gradient text', 'Glass surface'], + }, ], }, { title: 'Figma Tokens', items: [ - { label: 'Palette', to: '/ds/palette' }, - { label: 'Semantic Tokens', to: '/ds/semantic' }, + { + label: 'Palette', + to: '/ds/palette', + sections: [ + 'Green', + 'Terracotta', + 'Blue', + 'Purple', + 'Amber', + 'Neutral', + 'Neutral tint (cool)', + 'Category colors', + 'Library brand colors', + ], + }, + { + label: 'Semantic Tokens', + to: '/ds/semantic', + sections: [ + 'Text', + 'Background', + 'Border', + 'Icon', + 'Action', + 'Status', + 'Accent', + ], + }, ], }, { title: 'Components', 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' }, - { label: 'Maintainers', to: '/ds/maintainers' }, - { label: 'Spinner', to: '/ds/spinner' }, - { label: 'Collapsible', to: '/ds/collapsible' }, - { label: 'Breadcrumbs', to: '/ds/breadcrumbs' }, - { label: 'Cards & Surfaces', to: '/ds/cards' }, - { label: 'Stats Section', to: '/ds/stats' }, - { label: 'Navbar', to: '/ds/navbar' }, + { + label: 'Buttons', + to: '/ds/buttons', + sections: [ + 'Variants', + 'Gradient (landing CTA)', + 'Colors', + 'Sizes', + 'Link buttons', + 'Icon buttons', + 'Rounded', + 'With icons & states', + 'Button group', + 'Leading & trailing icons', + 'Split button', + ], + }, + { + label: 'Badges', + to: '/ds/badges', + sections: ['Variants', 'Corner styles', 'In context'], + }, + { + label: 'Eyebrow', + to: '/ds/eyebrow', + sections: ['Context', 'Tones', 'With icon', 'Brand accent'], + }, + { + label: 'Inputs', + to: '/ds/inputs', + sections: [ + 'Default', + 'Focus rings', + 'With a label & disabled', + 'Progressive search', + 'Persistent search', + 'Large search target', + ], + }, + { + label: 'Dropdown', + to: '/ds/dropdown', + sections: ['Basic menu'], + }, + { + label: 'Avatar', + to: '/ds/avatar', + sections: [ + 'Sizes', + 'Fallbacks', + 'Maintainer card', + 'Responsive maintainer grid', + ], + }, + { + label: 'Spinner', + to: '/ds/spinner', + sections: ['Sizes & color', 'Headbanger'], + }, + { + label: 'Collapsible', + to: '/ds/collapsible', + sections: ['Disclosure'], + }, + { + label: 'Breadcrumbs', + to: '/ds/breadcrumbs', + sections: ['Section + on-this-page'], + }, + { + label: 'Cards & Surfaces', + to: '/ds/cards', + sections: ['Card', 'Inline code', 'Blog post card', 'Tooltip'], + }, + { + label: 'Stats Section', + to: '/ds/stats', + sections: ['Preview'], + }, + { + label: 'Navbar', + to: '/ds/navbar', + sections: [ + 'Anatomy', + 'Mega menu item', + 'Menu variants', + 'Regions', + 'Layout & spacing', + 'Responsive behavior', + 'Source', + ], + }, ], }, ] + +export function toDsSectionId(title: string) { + return title + .toLowerCase() + .replace(/&/g, ' and ') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') +} diff --git a/src/components/ds/ui/index.tsx b/src/components/ds/ui/index.tsx index aa1ad7632..b4c89f197 100644 --- a/src/components/ds/ui/index.tsx +++ b/src/components/ds/ui/index.tsx @@ -2,7 +2,13 @@ import * as React from 'react' import { twMerge } from 'tailwind-merge' import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import { Link } from '@tanstack/react-router' -import { CaretDownIcon, CircleNotchIcon, UserIcon } from '@phosphor-icons/react' +import { + CaretDownIcon, + CircleNotchIcon, + MagnifyingGlassIcon as MagnifyingGlass, + UserIcon, + XIcon as X, +} from '@phosphor-icons/react' import type { MarkdownHeading } from '~/utils/markdown' import type { LibraryId } from '~/libraries/ids' import type { LibraryCategory } from '~/libraries/categories' @@ -416,6 +422,157 @@ export const FormInput = React.forwardRef( }, ) +type FormSelectProps = React.SelectHTMLAttributes & { + focusRing?: 'blue' | 'orange' | 'purple' +} + +export const FormSelect = React.forwardRef( + function FormSelect({ className, focusRing = 'blue', ...props }, ref) { + return ( + { + props.onKeyDown?.(event) + if (progressive && event.key === 'Escape') close(true) + }} + /> + ) + + if (!progressive) { + return ( + + ) + } + + return ( +
+
+ + {React.cloneElement(input, { + tabIndex: open ? props.tabIndex : -1, + 'aria-hidden': !open, + className: twMerge( + input.props.className, + 'h-12 pb-4 pt-2 transition-opacity ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none', + open + ? 'opacity-100 duration-[220ms]' + : 'pointer-events-none opacity-0 duration-100', + skipMotion && 'transition-none', + ), + })} + +
+
+ ) + }, +) + /* ------------------------------------------------------------------- Card -- */ export function Card({ diff --git a/src/components/home/HomeNewsletterSection.tsx b/src/components/home/HomeNewsletterSection.tsx index 0a0769f7d..7e722e31f 100644 --- a/src/components/home/HomeNewsletterSection.tsx +++ b/src/components/home/HomeNewsletterSection.tsx @@ -1,6 +1,5 @@ import { EnvelopeSimpleIcon } from '@phosphor-icons/react' import { NewsletterSignup } from '~/components/NewsletterSignup' -import { Footer } from '~/components/Footer' export function HomeNewsletterSection() { return ( @@ -39,7 +38,6 @@ export function HomeNewsletterSection() {
-
) } diff --git a/src/components/shop/CartDrawer.tsx b/src/components/shop/CartDrawer.tsx index a4bfeabe0..e9582e004 100644 --- a/src/components/shop/CartDrawer.tsx +++ b/src/components/shop/CartDrawer.tsx @@ -39,7 +39,7 @@ export function CartDrawer({ open, onOpenChange }: CartDrawerProps) { 'max-h-[calc(100dvh-var(--navbar-height,56px)-1rem)]', 'flex flex-col rounded-xl', 'bg-shop-bg-2 border border-shop-line text-shop-text', - 'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.4)]', + 'shadow-2xl', )} aria-describedby={undefined} > @@ -89,7 +89,7 @@ function DrawerEmpty({ onClose }: { onClose: () => void }) { onClick={onClose} className=" inline-flex items-center gap-2 px-4 py-2.5 rounded-md - bg-shop-accent text-shop-accent-ink font-semibold text-[13px] + bg-shop-accent text-shop-accent-ink font-semibold text-shop-ui transition-[filter] hover:brightness-110 group " > @@ -125,7 +125,7 @@ function DrawerFooter({ href={cart.checkoutUrl} className=" w-full h-10 rounded-md bg-shop-accent text-shop-accent-ink - font-semibold text-[13px] flex items-center justify-center gap-2 + font-semibold text-shop-ui flex items-center justify-center gap-2 transition-[filter] hover:brightness-110 group " > diff --git a/src/components/shop/ProductCard.tsx b/src/components/shop/ProductCard.tsx index 4ce67bdd9..65fa2637c 100644 --- a/src/components/shop/ProductCard.tsx +++ b/src/components/shop/ProductCard.tsx @@ -1,99 +1,25 @@ import * as React from 'react' import { Link } from '@tanstack/react-router' import { ProductImage } from './ProductImage' -import { ShopMono } from './ui' +import { ShopBadge, ShopMono } from './ui' import { formatMoney, shopifyImageUrl } from '~/utils/shopify-format' import type { ProductListItem } from '~/utils/shopify-queries' - -const TWO_WEEKS_MS = 365 * 24 * 60 * 60 * 1000 - -// Kept in sync with ProductDrawer COLOR_HEX — last token wins ("Vintage Black" → black) -const COLOR_MAP: Record = { - black: '#0a0a0a', - white: '#f5f5f0', - cream: '#e4dcc4', - bone: '#e4dcc4', - natural: '#ddd3b8', - vintage: '#e8e0d0', - fog: '#c9c6ba', - sand: '#c8b97a', - ink: '#16130d', - navy: '#1a2e50', - slate: '#2e3339', - olive: '#5a5a3a', - rust: '#b84a27', - red: '#c41d1d', - blue: '#1d4ed8', - sea: '#3a5d66', - green: '#15803d', - gray: '#6b7280', - grey: '#6b7280', - charcoal: '#3a3a3c', - heather: '#8a8a9a', - denim: '#1a4569', - brown: '#6b3a2a', - pink: '#e8749a', - purple: '#7c3aed', - yellow: '#ca8a04', - orange: '#c2410c', - royal: '#4169e1', - kelly: '#4daa59', - aqua: '#00c4d4', - rose: '#c8818a', - dusty: '#c8818a', - coral: '#e8756a', - forest: '#228b22', - teal: '#0d9488', - lavender: '#967bb6', - lilac: '#967bb6', - tan: '#d2b48c', - ivory: '#fffff0', - gold: '#c9a227', - silver: '#a8a9ad', - ash: '#b2bec3', - stone: '#78716c', - moss: '#6b7c55', - sage: '#87a878', - sky: '#0ea5e9', - midnight: '#1e1b4b', - espresso: '#3c1f0f', - // card-specific - mixed: '#ef4c7a', - holo: '#d6e7ff', - polished: '#c5b07a', - blend: '#e8e0d0', -} - -// Last token wins: "Vintage Black" → ["vintage","black"] reversed → "black" wins -function colorHex(name: string): string | undefined { - const tokens = name - .toLowerCase() - .split(/[\s_-]+/) - .reverse() - for (const token of tokens) { - if (COLOR_MAP[token]) return COLOR_MAP[token] - } - return undefined -} - -const NEW_BADGE_GRADIENT = - 'linear-gradient(180deg, rgba(116,220,255,0.99) 8.23%, rgba(255,242,124,0.99) 29.88%, rgba(255,160,92,0.99) 61.46%, rgba(255,95,95,0.99) 89.43%)' +import { resolveShopProductColor } from '~/utils/shop-color' function NewBadge() { return ( -
- - NEW - -
+ New + ) } type ProductCardProps = { product: ProductListItem + isNew?: boolean sizes?: string loading?: 'eager' | 'lazy' onQuickView?: (handle: string) => void @@ -101,6 +27,7 @@ type ProductCardProps = { export function ProductCard({ product, + isNew = false, sizes, loading = 'lazy', onQuickView, @@ -109,15 +36,11 @@ export function ProductCard({ const compareAt = product.compareAtPriceRange?.minVariantPrice const isRange = minVariantPrice.amount !== maxVariantPrice.amount - const isNew = product.publishedAt - ? Date.now() - new Date(product.publishedAt).getTime() < TWO_WEEKS_MS - : false - const colorOption = product.options?.find((o) => /colou?r/i.test(o.name)) const swatches = colorOption ? colorOption.values.slice(0, 6).map((v) => ({ name: v, - hex: colorHex(v), + hex: resolveShopProductColor(v), })) : [] @@ -148,10 +71,10 @@ export function ProductCard({ const cardBody = (
@@ -208,8 +131,7 @@ export function ProductCard({ s.hex ? { background: s.hex } : { - background: - 'conic-gradient(from 30deg,#ef4c7a,#f4c74a,#22c993,#36d3f3,#4b9bff,#ef4c7a)', + background: 'var(--gradient-commerce-spectrum)', } } /> diff --git a/src/components/shop/ProductDrawer.tsx b/src/components/shop/ProductDrawer.tsx index d298f5d4d..85fc57f0a 100644 --- a/src/components/shop/ProductDrawer.tsx +++ b/src/components/shop/ProductDrawer.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { twMerge } from 'tailwind-merge' import { getProduct } from '~/utils/shop.functions' import type { @@ -7,87 +7,21 @@ import type { ProductDetailVariant, } from '~/utils/shopify-queries' import { formatMoney } from '~/utils/shopify-format' +import { resolveShopProductColor, shopColorContrast } from '~/utils/shop-color' import { ProductImage } from './ProductImage' -import { ShopMono, ShopSelect } from './ui' +import { + ShopButton, + ShopChip, + ShopLabel, + ShopMono, + ShopQty, + ShopSelect, + ShopSize, +} from './ui' import { useAddToCart } from '~/hooks/useCart' import { useCartDrawerStore } from './cartDrawerStore' -// Color name → hex for chip fill. Tokenizes multi-word names ("Vintage White" → "white"). -const COLOR_HEX: Record = { - black: '#0a0a0a', - white: '#f5f5f0', - cream: '#e4dcc4', - bone: '#e4dcc4', - natural: '#ddd3b8', - vintage: '#e8e0d0', - fog: '#c9c6ba', - sand: '#c8b97a', - ink: '#16130d', - navy: '#1a2e50', - slate: '#2e3339', - olive: '#5a5a3a', - rust: '#b84a27', - red: '#c41d1d', - blue: '#1d4ed8', - sea: '#3a5d66', - green: '#15803d', - gray: '#6b7280', - grey: '#6b7280', - charcoal: '#3a3a3c', - heather: '#8a8a9a', - denim: '#1a4569', - brown: '#6b3a2a', - pink: '#e8749a', - purple: '#7c3aed', - yellow: '#ca8a04', - orange: '#c2410c', - royal: '#4169e1', - kelly: '#4daa59', - aqua: '#00c4d4', - rose: '#c8818a', - dusty: '#c8818a', - coral: '#e8756a', - maroon: '#800020', - forest: '#228b22', - teal: '#0d9488', - lavender: '#967bb6', - lilac: '#967bb6', - tan: '#d2b48c', - ivory: '#fffff0', - gold: '#c9a227', - silver: '#a8a9ad', - ash: '#b2bec3', - stone: '#78716c', - moss: '#6b7c55', - sage: '#87a878', - sky: '#0ea5e9', - midnight: '#1e1b4b', - espresso: '#3c1f0f', -} - const MAX_INLINE_OPTION_VALUES = 8 -const DEFAULT_DRAWER_WIDTH = 1000 -const MIN_DRAWER_WIDTH = 320 -const MOBILE_DRAWER_BREAKPOINT = 768 - -function isDarkColor(hex: string): boolean { - const h = hex.replace('#', '') - const r = parseInt(h.slice(0, 2), 16) - const g = parseInt(h.slice(2, 4), 16) - const b = parseInt(h.slice(4, 6), 16) - return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.15 -} - -function resolveColorHex(name: string): string | undefined { - const lower = name.toLowerCase() - if (COLOR_HEX[lower]) return COLOR_HEX[lower] - // Reverse so last token wins ("Vintage Black" → black, not vintage) - const tokens = lower.split(/[\s_-]+/).reverse() - for (const token of tokens) { - if (COLOR_HEX[token]) return COLOR_HEX[token] - } - return undefined -} function findMatchingVariant( variants: Array, @@ -127,18 +61,6 @@ function getInitialProduct( return product } -function getDrawerMaxWidth() { - if (typeof window === 'undefined') return DEFAULT_DRAWER_WIDTH - if (window.innerWidth < MOBILE_DRAWER_BREAKPOINT) return window.innerWidth - return Math.floor(window.innerWidth * 0.8) -} - -function clampDrawerWidth(width: number) { - if (typeof window === 'undefined') return width - if (window.innerWidth < MOBILE_DRAWER_BREAKPOINT) return window.innerWidth - return Math.max(MIN_DRAWER_WIDTH, Math.min(getDrawerMaxWidth(), width)) -} - export function ProductDrawer({ productHandle, initialProduct, @@ -146,31 +68,8 @@ export function ProductDrawer({ onClose, onChange, }: ProductDrawerProps) { - const [width, setWidth] = React.useState(DEFAULT_DRAWER_WIDTH) - const [isDragging, setIsDragging] = React.useState(false) - const dragStateRef = React.useRef<{ startX: number; startW: number }>({ - startX: 0, - startW: 0, - }) - const drawerRef = React.useRef(null) - - // Restore persisted width on mount - React.useEffect(() => { - const saved = parseInt(localStorage.getItem('drawerWidth') ?? '0', 10) - setWidth( - clampDrawerWidth( - saved >= MIN_DRAWER_WIDTH ? saved : DEFAULT_DRAWER_WIDTH, - ), - ) - }, []) - - React.useEffect(() => { - const onResize = () => setWidth((current) => clampDrawerWidth(current)) - window.addEventListener('resize', onResize) - return () => window.removeEventListener('resize', onResize) - }, []) - const isOpen = !!productHandle + const queryClient = useQueryClient() // Keep the last-known handle alive through the exit animation so the drawer // slides out with content visible (not empty). Uses the derived-state pattern @@ -181,6 +80,12 @@ export function ProductDrawer({ const [prevProductHandle, setPrevProductHandle] = React.useState< string | null >(productHandle) + const [lastReadyHandle, setLastReadyHandle] = React.useState( + null, + ) + const [animatedProductHandle, setAnimatedProductHandle] = React.useState< + string | null + >(null) if (productHandle !== prevProductHandle) { setPrevProductHandle(productHandle) if (productHandle) setDisplayHandle(productHandle) @@ -189,7 +94,11 @@ export function ProductDrawer({ // Clear displayHandle after exit animation completes React.useEffect(() => { if (!productHandle) { - const t = setTimeout(() => setDisplayHandle(null), 400) + const t = setTimeout(() => { + setDisplayHandle(null) + setLastReadyHandle(null) + setAnimatedProductHandle(null) + }, 400) return () => clearTimeout(t) } }, [productHandle]) @@ -201,13 +110,26 @@ export function ProductDrawer({ queryFn: () => getProduct({ data: { handle: displayHandle! } }), enabled: !!displayHandle, initialData: getInitialProduct(displayHandle, initialProduct), + placeholderData: (previousProduct) => previousProduct, staleTime: 5 * 60 * 1000, }) - // Delay the open animation until data is in cache. Close animates immediately. - const isAnimatedOpen = isOpen && !!prefetchedProduct + // Keep the previous query data visible until the next product is ready. + const visibleProduct = displayHandle ? prefetchedProduct : null + + // Stagger only product-to-product swaps. On first open, the sheet entrance is + // the only motion so the content and container never compete visually. + if (visibleProduct && visibleProduct.handle !== lastReadyHandle) { + setAnimatedProductHandle(lastReadyHandle ? visibleProduct.handle : null) + setLastReadyHandle(visibleProduct.handle) + } + const shouldAnimateContent = animatedProductHandle === visibleProduct?.handle - const effectiveWidth = width + // Wait for data on the first open, but keep the sheet visible while products swap. + const hasOpened = React.useRef(false) + if (isOpen && prefetchedProduct) hasOpened.current = true + if (!isOpen && !displayHandle) hasOpened.current = false + const isAnimatedOpen = isOpen && hasOpened.current const navigateStep = React.useCallback( (dir: number) => { @@ -220,6 +142,27 @@ export function ProductDrawer({ [productHandle, allHandles, onChange], ) + // Keep neighboring product details warm so arrow navigation feels immediate. + React.useEffect(() => { + if (!productHandle || allHandles.length < 2) return + const currentIndex = allHandles.indexOf(productHandle) + if (currentIndex < 0) return + + const neighborHandles = [ + allHandles[(currentIndex - 1 + allHandles.length) % allHandles.length], + allHandles[(currentIndex + 1) % allHandles.length], + ] + + for (const handle of neighborHandles) { + if (!handle) continue + void queryClient.prefetchQuery({ + queryKey: ['shopify', 'product', handle], + queryFn: () => getProduct({ data: { handle } }), + staleTime: 5 * 60 * 1000, + }) + } + }, [allHandles, productHandle, queryClient]) + // Keyboard nav React.useEffect(() => { if (!isOpen) return @@ -232,40 +175,6 @@ export function ProductDrawer({ return () => window.removeEventListener('keydown', onKey) }, [isOpen, onClose, navigateStep]) - // Splitter drag - const onSplitterMouseDown = (e: React.MouseEvent) => { - e.preventDefault() - const currentW = drawerRef.current?.getBoundingClientRect().width ?? width - dragStateRef.current = { startX: e.clientX, startW: currentW } - setIsDragging(true) - } - - React.useEffect(() => { - if (!isDragging) return - const onMove = (e: MouseEvent) => { - const dx = dragStateRef.current.startX - e.clientX - const maxW = getDrawerMaxWidth() - const next = Math.max( - MIN_DRAWER_WIDTH, - Math.min(maxW, dragStateRef.current.startW + dx), - ) - setWidth(Math.round(next)) - } - const onUp = () => { - setIsDragging(false) - localStorage.setItem( - 'drawerWidth', - String(drawerRef.current?.getBoundingClientRect().width ?? width), - ) - } - window.addEventListener('mousemove', onMove) - window.addEventListener('mouseup', onUp) - return () => { - window.removeEventListener('mousemove', onMove) - window.removeEventListener('mouseup', onUp) - } - }, [isDragging, width]) - return ( <> {/* Scrim */} @@ -275,7 +184,7 @@ export function ProductDrawer({ tabIndex={isAnimatedOpen ? 0 : -1} onClick={onClose} className={twMerge( - 'fixed inset-0 z-[60] bg-black/50 backdrop-blur-sm transition-opacity duration-300', + 'shop-product-scrim fixed inset-0 z-[60] bg-black/50 backdrop-blur-sm transition-opacity motion-reduce:transition-none', isAnimatedOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none', @@ -284,63 +193,17 @@ export function ProductDrawer({ {/* Drawer */} + + {allHandles.length > 1 ? ( + <> + + + + + ) : null} ) } -/* ─── Lazy-loaded drawer body ─────────────────────────────────────────── */ +/* ─── Full product content ────────────────────────────────────────────── */ -function DrawerBody({ - productHandle, - initialProduct, +function DrawerContent({ + product, + animateIn, }: { - productHandle: string - initialProduct?: ProductDetail | null + product: ProductDetail + animateIn: boolean }) { - const { data: product, isLoading } = useQuery({ - queryKey: ['shopify', 'product', productHandle], - queryFn: () => getProduct({ data: { handle: productHandle } }), - initialData: getInitialProduct(productHandle, initialProduct), - staleTime: 5 * 60 * 1000, - }) - - if (isLoading || !product) { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
- ) - } - - return -} - -/* ─── Full product content ────────────────────────────────────────────── */ - -function DrawerContent({ product }: { product: ProductDetail }) { const variants = product.variants.nodes const [selected, setSelected] = React.useState>(() => @@ -463,14 +349,16 @@ function DrawerContent({ product }: { product: ProductDetail }) { }, [showAdded]) return ( -
+
- {/* Hero image + vertical thumbnail strip */} + {/* Hero image + horizontal thumbnail carousel */}
- {/* Main image — leaves room for thumbnail strip when present */} -
1 ? ' mr-[108px]' : ''}`} - > +
{heroImage ? ( - {/* Vertical thumbnail strip — absolutely pinned so it scrolls within hero height */} + {/* Thumbnail carousel */} {product.images.nodes.length > 1 ? ( -
+
{product.images.nodes.map((img, i) => ( + ) })}
@@ -635,11 +513,11 @@ function DrawerContent({ product }: { product: ProductDetail }) { return (
- + {option.name} - +
{option.values.map((value) => { const isSelected = selected[option.name] === value @@ -648,46 +526,23 @@ function DrawerContent({ product }: { product: ProductDetail }) { [option.name]: value, }) const isUnavailable = !match?.availableForSale - const hex = resolveColorHex(value) + const hex = resolveShopProductColor(value) return ( - + ) })}
@@ -696,40 +551,25 @@ function DrawerContent({ product }: { product: ProductDetail }) { })} {/* Quantity pill */} -
- +
+ Quantity - -
- - - {quantity} - - -
+
+
{/* Add to Cart */} -
-
{/* Description */} {product.descriptionHtml ? ( -
+
Description
diff --git a/src/components/shop/ShopDropCard.tsx b/src/components/shop/ShopDropCard.tsx index ffeec2d08..ddc271f3a 100644 --- a/src/components/shop/ShopDropCard.tsx +++ b/src/components/shop/ShopDropCard.tsx @@ -27,16 +27,16 @@ export function ShopDropCard({ status, headline, rows, cta, ctaSub }: Props) { {status} -
+
{headline}
{rows?.map((row) => (
{row.label} - + {row.value}
@@ -46,7 +46,7 @@ export function ShopDropCard({ status, headline, rows, cta, ctaSub }: Props) { className=" mt-3 flex items-center justify-center gap-2 px-3 py-2.5 rounded-md bg-shop-accent text-shop-accent-ink - text-[12.5px] font-semibold tracking-[0.01em] + text-shop-sm font-semibold tracking-[0.01em] transition-[filter] hover:brightness-110 group " > @@ -56,7 +56,7 @@ export function ShopDropCard({ status, headline, rows, cta, ctaSub }: Props) { {ctaSub ? ( - + {ctaSub} ) : null} diff --git a/src/components/shop/ShopNote.tsx b/src/components/shop/ShopNote.tsx index c85c54c1b..782294d34 100644 --- a/src/components/shop/ShopNote.tsx +++ b/src/components/shop/ShopNote.tsx @@ -11,9 +11,9 @@ export function ShopNote({ children: React.ReactNode }) { return ( -
+
{pill ? ( - + {pill} ) : null} diff --git a/src/components/shop/ShopSpecs.tsx b/src/components/shop/ShopSpecs.tsx index 50006f8bf..077face03 100644 --- a/src/components/shop/ShopSpecs.tsx +++ b/src/components/shop/ShopSpecs.tsx @@ -8,11 +8,11 @@ export function ShopSpecs({ specs }: { specs: Array }) {
- + {spec.label} {spec.value} diff --git a/src/components/shop/ShopStrip.tsx b/src/components/shop/ShopStrip.tsx index bb83ddb3a..5f1a20c90 100644 --- a/src/components/shop/ShopStrip.tsx +++ b/src/components/shop/ShopStrip.tsx @@ -3,7 +3,7 @@ import { ShopPanel } from './ui' /** Bottom-of-page info strip (shipping, returns, etc.). */ export function ShopStrip({ items }: { items: Array }) { return ( - + {items.map((item) => ( = { - new: 'bg-shop-accent text-shop-accent-ink border-transparent font-semibold', + new: 'text-black border-transparent font-semibold [background:var(--gradient-commerce-new)]', sale: 'text-shop-orange border-shop-orange bg-transparent', neutral: 'bg-shop-bg-2 text-shop-text border-shop-line-2', } diff --git a/src/components/shop/ui/Chip.tsx b/src/components/shop/ui/Chip.tsx index 222b28f2a..24ec4de6b 100644 --- a/src/components/shop/ui/Chip.tsx +++ b/src/components/shop/ui/Chip.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { twMerge } from 'tailwind-merge' +import { shopColorContrast, shopColorWithAlpha } from '~/utils/shop-color' type Props = React.ButtonHTMLAttributes & { isSelected?: boolean @@ -30,15 +31,17 @@ export const ShopChip = React.forwardRef( ) { const chipStyle: React.CSSProperties = colorBg ? { - backgroundColor: isSelected ? colorBg : hexToRgba(colorBg, 0.8), - borderColor: isSelected ? colorBg : hexToRgba(colorBg, 0.5), - color: contrastColor(colorBg), + backgroundColor: isSelected + ? colorBg + : shopColorWithAlpha(colorBg, 0.8), + borderColor: isSelected ? colorBg : shopColorWithAlpha(colorBg, 0.5), + color: shopColorContrast(colorBg), } : isSelected && selectedBg ? { backgroundColor: selectedBg, borderColor: selectedBg, - color: selectedTextColor ?? contrastColor(selectedBg), + color: selectedTextColor ?? shopColorContrast(selectedBg), } : {} @@ -71,21 +74,3 @@ export const ShopChip = React.forwardRef( ) }, ) - -function hexToRgba(hex: string, alpha: number): string { - const h = hex.replace('#', '') - const r = parseInt(h.slice(0, 2), 16) - const g = parseInt(h.slice(2, 4), 16) - const b = parseInt(h.slice(4, 6), 16) - return `rgba(${r}, ${g}, ${b}, ${alpha})` -} - -/** Returns '#000' or '#fff' based on the perceived luminance of a hex color. */ -function contrastColor(hex: string): string { - const h = hex.replace('#', '') - const r = parseInt(h.slice(0, 2), 16) - const g = parseInt(h.slice(2, 4), 16) - const b = parseInt(h.slice(4, 6), 16) - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 - return luminance > 0.55 ? '#000000' : '#ffffff' -} diff --git a/src/components/shop/ui/Crumb.tsx b/src/components/shop/ui/Crumb.tsx index 9b74be9be..f60078d4a 100644 --- a/src/components/shop/ui/Crumb.tsx +++ b/src/components/shop/ui/Crumb.tsx @@ -11,7 +11,7 @@ export function ShopCrumbs({ crumbs }: { crumbs: Array }) { return (
-
) } diff --git a/src/routes/blog.index.tsx b/src/routes/blog.index.tsx index 28006d33b..50a4311f3 100644 --- a/src/routes/blog.index.tsx +++ b/src/routes/blog.index.tsx @@ -5,7 +5,6 @@ import { BlogAuthorFilter } from '~/components/BlogAuthorFilter' import { BlogCard, type BlogCardPost } from '~/components/BlogCard' import { BlogSearchFilter } from '~/components/BlogSearchFilter' import { Card } from '~/components/Card' -import { Footer } from '~/components/Footer' import { LibrariesWidget } from '~/components/LibrariesWidget' import { RecentPostsWidget } from '~/components/RecentPostsWidget' import { PartnersRail, RightRail } from '~/components/RightRail' @@ -204,7 +203,6 @@ function BlogIndex() {
-
) } diff --git a/src/routes/ds.avatar.tsx b/src/routes/ds.avatar.tsx index 6f5d3dcd4..6ae223788 100644 --- a/src/routes/ds.avatar.tsx +++ b/src/routes/ds.avatar.tsx @@ -1,7 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' import { seo } from '~/utils/seo' import { Avatar } from '~/components/ds/ui' +import { MaintainerCard } from '~/components/MaintainerCard' import { ComponentPreview, DsPage, DsSection } from '~/components/ds/DsKit' +import { coreMaintainers } from '~/libraries/maintainers' export const Route = createFileRoute('/ds/avatar')({ component: AvatarPage, @@ -19,7 +21,7 @@ function AvatarPage() { return ( + + + `} + > +
+ +
+
+
+ + + + {maintainers.map((maintainer) => ( + + ))} +
`} + > + {coreMaintainers.slice(0, 3).map((maintainer) => ( + + ))} + + ) } diff --git a/src/routes/ds.buttons.tsx b/src/routes/ds.buttons.tsx index aee6de1a5..75242d644 100644 --- a/src/routes/ds.buttons.tsx +++ b/src/routes/ds.buttons.tsx @@ -1,4 +1,4 @@ -import type * as React from 'react' +import * as React from 'react' import { createFileRoute } from '@tanstack/react-router' import { ArrowRightIcon, @@ -84,6 +84,28 @@ function ButtonMode({ ) } +const PERIODS = ['Day', 'Week', 'Month'] as const + +function SegmentedButtonGroup() { + const [period, setPeriod] = React.useState<(typeof PERIODS)[number]>('Week') + + return ( + + {PERIODS.map((option) => ( + + ))} + + ) +} + function ButtonsPage() { return ( Blue @@ -262,22 +285,12 @@ function ButtonsPage() { > - - - + + + `} > - - - - - + diff --git a/src/routes/ds.eyebrow.tsx b/src/routes/ds.eyebrow.tsx index bb792a8a2..efec7d092 100644 --- a/src/routes/ds.eyebrow.tsx +++ b/src/routes/ds.eyebrow.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { createFileRoute } from '@tanstack/react-router' import { SparkleIcon, GitBranchIcon, DatabaseIcon } from '@phosphor-icons/react' import { seo } from '~/utils/seo' -import { Eyebrow } from '~/components/ds/ui' +import { Eyebrow, FormSelect } from '~/components/ds/ui' import { ComponentPreview, DsPage, DsSection } from '~/components/ds/DsKit' import type { LibraryId } from '~/libraries/ids' @@ -42,18 +42,23 @@ function EyebrowPlayground() { diff --git a/src/routes/ds.inputs.tsx b/src/routes/ds.inputs.tsx index 68b1df8de..1531c4884 100644 --- a/src/routes/ds.inputs.tsx +++ b/src/routes/ds.inputs.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { seo } from '~/utils/seo' -import { FormInput } from '~/components/ds/ui' +import { FormInput, SearchInput } from '~/components/ds/ui' import { ComponentPreview, DsPage, DsSection } from '~/components/ds/DsKit' export const Route = createFileRoute('/ds/inputs')({ @@ -68,6 +68,42 @@ function InputsPage() { + + + `} + > + + + + + + `} + > + + + + + + `} + > + + + ) } diff --git a/src/routes/ds.maintainers.tsx b/src/routes/ds.maintainers.tsx index 08040ec5d..e40140671 100644 --- a/src/routes/ds.maintainers.tsx +++ b/src/routes/ds.maintainers.tsx @@ -1,57 +1,7 @@ -import { createFileRoute } from '@tanstack/react-router' -import { MaintainerCard } from '~/components/MaintainerCard' -import { ComponentPreview, DsPage, DsSection } from '~/components/ds/DsKit' -import { coreMaintainers } from '~/libraries/maintainers' -import { seo } from '~/utils/seo' +import { createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/ds/maintainers')({ - component: MaintainersPage, - head: () => ({ - meta: seo({ - title: 'Maintainers | TanStack Design System', - description: - 'The shared maintainer card used for core teams, instructors, and library maintainers across TanStack.', - }), - }), + beforeLoad: () => { + throw redirect({ to: '/ds/avatar', hash: 'maintainer-card' }) + }, }) - -function MaintainersPage() { - return ( - - - `} - > -
- -
-
-
- - - - {maintainers.map((maintainer) => ( - - ))} -`} - > - {coreMaintainers.slice(0, 3).map((maintainer) => ( - - ))} - - -
- ) -} diff --git a/src/routes/ds.tsx b/src/routes/ds.tsx index eefaccbb4..d8bc60fae 100644 --- a/src/routes/ds.tsx +++ b/src/routes/ds.tsx @@ -1,6 +1,13 @@ -import { Link, Outlet, createFileRoute } from '@tanstack/react-router' +import * as React from 'react' +import { CaretRightIcon as CaretRight } from '@phosphor-icons/react' +import { + Link, + Outlet, + createFileRoute, + useLocation, +} from '@tanstack/react-router' import { seo } from '~/utils/seo' -import { dsNav } from '~/components/ds/ds-nav' +import { dsNav, toDsSectionId } from '~/components/ds/ds-nav' export const Route = createFileRoute('/ds')({ component: DesignSystemLayout, @@ -14,6 +21,79 @@ export const Route = createFileRoute('/ds')({ }) function DesignSystemSidebar() { + const location = useLocation() + const [expandedPath, setExpandedPath] = React.useState( + location.pathname, + ) + const cancelScrollRef = React.useRef<() => void>(() => {}) + + React.useEffect(() => { + setExpandedPath(location.pathname) + }, [location.pathname]) + + React.useEffect(() => () => cancelScrollRef.current(), []) + + const navigateToSection = ( + event: React.MouseEvent, + path: string, + sectionId: string, + ) => { + if (path !== location.pathname) return + + const target = document.getElementById(sectionId) + if (!target) return + + event.preventDefault() + window.history.pushState(null, '', `#${sectionId}`) + const reducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches + + if (event.detail === 0 || reducedMotion) { + target.scrollIntoView({ behavior: 'auto', block: 'start' }) + return + } + + cancelScrollRef.current() + const start = window.scrollY + const scrollMargin = Number.parseFloat( + window.getComputedStyle(target).scrollMarginTop, + ) + const destination = + start + target.getBoundingClientRect().top - scrollMargin + const distance = destination - start + const duration = Math.min(480, Math.max(300, Math.abs(distance) * 0.4)) + const startedAt = performance.now() + let frame = 0 + + const cancel = () => { + window.cancelAnimationFrame(frame) + window.removeEventListener('wheel', cancel) + window.removeEventListener('touchstart', cancel) + window.removeEventListener('pointerdown', cancel) + window.removeEventListener('keydown', cancel) + } + + const step = (now: number) => { + const progress = Math.min(1, (now - startedAt) / duration) + const eased = 1 - Math.pow(1 - progress, 5) + window.scrollTo(0, start + distance * eased) + + if (progress < 1) { + frame = window.requestAnimationFrame(step) + } else { + cancel() + } + } + + cancelScrollRef.current = cancel + window.addEventListener('wheel', cancel, { passive: true }) + window.addEventListener('touchstart', cancel, { passive: true }) + window.addEventListener('pointerdown', cancel, { passive: true }) + window.addEventListener('keydown', cancel) + frame = window.requestAnimationFrame(step) + } + return (