diff --git a/package.json b/package.json index d5901a78d..6426a014a 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "drizzle-orm": "^0.45.2", "encoding-sniffer": "^0.2.1", "esbuild-wasm": "0.27.4", + "figma-squircle": "^1.1.0", "gifenc": "^1.0.3", "gray-matter": "^4.0.3", "iconv-lite": "^0.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02d2eec6e..1ed76f9c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: esbuild-wasm: specifier: 0.27.4 version: 0.27.4 + figma-squircle: + specifier: ^1.1.0 + version: 1.1.0 gifenc: specifier: ^1.0.3 version: 1.0.3 @@ -5045,6 +5048,9 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figma-squircle@1.1.0: + resolution: {integrity: sha512-3aa6bOurrsybsuYo1TMRuasBpXgVjgnmOUtZ40il6e1dJF2lhTRq89z7oy1uzKDspbH3630rLZD2UvJqXDiktA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -11617,6 +11623,8 @@ snapshots: fflate@0.8.2: {} + figma-squircle@1.1.0: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 diff --git a/src/components/ApplicationStarter.tsx b/src/components/ApplicationStarter.tsx index c4ec2c9b7..99bafd351 100644 --- a/src/components/ApplicationStarter.tsx +++ b/src/components/ApplicationStarter.tsx @@ -18,11 +18,7 @@ import type { ApplicationStarterContext, ApplicationStarterResult, } from '~/utils/application-starter' -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '~/components/Collapsible' +import { Panel, PanelContent, PanelTrigger } from '~/components/Panel' import { GeneratedPromptPreviewBody, GeneratedPromptPreviewHeader, @@ -868,8 +864,8 @@ export function ApplicationStarter({ ) : null} - - + + {showOptionsSection ? (
@@ -946,8 +942,8 @@ export function ApplicationStarter({
) : null} -
-
+ + ) : ( <> @@ -964,7 +960,11 @@ export function ApplicationStarter({ // focus glow (an absolute child at -z-10) bleed out from behind // the card; the inner wrapper below re-clips the card content. isHomeStarter && - 'overflow-visible rounded-[36px] [corner-shape:squircle] dark:border-transparent dark:bg-[#171717]', + 'overflow-visible rounded-[36px] [corner-shape:squircle] transition-colors dark:border-transparent dark:bg-[#171717]', + // Light: a subtle recessed fill at rest that brightens to white + // on focus (no shadow). Dark keeps its #171717 fill. + isHomeStarter && + (isPromptFocused ? 'bg-white' : 'bg-gray-50'), )} > {/* Home: a warm terracotta glow that grows in from behind the card @@ -1108,9 +1108,11 @@ export function ApplicationStarter({ ) : null} {/* Home: the hint sits lateral to the prompt text (top-right); - once the user types, the gradient Go CTA replaces it. */} + once the user types, the gradient Go CTA replaces it. The + h-6 box matches the prompt's first line (leading-6 at top-6) + so the hint text is vertically centered on that line. */} {isHomeStarter ? ( -
+
{showActionSection ? (
) : null} - - + + {showPromptPreview && hasGeneratedPrompt ? (
@@ -1571,9 +1573,9 @@ function StarterCustomizationSection({ title: string }) { return ( - +
- - - {open ? children : null} + + {open ? children : null}
-
+ ) } diff --git a/src/components/BlogBrowseNav.tsx b/src/components/BlogBrowseNav.tsx deleted file mode 100644 index 58fcc7c31..000000000 --- a/src/components/BlogBrowseNav.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import * as React from 'react' -import { CaretDownIcon, RssIcon } from '@phosphor-icons/react' -import { twMerge } from 'tailwind-merge' -import { BlogAuthorFilter } from '~/components/BlogAuthorFilter' -import { BlogSearchFilter } from '~/components/BlogSearchFilter' -import { Eyebrow } from '~/components/ds/ui' -import { categoryTextColor, libraryCategories } from '~/libraries/categories' -import { fallbackLibraryIcon, libraryIcons } from '~/libraries/icons' -import type { LibrarySlim } from '~/libraries' - -export type BlogTopic = { library: LibrarySlim; count: number } -export type BlogYear = { year: string; count: number } - -type BlogBrowseNavProps = { - searchQuery: string - onSearchChange: (query: string) => void - topics: Array - totalCount: number - selectedLibrary: string | undefined - onLibraryToggle: (libraryId: string | undefined) => void - authors: Array - selectedAuthor: string | undefined - onAuthorChange: (author: string | undefined) => void - years: Array - selectedYear: string | undefined - onYearToggle: (year: string | undefined) => void - hasActiveFilters: boolean - onClearAll: () => void - /** Scopes the search input's `id` so the desktop and mobile copies of this - * nav don't collide on duplicate ids. */ - idPrefix: string -} - -const rowClassName = - 'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors' - -function rowStateClass(isActive: boolean) { - return isActive - ? 'bg-blue-500/10 font-semibold text-blue-700 dark:text-blue-300' - : 'text-text-secondary hover:bg-surface-state-hover' -} - -// The count rides alongside the label in parens (ragged right edge) rather than -// right-aligned in its own column. -function Count({ value }: { value: number }) { - return ({value}) -} - -// A collapsible section header (Eyebrow + chevron) for progressive disclosure. -function DisclosureSummary({ label }: { label: string }) { - return ( - - {label} - - - ) -} - -export function BlogBrowseNav({ - searchQuery, - onSearchChange, - topics, - totalCount, - selectedLibrary, - onLibraryToggle, - authors, - selectedAuthor, - onAuthorChange, - years, - selectedYear, - onYearToggle, - hasActiveFilters, - onClearAll, - idPrefix, -}: BlogBrowseNavProps) { - // Progressive disclosure: Topics and Archive start collapsed, but open on - // mount if that facet already has an active selection. - const [topicsOpen, setTopicsOpen] = React.useState(Boolean(selectedLibrary)) - const [archiveOpen, setArchiveOpen] = React.useState(Boolean(selectedYear)) - - return ( - - ) -} diff --git a/src/components/BlogFilterBar.tsx b/src/components/BlogFilterBar.tsx new file mode 100644 index 000000000..bd3635cd6 --- /dev/null +++ b/src/components/BlogFilterBar.tsx @@ -0,0 +1,357 @@ +import * as React from 'react' +import { + CaretDownIcon, + CheckIcon, + RssIcon, + SlidersHorizontalIcon, +} from '@phosphor-icons/react' +import { twMerge } from 'tailwind-merge' +import { + Button, + Dropdown, + DropdownContent, + DropdownItem, + DropdownTrigger, + SearchInput, +} from '~/components/ds/ui' +import { categoryTextColor, libraryCategories } from '~/libraries/categories' +import { fallbackLibraryIcon, libraryIcons } from '~/libraries/icons' +import type { LibrarySlim } from '~/libraries' + +export type BlogTopic = { library: LibrarySlim; count: number } +export type BlogYear = { year: string; count: number } + +type BlogFilterBarProps = { + searchQuery: string + onSearchChange: (query: string) => void + topics: Array + totalCount: number + selectedLibrary: string | undefined + onLibraryToggle: (libraryId: string | undefined) => void + authors: Array + selectedAuthor: string | undefined + onAuthorChange: (author: string | undefined) => void + years: Array + selectedYear: string | undefined + onYearToggle: (year: string | undefined) => void + hasActiveFilters: boolean + onClearAll: () => void +} + +// The DS ghost Button pre-applies its hover treatment below 900px as a touch +// affordance; in this outlined toolbar that reads as a filled/"selected" state. +// Keep the DS Button but flatten it back to a plain outlined pill on mobile so +// it matches the search field. (Applied only to idle/ghost buttons — an active +// facet keeps its filled secondary look.) +const FLAT_ON_MOBILE = + 'max-[899px]:border-border-default max-[899px]:bg-transparent max-[899px]:shadow-none' + +// The count rides alongside the label in parens, matching the old browse nav. +function Count({ value }: { value: number }) { + return ({value}) +} + +function MenuItem({ + selected, + onSelect, + children, +}: { + selected: boolean + onSelect: () => void + children: React.ReactNode +}) { + return ( + + + {children} + + {selected ? ( + + ) : null} + + ) +} + +export function BlogFilterBar({ + searchQuery, + onSearchChange, + topics, + totalCount, + selectedLibrary, + onLibraryToggle, + authors, + selectedAuthor, + onAuthorChange, + years, + selectedYear, + onYearToggle, + hasActiveFilters, + onClearAll, +}: BlogFilterBarProps) { + // On narrow screens the facet dropdowns collapse behind a single toggle and + // expand into a horizontal, scrollable row beneath the search field. + const [mobileOpen, setMobileOpen] = React.useState(false) + + // Ignore URL author values that don't correspond to a real author. + const activeAuthor = + selectedAuthor && authors.includes(selectedAuthor) + ? selectedAuthor + : undefined + + const selectedTopic = topics.find( + ({ library }) => library.id === selectedLibrary, + ) + const topicLabel = selectedTopic + ? selectedTopic.library.name.replace('TanStack ', '') + : 'All topics' + const selectedTopicCategory = selectedTopic + ? (libraryCategories[selectedTopic.library.id] ?? 'tooling') + : undefined + const SelectedTopicIcon = selectedTopic + ? (libraryIcons[selectedTopic.library.id] ?? fallbackLibraryIcon) + : null + + // The facet dropdowns, rendered in both the desktop inline row and the + // mobile expandable column (a fresh call so each slot owns its instances). + // Each is the DS Dropdown + a DS Button trigger (ghost when idle, secondary + // when a selection is active), matching the canonical Dropdown pattern. + // `block` makes the triggers full-width (label left, caret right) for the + // stacked mobile column. + const renderFilters = (block = false) => { + const triggerClass = block ? 'h-10 w-full justify-between' : 'h-10 shrink-0' + return ( + <> + + + + + + onLibraryToggle(undefined)} + > + + All topics + + + + {topics.map(({ library, count }) => { + const Icon = libraryIcons[library.id] ?? fallbackLibraryIcon + const category = libraryCategories[library.id] ?? 'tooling' + const isActive = selectedLibrary === library.id + return ( + + onLibraryToggle(isActive ? undefined : library.id) + } + > + + + {library.name.replace('TanStack ', '')} + + + + ) + })} + + + + {authors.length ? ( + + + + + + onAuthorChange(undefined)} + > + All authors + + {authors.map((name) => { + const isActive = activeAuthor === name + return ( + onAuthorChange(isActive ? undefined : name)} + > + {name} + + ) + })} + + + ) : null} + + {years.length ? ( + + + + + + onYearToggle(undefined)} + > + All years + + {years.map(({ year, count }) => { + const isActive = selectedYear === year + return ( + onYearToggle(isActive ? undefined : year)} + > + + {year} + + + + ) + })} + + + ) : null} + + {hasActiveFilters ? ( + + ) : null} + + ) + } + + return ( +
+
+
+ onSearchChange(event.currentTarget.value)} + /> +
+ +
+ {/* Wide screens: the facet dropdowns sit inline on the bar. */} +
+ {renderFilters()} +
+ + {/* Narrow screens: one toggle that expands the horizontal set below. */} + + + +
+
+ + {/* Mobile-only expandable column: the facet dropdowns stacked full-width, + animated open via a grid-rows height transition. */} +
+
+
{renderFilters(true)}
+
+
+
+ ) +} diff --git a/src/components/ButtonGroup.tsx b/src/components/ButtonGroup.tsx index e2b6d9502..c22aa36f6 100644 --- a/src/components/ButtonGroup.tsx +++ b/src/components/ButtonGroup.tsx @@ -1,8 +1,23 @@ import * as React from 'react' import { twMerge } from 'tailwind-merge' +import { + segmentClasses, + segmentTrackClasses, + type SegmentSize, +} from '~/components/ds/ui/Tabs' type ButtonGroupProps = React.ComponentProps<'div'> +/** + * Low-level toolbar container: joins whatever buttons you drop in with shared + * edges and a single outer border, restyling children via descendant selectors + * so it's agnostic to which Button you use. Reach for this when the group is + * heterogeneous — mixed toggles, a primary action, a dropdown trigger. For a + * single-select segmented control, use `SegmentedControl` below instead. + * + * The `[&>[aria-pressed=true]]` selector paints the pressed/selected child, so + * any button that sets `aria-pressed` gets the active treatment for free. + */ export function ButtonGroup({ children, className, @@ -25,3 +40,78 @@ export function ButtonGroup({
) } + +/* ------------------------------------------------------- SegmentedControl -- */ + +type SegmentedControlOption = { + value: TValue + /** Visible label — accepts any node, so lead with an icon: `<> Day`. */ + label: React.ReactNode + /** Accessible name when the label is icon-only or needs disambiguation. */ + 'aria-label'?: string + disabled?: boolean +} + +type SegmentedControlProps = { + options: ReadonlyArray> + value: TValue + onValueChange: (value: TValue) => void + /** Labels the group for assistive tech (the control has no visible label). */ + 'aria-label': string + size?: SegmentSize + /** Stretch to fill the container, splitting width evenly across options. */ + fullWidth?: boolean + className?: string +} + +/** + * Declarative single-select segmented control. Give it `options` and a + * controlled `value`; it manages selection, the `aria-pressed` state, and group + * semantics. A flat, equal-size segmented track: unselected options are + * transparent, hover fills with the warm `action-secondary`, and the selected + * option is a filled squircle chip (`background-inverse`). Shares the segment + * styling with `Tabs` so the two stay visually locked. For tabs that switch + * page content (with panels + tab a11y), use `Tabs` instead. + */ +export function SegmentedControl({ + options, + value, + onValueChange, + 'aria-label': ariaLabel, + size = 'md', + fullWidth = false, + className, +}: SegmentedControlProps) { + return ( +
+ {options.map((option) => { + const selected = value === option.value + return ( + + ) + })} +
+ ) +} diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 77417c6d5..9d77329b7 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -21,7 +21,7 @@ export const Card: CardComponent = ({ as, children, className, ...props }) => { Component, { className: twMerge( - 'bg-white dark:bg-gray-900 rounded-lg shadow-md border border-gray-200 dark:border-gray-800', + 'bg-white dark:bg-gray-900 rounded-lg corner-squircle shadow-md border border-gray-200 dark:border-gray-800', className, ), ...props, diff --git a/src/components/Collapsible.tsx b/src/components/Collapsible.tsx deleted file mode 100644 index c126f9723..000000000 --- a/src/components/Collapsible.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import * as React from 'react' -import { twMerge } from 'tailwind-merge' - -type CollapsibleRenderProps = { - open: boolean - orientation: 'horizontal' | 'vertical' - toggle: () => void -} - -type CollapsibleProps = { - open?: boolean - defaultOpen?: boolean - orientation?: 'horizontal' | 'vertical' - onOpenChange?: (open: boolean) => void - children: - | React.ReactNode - | ((props: CollapsibleRenderProps) => React.ReactNode) - className?: string -} - -type CollapsibleTriggerProps = React.ButtonHTMLAttributes & { - children: React.ReactNode -} - -type CollapsibleContentProps = React.HTMLAttributes & { - children: React.ReactNode -} - -const CollapsibleContext = React.createContext( - null, -) - -function useCollapsible() { - const context = React.useContext(CollapsibleContext) - if (!context) { - throw new Error('Collapsible components must be used within a Collapsible') - } - return context -} - -export function Collapsible({ - open: controlledOpen, - defaultOpen = false, - orientation = 'vertical', - onOpenChange, - children, - className, -}: CollapsibleProps) { - const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen) - - const isControlled = controlledOpen !== undefined - const open = isControlled ? controlledOpen : uncontrolledOpen - - const toggle = React.useCallback(() => { - if (isControlled) { - onOpenChange?.(!open) - } else { - setUncontrolledOpen((prev) => { - const next = !prev - onOpenChange?.(next) - return next - }) - } - }, [isControlled, open, onOpenChange]) - - const value = React.useMemo( - () => ({ open, orientation, toggle }), - [open, orientation, toggle], - ) - - return ( - -
- {typeof children === 'function' ? children(value) : children} -
-
- ) -} - -export function CollapsibleTrigger({ - children, - className, - onClick, - onMouseDown, - type = 'button', - ...props -}: CollapsibleTriggerProps) { - const { open, toggle } = useCollapsible() - - return ( - - ) -} - -export const CollapsibleContent = React.forwardRef< - HTMLDivElement, - CollapsibleContentProps ->(function CollapsibleContent({ children, className, ...props }, ref) { - const { open, orientation } = useCollapsible() - const horizontal = orientation === 'horizontal' - - return ( -
-
- {children} -
-
- ) -}) diff --git a/src/components/FrameworkIconTabs.tsx b/src/components/FrameworkIconTabs.tsx index e1cbb6d1a..0034eeece 100644 --- a/src/components/FrameworkIconTabs.tsx +++ b/src/components/FrameworkIconTabs.tsx @@ -21,20 +21,27 @@ export function FrameworkIconTabs({ [frameworks], ) + // Not a real tablist: these buttons don't switch a tab panel, they swap the + // StackBlitz embed via `onChange`. So this is a labeled group of toggle + // buttons (`aria-pressed`) rather than tabs — each button is individually + // Tab-focusable, no roving tabindex needed. return (
{options.map((opt) => (
diff --git a/src/components/LibraryStatusBadge.tsx b/src/components/LibraryStatusBadge.tsx index f733e127a..8fc1d04f3 100644 --- a/src/components/LibraryStatusBadge.tsx +++ b/src/components/LibraryStatusBadge.tsx @@ -4,15 +4,6 @@ import type { LibrarySlim } from '~/libraries' type LibraryBadge = NonNullable -const libraryBadgeVariants = { - new: 'success', - fresh: 'success', - soon: 'default', - alpha: 'info', - beta: 'warning', - RC: 'warning', -} as const - export function LibraryStatusBadge({ badge, className, @@ -20,12 +11,15 @@ export function LibraryStatusBadge({ badge: LibraryBadge className?: string }) { + // Status badges are intentionally neutral: the word (ALPHA / BETA / RC…) + // carries the meaning, color carries none. A single quiet chip never competes + // with a library's brand color and reads consistently across every surface. return ( diff --git a/src/components/MaintainerCard.tsx b/src/components/MaintainerCard.tsx index 9fc5693e6..7890bd91a 100644 --- a/src/components/MaintainerCard.tsx +++ b/src/components/MaintainerCard.tsx @@ -262,7 +262,7 @@ export function CompactMaintainerCard({ target="_blank" rel="noopener noreferrer" aria-label={`View ${maintainer.name}'s GitHub profile`} - className="group relative aspect-square block rounded-lg shadow-xs border border-gray-200 dark:border-gray-800 overflow-hidden" + className="group relative aspect-square block rounded-lg corner-squircle shadow-xs border border-gray-200 dark:border-gray-800 overflow-hidden" tabIndex={0} >
@@ -307,7 +307,7 @@ export function MaintainerRowCard({ className="relative shrink-0" tabIndex={0} > -
+
{`Avatar {badge ? ( - + // Neutral status chip — the word carries the meaning, not the color, + // so it stays consistent with the library hero badge. + {badge} ) : null} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 3f09c3e14..3d3c9c3e4 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -66,7 +66,7 @@ import { BrandXIcon } from '~/components/icons/BrandXIcon' import { YouTubeIcon } from '~/components/icons/YouTubeIcon' import { BlogPostCard } from '~/components/ds/ui/BlogPostCard' import { Button } from '~/components/ds/ui' -import { Collapsible, CollapsibleContent } from '~/components/Collapsible' +import { Panel, PanelContent } from '~/components/Panel' import { getProducts } from '~/utils/shop.functions' import { formatMoney, shopifyImageUrl } from '~/utils/shopify-format' import type { ProductListItem } from '~/utils/shopify-queries' @@ -675,12 +675,12 @@ export function Navbar({ children }: { children: React.ReactNode }) { ) const mobileMenu = ( - -
- - + + ) return ( diff --git a/src/components/Panel.tsx b/src/components/Panel.tsx new file mode 100644 index 000000000..4ca8691ad --- /dev/null +++ b/src/components/Panel.tsx @@ -0,0 +1,145 @@ +import * as React from 'react' +import { twMerge } from 'tailwind-merge' + +type PanelRenderProps = { + open: boolean + orientation: 'horizontal' | 'vertical' + toggle: () => void +} + +type PanelProps = { + open?: boolean + defaultOpen?: boolean + orientation?: 'horizontal' | 'vertical' + onOpenChange?: (open: boolean) => void + children: React.ReactNode | ((props: PanelRenderProps) => React.ReactNode) + className?: string +} + +type PanelTriggerProps = React.ButtonHTMLAttributes & { + children: React.ReactNode +} + +type PanelContentProps = React.HTMLAttributes & { + children: React.ReactNode +} + +const PanelContext = React.createContext(null) + +function usePanel() { + const context = React.useContext(PanelContext) + if (!context) { + throw new Error('Panel components must be used within a Panel') + } + return context +} + +export function Panel({ + open: controlledOpen, + defaultOpen = false, + orientation = 'vertical', + onOpenChange, + children, + className, +}: PanelProps) { + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen) + + const isControlled = controlledOpen !== undefined + const open = isControlled ? controlledOpen : uncontrolledOpen + + const toggle = React.useCallback(() => { + if (isControlled) { + onOpenChange?.(!open) + } else { + setUncontrolledOpen((prev) => { + const next = !prev + onOpenChange?.(next) + return next + }) + } + }, [isControlled, open, onOpenChange]) + + const value = React.useMemo( + () => ({ open, orientation, toggle }), + [open, orientation, toggle], + ) + + return ( + +
+ {typeof children === 'function' ? children(value) : children} +
+
+ ) +} + +export function PanelTrigger({ + children, + className, + onClick, + onMouseDown, + type = 'button', + ...props +}: PanelTriggerProps) { + const { open, toggle } = usePanel() + + return ( + + ) +} + +export const PanelContent = React.forwardRef( + function PanelContent({ children, className, ...props }, ref) { + const { open, orientation } = usePanel() + const horizontal = orientation === 'horizontal' + + return ( +
+
+ {children} +
+
+ ) + }, +) diff --git a/src/components/PartnersGrid.tsx b/src/components/PartnersGrid.tsx index 2cf52fafb..62c7cc60a 100644 --- a/src/components/PartnersGrid.tsx +++ b/src/components/PartnersGrid.tsx @@ -177,7 +177,7 @@ export function PartnersGrid({ // followed by its logo grid; the negative right/bottom margins let the cell // dividers meet the container edge so overflow-hidden trims the outer line. return ( -
+
{tiersWithPartners.map((row) => ( [0]['config'] -} type RightRailProps = { children: React.ReactNode @@ -85,184 +52,3 @@ export function RightRail({
) } - -const railTierLayout: Record< - PartnerTier, - { - flexBasis: string - minHeight: string - logoMaxWidth: string - logoMaxHeight: string - padding: string - } -> = { - gold: { - flexBasis: 'basis-full', - minHeight: 'min-h-[72px]', - logoMaxWidth: 'max-w-[180px]', - logoMaxHeight: 'max-h-[40px]', - padding: 'px-4 py-3', - }, - silver: { - flexBasis: 'basis-1/2', - minHeight: 'min-h-[60px]', - logoMaxWidth: 'max-w-[100px]', - logoMaxHeight: 'max-h-[26px]', - padding: 'px-2.5 py-2.5', - }, - bronze: { - flexBasis: 'basis-1/3', - minHeight: 'min-h-[56px]', - logoMaxWidth: 'max-w-[70px]', - logoMaxHeight: 'max-h-[22px]', - padding: 'px-2 py-2', - }, -} - -export function PartnersRail({ - analyticsPlacement, - partners, - title = 'Partners', - titleTo = '/partners', -}: { - analyticsPlacement: PartnerPlacement - partners: Array - title?: string - titleTo?: '/partners' -}) { - const placementContext = usePartnerPlacementContext({ - orderStrategy: 'tier-rotated', - surface: analyticsPlacement, - }) - - const rowsByTier = getPartnerTierGroupsForPlacement( - partners, - placementContext, - ) - - let slotIndex = 0 - - return ( -
- - {rowsByTier.map((row) => { - const flare = partnerTierFlares[row.tier] - return ( -
- {/* Tier-colored top line */} -
- {/* Absolute top-left tier label */} -
- {flare.icon} - {partnerTierLabels[row.tier]} -
- {row.partners.map((partner) => { - const index = slotIndex++ - return ( - - ) - })} -
- ) - })} -
- ) -} - -function PartnersRailItem({ - analyticsPlacement, - index, - placementContext, - partner, -}: { - analyticsPlacement: PartnerPlacement - index: number - placementContext: PartnerPlacementContext - partner: RailPartner -}) { - const layout = railTierLayout[partner.tier ?? 'bronze'] - const analyticsMetadata = getPartnerPlacementAnalyticsMetadata( - partner, - placementContext, - ) - const ref = useTrackedImpression<'partner_viewed', HTMLAnchorElement>({ - event: 'partner_viewed', - props: { - partner_id: partner.id, - placement: analyticsPlacement, - ...analyticsMetadata, - slot_index: index, - }, - }) - - return ( - { - let destinationHost: string | undefined - try { - destinationHost = new URL(partner.href).host - } catch { - // Bad/relative href — track without host rather than dropping. - } - trackEvent('partner_clicked', { - partner_id: partner.id, - placement: analyticsPlacement, - destination: 'external', - destination_host: destinationHost, - ...analyticsMetadata, - slot_index: index, - }) - }} - > -
- -
-
- ) -} diff --git a/src/components/ShowcaseCard.tsx b/src/components/ShowcaseCard.tsx index 84960981b..371329320 100644 --- a/src/components/ShowcaseCard.tsx +++ b/src/components/ShowcaseCard.tsx @@ -36,7 +36,7 @@ export function ShowcaseCard({ return (
@@ -192,7 +192,7 @@ export function ShowcaseCard({ export function ShowcaseCardSkeleton() { return ( -
+
diff --git a/src/components/Squircle.tsx b/src/components/Squircle.tsx new file mode 100644 index 000000000..4c7e4f7c7 --- /dev/null +++ b/src/components/Squircle.tsx @@ -0,0 +1,94 @@ +import * as React from 'react' +import { getSvgPath } from 'figma-squircle' + +/** + * Cross-browser squircle corners. + * + * Chromium reshapes corners natively via `corner-shape: squircle` (the + * `corner-squircle` utility), which correctly handles borders + shadows for + * free. Safari and Firefox don't support it yet, so this applies a superellipse + * `clip-path` there instead — matched to the element's own computed + * `border-radius` (so it tracks responsive Tailwind classes) and re-computed on + * resize. It is a deliberate no-op where native squircles work, so shadowed + * elements keep their shadows on Chromium. + * + * `clip-path` clips outset `box-shadow`, so only use this on shape-forward + * containers without an outer shadow (media/image wrappers, cards); leave + * shadowed buttons on the native-only path. + */ + +function supportsNativeSquircle(): boolean { + return ( + typeof CSS !== 'undefined' && + typeof CSS.supports === 'function' && + CSS.supports('corner-shape', 'squircle') + ) +} + +export function useSquircleFallback( + ref: React.RefObject, + smoothing = 0.6, +) { + React.useEffect(() => { + const el = ref.current + if (!el || supportsNativeSquircle()) return + + const apply = () => { + const { width, height } = el.getBoundingClientRect() + const style = getComputedStyle(el) + const radius = (prop: string) => + parseFloat(style.getPropertyValue(prop)) || 0 + const corners = { + topLeftCornerRadius: radius('border-top-left-radius'), + topRightCornerRadius: radius('border-top-right-radius'), + bottomRightCornerRadius: radius('border-bottom-right-radius'), + bottomLeftCornerRadius: radius('border-bottom-left-radius'), + } + + const hasRadius = Object.values(corners).some((r) => r > 0) + if (!width || !height || !hasRadius) { + el.style.clipPath = '' + return + } + + el.style.clipPath = `path('${getSvgPath({ + width, + height, + cornerSmoothing: smoothing, + ...corners, + })}')` + } + + apply() + const observer = new ResizeObserver(apply) + observer.observe(el) + // Element size may not change when a breakpoint flips the border-radius, so + // re-read on viewport resize too. + window.addEventListener('resize', apply) + + return () => { + observer.disconnect() + window.removeEventListener('resize', apply) + el.style.clipPath = '' + } + }, [ref, smoothing]) +} + +type SquircleProps = React.HTMLAttributes & { + /** 0–1; 0.6 approximates the native `squircle` (superellipse(2)) curve. */ + smoothing?: number +} + +/** A `
` that carries a squircle shape cross-browser (see useSquircleFallback). */ +export const Squircle = React.forwardRef( + function Squircle({ smoothing, children, ...rest }, forwardedRef) { + const localRef = React.useRef(null) + React.useImperativeHandle(forwardedRef, () => localRef.current!, []) + useSquircleFallback(localRef, smoothing) + return ( +
+ {children} +
+ ) + }, +) diff --git a/src/components/builder/FeaturePicker.tsx b/src/components/builder/FeaturePicker.tsx index a54a12ec1..6f76b2c10 100644 --- a/src/components/builder/FeaturePicker.tsx +++ b/src/components/builder/FeaturePicker.tsx @@ -32,11 +32,7 @@ import { getPartnersForPlacement, } from '~/utils/partner-placement' import { Tooltip } from '~/ui/Tooltip' -import { - Collapsible, - CollapsibleTrigger, - CollapsibleContent, -} from '~/components/Collapsible' +import { Panel, PanelTrigger, PanelContent } from '~/components/Panel' import type { FeatureId, FeatureInfo } from '~/builder/api' type FeatureCategory = @@ -336,8 +332,8 @@ function FeatureSection({ const effectiveOpen = forceOpen || isOpen return ( - - + +

{title} @@ -352,9 +348,9 @@ function FeatureSection({ effectiveOpen && 'rotate-180', )} /> - + - +
{features.map((feature) => ( ))}
-
- + + ) } @@ -572,8 +568,8 @@ function ExamplePicker() { if (examples.length === 0) return null return ( - - + +

Examples @@ -588,9 +584,9 @@ function ExamplePicker() { isOpen && 'rotate-180', )} /> - + - +
{examples.map((example) => { const isSelected = selectedExample === example.id @@ -647,7 +643,7 @@ function ExamplePicker() { ) })}
-
- + + ) } diff --git a/src/components/charts/ChartsNotebookPage.client.tsx b/src/components/charts/ChartsNotebookPage.client.tsx index c7f9434f5..fd1e208c0 100644 --- a/src/components/charts/ChartsNotebookPage.client.tsx +++ b/src/components/charts/ChartsNotebookPage.client.tsx @@ -13,7 +13,7 @@ import { createHighlighter } from '@tanstack/highlight/core' import { tsx } from '@tanstack/highlight/languages/tsx' import * as esbuild from 'esbuild-wasm' import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url' -import { Collapsible, CollapsibleContent } from '~/components/Collapsible' +import { Panel, PanelContent } from '~/components/Panel' import { ButtonGroup } from '~/components/ButtonGroup' import { NotebookGuideDialog } from '~/components/charts/NotebookGuideDialog' import { Button } from '~/components/ds/ui' @@ -1351,11 +1351,7 @@ export function ChartsNotebookPage() { } return ( - + {({ open }) => (
-

- + -

-
- + )} - +
)} - + ) } diff --git a/src/components/ds/DsKit.tsx b/src/components/ds/DsKit.tsx index c0ffb1e46..78298819a 100644 --- a/src/components/ds/DsKit.tsx +++ b/src/components/ds/DsKit.tsx @@ -48,22 +48,30 @@ export function DsDescription({ export function DsPage({ title, description, + header, children, }: { - title: string + title?: string description?: React.ReactNode + /** Replace the default title header entirely (e.g. the emblem PageHeader on + * the overview). When set, `title`/`description` are ignored. */ + header?: React.ReactNode children: React.ReactNode }) { return (
-
-

- {title} -

- {description ? ( - {description} - ) : null} -
+ {header ? ( +
{header}
+ ) : ( +
+

+ {title} +

+ {description ? ( + {description} + ) : null} +
+ )}
{children}
) diff --git a/src/components/ds/ds-nav.ts b/src/components/ds/ds-nav.ts index 2fc0fcd7b..7c021296a 100644 --- a/src/components/ds/ds-nav.ts +++ b/src/components/ds/ds-nav.ts @@ -99,6 +99,26 @@ export const dsNav: Array = [ { title: 'Components', items: [ + { + label: 'Avatar', + to: '/ds/avatar', + sections: [ + 'Sizes', + 'Fallbacks', + 'Maintainer card', + 'Responsive maintainer grid', + ], + }, + { + label: 'Badges', + to: '/ds/badges', + sections: ['Variants', 'Corner styles', 'In context', 'Library status'], + }, + { + label: 'Breadcrumbs', + to: '/ds/breadcrumbs', + sections: ['Section + on-this-page'], + }, { label: 'Buttons', to: '/ds/buttons', @@ -117,9 +137,14 @@ export const dsNav: Array = [ ], }, { - label: 'Badges', - to: '/ds/badges', - sections: ['Variants', 'Corner styles', 'In context'], + label: 'Cards & Surfaces', + to: '/ds/cards', + sections: ['Card', 'Inline code', 'Blog post card', 'Tooltip'], + }, + { + label: 'Dropdown', + to: '/ds/dropdown', + sections: ['Basic menu', 'Scrollable'], }, { label: 'Eyebrow', @@ -131,7 +156,7 @@ export const dsNav: Array = [ to: '/ds/inputs', sections: [ 'Default', - 'Focus rings', + 'Focus', 'With a label & disabled', 'Progressive search', 'Persistent search', @@ -139,39 +164,35 @@ export const dsNav: Array = [ ], }, { - label: 'Dropdown', - to: '/ds/dropdown', - sections: ['Basic menu'], - }, - { - label: 'Avatar', - to: '/ds/avatar', + label: 'Navbar', + to: '/ds/navbar', sections: [ - 'Sizes', - 'Fallbacks', - 'Maintainer card', - 'Responsive maintainer grid', + 'Anatomy', + 'Mega menu item', + 'Layout & spacing', + 'Responsive behavior', + 'Source', ], }, { - label: 'Spinner', - to: '/ds/spinner', - sections: ['Sizes & color', 'Headbanger'], + label: 'Page Header', + to: '/ds/page-header', + sections: ['Left-aligned', 'Centered', 'Marks & actions'], }, { - label: 'Collapsible', - to: '/ds/collapsible', + label: 'Panel', + to: '/ds/panel', sections: ['Disclosure'], }, { - label: 'Breadcrumbs', - to: '/ds/breadcrumbs', - sections: ['Section + on-this-page'], + label: 'Partner Rail', + to: '/ds/partner-rail', + sections: ['Rail', 'Tiers', 'Per-logo scale'], }, { - label: 'Cards & Surfaces', - to: '/ds/cards', - sections: ['Card', 'Inline code', 'Blog post card', 'Tooltip'], + label: 'Spinner', + to: '/ds/spinner', + sections: ['Sizes & color', 'Headbanger'], }, { label: 'Stats Section', @@ -179,17 +200,9 @@ export const dsNav: Array = [ sections: ['Preview'], }, { - label: 'Navbar', - to: '/ds/navbar', - sections: [ - 'Anatomy', - 'Mega menu item', - 'Menu variants', - 'Regions', - 'Layout & spacing', - 'Responsive behavior', - 'Source', - ], + label: 'Tabs', + to: '/ds/tabs', + sections: ['Primary', 'Secondary', 'Icon only'], }, ], }, diff --git a/src/components/ds/ui/BlogPostCard.tsx b/src/components/ds/ui/BlogPostCard.tsx index 5b1b39f8a..3d1e040a5 100644 --- a/src/components/ds/ui/BlogPostCard.tsx +++ b/src/components/ds/ui/BlogPostCard.tsx @@ -1,6 +1,7 @@ import { Link } from '@tanstack/react-router' import { twMerge } from 'tailwind-merge' import { CoverFallback } from '~/components/CoverFallback' +import { Squircle } from '~/components/Squircle' import { formatAuthors, formatPublishedDate, @@ -8,6 +9,7 @@ import { } from '~/utils/blog-format' import type { RecentPost } from '~/utils/blog.functions' import { getOptimizedImageUrl } from '~/utils/optimizedImage' +import { categoryTextColor, libraryCategories } from '~/libraries/categories' // The card renders library tags when the post carries a `library`; `RecentPost` // (nav / homepage) omits it, so those usages stay tag-free. @@ -19,6 +21,8 @@ export function BlogPostCard({ post, size = 'sm', featured = false, + showLibraryBadges = true, + showCategory = false, }: { className?: string onNavigate?: () => void @@ -29,26 +33,43 @@ export function BlogPostCard({ /** Hero treatment: image and copy sit side-by-side on wider screens with the * largest type. Used for the single latest post atop the Blog index. */ featured?: boolean + /** Show the library tag(s) over the image. Off for a single-library blog, + * where every card would carry the same redundant badge. */ + showLibraryBadges?: boolean + /** Lead the meta line with the primary library as a category + * ("{Library} · {Author} · {Date}"). Used on the main Blog index. */ + showCategory?: boolean }) { const isLarge = featured || size === 'lg' - const blogLibraries = getBlogLibraries(post.library) + const blogLibraries = showLibraryBadges ? getBlogLibraries(post.library) : [] + const primaryLibrary = showCategory + ? getBlogLibraries(post.library)[0] + : undefined + const categoryLabel = primaryLibrary?.name.replace('TanStack ', '') + const categoryColor = primaryLibrary + ? categoryTextColor[libraryCategories[primaryLibrary.id] ?? 'tooling'] + : undefined const cardClassName = twMerge( - 'group/post flex flex-col rounded-xl corner-squircle transition-colors hover:bg-surface-state-hover focus-visible:bg-surface-state-hover focus-visible:outline-none', + // Hover is cued by the text brightening (title/excerpt/byline, via + // group-hover below) plus a subtle lift — no background fill, so the card + // needs no inset padding. `motion-safe` drops the lift for reduced-motion; + // the focus ring keeps keyboard focus visible now the bg cue is gone. + 'group/post flex flex-col rounded-xl corner-squircle transition-transform duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2 focus-visible:ring-offset-background-default motion-safe:hover:-translate-y-0.5', featured - ? 'gap-4 p-4 md:flex-row md:gap-6' + ? 'gap-4 md:min-h-[410px] md:flex-row md:gap-6' : isLarge - ? 'gap-4 p-4' - : 'gap-3 p-3', + ? 'gap-4' + : 'gap-3', className, ) const content = ( <> -
{post.headerImage ? ( @@ -87,21 +108,21 @@ export function BlogPostCard({ ))}
) : null} -
+
{/* Plain strings (not twMerge): the DS `text-ds-*` size utilities and the `text-*` color utilities both start with `text-`, and twMerge would drop the color. */}

+ {categoryLabel ? ( + <> + + {categoryLabel} + + {' · '} + + ) : null} {formatAuthors(post.authors)} · {formatPublishedDate(post.published)}
diff --git a/src/components/ds/ui/PageHeader.tsx b/src/components/ds/ui/PageHeader.tsx index 3b7904540..85411e100 100644 --- a/src/components/ds/ui/PageHeader.tsx +++ b/src/components/ds/ui/PageHeader.tsx @@ -52,7 +52,7 @@ export function PageHeader({ )} >

- + {usingBrandMark ? TanStack : null} {mark} {title} diff --git a/src/components/ds/ui/PartnerRail.rules.md b/src/components/ds/ui/PartnerRail.rules.md new file mode 100644 index 000000000..19c54c14b --- /dev/null +++ b/src/components/ds/ui/PartnerRail.rules.md @@ -0,0 +1,69 @@ +# Partner Rail — sizing rules + +How partner logos are sized in the tiered rail. Workshop it live at +`/ds/partner-rail`. + +## The model + +`rendered size = contain(tier box) × per-logo scale` + +Two independent layers: + +1. **Tier box** — the size budget for a tier. Gold is the source of truth; + silver and bronze derive from it. +2. **Per-logo `scale`** — a global optical-weight multiplier per partner, so + logos of different shapes read at a consistent size. + +## Layer 1 — tiers (gold is the only knob) + +Defined in [`PartnerTierLogo.tsx`](./PartnerTierLogo.tsx): + +```ts +PARTNER_LOGO_TIER_STEP = 0.75 +PARTNER_LOGO_GOLD = { maxWidth, maxHeight } // px — the source of truth +``` + +- `gold = PARTNER_LOGO_GOLD` +- `silver = gold × 0.75` +- `bronze = gold × 0.75²` (0.5625) + +Both dimensions scale by the same factor, so any given logo is exactly one step +(×0.75) smaller than the tier above it. + +- **To resize the whole rail, change `PARTNER_LOGO_GOLD` only.** +- **Never hand-set silver/bronze** — that reintroduces the inversion this rubric + fixes. + +## Layer 2 — per-logo `scale` (optical weight) + +A `transform: scale()` on the logo, set per partner in +[`src/utils/partners.tsx`](../../../utils/partners.tsx) as `image.scale`. It is +**global** — it affects every placement (partners page, heroes, docs rail), not +just this rail. Default is `1.0` (unset). + +- `< 1` for logos that read **heavy / too large**: dense or solid marks, + monograms, icon-forward lockups (e.g. Unkey 0.7, Clerk 0.72, Strapi 0.8). +- `> 1` for logos that read **light / too small**: thin wordmarks, SVGs with + internal padding, wordmarks that are short for their width (e.g. Netlify 1.25, + OpenRouter 1.25, PowerSync 1.2, AG Grid 1.1). +- Keep values roughly in `0.7–1.3`. Calibrate at one tier against a reference + logo, then spot-check the partners page since `scale` is global. + +## Adding / changing a partner + +1. Set the partner's `tier` in `partners.tsx`. +2. Leave `scale` unset to start. +3. Open `/ds/partner-rail`, and nudge its scale slider until it matches its + peers. Copy the value from the config output into `image.scale`. + +## Layout vs size + +These are **layout** knobs (in `PartnerRail.tsx`), separate from logo size: +`rowHeight`, `perRow` (bronze is two-up), `idleOpacity` (lower tiers rest more +muted, lift to color on rail hover). + +## Don'ts + +- **No per-placement height hacks.** There used to be a Netlify-only + `max-h-[31px]` override in the rail; it's gone. If a logo reads wrong, fix it + with its global `scale`, not a one-off in a placement. diff --git a/src/components/ds/ui/PartnerRail.tsx b/src/components/ds/ui/PartnerRail.tsx new file mode 100644 index 000000000..2f0f857ad --- /dev/null +++ b/src/components/ds/ui/PartnerRail.tsx @@ -0,0 +1,220 @@ +import { Link } from '@tanstack/react-router' +import { twMerge } from 'tailwind-merge' +import { + partnerTierFlares, + partnerTierLabels, + type PartnerTier, + type RailPartner, +} from '~/utils/partners' +import { + getPartnerPlacementAnalyticsMetadata, + getPartnerTierGroupsForPlacement, + type PartnerPlacementContext, +} from '~/utils/partner-placement' +import { usePartnerPlacementContext } from '~/utils/usePartnerPlacementContext' +import { + trackEvent, + useTrackedImpression, + type PartnerPlacement, +} from '~/utils/analytics' +import { + PARTNER_INQUIRY_HREF, + trackPartnerInquiry, +} from '~/utils/partner-inquiry' +import { PartnerTierLogo, type PartnerLogoSizing } from './PartnerTierLogo' + +// Per-tier LAYOUT (not logo size — that lives in the rubric). Every tier is a +// single column; lower tiers rest more muted and lift to color on hover. +const tierLayout: Record< + PartnerTier, + { rowHeight: string; idleOpacity: string } +> = { + gold: { rowHeight: 'h-[80px]', idleOpacity: '' }, + silver: { rowHeight: 'h-[62px]', idleOpacity: 'opacity-80' }, + bronze: { rowHeight: 'h-[56px]', idleOpacity: 'opacity-65' }, +} + +// Centered tier header: a hairline on each side of the tier's icon + label. +function TierHeader({ tier }: { tier: PartnerTier }) { + const flare = partnerTierFlares[tier] + return ( +
+ + + {flare.icon} + + {partnerTierLabels[tier]} + + + +
+ ) +} + +/** + * The tiered partner rail: partners grouped into centered tier sections, each + * logo sized by the shared rubric (see PartnerTierLogo / PartnerRail.rules.md). + * `sizing` and `scaleOverrides` are workshop hooks — omit them in production. + */ +export function PartnerRail({ + analyticsPlacement, + partners, + title = 'Partners', + titleTo = '/partners', + sizing, + scaleOverrides, + rowGaps, + mode, +}: { + analyticsPlacement: PartnerPlacement + partners: Array + title?: string + titleTo?: '/partners' + sizing?: PartnerLogoSizing + scaleOverrides?: Record + /** Vertical gap (px) between logo rows, per tier — workshop hook. */ + rowGaps?: Partial> + /** Force the light/dark logo variant (for dual-theme previews). */ + mode?: 'light' | 'dark' +}) { + const placementContext = usePartnerPlacementContext({ + orderStrategy: 'tier-rotated', + surface: analyticsPlacement, + }) + + const rowsByTier = getPartnerTierGroupsForPlacement( + partners, + placementContext, + ) + + let slotIndex = 0 + + return ( +
+ + {rowsByTier.map((row) => ( +
+ +
+ {row.partners.map((partner) => { + const index = slotIndex++ + return ( + + ) + })} +
+
+ ))} +
+ ) +} + +function PartnerRailLogo({ + analyticsPlacement, + index, + placementContext, + partner, + sizing, + scaleOverride, + mode, +}: { + analyticsPlacement: PartnerPlacement + index: number + placementContext: PartnerPlacementContext + partner: RailPartner + sizing?: PartnerLogoSizing + scaleOverride?: number + mode?: 'light' | 'dark' +}) { + const tier = partner.tier ?? 'bronze' + const layout = tierLayout[tier] + const analyticsMetadata = getPartnerPlacementAnalyticsMetadata( + partner, + placementContext, + ) + const ref = useTrackedImpression<'partner_viewed', HTMLAnchorElement>({ + event: 'partner_viewed', + props: { + partner_id: partner.id, + placement: analyticsPlacement, + ...analyticsMetadata, + slot_index: index, + }, + }) + + return ( + { + let destinationHost: string | undefined + try { + destinationHost = new URL(partner.href).host + } catch { + // Bad/relative href — track without host rather than dropping. + } + trackEvent('partner_clicked', { + partner_id: partner.id, + placement: analyticsPlacement, + destination: 'external', + destination_host: destinationHost, + ...analyticsMetadata, + slot_index: index, + }) + }} + > + + + ) +} diff --git a/src/components/ds/ui/PartnerTierLogo.tsx b/src/components/ds/ui/PartnerTierLogo.tsx new file mode 100644 index 000000000..97ecd8e18 --- /dev/null +++ b/src/components/ds/ui/PartnerTierLogo.tsx @@ -0,0 +1,87 @@ +import { twMerge } from 'tailwind-merge' +import { + PartnerImage, + type PartnerImageConfig, + type PartnerTier, +} from '~/utils/partners' + +/* --------------------------------------------------------- sizing rubric -- */ +// Gold is the source of truth. Each tier steps down by TIER_STEP (both width and +// height together), so a given logo renders exactly one step (×0.75) smaller +// than the tier above it. To resize the whole rail, change PARTNER_LOGO_GOLD +// only — never hand-set silver/bronze. See PartnerRail.rules.md. +export const PARTNER_LOGO_TIER_STEP = 0.75 +export const PARTNER_LOGO_GOLD = { maxWidth: 170, maxHeight: 40 } // px + +export type PartnerLogoSizing = { + goldMaxWidth: number + goldMaxHeight: number + tierStep: number +} + +export const DEFAULT_PARTNER_LOGO_SIZING: PartnerLogoSizing = { + goldMaxWidth: PARTNER_LOGO_GOLD.maxWidth, + goldMaxHeight: PARTNER_LOGO_GOLD.maxHeight, + tierStep: PARTNER_LOGO_TIER_STEP, +} + +const TIER_ORDER: Array = ['gold', 'silver', 'bronze'] + +/** Derived logo box for a tier: gold × tierStep ** (steps below gold). */ +export function partnerLogoTierSize( + tier: PartnerTier, + sizing: PartnerLogoSizing = DEFAULT_PARTNER_LOGO_SIZING, +): { maxWidth: number; maxHeight: number } { + const step = Math.max(0, TIER_ORDER.indexOf(tier)) + const factor = sizing.tierStep ** step + return { + maxWidth: Math.round(sizing.goldMaxWidth * factor), + maxHeight: Math.round(sizing.goldMaxHeight * factor), + } +} + +/* ----------------------------------------------------------- component --- */ +export function PartnerTierLogo({ + image, + name, + tier, + sizing, + scaleOverride, + mode, + className, +}: { + image: PartnerImageConfig + name: string + tier: PartnerTier + /** Overrides the gold base / step — used by the workshop; production omits it. */ + sizing?: PartnerLogoSizing + /** Workshop-only per-logo optical weight; production reads image.scale. */ + scaleOverride?: number + /** Force the light/dark logo variant (for dual-theme previews); production + * omits it so the logo follows the global theme. */ + mode?: 'light' | 'dark' + /** Wrapper presentation (grayscale / idle opacity) supplied by the caller. */ + className?: string +}) { + const { maxWidth, maxHeight } = partnerLogoTierSize(tier, sizing) + const config: PartnerImageConfig = + scaleOverride !== undefined ? { ...image, scale: scaleOverride } : image + + return ( +
+ +
+ ) +} diff --git a/src/components/ds/ui/StatsSection.tsx b/src/components/ds/ui/StatsSection.tsx index 35874885f..05a5eb72e 100644 --- a/src/components/ds/ui/StatsSection.tsx +++ b/src/components/ds/ui/StatsSection.tsx @@ -84,7 +84,7 @@ function StatValue({ function HomeStatCard({ stat, iconTop }: { stat: StatItem; iconTop: boolean }) { const className = twMerge( - 'rounded-xl border border-border-subtle bg-background-surface p-5', + 'rounded-xl corner-squircle border border-border-subtle bg-background-surface p-5', iconTop ? 'flex flex-col items-start gap-4' : 'flex items-center gap-4', ) @@ -272,7 +272,7 @@ export function StatsSection({ return (
= { + sm: 'h-8 gap-1.5 px-2.5 text-sm [&_svg]:size-4 rounded-[12px]', + md: 'h-10 gap-2 px-3 text-sm [&_svg]:size-4 rounded-[14px]', + lg: 'h-13 gap-2 px-4 text-base [&_svg]:size-5 rounded-[18px]', +} + +export function segmentClasses( + size: SegmentSize, + selected: boolean, + className?: string, +) { + return twMerge( + segmentBase, + segmentSizeStyles[size], + selected + ? 'bg-background-inverse text-text-inverse' + : 'text-text-secondary hover:bg-action-secondary hover:text-text-primary', + className, + ) +} + +// The track's "external" corner radius per size (14 / 16 / 20px) — 2px larger +// than the chip so the nested corners stay concentric. +const segmentTrackRadius: Record = { + sm: 'rounded-[14px]', + md: 'rounded-[16px]', + lg: 'rounded-[20px]', +} + +/** The rounded track that wraps a row of segments, sized to match its chips. */ +export function segmentTrackClasses(size: SegmentSize) { + return `inline-flex items-center gap-0.5 corner-squircle border border-border-subtle bg-background-subtle p-0.5 ${segmentTrackRadius[size]}` +} + +/* ----------------------------------------------------------- Primary tabs -- + + The "primary" style is the underline tab used across the docs (framework / + code tabs): a row on a baseline border, with a colored underline under the + active tab. Heavier and more structural than the segmented "secondary" style. */ + +// `overflow-x-auto` lets a long tab row scroll horizontally; `overflow-y-hidden` +// is required alongside it — otherwise the browser computes overflow-y as `auto` +// too and the triggers' `-mb-px` (overlapping the baseline) trips a phantom +// vertical scrollbar. +const primaryListClasses = + 'flex items-center gap-1 overflow-x-auto overflow-y-hidden border-b border-border-default' + +const primaryTriggerBase = + 'relative -mb-px inline-flex shrink-0 items-center justify-center border-b-2 font-semibold ' + + 'transition-colors duration-150 ease-out cursor-pointer select-none ' + + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:rounded-sm ' + + 'disabled:cursor-not-allowed disabled:opacity-50' + +const primaryTriggerSize: Record = { + sm: 'gap-1.5 px-2.5 py-1.5 text-sm [&_svg]:size-4', + md: 'gap-2 px-3 py-2 text-sm [&_svg]:size-4', + lg: 'gap-2 px-3.5 py-2.5 text-base [&_svg]:size-5', +} + +export function primaryTriggerClasses( + size: SegmentSize, + selected: boolean, + className?: string, +) { + return twMerge( + primaryTriggerBase, + primaryTriggerSize[size], + selected + ? 'border-text-primary text-text-primary' + : 'border-transparent text-text-secondary hover:text-text-primary', + className, + ) +} + +/* --------------------------------------------------------------------- Tabs -- + + Two styles, one API: + - `variant="primary"` — the prominent underline tabs the docs use to switch a + page's main content (framework / code tabs). + - `variant="secondary"` (default) — the compact segmented track for switching + context inside page content. + + Compound + context API (mirrors the Dropdown wrappers), hand-rolled for full + tab semantics — `role="tablist/tab/tabpanel"`, `aria-selected`, roving + tabindex and Arrow/Home/End keyboard navigation. Controlled + (`value`/`onValueChange`) or uncontrolled (`defaultValue`). */ + +type TabsVariant = 'primary' | 'secondary' + +type TabsContextValue = { + value: string + setValue: (value: string) => void + idBase: string + variant: TabsVariant +} + +const TabsContext = React.createContext(null) + +// Size flows from TabsList to its triggers without threading a prop through JSX. +const SegmentSizeContext = React.createContext('md') + +function useTabsContext(component: string) { + const ctx = React.useContext(TabsContext) + if (!ctx) { + throw new Error(`<${component}> must be used within `) + } + return ctx +} + +export function Tabs({ + value: controlledValue, + defaultValue, + onValueChange, + variant = 'secondary', + className, + children, +}: { + value?: string + defaultValue?: string + onValueChange?: (value: string) => void + /** `primary` = underline; `secondary` = segmented (default). */ + variant?: TabsVariant + className?: string + children: React.ReactNode +}) { + const isControlled = controlledValue !== undefined + const idBase = React.useId() + const [internalValue, setInternalValue] = React.useState(defaultValue ?? '') + const value = controlledValue ?? internalValue + + const setValue = React.useCallback( + (next: string) => { + if (!isControlled) setInternalValue(next) + onValueChange?.(next) + }, + [isControlled, onValueChange], + ) + + const ctx = React.useMemo( + () => ({ value, setValue, idBase, variant }), + [value, setValue, idBase, variant], + ) + + return ( + +
{children}
+
+ ) +} + +export function TabsList({ + 'aria-label': ariaLabel, + size = 'md', + className, + children, +}: { + 'aria-label': string + size?: SegmentSize + className?: string + children: React.ReactNode +}) { + const { variant } = useTabsContext('TabsList') + // Automatic activation: focus follows arrow keys and selects as it moves, + // the expected behavior for a horizontal tablist. Reuses each trigger's own + // click handler so selection stays in one place. + const onKeyDown = (event: React.KeyboardEvent) => { + const keys = ['ArrowRight', 'ArrowLeft', 'Home', 'End'] + if (!keys.includes(event.key)) return + const tabs = Array.from( + event.currentTarget.querySelectorAll( + '[role="tab"]:not(:disabled)', + ), + ) + if (tabs.length === 0) return + const current = tabs.indexOf(document.activeElement as HTMLButtonElement) + let next = current + if (event.key === 'ArrowRight') + next = current < 0 ? 0 : (current + 1) % tabs.length + else if (event.key === 'ArrowLeft') + next = + current < 0 + ? tabs.length - 1 + : (current - 1 + tabs.length) % tabs.length + else if (event.key === 'Home') next = 0 + else if (event.key === 'End') next = tabs.length - 1 + event.preventDefault() + const target = tabs[next] + target.focus() + target.click() + } + + return ( + +
+ {children} +
+
+ ) +} + +export function TabsTrigger({ + value, + icon, + disabled, + className, + children, +}: { + value: string + /** Optional leading icon node (sized by the segment). */ + icon?: React.ReactNode + disabled?: boolean + className?: string + children: React.ReactNode +}) { + const { + value: active, + setValue, + idBase, + variant, + } = useTabsContext('TabsTrigger') + const size = React.useContext(SegmentSizeContext) + const selected = active === value + + return ( + + ) +} + +export function TabsPanel({ + value, + className, + children, +}: { + value: string + className?: string + children: React.ReactNode +}) { + const { value: active, idBase } = useTabsContext('TabsPanel') + const selected = active === value + + return ( + + ) +} diff --git a/src/components/ds/ui/index.tsx b/src/components/ds/ui/index.tsx index c014cc8a7..5f5de0f9c 100644 --- a/src/components/ds/ui/index.tsx +++ b/src/components/ds/ui/index.tsx @@ -392,24 +392,20 @@ export function Eyebrow({ /* -------------------------------------------------------------- FormInput -- */ -type FormInputProps = React.InputHTMLAttributes & { - focusRing?: 'blue' | 'orange' | 'purple' -} +type FormInputProps = React.InputHTMLAttributes -const ringStyles: Record, string> = { - blue: 'focus:border-border-focus focus:ring-border-focus/40', - orange: 'focus:border-accent-warm focus:ring-accent-warm/40', - purple: 'focus:border-accent-creative focus:ring-accent-creative/40', -} +// Focus is a single neutral border-color change (no ring): the border lifts to +// the strong neutral token — the lightest neutral on dark surfaces. +const inputFocusClass = 'focus:border-border-strong focus:outline-none' export const FormInput = React.forwardRef( - function FormInput({ className, focusRing = 'blue', ...props }, ref) { + function FormInput({ className, ...props }, ref) { return ( ( }, ) -type FormSelectProps = React.SelectHTMLAttributes & { - focusRing?: 'blue' | 'orange' | 'purple' -} +type FormSelectProps = React.SelectHTMLAttributes export const FormSelect = React.forwardRef( - function FormSelect({ className, focusRing = 'blue', ...props }, ref) { + function FormSelect({ className, ...props }, ref) { return ( onChange(Number(e.currentTarget.value))} + className="w-full accent-amber-500" + /> + + ) +} + +function ConfigOutput({ + sizing, + scales, + rowGaps, +}: { + sizing: PartnerLogoSizing + scales: Record + rowGaps: Record +}) { + const scaleLines = activePartners + .filter((p) => Math.abs((scales[p.id] ?? 1) - 1) > 0.001) + .map((p) => ` ${p.id}: scale ${(scales[p.id] ?? 1).toFixed(2)}`) + .join('\n') + const text = `PARTNER_LOGO_GOLD = { maxWidth: ${sizing.goldMaxWidth}, maxHeight: ${sizing.goldMaxHeight} } +PARTNER_LOGO_TIER_STEP = ${sizing.tierStep.toFixed(2)} +row gap per tier = gold ${rowGaps.gold}px · silver ${rowGaps.silver}px · bronze ${rowGaps.bronze}px + +// image.scale per partner (paste into partners.tsx): +${scaleLines || ' (all default 1.0)'}` + return ( +
+      {text}
+    
+ ) +} diff --git a/src/routes/ds.tabs.tsx b/src/routes/ds.tabs.tsx new file mode 100644 index 000000000..943be1208 --- /dev/null +++ b/src/routes/ds.tabs.tsx @@ -0,0 +1,249 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { + ListIcon, + SquaresFourIcon, + ColumnsIcon, + CaretDownIcon, + CheckIcon, +} from '@phosphor-icons/react' +import { seo } from '~/utils/seo' +import { + Tabs, + TabsList, + TabsTrigger, + Dropdown, + DropdownContent, + DropdownItem, + DropdownTrigger, + type SegmentSize, +} from '~/components/ds/ui' +import { ComponentPreview, DsPage, DsSection } from '~/components/ds/DsKit' + +export const Route = createFileRoute('/ds/tabs')({ + component: TabsPage, + head: () => ({ + meta: seo({ + title: 'Tabs | TanStack Design System', + description: + 'Two tab styles — prominent underline (primary) and compact segmented (secondary) — sharing one accessible API.', + }), + }), +}) + +function TabsMode({ + children, + mode, +}: { + children: React.ReactNode + mode: 'light' | 'dark' +}) { + return ( +
+
+ {mode} +
+ {children} +
+ ) +} + +/* ------------------------------------------------------------- Size selector -- + + Same treatment as the Iconography page controls: a bordered dropdown trigger + with a caret, menu items carrying a check on the active option. It lives above + the light/dark split so one selector drives both panels. */ + +const TAB_SIZES = [ + { value: 'sm', label: 'Small', px: 32 }, + { value: 'md', label: 'Medium', px: 40 }, + { value: 'lg', label: 'Large', px: 52 }, +] as const + +const selectorTriggerClass = + '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 SizeSelector({ + value, + onChange, +}: { + value: SegmentSize + onChange: (value: SegmentSize) => void +}) { + const current = TAB_SIZES.find((s) => s.value === value) ?? TAB_SIZES[1] + return ( + + + + + + {TAB_SIZES.map((s) => ( + onChange(s.value)}> + {s.label} + {s.px}px + {s.value === value ? ( + + ) : null} + + ))} + + + ) +} + +/** A tab display with a size selector above it, rendered in light + dark. */ +function SizedTabsPreview({ + variant, + ariaLabel, + options, + code, +}: { + variant: 'primary' | 'secondary' + ariaLabel: string + options: ReadonlyArray<{ value: string; label: string }> + code: string +}) { + const [size, setSize] = React.useState('md') + const demo = ( + + + {options.map((o) => ( + + {o.label} + + ))} + + + ) + return ( + +
+ + Size + + +
+
+ {demo} + {demo} +
+
+ ) +} + +const FRAMEWORKS = [ + { value: 'react', label: 'React' }, + { value: 'solid', label: 'Solid' }, + { value: 'vue', label: 'Vue' }, + { value: 'svelte', label: 'Svelte' }, +] as const + +const SECTIONS = [ + { value: 'overview', label: 'Overview' }, + { value: 'usage', label: 'Usage' }, + { value: 'api', label: 'API' }, +] as const + +const LAYOUTS = [ + { value: 'list', label: 'List', icon: }, + { value: 'grid', label: 'Grid', icon: }, + { value: 'columns', label: 'Columns', icon: }, +] as const + +function IconOnlyDemo() { + return ( +
+ {(['light', 'dark'] as const).map((mode) => ( + + + + {LAYOUTS.map((l) => ( + + {l.label} + + ))} + + + + + {LAYOUTS.map((l) => ( + + {l.label} + + ))} + + + + ))} +
+ ) +} + +function TabsPage() { + return ( + + + + + React + Solid + Vue + +
`} + /> + + + + + + Overview + Usage + API + +`} + /> + + + + }> + Grid +`} + > + + + + + ) +} diff --git a/src/routes/ds.typography.tsx b/src/routes/ds.typography.tsx index 1c5eec490..64e2ceb49 100644 --- a/src/routes/ds.typography.tsx +++ b/src/routes/ds.typography.tsx @@ -107,22 +107,22 @@ const GROUPS: Array = [ { name: 'mono/display', cls: 'text-ds-mono-display', - spec: '24 / 31 · Regular', + spec: '24 / 31 · Medium', }, - { name: 'mono/lg', cls: 'text-ds-mono-lg', spec: '18 / 27 · Regular' }, - { name: 'mono/md', cls: 'text-ds-mono-md', spec: '16 / 24 · Light' }, - { name: 'mono/sm', cls: 'text-ds-mono-sm', spec: '14 / 21 · Light' }, - { name: 'mono/xs', cls: 'text-ds-mono-xs', spec: '12 / 16 · Light' }, - { name: 'mono/2xs', cls: 'text-ds-mono-2xs', spec: '10 / 14 · Light' }, + { name: 'mono/lg', cls: 'text-ds-mono-lg', spec: '18 / 27 · Medium' }, + { name: 'mono/md', cls: 'text-ds-mono-md', spec: '16 / 24 · Regular' }, + { name: 'mono/sm', cls: 'text-ds-mono-sm', spec: '14 / 21 · Regular' }, + { name: 'mono/xs', cls: 'text-ds-mono-xs', spec: '12 / 16 · Regular' }, + { name: 'mono/2xs', cls: 'text-ds-mono-2xs', spec: '10 / 14 · Regular' }, { name: 'mono/caps', cls: 'text-ds-mono-caps uppercase', - spec: '12 / 14 · Regular · +1.5 · UPPER', + spec: '12 / 14 · Medium · +1.5 · UPPER', }, { name: 'mono/caps-xs', cls: 'text-ds-mono-caps-xs uppercase', - spec: '10 / 12 · Regular · +1.2 · UPPER', + spec: '10 / 12 · Medium · +1.2 · UPPER', }, ], }, diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 247afbd7e..04924831c 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -20,6 +20,7 @@ import { HomeSocialProofSection } from '~/components/home/HomeSocialProofSection import { HomeStatsSection } from '~/components/home/HomeStatsSection' import { useQuery } from '@tanstack/react-query' import { Button, Eyebrow } from '~/components/ds/ui' +import { Squircle } from '~/components/Squircle' import { useInView } from '~/hooks/useInView' import { useNpmDownloadCounter } from '~/hooks/useNpmDownloadCounter' import { homepageNpmStatsSummaryQuery, ossStatsQuery } from '~/queries/stats' @@ -99,12 +100,15 @@ function Index() {
for the web

-

+

Headless, type-safe, composable tools for building modern web applications that work naturally for developers and reliably for agents

-
+ {/* The photo is always light, so scope the CTAs to the DS + light mode — the Buttons then render as their standard DS + light-mode selves (no per-button color overrides). */} +
- {/* Link-style button; color pinned to black because the photo - is always light. The responsive override supersedes the - link variant's mobile emphasis color. */} @@ -186,7 +186,7 @@ function HeroPalmMedia() { return ( <> -
@@ -219,7 +219,7 @@ function HeroPalmMedia() { > -
+ {entry.diff && hasChanges && ( - - + +
{entry.diff.added.map((skill) => ( ))}
-
-
+ + )}
) @@ -645,8 +645,8 @@ function ChangelogSkillRow({
{diffVersions && ( - - + +
-
-
+ + )}
) diff --git a/src/routes/intent/registry/$packageName.tsx b/src/routes/intent/registry/$packageName.tsx index 7ec483667..1e88adcf3 100644 --- a/src/routes/intent/registry/$packageName.tsx +++ b/src/routes/intent/registry/$packageName.tsx @@ -9,7 +9,7 @@ import { import { useSuspenseQuery, useQuery } from '@tanstack/react-query' import * as v from 'valibot' import { CopyIcon, CheckIcon } from '@phosphor-icons/react' -import { Collapsible, CollapsibleContent } from '~/components/Collapsible' +import { Panel, PanelContent } from '~/components/Panel' import { seo } from '~/utils/seo' import { intentPackageDetailQueryOptions, @@ -508,8 +508,8 @@ function MobileSkillsDrawer({ /> - - + +
setOpen(false)} />
-
-
+ +
) } diff --git a/src/routes/partners.netlify.tsx b/src/routes/partners.netlify.tsx index 5b529b49d..62fe0a273 100644 --- a/src/routes/partners.netlify.tsx +++ b/src/routes/partners.netlify.tsx @@ -18,11 +18,7 @@ import { twMerge } from 'tailwind-merge' import { Footer } from '~/components/Footer' import { Card } from '~/components/Card' import { Button } from '~/ui' -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '~/components/Collapsible' +import { Panel, PanelContent, PanelTrigger } from '~/components/Panel' import { CodeBlock } from '~/components/markdown/CodeBlock' import { seo } from '~/utils/seo' import { getPartnerById, PartnerImage } from '~/utils/partners' @@ -721,13 +717,13 @@ function NetlifyPartnerPage() { {faqs.map(({ q, a }, i) => { const isOpen = openFaq === i return ( - setOpenFaq(next ? i : null)} className="border-b border-gray-200 dark:border-gray-800" > - + {q} @@ -737,13 +733,13 @@ function NetlifyPartnerPage() { isOpen && 'rotate-45', )} /> - - + +

{a}

-
-
+ + ) })}
diff --git a/src/routes/partners.railway.tsx b/src/routes/partners.railway.tsx index d3314e463..9028dc796 100644 --- a/src/routes/partners.railway.tsx +++ b/src/routes/partners.railway.tsx @@ -17,11 +17,7 @@ import { import { twMerge } from 'tailwind-merge' import { Card } from '~/components/Card' import { Button } from '~/ui' -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '~/components/Collapsible' +import { Panel, PanelContent, PanelTrigger } from '~/components/Panel' import { CodeBlock } from '~/components/markdown/CodeBlock' import { seo } from '~/utils/seo' import { @@ -692,13 +688,13 @@ function RailwayPartnerPage() { {faqs.map(({ q, a }, i) => { const isOpen = openFaq === i return ( - setOpenFaq(next ? i : null)} className="border-b border-gray-200 dark:border-gray-800" > - + {q} @@ -708,13 +704,13 @@ function RailwayPartnerPage() { isOpen && 'rotate-45', )} /> - - + +

{a}

-
-
+ + ) })}
diff --git a/src/styles/app.css b/src/styles/app.css index 7fcd05201..fba8872c9 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -644,6 +644,18 @@ html.dark { --color-category-tooling: var(--color-ds-neutral-400); --color-surface-state-hover: #1111110f; --color-surface-state-pressed: #1111111f; + + /* Light surfaces carry the stronger mono weights (mirrors html.light) so a + forced-light subtree renders mono as medium / regular — not regular / + light — matching the real site's light mode. */ + --text-ds-mono-display--font-weight: 500; + --text-ds-mono-lg--font-weight: 500; + --text-ds-mono-md--font-weight: 400; + --text-ds-mono-sm--font-weight: 400; + --text-ds-mono-xs--font-weight: 400; + --text-ds-mono-2xs--font-weight: 400; + --text-ds-mono-caps--font-weight: 500; + --text-ds-mono-caps-xs--font-weight: 500; } .ds-mode-dark { @@ -887,6 +899,101 @@ button { animation-delay: 140ms; } +/* ------------------------------------------------------------------ * + * Blog filter motion — native
reveals animated via the + * ::details-content pseudo, driven off the [open] attribute. This path is + * reliable even when React controls `open` (animating on the display:none→ + * block toggle is not). `interpolate-size` lets height animate to/from auto. + * ------------------------------------------------------------------ */ + +/* Facet accordion sections (Topics / Author / Archive) expand their height + over 250ms so it's clear which one is open — on open and on close. */ +.ts-accordion { + interpolate-size: allow-keywords; +} + +.ts-accordion::details-content { + block-size: 0; + overflow: clip; + opacity: 0; + transition: + block-size 250ms cubic-bezier(0.2, 0.8, 0.2, 1), + opacity 200ms ease, + content-visibility 250ms allow-discrete; +} + +.ts-accordion[open]::details-content { + block-size: auto; + opacity: 1; +} + +/* Newly revealed "Load more" cards fade up, staggered by batch position. + Only the appended batch carries this class (see blog.index.tsx), so it + never fires on first paint or filter changes. */ +@keyframes blog-card-in { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: none; + } +} + +.blog-card-enter { + animation: blog-card-in 300ms cubic-bezier(0.16, 1, 0.3, 1) both; +} +.blog-card-enter.enter-1 { + animation-delay: 40ms; +} +.blog-card-enter.enter-2 { + animation-delay: 80ms; +} +.blog-card-enter.enter-3 { + animation-delay: 120ms; +} +.blog-card-enter.enter-4 { + animation-delay: 160ms; +} +.blog-card-enter.enter-5 { + animation-delay: 200ms; +} + +/* Skip the movement for reduced-motion users; the panels just appear. */ +@media (prefers-reduced-motion: reduce) { + .ts-accordion::details-content { + transition-property: content-visibility; + } + + .blog-card-enter { + animation: none; + } +} + +/* Blog index year grid: 1 → 2 → 3 columns with faint full-height column + dividers in the gutters, drawn as a left border on non-first-column cards + (grid cells stretch to the row height, so the line is full-height). Lives + in CSS because the per-breakpoint column math is awkward as Tailwind + nth-child variants. */ +.blog-year-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + row-gap: 2rem; +} + +@media (min-width: 768px) { + .blog-year-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 2rem; + } + .blog-year-grid > *:nth-child(2n) { + border-left: 1px solid var(--color-border-subtle); + padding-left: 2rem; + } +} + .ts-mega-dropdown-panel { position: relative; isolation: isolate; @@ -1248,6 +1355,36 @@ button { background: color-mix(in srgb, var(--color-text-primary) 40%, transparent); } + /* Subtle, track-less scrollbar for scrollable menus (DropdownContent + maxHeight). A thin, low-opacity thumb meant to read only as a "more + content" indicator, not a grab target. Prefixed with `html` to out-specify + the global scrollbar rules above (equal specificity, later source order + wins). */ + html .ds-scroll-subtle { + scrollbar-width: thin; + scrollbar-color: color-mix( + in srgb, + var(--color-text-primary) 18%, + transparent + ) + transparent; + } + html .ds-scroll-subtle::-webkit-scrollbar { + width: 5px; + height: 5px; + } + html .ds-scroll-subtle::-webkit-scrollbar-track { + background: transparent; + } + html .ds-scroll-subtle::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--color-text-primary) 18%, transparent); + border: 0; + border-radius: 9999px; + } + html .ds-scroll-subtle::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--color-text-primary) 30%, transparent); + } + [disabled] { @apply opacity-50 pointer-events-none; } diff --git a/src/utils/partners.tsx b/src/utils/partners.tsx index 282505433..a8f1a4ab6 100644 --- a/src/utils/partners.tsx +++ b/src/utils/partners.tsx @@ -31,6 +31,7 @@ import strapiLightSvg from '~/images/strapi-light.svg' import strapiDarkSvg from '~/images/strapi-dark.svg' import serpapiWhiteSvg from '~/images/serpapi-white.svg' import serpapiBlackSvg from '~/images/serpapi-black.svg' +import type { CSSProperties } from 'react' import type { Library } from '~/libraries' import cloudflareWhiteSvg from '~/images/cloudflare-white.svg' import cloudflareBlackSvg from '~/images/cloudflare-black.svg' @@ -56,7 +57,7 @@ function LearnMoreButton() { ) } -type PartnerImageConfig = +export type PartnerImageConfig = | { light: string; dark: string; scale?: number } | { src: string; scale?: number } @@ -171,14 +172,43 @@ export function PartnerImage({ className, config, alt, + style, + mode, }: { className?: string config: PartnerImageConfig alt: string + /** Merged onto the — e.g. a runtime-derived `maxHeight` from the + * tier-sizing rubric that can't be expressed as a static Tailwind class. */ + style?: CSSProperties + /** Force the light or dark logo variant instead of following the global + * `dark:` theme. Used by previews that render both themes on one page (where + * the CSS `dark:` variant can't be scoped per panel). */ + mode?: 'light' | 'dark' }) { const scaleStyle = config.scale ? { transform: `scale(${config.scale})` } : {} if ('light' in config && 'dark' in config) { + // Forced variant: render only the requested asset, no `dark:` toggling. + if (mode) { + return ( +
+ {alt} +
+ ) + } return (