From c9768e25aec412766f13596abf85cb38c80e4499 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Tue, 18 Aug 2026 13:59:37 -0600 Subject: [PATCH 01/50] feat(ds): tabs system, Panel rename, neutral status badges, squircles Design-system pass building on the grouped-buttons branch. - Tabs: new DS Tabs component with two styles (primary underline, secondary segmented) sharing one accessible API (role=tablist/tab/tabpanel, aria-selected, roving tabindex + arrow/home/end keys). /ds/tabs showcase with a size selector; per-size squircle corner radii. - SegmentedControl flattened to the Figma spec (equal chips, warm hover, filled squircle selection, shared styling with Tabs); its showcase merged into Tabs. - Rename Collapsible -> Panel everywhere: component, file, ~10 production consumers, and the DS page/route (/ds/collapsible -> /ds/panel). - Neutral library status badges: LibraryStatusBadge and the nav MegaMenuItem badge no longer color-code alpha/beta/RC/new, so status never competes with a library's brand color; documented in the DS Badges showcase. - Contributors page (all libraries): replace the bespoke layout toggle with the small-icon SegmentedControl. - Squircles: roll out the corner-squircle utility to card/surface components (Card, DS Card, ShowcaseCard, MaintainerCard, StatsSection, HomeSocialProof, PartnersGrid, ChartsCatalogGallery); cross-browser Squircle helper on blog cards and the homepage hero. - Blog index Linear-style refinements; hero mobile/CTA polish. - Sync the DS Navbar showcase to the live nav; emblem PageHeader on the DS overview; alphabetize the Components nav; prune dead ts-filter-details CSS. Co-Authored-By: Claude Opus 4.8 --- package.json | 1 + pnpm-lock.yaml | 8 + src/components/ApplicationStarter.tsx | 42 +-- src/components/BlogBrowseNav.tsx | 235 ++++++++++---- src/components/ButtonGroup.tsx | 88 +++++- src/components/Card.tsx | 2 +- src/components/LibraryLayout.tsx | 12 +- src/components/LibraryStatusBadge.tsx | 16 +- src/components/MaintainerCard.tsx | 6 +- src/components/MegaMenuItem.tsx | 4 +- src/components/Navbar.tsx | 10 +- src/components/{Collapsible.tsx => Panel.tsx} | 44 +-- src/components/PartnersGrid.tsx | 2 +- src/components/ShowcaseCard.tsx | 4 +- src/components/Squircle.tsx | 93 ++++++ src/components/builder/FeaturePicker.tsx | 32 +- .../charts/ChartsNotebookPage.client.tsx | 18 +- src/components/ds/DsKit.tsx | 26 +- src/components/ds/ds-nav.ts | 85 ++--- src/components/ds/ui/BlogPostCard.tsx | 51 ++- src/components/ds/ui/PageHeader.tsx | 2 +- src/components/ds/ui/StatsSection.tsx | 4 +- src/components/ds/ui/Tabs.tsx | 299 ++++++++++++++++++ src/components/ds/ui/index.tsx | 31 +- .../home/HomeSocialProofSection.tsx | 2 +- .../landing/ChartsCatalogGallery.tsx | 4 +- src/routeTree.gen.ts | 63 ++-- .../$libraryId/$version.docs.blog.tsx | 106 ++++--- .../$libraryId/$version.docs.contributors.tsx | 59 ++-- src/routes/blog.index.tsx | 183 +++++++---- src/routes/ds.badges.tsx | 30 +- src/routes/ds.buttons.tsx | 54 ++-- src/routes/ds.index.tsx | 11 +- src/routes/ds.navbar.tsx | 45 ++- .../{ds.collapsible.tsx => ds.panel.tsx} | 46 +-- src/routes/ds.tabs.tsx | 249 +++++++++++++++ src/routes/index.tsx | 16 +- .../intent/registry/$packageName.index.tsx | 18 +- src/routes/intent/registry/$packageName.tsx | 10 +- src/routes/partners.netlify.tsx | 20 +- src/routes/partners.railway.tsx | 20 +- src/styles/app.css | 111 +++++++ 42 files changed, 1659 insertions(+), 503 deletions(-) rename src/components/{Collapsible.tsx => Panel.tsx} (65%) create mode 100644 src/components/Squircle.tsx create mode 100644 src/components/ds/ui/Tabs.tsx rename src/routes/{ds.collapsible.tsx => ds.panel.tsx} (61%) create mode 100644 src/routes/ds.tabs.tsx diff --git a/package.json b/package.json index 1bba510f5..d4ca3de07 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,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 dfa084cad..3b45dc22a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,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 @@ -5008,6 +5011,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'} @@ -11542,6 +11548,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..b6a646607 100644 --- a/src/components/ApplicationStarter.tsx +++ b/src/components/ApplicationStarter.tsx @@ -19,10 +19,10 @@ import type { ApplicationStarterResult, } from '~/utils/application-starter' import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '~/components/Collapsible' + Panel, + PanelContent, + PanelTrigger, +} from '~/components/Panel' import { GeneratedPromptPreviewBody, GeneratedPromptPreviewHeader, @@ -868,8 +868,8 @@ export function ApplicationStarter({ ) : null} - - + + {showOptionsSection ? (
@@ -946,8 +946,8 @@ export function ApplicationStarter({
) : null} -
-
+ + ) : ( <> @@ -1170,8 +1170,8 @@ export function ApplicationStarter({ ) : null} - - + ) : null} - - + + - - + ) : null} - - + + {showPromptPreview && hasGeneratedPrompt ? (
@@ -1571,9 +1571,9 @@ function StarterCustomizationSection({ title: string }) { return ( - +
- - - {open ? children : null} + + {open ? children : null}
-
+ ) } diff --git a/src/components/BlogBrowseNav.tsx b/src/components/BlogBrowseNav.tsx index 58fcc7c31..8822357a6 100644 --- a/src/components/BlogBrowseNav.tsx +++ b/src/components/BlogBrowseNav.tsx @@ -1,19 +1,23 @@ import * as React from 'react' -import { CaretDownIcon, RssIcon } from '@phosphor-icons/react' +import { CaretDownIcon } 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 { + categoryTextColor, + libraryCategories, + type LibraryCategory, +} from '~/libraries/categories' import { fallbackLibraryIcon, libraryIcons } from '~/libraries/icons' +import { findMaintainerByAuthorName } from '~/utils/authors' +import authorFallbackAvatar from '~/images/author-fallback.svg' import type { LibrarySlim } from '~/libraries' export type BlogTopic = { library: LibrarySlim; count: number } export type BlogYear = { year: string; count: number } +type FilterPanel = 'topics' | 'author' | 'archive' + type BlogBrowseNavProps = { - searchQuery: string - onSearchChange: (query: string) => void topics: Array totalCount: number selectedLibrary: string | undefined @@ -26,13 +30,10 @@ type BlogBrowseNavProps = { 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' + 'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-[color,background-color,transform] duration-150 ease-out motion-safe:active:scale-[0.98]' function rowStateClass(isActive: boolean) { return isActive @@ -40,25 +41,72 @@ function rowStateClass(isActive: boolean) { : 'text-text-secondary hover:bg-surface-state-hover' } +// A selected topic row is tinted to its library's category rather than the +// generic blue the other facets use. Written as literal classes so Tailwind's +// JIT emits each category utility. +const categoryActiveRow: Record = { + framework: 'bg-category-framework/10 font-semibold text-category-framework', + data: 'bg-category-data/10 font-semibold text-category-data', + ui: 'bg-category-ui/10 font-semibold text-category-ui', + performance: + 'bg-category-performance/10 font-semibold text-category-performance', + tooling: 'bg-category-tooling/10 font-semibold text-category-tooling', +} + // 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}) } +function getAuthorAvatar(name: string): string { + return findMaintainerByAuthorName(name)?.avatar ?? authorFallbackAvatar +} + // A collapsible section header (Eyebrow + chevron) for progressive disclosure. -function DisclosureSummary({ label }: { label: string }) { +// When collapsed, the current selection rides alongside the label so the active +// filter stays visible without expanding; it hides on open, where the +// highlighted row already carries it. A non-default selection is tinted to read +// as active. +function DisclosureSummary({ + label, + value, + isActive, + icon, + activeClassName, +}: { + label: string + value: string + isActive: boolean + /** Rendered before the value when active (e.g. the selected library icon). */ + icon?: React.ReactNode + /** Color treatment for an active selection; defaults to blue. Topics passes + * its library-category color here. */ + activeClassName?: string +}) { return ( - - {label} - + + + {label} + + {isActive ? icon : null} + {value} + + + ) } export function BlogBrowseNav({ - searchQuery, - onSearchChange, topics, totalCount, selectedLibrary, @@ -71,38 +119,44 @@ export function BlogBrowseNav({ 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)) + // The three facet panels behave as an accordion: every facet starts collapsed, + // and opening one closes the others. Selections persist regardless — they live + // in the URL, not the panel's open state — and stay legible in the collapsed + // header (see DisclosureSummary). + const [openPanel, setOpenPanel] = React.useState(null) + const togglePanel = (panel: FilterPanel) => (open: boolean) => + setOpenPanel((current) => + open ? panel : current === panel ? null : current, + ) + + // Ignore URL values that don't correspond to a real author in the list. + const activeAuthor = + selectedAuthor && authors.includes(selectedAuthor) + ? selectedAuthor + : undefined + + // Current-selection labels shown in each collapsed section header. + 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 return ( - ) -} diff --git a/src/components/BlogFilterBar.tsx b/src/components/BlogFilterBar.tsx new file mode 100644 index 000000000..55b4cc659 --- /dev/null +++ b/src/components/BlogFilterBar.tsx @@ -0,0 +1,358 @@ +import * as React from 'react' +import { + CaretDownIcon, + CheckIcon, + RssIcon, + SlidersHorizontalIcon, +} from '@phosphor-icons/react' +import { twMerge } from 'tailwind-merge' +import { + Dropdown, + DropdownContent, + DropdownItem, + DropdownTrigger, + SearchInput, +} from '~/components/ds/ui' +import { + categoryTextColor, + libraryCategories, + type LibraryCategory, +} 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 +} + +// A selected topic pill is tinted to its library's category rather than the +// generic blue the other facets use. Literal classes so Tailwind's JIT emits +// each category utility. +const categoryActivePill: Record = { + framework: 'bg-category-framework/10 text-category-framework', + data: 'bg-category-data/10 text-category-data', + ui: 'bg-category-ui/10 text-category-ui', + performance: 'bg-category-performance/10 text-category-performance', + tooling: 'bg-category-tooling/10 text-category-tooling', +} + +function filterTriggerClass(isActive: boolean, activeClassName?: string) { + return twMerge( + 'inline-flex h-10 shrink-0 items-center gap-1.5 rounded-full border px-4 text-sm transition-colors', + isActive + ? twMerge( + 'border-transparent font-semibold', + activeClassName ?? 'bg-blue-500/10 text-blue-700 dark:text-blue-300', + ) + : 'border-border-default text-text-secondary hover:bg-surface-state-hover hover:text-text-primary', + ) +} + +// 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 row (a fresh call so each slot owns its instances). + const renderFilters = () => ( + <> + + + + + + 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 row: a horizontal, scrollable set of the facet + dropdowns, animated open via a grid-rows height transition. */} +
+
+
+ {renderFilters()} +
+
+
+
+ ) +} diff --git a/src/components/RightRail.tsx b/src/components/RightRail.tsx index 0d80f9e3f..a9fcc4a14 100644 --- a/src/components/RightRail.tsx +++ b/src/components/RightRail.tsx @@ -119,16 +119,56 @@ const railTierLayout: Record< }, } +// The stepladder is a single right-aligned column: one partner per row, the +// row (and its logo) narrowing tier by tier so gold sits widest at the top and +// bronze narrowest at the bottom — a ladder stepping down and in. +const stepladderTierLayout: Record< + PartnerTier, + { + width: string + minHeight: string + logoMaxWidth: string + logoMaxHeight: string + padding: string + } +> = { + gold: { + width: 'w-full', + minHeight: 'min-h-[76px]', + logoMaxWidth: 'max-w-[190px]', + logoMaxHeight: 'max-h-[42px]', + padding: 'px-5 py-4', + }, + silver: { + width: 'w-[80%]', + minHeight: 'min-h-[62px]', + logoMaxWidth: 'max-w-[130px]', + logoMaxHeight: 'max-h-[30px]', + padding: 'px-4 py-3', + }, + bronze: { + width: 'w-[62%]', + minHeight: 'min-h-[50px]', + logoMaxWidth: 'max-w-[90px]', + logoMaxHeight: 'max-h-[24px]', + padding: 'px-3 py-2.5', + }, +} + export function PartnersRail({ analyticsPlacement, partners, title = 'Partners', titleTo = '/partners', + layout = 'grid', }: { analyticsPlacement: PartnerPlacement partners: Array title?: string titleTo?: '/partners' + /** `grid` (default) tiles logos in tier rows; `stepladder` is a single + * right-aligned column that narrows tier by tier. */ + layout?: 'grid' | 'stepladder' }) { const placementContext = usePartnerPlacementContext({ orderStrategy: 'tier-rotated', @@ -142,6 +182,45 @@ export function PartnersRail({ let slotIndex = 0 + if (layout === 'stepladder') { + return ( +
+ +
+ {rowsByTier.map((row) => + row.partners.map((partner) => { + const index = slotIndex++ + return ( + + ) + }), + )} +
+
+ ) + } + return (
@@ -201,13 +280,26 @@ function PartnersRailItem({ index, placementContext, partner, + variant = 'grid', }: { analyticsPlacement: PartnerPlacement index: number placementContext: PartnerPlacementContext partner: RailPartner + variant?: 'grid' | 'stepladder' }) { - const layout = railTierLayout[partner.tier ?? 'bronze'] + const tier = partner.tier ?? 'bronze' + // Stepladder rows are self-contained rounded cards sized by tier width; grid + // cells are borderless tiles that share edges within a tier row. + const isStepladder = variant === 'stepladder' + const layout = isStepladder + ? stepladderTierLayout[tier] + : railTierLayout[tier] + // Read the differing size key from the concretely-typed config so the union + // stays narrowed (grid → flexBasis, stepladder → width). + const sizingClass = isStepladder + ? stepladderTierLayout[tier].width + : railTierLayout[tier].flexBasis const analyticsMetadata = getPartnerPlacementAnalyticsMetadata( partner, placementContext, @@ -229,8 +321,11 @@ function PartnersRailItem({ target="_blank" rel="noreferrer" className={twMerge( - 'flex items-center justify-center overflow-hidden border-r border-b border-gray-500/20 hover:bg-gray-500/10 transition-colors duration-150 ease-out', - layout.flexBasis, + 'flex items-center justify-center overflow-hidden transition-colors duration-150 ease-out hover:bg-gray-500/10', + isStepladder + ? 'rounded-lg border border-gray-500/20' + : 'border-b border-r border-gray-500/20', + sizingClass, layout.minHeight, layout.padding, )} diff --git a/src/routes/blog.index.tsx b/src/routes/blog.index.tsx index ccb6704c4..271488656 100644 --- a/src/routes/blog.index.tsx +++ b/src/routes/blog.index.tsx @@ -1,10 +1,9 @@ import * as React from 'react' -import { SlidersHorizontalIcon, RssIcon } from '@phosphor-icons/react' import { createFileRoute } from '@tanstack/react-router' import * as v from 'valibot' -import { BlogBrowseNav } from '~/components/BlogBrowseNav' +import { BlogFilterBar } from '~/components/BlogFilterBar' import { type BlogCardPost } from '~/components/BlogCard' -import { Eyebrow, SearchInput } from '~/components/ds/ui' +import { Eyebrow } from '~/components/ds/ui' import { BlogPostCard } from '~/components/ds/ui/BlogPostCard' import { PageHeader } from '~/components/ds/ui/PageHeader' import { PartnersRail } from '~/components/RightRail' @@ -138,7 +137,6 @@ function BlogIndex() { const featuredPost = hasActiveFilters ? undefined : filteredPosts[0] const gridPosts = featuredPost ? filteredPosts.slice(1) : filteredPosts - const [filtersOpen, setFiltersOpen] = React.useState(false) const [visibleCount, setVisibleCount] = React.useState(POSTS_PER_PAGE) // Index from which cards animate in. Set only when "Load more" reveals a // batch, so the entrance never fires on first paint or filter changes. @@ -163,8 +161,10 @@ function BlogIndex() { replace: true, }) - // Shared by the desktop sidebar and the mobile disclosure copies of the nav. - const browseNavProps = { + // Everything the inline filter bar needs: search + the three URL-backed facets. + const filterBarProps = { + searchQuery, + onSearchChange, topics, totalCount: frontMatters.length, selectedLibrary, @@ -195,81 +195,25 @@ function BlogIndex() {
-
-
+
+
- {/* One cohesive pill: the search field, then the RSS feed and the - filter toggle as icons inside it, each parted by a faint - divider. Centered beneath the masthead. */} -
- - onSearchChange(event.currentTarget.value) - } - trailing={ -
- - - - - - -
- } - /> -
+ {/* Search on the left, the facet filters and RSS on the right — + one toolbar spanning the content width. On narrow screens the + facets collapse behind a single toggle. */} +
- {/* The browse nav slides in on the left when the filter toggle is - on; the content column reflows to make room. */} -
- + {/* The toolbar sits 8px above this divider, which tops the content + column beneath it. */} +
-
+
+
{featuredPost ? (
From 4893dee35ee9a4a012a464ee5a039d94e3acb221 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Wed, 19 Aug 2026 23:30:24 -0600 Subject: [PATCH 18/50] fix(blog): symmetric padding on stepladder partner logos Equal padding on all four sides per tier (gold p-4, silver p-3, bronze p-2.5) instead of the wider horizontal padding, so each logo sits evenly centered in its card. Co-Authored-By: Claude Opus 4.8 --- src/components/RightRail.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/RightRail.tsx b/src/components/RightRail.tsx index a9fcc4a14..1706b922a 100644 --- a/src/components/RightRail.tsx +++ b/src/components/RightRail.tsx @@ -137,21 +137,21 @@ const stepladderTierLayout: Record< minHeight: 'min-h-[76px]', logoMaxWidth: 'max-w-[190px]', logoMaxHeight: 'max-h-[42px]', - padding: 'px-5 py-4', + padding: 'p-4', }, silver: { width: 'w-[80%]', minHeight: 'min-h-[62px]', logoMaxWidth: 'max-w-[130px]', logoMaxHeight: 'max-h-[30px]', - padding: 'px-4 py-3', + padding: 'p-3', }, bronze: { width: 'w-[62%]', minHeight: 'min-h-[50px]', logoMaxWidth: 'max-w-[90px]', logoMaxHeight: 'max-h-[24px]', - padding: 'px-3 py-2.5', + padding: 'p-2.5', }, } From 9bdd71a9781652d5245dfa0c661c02a8d3f237e3 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Wed, 19 Aug 2026 23:34:59 -0600 Subject: [PATCH 19/50] refactor(blog): stepladder rail uses row dividers, not logo cards Drop the border/rounded card around each stepladder partner logo in favor of a single hairline divider between rows (divide-y on the column), and increase vertical padding so each logo sits further from the dividers (gold py-6 / silver py-5 / bronze py-4). Co-Authored-By: Claude Opus 4.8 --- src/components/RightRail.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/components/RightRail.tsx b/src/components/RightRail.tsx index 1706b922a..bdbedcade 100644 --- a/src/components/RightRail.tsx +++ b/src/components/RightRail.tsx @@ -137,21 +137,21 @@ const stepladderTierLayout: Record< minHeight: 'min-h-[76px]', logoMaxWidth: 'max-w-[190px]', logoMaxHeight: 'max-h-[42px]', - padding: 'p-4', + padding: 'px-4 py-6', }, silver: { width: 'w-[80%]', minHeight: 'min-h-[62px]', logoMaxWidth: 'max-w-[130px]', logoMaxHeight: 'max-h-[30px]', - padding: 'p-3', + padding: 'px-3 py-5', }, bronze: { width: 'w-[62%]', minHeight: 'min-h-[50px]', logoMaxWidth: 'max-w-[90px]', logoMaxHeight: 'max-h-[24px]', - padding: 'p-2.5', + padding: 'px-2.5 py-4', }, } @@ -200,7 +200,7 @@ export function PartnersRail({ Become a Partner
-
+
{rowsByTier.map((row) => row.partners.map((partner) => { const index = slotIndex++ @@ -322,9 +322,7 @@ function PartnersRailItem({ rel="noreferrer" className={twMerge( 'flex items-center justify-center overflow-hidden transition-colors duration-150 ease-out hover:bg-gray-500/10', - isStepladder - ? 'rounded-lg border border-gray-500/20' - : 'border-b border-r border-gray-500/20', + isStepladder ? '' : 'border-b border-r border-gray-500/20', sizingClass, layout.minHeight, layout.padding, From 90f0e18d45e54311972050cb64734e554c261395 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Wed, 19 Aug 2026 23:39:27 -0600 Subject: [PATCH 20/50] refactor(blog): fixed per-tier row heights in stepladder rail Give every stepladder row a standardized height per tier (gold 90px, silver 70px, bronze 58px) instead of a min-height + vertical padding, so each step is uniform. The logo stays centered and bounded by its max-height; horizontal padding is unchanged. Co-Authored-By: Claude Opus 4.8 --- src/components/RightRail.tsx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/RightRail.tsx b/src/components/RightRail.tsx index bdbedcade..9395a5e6b 100644 --- a/src/components/RightRail.tsx +++ b/src/components/RightRail.tsx @@ -126,7 +126,7 @@ const stepladderTierLayout: Record< PartnerTier, { width: string - minHeight: string + height: string logoMaxWidth: string logoMaxHeight: string padding: string @@ -134,24 +134,24 @@ const stepladderTierLayout: Record< > = { gold: { width: 'w-full', - minHeight: 'min-h-[76px]', + height: 'h-[90px]', logoMaxWidth: 'max-w-[190px]', logoMaxHeight: 'max-h-[42px]', - padding: 'px-4 py-6', + padding: 'px-4', }, silver: { width: 'w-[80%]', - minHeight: 'min-h-[62px]', + height: 'h-[70px]', logoMaxWidth: 'max-w-[130px]', logoMaxHeight: 'max-h-[30px]', - padding: 'px-3 py-5', + padding: 'px-3', }, bronze: { width: 'w-[62%]', - minHeight: 'min-h-[50px]', + height: 'h-[58px]', logoMaxWidth: 'max-w-[90px]', logoMaxHeight: 'max-h-[24px]', - padding: 'px-2.5 py-4', + padding: 'px-2.5', }, } @@ -295,11 +295,15 @@ function PartnersRailItem({ const layout = isStepladder ? stepladderTierLayout[tier] : railTierLayout[tier] - // Read the differing size key from the concretely-typed config so the union - // stays narrowed (grid → flexBasis, stepladder → width). + // Read the differing size keys from the concretely-typed config so the union + // stays narrowed (grid → flexBasis + min-height, stepladder → width + fixed + // per-tier height). const sizingClass = isStepladder ? stepladderTierLayout[tier].width : railTierLayout[tier].flexBasis + const heightClass = isStepladder + ? stepladderTierLayout[tier].height + : railTierLayout[tier].minHeight const analyticsMetadata = getPartnerPlacementAnalyticsMetadata( partner, placementContext, @@ -324,7 +328,7 @@ function PartnersRailItem({ 'flex items-center justify-center overflow-hidden transition-colors duration-150 ease-out hover:bg-gray-500/10', isStepladder ? '' : 'border-b border-r border-gray-500/20', sizingClass, - layout.minHeight, + heightClass, layout.padding, )} onClick={() => { From 135ac66cdf5a039909a041b3d74f6f02791371e8 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Wed, 19 Aug 2026 23:56:17 -0600 Subject: [PATCH 21/50] refactor(blog): filter bar triggers use the DS Button Replace the hand-rolled trigger buttons and RSS/sliders icon buttons with the design-system Button (ghost when idle, secondary when a filter is active; icon-md sizing for RSS/sliders), matching the canonical Dropdown + Button pattern. Search (SearchInput), the dropdown menu (Dropdown/DropdownItem), and the cards (BlogPostCard) were already DS components; this removes the last bespoke styling from the toolbar. Co-Authored-By: Claude Opus 4.8 --- src/components/BlogFilterBar.tsx | 93 ++++++++++++-------------------- 1 file changed, 35 insertions(+), 58 deletions(-) diff --git a/src/components/BlogFilterBar.tsx b/src/components/BlogFilterBar.tsx index 55b4cc659..5e5c7945e 100644 --- a/src/components/BlogFilterBar.tsx +++ b/src/components/BlogFilterBar.tsx @@ -7,17 +7,14 @@ import { } from '@phosphor-icons/react' import { twMerge } from 'tailwind-merge' import { + Button, Dropdown, DropdownContent, DropdownItem, DropdownTrigger, SearchInput, } from '~/components/ds/ui' -import { - categoryTextColor, - libraryCategories, - type LibraryCategory, -} from '~/libraries/categories' +import { categoryTextColor, libraryCategories } from '~/libraries/categories' import { fallbackLibraryIcon, libraryIcons } from '~/libraries/icons' import type { LibrarySlim } from '~/libraries' @@ -41,29 +38,6 @@ type BlogFilterBarProps = { onClearAll: () => void } -// A selected topic pill is tinted to its library's category rather than the -// generic blue the other facets use. Literal classes so Tailwind's JIT emits -// each category utility. -const categoryActivePill: Record = { - framework: 'bg-category-framework/10 text-category-framework', - data: 'bg-category-data/10 text-category-data', - ui: 'bg-category-ui/10 text-category-ui', - performance: 'bg-category-performance/10 text-category-performance', - tooling: 'bg-category-tooling/10 text-category-tooling', -} - -function filterTriggerClass(isActive: boolean, activeClassName?: string) { - return twMerge( - 'inline-flex h-10 shrink-0 items-center gap-1.5 rounded-full border px-4 text-sm transition-colors', - isActive - ? twMerge( - 'border-transparent font-semibold', - activeClassName ?? 'bg-blue-500/10 text-blue-700 dark:text-blue-300', - ) - : 'border-border-default text-text-secondary hover:bg-surface-state-hover hover:text-text-primary', - ) -} - // The count rides alongside the label in parens, matching the old browse nav. function Count({ value }: { value: number }) { return ({value}) @@ -136,19 +110,16 @@ export function BlogFilterBar({ : null // The facet dropdowns, rendered in both the desktop inline row and the - // mobile expandable row (a fresh call so each slot owns its instances). + // mobile expandable row (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. const renderFilters = () => ( <> - + - + - + Clear - + ) : null} ) @@ -307,14 +280,15 @@ export function BlogFilterBar({
{/* Narrow screens: one toggle that expands the horizontal set below. */} - + - - +
From 7aaeb414ee3c30a12bc992171b41bcf9ae3d8364 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Thu, 20 Aug 2026 09:48:54 -0600 Subject: [PATCH 22/50] feat(blog): tiered partners rail with centered tier sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the stepladder rail variant with a `tiered` layout matching the design reference: each tier is a centered section (hairline + tier icon + label header) whose logos scale down tier by tier and fade slightly — gold largest and one-up, silver medium and one-up, bronze smallest and two-up. Keeps the data-driven partner logos and the grayscale→hover colorize behavior. The docs and blog-post rails keep the default grid. Co-Authored-By: Claude Opus 4.8 --- src/components/RightRail.tsx | 162 +++++++++++++++++++++-------------- src/routes/blog.index.tsx | 2 +- 2 files changed, 97 insertions(+), 67 deletions(-) diff --git a/src/components/RightRail.tsx b/src/components/RightRail.tsx index 9395a5e6b..0840ea8e7 100644 --- a/src/components/RightRail.tsx +++ b/src/components/RightRail.tsx @@ -119,42 +119,60 @@ const railTierLayout: Record< }, } -// The stepladder is a single right-aligned column: one partner per row, the -// row (and its logo) narrowing tier by tier so gold sits widest at the top and -// bronze narrowest at the bottom — a ladder stepping down and in. -const stepladderTierLayout: Record< +// The tiered rail groups partners into centered tier sections (a hairline + +// icon + label header per tier). Logos scale down tier by tier — gold largest +// and one-up, silver medium and one-up, bronze smallest and two-up — and fade +// slightly, matching the design reference's spacing and relative scale. +const tieredTierLayout: Record< PartnerTier, { - width: string - height: string + rowHeight: string logoMaxWidth: string logoMaxHeight: string - padding: string + perRow: 1 | 2 + idleOpacity: string } > = { gold: { - width: 'w-full', - height: 'h-[90px]', - logoMaxWidth: 'max-w-[190px]', - logoMaxHeight: 'max-h-[42px]', - padding: 'px-4', + rowHeight: 'h-[80px]', + logoMaxWidth: 'max-w-[210px]', + logoMaxHeight: 'max-h-[46px]', + perRow: 1, + idleOpacity: '', }, silver: { - width: 'w-[80%]', - height: 'h-[70px]', - logoMaxWidth: 'max-w-[130px]', + rowHeight: 'h-[62px]', + logoMaxWidth: 'max-w-[145px]', logoMaxHeight: 'max-h-[30px]', - padding: 'px-3', + perRow: 1, + idleOpacity: 'opacity-80', }, bronze: { - width: 'w-[62%]', - height: 'h-[58px]', - logoMaxWidth: 'max-w-[90px]', - logoMaxHeight: 'max-h-[24px]', - padding: 'px-2.5', + rowHeight: 'h-[56px]', + logoMaxWidth: 'max-w-[100px]', + logoMaxHeight: 'max-h-[22px]', + perRow: 2, + 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]} + + + +
+ ) +} + export function PartnersRail({ analyticsPlacement, partners, @@ -166,9 +184,9 @@ export function PartnersRail({ partners: Array title?: string titleTo?: '/partners' - /** `grid` (default) tiles logos in tier rows; `stepladder` is a single - * right-aligned column that narrows tier by tier. */ - layout?: 'grid' | 'stepladder' + /** `grid` (default) tiles logos in tier rows; `tiered` stacks centered tier + * sections whose logos scale down tier by tier (bronze is two-up). */ + layout?: 'grid' | 'tiered' }) { const placementContext = usePartnerPlacementContext({ orderStrategy: 'tier-rotated', @@ -182,9 +200,9 @@ export function PartnersRail({ let slotIndex = 0 - if (layout === 'stepladder') { + if (layout === 'tiered') { return ( -
+
-
- {rowsByTier.map((row) => - row.partners.map((partner) => { - const index = slotIndex++ - return ( - - ) - }), - )} -
+ {rowsByTier.map((row) => ( +
+ +
+ {row.partners.map((partner) => { + const index = slotIndex++ + return ( + + ) + })} +
+
+ ))}
) } @@ -286,24 +313,29 @@ function PartnersRailItem({ index: number placementContext: PartnerPlacementContext partner: RailPartner - variant?: 'grid' | 'stepladder' + variant?: 'grid' | 'tiered' }) { const tier = partner.tier ?? 'bronze' - // Stepladder rows are self-contained rounded cards sized by tier width; grid - // cells are borderless tiles that share edges within a tier row. - const isStepladder = variant === 'stepladder' - const layout = isStepladder - ? stepladderTierLayout[tier] - : railTierLayout[tier] - // Read the differing size keys from the concretely-typed config so the union - // stays narrowed (grid → flexBasis + min-height, stepladder → width + fixed - // per-tier height). - const sizingClass = isStepladder - ? stepladderTierLayout[tier].width - : railTierLayout[tier].flexBasis - const heightClass = isStepladder - ? stepladderTierLayout[tier].height - : railTierLayout[tier].minHeight + // Tiered cells are borderless, fixed-height logo slots that fade by tier; grid + // cells are borderless tiles that share edges within a tier row. Both read + // logo bounds from their own concretely-typed config. + const isTiered = variant === 'tiered' + const grid = railTierLayout[tier] + const tiered = tieredTierLayout[tier] + const logoMaxWidth = isTiered ? tiered.logoMaxWidth : grid.logoMaxWidth + const logoMaxHeight = isTiered ? tiered.logoMaxHeight : grid.logoMaxHeight + const cellClass = isTiered + ? twMerge('w-full px-2', tiered.rowHeight) + : twMerge( + 'border-b border-r border-gray-500/20', + grid.flexBasis, + grid.minHeight, + grid.padding, + ) + // Lower tiers rest more muted and lift to full opacity/color on rail hover. + const logoIdleClass = isTiered + ? twMerge(tiered.idleOpacity, 'group-hover/rail:opacity-100') + : '' const analyticsMetadata = getPartnerPlacementAnalyticsMetadata( partner, placementContext, @@ -326,10 +358,7 @@ function PartnersRailItem({ rel="noreferrer" className={twMerge( 'flex items-center justify-center overflow-hidden transition-colors duration-150 ease-out hover:bg-gray-500/10', - isStepladder ? '' : 'border-b border-r border-gray-500/20', - sizingClass, - heightClass, - layout.padding, + cellClass, )} onClick={() => { let destinationHost: string | undefined @@ -350,12 +379,13 @@ function PartnersRailItem({ >
diff --git a/src/routes/blog.index.tsx b/src/routes/blog.index.tsx index 271488656..e7488bf0d 100644 --- a/src/routes/blog.index.tsx +++ b/src/routes/blog.index.tsx @@ -307,7 +307,7 @@ function BlogIndex() {
From aeb0608962b7394485a85012bea00f3c965b2a86 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Thu, 20 Aug 2026 10:01:26 -0600 Subject: [PATCH 23/50] feat(blog): full-width stacked filter dropdowns on mobile The mobile expandable filter set now stacks the facet dropdowns full-width (label left, caret right) instead of a horizontal scroll row, giving each a large tap target. renderFilters(block) drives the full-width trigger styling; desktop keeps the inline auto-width row. Co-Authored-By: Claude Opus 4.8 --- src/components/BlogFilterBar.tsx | 263 ++++++++++++++++--------------- 1 file changed, 133 insertions(+), 130 deletions(-) diff --git a/src/components/BlogFilterBar.tsx b/src/components/BlogFilterBar.tsx index 5e5c7945e..4039e748b 100644 --- a/src/components/BlogFilterBar.tsx +++ b/src/components/BlogFilterBar.tsx @@ -110,133 +110,65 @@ export function BlogFilterBar({ : null // The facet dropdowns, rendered in both the desktop inline row and the - // mobile expandable row (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. - const renderFilters = () => ( - <> - - - - - - 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 ? ( + // 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 ( + <> - - - onAuthorChange(undefined)} - > - All authors - - {authors.map((name) => { - const isActive = activeAuthor === name - return ( - onAuthorChange(isActive ? undefined : name)} - > - {name} - - ) - })} - - - ) : null} - - {years.length ? ( - - - onYearToggle(undefined)} + selected={!selectedLibrary} + onSelect={() => onLibraryToggle(undefined)} > - All years + + All topics + + - {years.map(({ year, count }) => { - const isActive = selectedYear === year + {topics.map(({ library, count }) => { + const Icon = libraryIcons[library.id] ?? fallbackLibraryIcon + const category = libraryCategories[library.id] ?? 'tooling' + const isActive = selectedLibrary === library.id return ( onYearToggle(isActive ? undefined : year)} + onSelect={() => + onLibraryToggle(isActive ? undefined : library.id) + } > + - {year} + {library.name.replace('TanStack ', '')} @@ -244,21 +176,94 @@ export function BlogFilterBar({ })} - ) : null} - {hasActiveFilters ? ( - - ) : null} - - ) + {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 (
@@ -316,8 +321,8 @@ export function BlogFilterBar({
- {/* Mobile-only expandable row: a horizontal, scrollable set of the facet - dropdowns, animated open via a grid-rows height transition. */} + {/* Mobile-only expandable column: the facet dropdowns stacked full-width, + animated open via a grid-rows height transition. */}
-
- {renderFilters()} -
+
{renderFilters(true)}
From 79d5f2f085a32f7a5c537e4316a7bc0050763f53 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Thu, 20 Aug 2026 10:01:38 -0600 Subject: [PATCH 24/50] fix(blog): give the Netlify rail logo ~20px more height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Netlify's wordmark is short for its width, so at the gold max-height it read smaller than its peers. Bump its tiered-rail max-height to 62px (× the 1.25 image scale ≈ 78px, within the 80px gold row). Co-Authored-By: Claude Opus 4.8 --- src/components/RightRail.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/RightRail.tsx b/src/components/RightRail.tsx index 0840ea8e7..2d89ff9f9 100644 --- a/src/components/RightRail.tsx +++ b/src/components/RightRail.tsx @@ -323,7 +323,14 @@ function PartnersRailItem({ const grid = railTierLayout[tier] const tiered = tieredTierLayout[tier] const logoMaxWidth = isTiered ? tiered.logoMaxWidth : grid.logoMaxWidth - const logoMaxHeight = isTiered ? tiered.logoMaxHeight : grid.logoMaxHeight + // Netlify's wordmark is short for its width, so it reads smaller than its + // gold peers; give it ~20px more height in the tiered rail (62px × the 1.25 + // image scale ≈ 78px, still within the 80px gold row). + const logoMaxHeight = isTiered + ? partner.id === 'netlify' + ? 'max-h-[62px]' + : tiered.logoMaxHeight + : grid.logoMaxHeight const cellClass = isTiered ? twMerge('w-full px-2', tiered.rowHeight) : twMerge( From 279cdb51c85e2ff6d1d32d59693e8cff22b5cd58 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 21 Aug 2026 13:07:36 -0600 Subject: [PATCH 25/50] style(blog): shrink tiered rail gold logos 50%, silver 25% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scale down the tiered rail logo bounds: gold ×0.5 (210×46 → 105×23, Netlify override 62 → 31 to stay proportional) and silver ×0.75 (145×30 → 109×23). Row heights unchanged. Co-Authored-By: Claude Opus 4.8 --- src/components/RightRail.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/RightRail.tsx b/src/components/RightRail.tsx index 2d89ff9f9..ac9af131a 100644 --- a/src/components/RightRail.tsx +++ b/src/components/RightRail.tsx @@ -135,15 +135,15 @@ const tieredTierLayout: Record< > = { gold: { rowHeight: 'h-[80px]', - logoMaxWidth: 'max-w-[210px]', - logoMaxHeight: 'max-h-[46px]', + logoMaxWidth: 'max-w-[105px]', + logoMaxHeight: 'max-h-[23px]', perRow: 1, idleOpacity: '', }, silver: { rowHeight: 'h-[62px]', - logoMaxWidth: 'max-w-[145px]', - logoMaxHeight: 'max-h-[30px]', + logoMaxWidth: 'max-w-[109px]', + logoMaxHeight: 'max-h-[23px]', perRow: 1, idleOpacity: 'opacity-80', }, @@ -324,11 +324,11 @@ function PartnersRailItem({ const tiered = tieredTierLayout[tier] const logoMaxWidth = isTiered ? tiered.logoMaxWidth : grid.logoMaxWidth // Netlify's wordmark is short for its width, so it reads smaller than its - // gold peers; give it ~20px more height in the tiered rail (62px × the 1.25 - // image scale ≈ 78px, still within the 80px gold row). + // gold peers; give it a proportional height boost in the tiered rail + // (~1.35× the gold base height). const logoMaxHeight = isTiered ? partner.id === 'netlify' - ? 'max-h-[62px]' + ? 'max-h-[31px]' : tiered.logoMaxHeight : grid.logoMaxHeight const cellClass = isTiered From b5dc79ab3b5b99a8d1ba18da3aa1ef0c59bec647 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 21 Aug 2026 14:20:12 -0600 Subject: [PATCH 26/50] feat(ds): PartnerRail component + tier-sizing rubric + workshop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the tiered partner rail into a DS-managed component with a systematic logo-sizing rubric, workshopped on a new DS page. - PartnerTierLogo (src/components/ds/ui): the rubric. Gold is the source of truth (PARTNER_LOGO_GOLD); silver = ×0.75, bronze = ×0.5625 derive via partnerLogoTierSize(). Both dimensions scale together, so a logo is exactly one step smaller per tier (fixes the prior silver>gold inversion). Per-logo optical weight stays the global image.scale. - PartnerRail (src/components/ds/ui): the tiered rail extracted from RightRail, rendering each logo via PartnerTierLogo; keeps tier grouping, analytics, and layout (rowHeight/perRow/idleOpacity). Optional sizing / scaleOverrides props are workshop hooks. Imported directly (not via the ds/ui barrel) so partners.tsx's .svg imports stay out of the barrel's unit-test graph. - PartnerImage gains an optional `style` (forwarded to ) so the runtime-derived max-height applies inline; PartnerImageConfig and a shared RailPartner type are now exported from partners.tsx. - /ds/partner-rail showcase + left-nav entry, with TEMPORARY workshop controls (gold w/h, tier step, per-logo scale sliders + a copy-paste config output) to balance the values, then delete. - PartnerRail.rules.md documents the rubric. - blog.index uses the DS PartnerRail; RightRail is now grid-only and the two Netlify special-cases are gone (handled by its global scale). Deferred: propagate the rubric to the partners grid, directory cards, and docs rail once the look is locked. Co-Authored-By: Claude Opus 4.8 --- src/components/RightRail.tsx | 162 +------------ src/components/ds/ds-nav.ts | 5 + src/components/ds/ui/PartnerRail.rules.md | 69 ++++++ src/components/ds/ui/PartnerRail.tsx | 205 ++++++++++++++++ src/components/ds/ui/PartnerTierLogo.tsx | 82 +++++++ src/components/ds/ui/index.tsx | 5 + src/routeTree.gen.ts | 21 ++ src/routes/blog.index.tsx | 5 +- src/routes/ds.partner-rail.tsx | 273 ++++++++++++++++++++++ src/utils/partners.tsx | 23 +- 10 files changed, 693 insertions(+), 157 deletions(-) create mode 100644 src/components/ds/ui/PartnerRail.rules.md create mode 100644 src/components/ds/ui/PartnerRail.tsx create mode 100644 src/components/ds/ui/PartnerTierLogo.tsx create mode 100644 src/routes/ds.partner-rail.tsx diff --git a/src/components/RightRail.tsx b/src/components/RightRail.tsx index ac9af131a..545403f7f 100644 --- a/src/components/RightRail.tsx +++ b/src/components/RightRail.tsx @@ -5,8 +5,8 @@ import { PartnerImage, partnerTierFlares, partnerTierLabels, - type Partner, type PartnerTier, + type RailPartner, } from '~/utils/partners' import { getPartnerPlacementAnalyticsMetadata, @@ -24,16 +24,6 @@ import { trackPartnerInquiry, } from '~/utils/partner-inquiry' -type RailPartner = { - category: Partner['category'] - id: string - name: string - href: string - score: number - tier?: PartnerTier - image: Parameters[0]['config'] -} - type RightRailProps = { children: React.ReactNode className?: string @@ -119,74 +109,16 @@ const railTierLayout: Record< }, } -// The tiered rail groups partners into centered tier sections (a hairline + -// icon + label header per tier). Logos scale down tier by tier — gold largest -// and one-up, silver medium and one-up, bronze smallest and two-up — and fade -// slightly, matching the design reference's spacing and relative scale. -const tieredTierLayout: Record< - PartnerTier, - { - rowHeight: string - logoMaxWidth: string - logoMaxHeight: string - perRow: 1 | 2 - idleOpacity: string - } -> = { - gold: { - rowHeight: 'h-[80px]', - logoMaxWidth: 'max-w-[105px]', - logoMaxHeight: 'max-h-[23px]', - perRow: 1, - idleOpacity: '', - }, - silver: { - rowHeight: 'h-[62px]', - logoMaxWidth: 'max-w-[109px]', - logoMaxHeight: 'max-h-[23px]', - perRow: 1, - idleOpacity: 'opacity-80', - }, - bronze: { - rowHeight: 'h-[56px]', - logoMaxWidth: 'max-w-[100px]', - logoMaxHeight: 'max-h-[22px]', - perRow: 2, - 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]} - - - -
- ) -} - export function PartnersRail({ analyticsPlacement, partners, title = 'Partners', titleTo = '/partners', - layout = 'grid', }: { analyticsPlacement: PartnerPlacement partners: Array title?: string titleTo?: '/partners' - /** `grid` (default) tiles logos in tier rows; `tiered` stacks centered tier - * sections whose logos scale down tier by tier (bronze is two-up). */ - layout?: 'grid' | 'tiered' }) { const placementContext = usePartnerPlacementContext({ orderStrategy: 'tier-rotated', @@ -200,54 +132,6 @@ export function PartnersRail({ let slotIndex = 0 - if (layout === 'tiered') { - return ( -
- - {rowsByTier.map((row) => ( -
- -
- {row.partners.map((partner) => { - const index = slotIndex++ - return ( - - ) - })} -
-
- ))} -
- ) - } - return (
@@ -307,42 +191,13 @@ function PartnersRailItem({ index, placementContext, partner, - variant = 'grid', }: { analyticsPlacement: PartnerPlacement index: number placementContext: PartnerPlacementContext partner: RailPartner - variant?: 'grid' | 'tiered' }) { - const tier = partner.tier ?? 'bronze' - // Tiered cells are borderless, fixed-height logo slots that fade by tier; grid - // cells are borderless tiles that share edges within a tier row. Both read - // logo bounds from their own concretely-typed config. - const isTiered = variant === 'tiered' - const grid = railTierLayout[tier] - const tiered = tieredTierLayout[tier] - const logoMaxWidth = isTiered ? tiered.logoMaxWidth : grid.logoMaxWidth - // Netlify's wordmark is short for its width, so it reads smaller than its - // gold peers; give it a proportional height boost in the tiered rail - // (~1.35× the gold base height). - const logoMaxHeight = isTiered - ? partner.id === 'netlify' - ? 'max-h-[31px]' - : tiered.logoMaxHeight - : grid.logoMaxHeight - const cellClass = isTiered - ? twMerge('w-full px-2', tiered.rowHeight) - : twMerge( - 'border-b border-r border-gray-500/20', - grid.flexBasis, - grid.minHeight, - grid.padding, - ) - // Lower tiers rest more muted and lift to full opacity/color on rail hover. - const logoIdleClass = isTiered - ? twMerge(tiered.idleOpacity, 'group-hover/rail:opacity-100') - : '' + const layout = railTierLayout[partner.tier ?? 'bronze'] const analyticsMetadata = getPartnerPlacementAnalyticsMetadata( partner, placementContext, @@ -364,8 +219,10 @@ function PartnersRailItem({ target="_blank" rel="noreferrer" className={twMerge( - 'flex items-center justify-center overflow-hidden transition-colors duration-150 ease-out hover:bg-gray-500/10', - cellClass, + 'flex items-center justify-center overflow-hidden border-b border-r border-gray-500/20 transition-colors duration-150 ease-out hover:bg-gray-500/10', + layout.flexBasis, + layout.minHeight, + layout.padding, )} onClick={() => { let destinationHost: string | undefined @@ -386,13 +243,12 @@ function PartnersRailItem({ >
diff --git a/src/components/ds/ds-nav.ts b/src/components/ds/ds-nav.ts index 0e233d23d..4aff0f569 100644 --- a/src/components/ds/ds-nav.ts +++ b/src/components/ds/ds-nav.ts @@ -184,6 +184,11 @@ export const dsNav: Array = [ to: '/ds/panel', sections: ['Disclosure'], }, + { + label: 'Partner Rail', + to: '/ds/partner-rail', + sections: ['Rail', 'Tiers', 'Per-logo scale'], + }, { label: 'Spinner', to: '/ds/spinner', 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..2c244a892 --- /dev/null +++ b/src/components/ds/ui/PartnerRail.tsx @@ -0,0 +1,205 @@ +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). Gold and silver +// stack one-up; bronze is two-up. Lower tiers rest more muted and lift on hover. +const tierLayout: Record< + PartnerTier, + { rowHeight: string; perRow: 1 | 2; idleOpacity: string } +> = { + gold: { rowHeight: 'h-[80px]', perRow: 1, idleOpacity: '' }, + silver: { rowHeight: 'h-[62px]', perRow: 1, idleOpacity: 'opacity-80' }, + bronze: { rowHeight: 'h-[56px]', perRow: 2, 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, +}: { + analyticsPlacement: PartnerPlacement + partners: Array + title?: string + titleTo?: '/partners' + sizing?: PartnerLogoSizing + scaleOverrides?: Record +}) { + 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, +}: { + analyticsPlacement: PartnerPlacement + index: number + placementContext: PartnerPlacementContext + partner: RailPartner + sizing?: PartnerLogoSizing + scaleOverride?: number +}) { + 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..510ea3aec --- /dev/null +++ b/src/components/ds/ui/PartnerTierLogo.tsx @@ -0,0 +1,82 @@ +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, + 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 + /** 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/index.tsx b/src/components/ds/ui/index.tsx index 6bd1bff69..fb8fae7c1 100644 --- a/src/components/ds/ui/index.tsx +++ b/src/components/ds/ui/index.tsx @@ -911,3 +911,8 @@ export { type StatsLayout, type StatItem, } from './StatsSection' +// NOTE: PartnerRail / PartnerTierLogo are intentionally NOT re-exported here. +// They import ~/utils/partners (which imports many .svg assets), and this +// barrel is imported by components covered by the .svg-less unit test runner — +// pulling partners in through the barrel breaks those tests. Import them +// directly from './PartnerRail' / './PartnerTierLogo' instead. diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 2cd7de66c..362ef3231 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -77,6 +77,7 @@ import { Route as DsStatsRouteImport } from './routes/ds.stats' import { Route as DsSpinnerRouteImport } from './routes/ds.spinner' import { Route as DsShadowsRouteImport } from './routes/ds.shadows' import { Route as DsSemanticRouteImport } from './routes/ds.semantic' +import { Route as DsPartnerRailRouteImport } from './routes/ds.partner-rail' import { Route as DsPanelRouteImport } from './routes/ds.panel' import { Route as DsPaletteRouteImport } from './routes/ds.palette' import { Route as DsPageHeaderRouteImport } from './routes/ds.page-header' @@ -552,6 +553,11 @@ const DsSemanticRoute = DsSemanticRouteImport.update({ path: '/semantic', getParentRoute: () => DsRoute, } as any) +const DsPartnerRailRoute = DsPartnerRailRouteImport.update({ + id: '/partner-rail', + path: '/partner-rail', + getParentRoute: () => DsRoute, +} as any) const DsPanelRoute = DsPanelRouteImport.update({ id: '/panel', path: '/panel', @@ -1355,6 +1361,7 @@ export interface FileRoutesByFullPath { '/ds/page-header': typeof DsPageHeaderRoute '/ds/palette': typeof DsPaletteRoute '/ds/panel': typeof DsPanelRoute + '/ds/partner-rail': typeof DsPartnerRailRoute '/ds/semantic': typeof DsSemanticRoute '/ds/shadows': typeof DsShadowsRoute '/ds/spinner': typeof DsSpinnerRoute @@ -1551,6 +1558,7 @@ export interface FileRoutesByTo { '/ds/page-header': typeof DsPageHeaderRoute '/ds/palette': typeof DsPaletteRoute '/ds/panel': typeof DsPanelRoute + '/ds/partner-rail': typeof DsPartnerRailRoute '/ds/semantic': typeof DsSemanticRoute '/ds/shadows': typeof DsShadowsRoute '/ds/spinner': typeof DsSpinnerRoute @@ -1753,6 +1761,7 @@ export interface FileRoutesById { '/ds/page-header': typeof DsPageHeaderRoute '/ds/palette': typeof DsPaletteRoute '/ds/panel': typeof DsPanelRoute + '/ds/partner-rail': typeof DsPartnerRailRoute '/ds/semantic': typeof DsSemanticRoute '/ds/shadows': typeof DsShadowsRoute '/ds/spinner': typeof DsSpinnerRoute @@ -1959,6 +1968,7 @@ export interface FileRouteTypes { | '/ds/page-header' | '/ds/palette' | '/ds/panel' + | '/ds/partner-rail' | '/ds/semantic' | '/ds/shadows' | '/ds/spinner' @@ -2155,6 +2165,7 @@ export interface FileRouteTypes { | '/ds/page-header' | '/ds/palette' | '/ds/panel' + | '/ds/partner-rail' | '/ds/semantic' | '/ds/shadows' | '/ds/spinner' @@ -2356,6 +2367,7 @@ export interface FileRouteTypes { | '/ds/page-header' | '/ds/palette' | '/ds/panel' + | '/ds/partner-rail' | '/ds/semantic' | '/ds/shadows' | '/ds/spinner' @@ -3063,6 +3075,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DsSemanticRouteImport parentRoute: typeof DsRoute } + '/ds/partner-rail': { + id: '/ds/partner-rail' + path: '/partner-rail' + fullPath: '/ds/partner-rail' + preLoaderRoute: typeof DsPartnerRailRouteImport + parentRoute: typeof DsRoute + } '/ds/panel': { id: '/ds/panel' path: '/panel' @@ -4281,6 +4300,7 @@ interface DsRouteChildren { DsPageHeaderRoute: typeof DsPageHeaderRoute DsPaletteRoute: typeof DsPaletteRoute DsPanelRoute: typeof DsPanelRoute + DsPartnerRailRoute: typeof DsPartnerRailRoute DsSemanticRoute: typeof DsSemanticRoute DsShadowsRoute: typeof DsShadowsRoute DsSpinnerRoute: typeof DsSpinnerRoute @@ -4308,6 +4328,7 @@ const DsRouteChildren: DsRouteChildren = { DsPageHeaderRoute: DsPageHeaderRoute, DsPaletteRoute: DsPaletteRoute, DsPanelRoute: DsPanelRoute, + DsPartnerRailRoute: DsPartnerRailRoute, DsSemanticRoute: DsSemanticRoute, DsShadowsRoute: DsShadowsRoute, DsSpinnerRoute: DsSpinnerRoute, diff --git a/src/routes/blog.index.tsx b/src/routes/blog.index.tsx index e7488bf0d..a565e991e 100644 --- a/src/routes/blog.index.tsx +++ b/src/routes/blog.index.tsx @@ -4,9 +4,9 @@ import * as v from 'valibot' import { BlogFilterBar } from '~/components/BlogFilterBar' import { type BlogCardPost } from '~/components/BlogCard' import { Eyebrow } from '~/components/ds/ui' +import { PartnerRail } from '~/components/ds/ui/PartnerRail' import { BlogPostCard } from '~/components/ds/ui/BlogPostCard' import { PageHeader } from '~/components/ds/ui/PageHeader' -import { PartnersRail } from '~/components/RightRail' import { libraries, type LibrarySlim } from '~/libraries' import { getDistinctAuthors, @@ -304,10 +304,9 @@ function BlogIndex() { rail beside the centered 1280px content without overlapping it. */}
-
diff --git a/src/routes/ds.partner-rail.tsx b/src/routes/ds.partner-rail.tsx new file mode 100644 index 000000000..dbbdb8f10 --- /dev/null +++ b/src/routes/ds.partner-rail.tsx @@ -0,0 +1,273 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { seo } from '~/utils/seo' +import { ComponentPreview, DsPage, DsSection } from '~/components/ds/DsKit' +import { PartnerRail } from '~/components/ds/ui/PartnerRail' +import { + PARTNER_LOGO_GOLD, + PARTNER_LOGO_TIER_STEP, + partnerLogoTierSize, + type PartnerLogoSizing, +} from '~/components/ds/ui/PartnerTierLogo' +import { partners, type PartnerTier } from '~/utils/partners' + +export const Route = createFileRoute('/ds/partner-rail')({ + component: PartnerRailPage, + head: () => ({ + meta: seo({ + title: 'Partner Rail | TanStack Design System', + description: + 'The tiered partner rail and its logo-sizing rubric — gold is the source of truth, silver and bronze derive by a 25% step.', + }), + }), +}) + +const activePartners = partners.filter((p) => p.status === 'active') +const TIERS: Array = ['gold', 'silver', 'bronze'] + +function PartnerRailPage() { + // Workshop state — see PartnerRailWorkshop. Once the values are balanced, bake + // them into PartnerTierLogo's constants + each partner's image.scale, then + // delete the workshop panel and PartnerRail's sizing/scaleOverrides props. + const [sizing, setSizing] = React.useState({ + goldMaxWidth: PARTNER_LOGO_GOLD.maxWidth, + goldMaxHeight: PARTNER_LOGO_GOLD.maxHeight, + tierStep: PARTNER_LOGO_TIER_STEP, + }) + const [scales, setScales] = React.useState>(() => + Object.fromEntries(activePartners.map((p) => [p.id, p.image.scale ?? 1])), + ) + + return ( + + + `} + > +
+ + +
+
+ +
+ + + +
+ {TIERS.map((tier) => { + const { maxWidth, maxHeight } = partnerLogoTierSize(tier, sizing) + return ( +
+
+
+ {tier}: {maxWidth}×{maxHeight} +
+
+ ) + })} +
+ + + + + +

+ Tune each logo's scale with the workshop sliders above + until every logo in a tier reads at a consistent visual size against + a reference, then copy the values from the config output into{' '} + partners.tsx. +

+
+
+ + ) +} + +function RailMode({ + mode, + sizing, + scales, +}: { + mode: 'light' | 'dark' + sizing: PartnerLogoSizing + scales: Record +}) { + return ( +
+
+ {mode} +
+
+ +
+
+ ) +} + +/* --------------------------------------------------------------------------- + * TEMPORARY workshop controls — delete once the sizing is balanced. + * Deprecation: bake the config output into PartnerTierLogo's constants and each + * partner's `image.scale`, then remove this panel and PartnerRail's + * `sizing` / `scaleOverrides` props. + * ------------------------------------------------------------------------- */ +function PartnerRailWorkshop({ + sizing, + setSizing, + scales, + setScales, +}: { + sizing: PartnerLogoSizing + setSizing: React.Dispatch> + scales: Record + setScales: React.Dispatch>> +}) { + return ( +
+
+ Workshop controls · temporary — remove once balanced +
+
+ setSizing((s) => ({ ...s, goldMaxWidth: v }))} + /> + setSizing((s) => ({ ...s, goldMaxHeight: v }))} + /> + setSizing((s) => ({ ...s, tierStep: v }))} + /> +
+
+
+ Per-logo scale +
+
+ {activePartners.map((p) => ( + setScales((s) => ({ ...s, [p.id]: v }))} + /> + ))} +
+
+ +
+ ) +} + +function Slider({ + label, + min, + max, + step, + value, + onChange, +}: { + label: string + min: number + max: number + step: number + value: number + onChange: (value: number) => void +}) { + return ( + + ) +} + +function ConfigOutput({ + sizing, + scales, +}: { + sizing: PartnerLogoSizing + scales: 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)} + +// image.scale per partner (paste into partners.tsx): +${scaleLines || ' (all default 1.0)'}` + return ( +
+      {text}
+    
+ ) +} diff --git a/src/utils/partners.tsx b/src/utils/partners.tsx index 282505433..186d4bfc8 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,10 +172,14 @@ export function PartnerImage({ className, config, alt, + style, }: { 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 }) { const scaleStyle = config.scale ? { transform: `scale(${config.scale})` } : {} @@ -191,6 +196,7 @@ export function PartnerImage({ className={ className ? `${className} dark:hidden` : 'w-full dark:hidden' } + style={style} width={200} height={100} sizes="(max-width: 640px) 80px, (max-width: 1024px) 150px, 200px" @@ -204,6 +210,7 @@ export function PartnerImage({ ? `${className} hidden dark:block` : 'w-full hidden dark:block' } + style={style} width={200} height={100} sizes="(max-width: 640px) 80px, (max-width: 1024px) 150px, 200px" @@ -218,6 +225,7 @@ export function PartnerImage({ src={config.src} alt={alt} className={className ?? 'w-full'} + style={style} width={200} height={100} loading="lazy" @@ -227,6 +235,19 @@ export function PartnerImage({ ) } +// Shared shape for the partner rails — a slim projection of a Partner plus the +// image config the rail renders. Lives here so the DS PartnerRail and the +// legacy RightRail can share one type. +export type RailPartner = { + category: Partner['category'] + id: string + name: string + href: string + score: number + tier?: PartnerTier + image: PartnerImageConfig +} + export const partnerCategories = [ 'code-review', 'deployment', From 3f8477e701c1db4fce9592794e3cc70fe8318d0e Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 21 Aug 2026 14:41:35 -0600 Subject: [PATCH 27/50] fix(ds): show logos in the partner-rail dark preview panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dark showcase panel used only `.ds-mode-dark` (token swap), which doesn't trigger Tailwind's `.dark`-based variant — so PartnerImage kept rendering the light, dark-ink logos on a dark background (invisible). Add a local `dark` class to the dark panel so the dark logo variant shows. Co-Authored-By: Claude Opus 4.8 --- src/routes/ds.partner-rail.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/routes/ds.partner-rail.tsx b/src/routes/ds.partner-rail.tsx index dbbdb8f10..a540d1c73 100644 --- a/src/routes/ds.partner-rail.tsx +++ b/src/routes/ds.partner-rail.tsx @@ -126,7 +126,10 @@ function RailMode({ }) { return (
Date: Fri, 21 Aug 2026 14:50:30 -0600 Subject: [PATCH 28/50] feat(ds): add vertical-spacing (row gap) control to the partner rail workshop PartnerRail gains an optional rowGap prop (px) applied as the vertical gap between logo rows within a tier (works for the flex-col gold/silver stacks and the bronze 2-up grid). The workshop adds a "Vertical spacing" slider and includes the value in the config output. Co-Authored-By: Claude Opus 4.8 --- src/components/ds/ui/PartnerRail.tsx | 4 +++ src/routes/ds.partner-rail.tsx | 37 +++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/components/ds/ui/PartnerRail.tsx b/src/components/ds/ui/PartnerRail.tsx index 2c244a892..b4fc3d2f4 100644 --- a/src/components/ds/ui/PartnerRail.tsx +++ b/src/components/ds/ui/PartnerRail.tsx @@ -63,6 +63,7 @@ export function PartnerRail({ titleTo = '/partners', sizing, scaleOverrides, + rowGap, }: { analyticsPlacement: PartnerPlacement partners: Array @@ -70,6 +71,8 @@ export function PartnerRail({ titleTo?: '/partners' sizing?: PartnerLogoSizing scaleOverrides?: Record + /** Vertical gap (px) between logo rows within a tier — workshop hook. */ + rowGap?: number }) { const placementContext = usePartnerPlacementContext({ orderStrategy: 'tier-rotated', @@ -109,6 +112,7 @@ export function PartnerRail({ ? 'grid grid-cols-2' : 'flex flex-col' } + style={rowGap !== undefined ? { rowGap } : undefined} > {row.partners.map((partner) => { const index = slotIndex++ diff --git a/src/routes/ds.partner-rail.tsx b/src/routes/ds.partner-rail.tsx index a540d1c73..982e69305 100644 --- a/src/routes/ds.partner-rail.tsx +++ b/src/routes/ds.partner-rail.tsx @@ -37,6 +37,7 @@ function PartnerRailPage() { const [scales, setScales] = React.useState>(() => Object.fromEntries(activePartners.map((p) => [p.id, p.image.scale ?? 1])), ) + const [rowGap, setRowGap] = React.useState(0) return ( `} >
- - + +
@@ -119,10 +132,12 @@ function RailMode({ mode, sizing, scales, + rowGap, }: { mode: 'light' | 'dark' sizing: PartnerLogoSizing scales: Record + rowGap: number }) { return (
@@ -161,11 +177,15 @@ function PartnerRailWorkshop({ setSizing, scales, setScales, + rowGap, + setRowGap, }: { sizing: PartnerLogoSizing setSizing: React.Dispatch> scales: Record setScales: React.Dispatch>> + rowGap: number + setRowGap: React.Dispatch> }) { return (
@@ -197,6 +217,14 @@ function PartnerRailWorkshop({ value={sizing.tierStep} onChange={(v) => setSizing((s) => ({ ...s, tierStep: v }))} /> +
@@ -216,7 +244,7 @@ function PartnerRailWorkshop({ ))}
- +
) } @@ -255,9 +283,11 @@ function Slider({ function ConfigOutput({ sizing, scales, + rowGap, }: { sizing: PartnerLogoSizing scales: Record + rowGap: number }) { const scaleLines = activePartners .filter((p) => Math.abs((scales[p.id] ?? 1) - 1) > 0.001) @@ -265,6 +295,7 @@ function ConfigOutput({ .join('\n') const text = `PARTNER_LOGO_GOLD = { maxWidth: ${sizing.goldMaxWidth}, maxHeight: ${sizing.goldMaxHeight} } PARTNER_LOGO_TIER_STEP = ${sizing.tierStep.toFixed(2)} +row gap (between logos) = ${rowGap}px // image.scale per partner (paste into partners.tsx): ${scaleLines || ' (all default 1.0)'}` From b4646d55ba93031c38f864a1a4b5b96a95b965fe Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 21 Aug 2026 15:02:39 -0600 Subject: [PATCH 29/50] feat(ds): make partner-rail vertical spacing per-tier Replace the single rowGap with a per-tier rowGaps map (Partial>) applied to each tier's logo container. The workshop now has gold/silver/bronze spacing sliders and the config output reports all three. Co-Authored-By: Claude Opus 4.8 --- src/components/ds/ui/PartnerRail.tsx | 12 ++++-- src/routes/ds.partner-rail.tsx | 55 ++++++++++++++++------------ 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/src/components/ds/ui/PartnerRail.tsx b/src/components/ds/ui/PartnerRail.tsx index b4fc3d2f4..84e561a0d 100644 --- a/src/components/ds/ui/PartnerRail.tsx +++ b/src/components/ds/ui/PartnerRail.tsx @@ -63,7 +63,7 @@ export function PartnerRail({ titleTo = '/partners', sizing, scaleOverrides, - rowGap, + rowGaps, }: { analyticsPlacement: PartnerPlacement partners: Array @@ -71,8 +71,8 @@ export function PartnerRail({ titleTo?: '/partners' sizing?: PartnerLogoSizing scaleOverrides?: Record - /** Vertical gap (px) between logo rows within a tier — workshop hook. */ - rowGap?: number + /** Vertical gap (px) between logo rows, per tier — workshop hook. */ + rowGaps?: Partial> }) { const placementContext = usePartnerPlacementContext({ orderStrategy: 'tier-rotated', @@ -112,7 +112,11 @@ export function PartnerRail({ ? 'grid grid-cols-2' : 'flex flex-col' } - style={rowGap !== undefined ? { rowGap } : undefined} + style={ + rowGaps?.[row.tier] !== undefined + ? { rowGap: rowGaps[row.tier] } + : undefined + } > {row.partners.map((partner) => { const index = slotIndex++ diff --git a/src/routes/ds.partner-rail.tsx b/src/routes/ds.partner-rail.tsx index 982e69305..d2d5b75a2 100644 --- a/src/routes/ds.partner-rail.tsx +++ b/src/routes/ds.partner-rail.tsx @@ -37,7 +37,11 @@ function PartnerRailPage() { const [scales, setScales] = React.useState>(() => Object.fromEntries(activePartners.map((p) => [p.id, p.image.scale ?? 1])), ) - const [rowGap, setRowGap] = React.useState(0) + const [rowGaps, setRowGaps] = React.useState>({ + gold: 0, + silver: 0, + bronze: 0, + }) return (
@@ -72,8 +76,8 @@ function PartnerRailPage() { setSizing={setSizing} scales={scales} setScales={setScales} - rowGap={rowGap} - setRowGap={setRowGap} + rowGaps={rowGaps} + setRowGaps={setRowGaps} /> @@ -132,12 +136,12 @@ function RailMode({ mode, sizing, scales, - rowGap, + rowGaps, }: { mode: 'light' | 'dark' sizing: PartnerLogoSizing scales: Record - rowGap: number + rowGaps: Record }) { return (
@@ -177,15 +181,15 @@ function PartnerRailWorkshop({ setSizing, scales, setScales, - rowGap, - setRowGap, + rowGaps, + setRowGaps, }: { sizing: PartnerLogoSizing setSizing: React.Dispatch> scales: Record setScales: React.Dispatch>> - rowGap: number - setRowGap: React.Dispatch> + rowGaps: Record + setRowGaps: React.Dispatch>> }) { return (
@@ -217,14 +221,17 @@ function PartnerRailWorkshop({ value={sizing.tierStep} onChange={(v) => setSizing((s) => ({ ...s, tierStep: v }))} /> - + {TIERS.map((tier) => ( + setRowGaps((g) => ({ ...g, [tier]: v }))} + /> + ))}
@@ -244,7 +251,7 @@ function PartnerRailWorkshop({ ))}
- +
) } @@ -283,11 +290,11 @@ function Slider({ function ConfigOutput({ sizing, scales, - rowGap, + rowGaps, }: { sizing: PartnerLogoSizing scales: Record - rowGap: number + rowGaps: Record }) { const scaleLines = activePartners .filter((p) => Math.abs((scales[p.id] ?? 1) - 1) > 0.001) @@ -295,7 +302,7 @@ function ConfigOutput({ .join('\n') const text = `PARTNER_LOGO_GOLD = { maxWidth: ${sizing.goldMaxWidth}, maxHeight: ${sizing.goldMaxHeight} } PARTNER_LOGO_TIER_STEP = ${sizing.tierStep.toFixed(2)} -row gap (between logos) = ${rowGap}px +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)'}` From 8f93151d0f62df1d09c78402fccd8aae924b0bb4 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 21 Aug 2026 15:34:03 -0600 Subject: [PATCH 30/50] refactor(ds): group partner-rail workshop controls by tier Reorganize the workshop: the rubric sliders (gold w/h, tier step) stay on top, then one group per tier (gold/silver/bronze). Each group's vertical-spacing slider sits right above the per-logo scale sliders for exactly that tier's logos, filtered by partner.tier. Headings show the tier and its logo count. Co-Authored-By: Claude Opus 4.8 --- src/routes/ds.partner-rail.tsx | 66 +++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/src/routes/ds.partner-rail.tsx b/src/routes/ds.partner-rail.tsx index d2d5b75a2..1a06a7e44 100644 --- a/src/routes/ds.partner-rail.tsx +++ b/src/routes/ds.partner-rail.tsx @@ -196,6 +196,7 @@ function PartnerRailWorkshop({
Workshop controls · temporary — remove once balanced
+ {/* Rubric (applies across all tiers) */}
setSizing((s) => ({ ...s, tierStep: v }))} /> - {TIERS.map((tier) => ( - setRowGaps((g) => ({ ...g, [tier]: v }))} - /> - ))} -
-
-
- Per-logo scale -
-
- {activePartners.map((p) => ( - setScales((s) => ({ ...s, [p.id]: v }))} - /> - ))} -
+ + {/* One group per tier: the tier's vertical spacing sits right above the + per-logo scale controls for exactly that tier's logos. */} + {TIERS.map((tier) => { + const tierPartners = activePartners.filter((p) => p.tier === tier) + return ( +
+
+ {tier} · {tierPartners.length} logos +
+
+ setRowGaps((g) => ({ ...g, [tier]: v }))} + /> +
+
+ {tierPartners.map((p) => ( + setScales((s) => ({ ...s, [p.id]: v }))} + /> + ))} +
+
+ ) + })}
) From 8c419e5dff16fef94b1bda0a7e748981f3dee5fe Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 21 Aug 2026 15:44:04 -0600 Subject: [PATCH 31/50] feat(ds): silver tier renders two-up in the partner rail Change the silver tier's perRow from 1 to 2 so it uses the two-column grid (like bronze); gold stays one-up. Co-Authored-By: Claude Opus 4.8 --- src/components/ds/ui/PartnerRail.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/ds/ui/PartnerRail.tsx b/src/components/ds/ui/PartnerRail.tsx index 84e561a0d..da9b0e500 100644 --- a/src/components/ds/ui/PartnerRail.tsx +++ b/src/components/ds/ui/PartnerRail.tsx @@ -23,14 +23,15 @@ import { } from '~/utils/partner-inquiry' import { PartnerTierLogo, type PartnerLogoSizing } from './PartnerTierLogo' -// Per-tier LAYOUT (not logo size — that lives in the rubric). Gold and silver -// stack one-up; bronze is two-up. Lower tiers rest more muted and lift on hover. +// Per-tier LAYOUT (not logo size — that lives in the rubric). Gold stacks +// one-up; silver and bronze are two-up. Lower tiers rest more muted and lift +// on hover. const tierLayout: Record< PartnerTier, { rowHeight: string; perRow: 1 | 2; idleOpacity: string } > = { gold: { rowHeight: 'h-[80px]', perRow: 1, idleOpacity: '' }, - silver: { rowHeight: 'h-[62px]', perRow: 1, idleOpacity: 'opacity-80' }, + silver: { rowHeight: 'h-[62px]', perRow: 2, idleOpacity: 'opacity-80' }, bronze: { rowHeight: 'h-[56px]', perRow: 2, idleOpacity: 'opacity-65' }, } From 474b417a165df0b81b3163de2e2d908eee6759c7 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 21 Aug 2026 15:48:19 -0600 Subject: [PATCH 32/50] feat(ds): single-column layout for all partner-rail tiers Every tier now stacks one logo per row. Drops the two-up grid branch and the now-unused perRow field from tierLayout. Co-Authored-By: Claude Opus 4.8 --- src/components/ds/ui/PartnerRail.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/components/ds/ui/PartnerRail.tsx b/src/components/ds/ui/PartnerRail.tsx index da9b0e500..080593c42 100644 --- a/src/components/ds/ui/PartnerRail.tsx +++ b/src/components/ds/ui/PartnerRail.tsx @@ -23,16 +23,15 @@ import { } from '~/utils/partner-inquiry' import { PartnerTierLogo, type PartnerLogoSizing } from './PartnerTierLogo' -// Per-tier LAYOUT (not logo size — that lives in the rubric). Gold stacks -// one-up; silver and bronze are two-up. Lower tiers rest more muted and lift -// on hover. +// 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; perRow: 1 | 2; idleOpacity: string } + { rowHeight: string; idleOpacity: string } > = { - gold: { rowHeight: 'h-[80px]', perRow: 1, idleOpacity: '' }, - silver: { rowHeight: 'h-[62px]', perRow: 2, idleOpacity: 'opacity-80' }, - bronze: { rowHeight: 'h-[56px]', perRow: 2, idleOpacity: 'opacity-65' }, + 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. @@ -108,11 +107,7 @@ export function PartnerRail({
Date: Fri, 21 Aug 2026 15:49:37 -0600 Subject: [PATCH 33/50] =?UTF-8?q?style(blog):=20narrow=20the=20partner=20r?= =?UTF-8?q?ail=20by=2040px=20(300=20=E2=86=92=20260)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce the rail container width on the blog index and the DS showcase. Co-Authored-By: Claude Opus 4.8 --- src/routes/blog.index.tsx | 2 +- src/routes/ds.partner-rail.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/blog.index.tsx b/src/routes/blog.index.tsx index a565e991e..19eb2d74f 100644 --- a/src/routes/blog.index.tsx +++ b/src/routes/blog.index.tsx @@ -303,7 +303,7 @@ function BlogIndex() { shows only when the viewport is wide enough (~1900px) to seat a 300px rail beside the centered 1280px content without overlapping it. */}
-
+
{mode}
-
+
Date: Fri, 21 Aug 2026 15:56:55 -0600 Subject: [PATCH 34/50] style(blog): set partner rail width to 220px Co-Authored-By: Claude Opus 4.8 --- src/routes/blog.index.tsx | 2 +- src/routes/ds.partner-rail.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/blog.index.tsx b/src/routes/blog.index.tsx index 19eb2d74f..bbb5e67de 100644 --- a/src/routes/blog.index.tsx +++ b/src/routes/blog.index.tsx @@ -303,7 +303,7 @@ function BlogIndex() { shows only when the viewport is wide enough (~1900px) to seat a 300px rail beside the centered 1280px content without overlapping it. */}
-
+
{mode}
-
+
Date: Fri, 21 Aug 2026 16:00:40 -0600 Subject: [PATCH 35/50] style(ds): tint partner-rail tier dividers to the tier eyebrow color Each tier header's hairlines now use bg-current + the tier's labelColor, so the divider matches its eyebrow (gold amber, silver slate, bronze bronze) in both light and dark, instead of the neutral border color. Co-Authored-By: Claude Opus 4.8 --- src/components/ds/ui/PartnerRail.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ds/ui/PartnerRail.tsx b/src/components/ds/ui/PartnerRail.tsx index 080593c42..b608465b3 100644 --- a/src/components/ds/ui/PartnerRail.tsx +++ b/src/components/ds/ui/PartnerRail.tsx @@ -39,14 +39,14 @@ function TierHeader({ tier }: { tier: PartnerTier }) { const flare = partnerTierFlares[tier] return (
- + {flare.icon} {partnerTierLabels[tier]} - +
) } From e2f1a9d3af619aa7671d88d8a4d394b836adb366 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 21 Aug 2026 16:02:24 -0600 Subject: [PATCH 36/50] style(ds): lighten partner-rail tier dividers to 40% opacity Keep the per-tier tint but soften the hairlines (bg-current/40). Co-Authored-By: Claude Opus 4.8 --- src/components/ds/ui/PartnerRail.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/ds/ui/PartnerRail.tsx b/src/components/ds/ui/PartnerRail.tsx index b608465b3..0500be54d 100644 --- a/src/components/ds/ui/PartnerRail.tsx +++ b/src/components/ds/ui/PartnerRail.tsx @@ -39,14 +39,18 @@ function TierHeader({ tier }: { tier: PartnerTier }) { const flare = partnerTierFlares[tier] return (
- + {flare.icon} {partnerTierLabels[tier]} - +
) } From 28f9f1a4e304c7d93fea63d976e60e790c2789d0 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 21 Aug 2026 16:10:52 -0600 Subject: [PATCH 37/50] =?UTF-8?q?style(ds):=20tier=20vertical-spacing=20sl?= =?UTF-8?q?ider=20ranges=200=E2=80=93100px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the per-tier vertical spacing as a slider (not a number input) and widen its range from 0–48 to 0–100px. Co-Authored-By: Claude Opus 4.8 --- src/routes/ds.partner-rail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/ds.partner-rail.tsx b/src/routes/ds.partner-rail.tsx index de7e26984..859ff7650 100644 --- a/src/routes/ds.partner-rail.tsx +++ b/src/routes/ds.partner-rail.tsx @@ -237,7 +237,7 @@ function PartnerRailWorkshop({ setRowGaps((g) => ({ ...g, [tier]: v }))} From 4aaf29b70ff4c6e9f84f263a26ef8569b9482495 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 21 Aug 2026 16:30:07 -0600 Subject: [PATCH 38/50] fix(ds): partner-rail preview forces the logo variant per panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PartnerImage swaps its light/dark logo via the global `dark:` variant, so when the /ds page itself is in dark mode both showcase panels inherited `.dark` and rendered the white logo — invisible on the light panel. Add an optional `mode` prop to PartnerImage (threaded through PartnerTierLogo → PartnerRail) that forces a single variant, and have the showcase's light/dark panels pass it. Production omits `mode`, so the blog rail still follows the global theme. Co-Authored-By: Claude Opus 4.8 --- src/components/ds/ui/PartnerRail.tsx | 7 +++++++ src/components/ds/ui/PartnerTierLogo.tsx | 5 +++++ src/routes/ds.partner-rail.tsx | 1 + src/utils/partners.tsx | 25 ++++++++++++++++++++++++ 4 files changed, 38 insertions(+) diff --git a/src/components/ds/ui/PartnerRail.tsx b/src/components/ds/ui/PartnerRail.tsx index 0500be54d..2f0f857ad 100644 --- a/src/components/ds/ui/PartnerRail.tsx +++ b/src/components/ds/ui/PartnerRail.tsx @@ -68,6 +68,7 @@ export function PartnerRail({ sizing, scaleOverrides, rowGaps, + mode, }: { analyticsPlacement: PartnerPlacement partners: Array @@ -77,6 +78,8 @@ export function PartnerRail({ 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', @@ -129,6 +132,7 @@ export function PartnerRail({ partner={partner} sizing={sizing} scaleOverride={scaleOverrides?.[partner.id]} + mode={mode} /> ) })} @@ -146,6 +150,7 @@ function PartnerRailLogo({ partner, sizing, scaleOverride, + mode, }: { analyticsPlacement: PartnerPlacement index: number @@ -153,6 +158,7 @@ function PartnerRailLogo({ partner: RailPartner sizing?: PartnerLogoSizing scaleOverride?: number + mode?: 'light' | 'dark' }) { const tier = partner.tier ?? 'bronze' const layout = tierLayout[tier] @@ -203,6 +209,7 @@ function PartnerRailLogo({ tier={tier} sizing={sizing} scaleOverride={scaleOverride} + mode={mode} className={twMerge( 'grayscale brightness-90 transition-[filter,opacity] duration-500 ease-out group-hover/rail:grayscale-0 group-hover/rail:brightness-100 group-hover/rail:opacity-100', layout.idleOpacity, diff --git a/src/components/ds/ui/PartnerTierLogo.tsx b/src/components/ds/ui/PartnerTierLogo.tsx index 510ea3aec..97ecd8e18 100644 --- a/src/components/ds/ui/PartnerTierLogo.tsx +++ b/src/components/ds/ui/PartnerTierLogo.tsx @@ -47,6 +47,7 @@ export function PartnerTierLogo({ tier, sizing, scaleOverride, + mode, className, }: { image: PartnerImageConfig @@ -56,6 +57,9 @@ export function PartnerTierLogo({ 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 }) { @@ -76,6 +80,7 @@ export function PartnerTierLogo({ style={{ maxHeight }} config={config} alt={name} + mode={mode} />
) diff --git a/src/routes/ds.partner-rail.tsx b/src/routes/ds.partner-rail.tsx index 859ff7650..6daded4dd 100644 --- a/src/routes/ds.partner-rail.tsx +++ b/src/routes/ds.partner-rail.tsx @@ -164,6 +164,7 @@ function RailMode({ sizing={sizing} scaleOverrides={scales} rowGaps={rowGaps} + mode={mode} />
diff --git a/src/utils/partners.tsx b/src/utils/partners.tsx index 186d4bfc8..a8f1a4ab6 100644 --- a/src/utils/partners.tsx +++ b/src/utils/partners.tsx @@ -173,6 +173,7 @@ export function PartnerImage({ config, alt, style, + mode, }: { className?: string config: PartnerImageConfig @@ -180,10 +181,34 @@ export function PartnerImage({ /** 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 (
Date: Mon, 24 Aug 2026 10:41:40 -0600 Subject: [PATCH 39/50] fix(home): elevate the Stack Builder prompt box in light mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The light-mode card was bg-white on a near-white page (≈1.01 background contrast) defined only by a faint gray-200 border, so the box barely read. Dark mode separates via a lighter #171717 fill; light mode can't go "lighter than white", so give it the light-mode analog — a soft drop shadow plus a gray-300 border — to read as a raised panel. Scoped to the home starter; dark mode (fill, no shadow) is unchanged. Co-Authored-By: Claude Opus 4.8 --- src/components/ApplicationStarter.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/ApplicationStarter.tsx b/src/components/ApplicationStarter.tsx index 9cf5da81b..e6d8c04bc 100644 --- a/src/components/ApplicationStarter.tsx +++ b/src/components/ApplicationStarter.tsx @@ -959,8 +959,12 @@ export function ApplicationStarter({ // Home: 36px full-squircle corners. `overflow-visible` lets the // focus glow (an absolute child at -z-10) bleed out from behind // the card; the inner wrapper below re-clips the card content. + // Light mode has no fill difference from the near-white page, so + // the box is elevated with a soft shadow + slightly stronger + // border to read as a raised panel — the light analog of dark + // mode's lighter #171717 fill (which needs no shadow/border). isHomeStarter && - 'overflow-visible rounded-[36px] [corner-shape:squircle] dark:border-transparent dark:bg-[#171717]', + 'overflow-visible rounded-[36px] border-gray-300 [corner-shape:squircle] shadow-[0_1px_2px_rgba(17,17,17,0.06),0_16px_40px_-16px_rgba(17,17,17,0.20)] dark:border-transparent dark:bg-[#171717] dark:shadow-none', )} > {/* Home: a warm terracotta glow that grows in from behind the card From 554c04c40e221e24280b2d352c0d9995faac4065 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Mon, 24 Aug 2026 10:58:30 -0600 Subject: [PATCH 40/50] style(home): soften the Stack Builder box shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lower the elevation shadow's opacity (0.20→0.12) and widen its blur/spread for a gentler lift. Co-Authored-By: Claude Opus 4.8 --- src/components/ApplicationStarter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ApplicationStarter.tsx b/src/components/ApplicationStarter.tsx index e6d8c04bc..00a654cee 100644 --- a/src/components/ApplicationStarter.tsx +++ b/src/components/ApplicationStarter.tsx @@ -964,7 +964,7 @@ export function ApplicationStarter({ // border to read as a raised panel — the light analog of dark // mode's lighter #171717 fill (which needs no shadow/border). isHomeStarter && - 'overflow-visible rounded-[36px] border-gray-300 [corner-shape:squircle] shadow-[0_1px_2px_rgba(17,17,17,0.06),0_16px_40px_-16px_rgba(17,17,17,0.20)] dark:border-transparent dark:bg-[#171717] dark:shadow-none', + 'overflow-visible rounded-[36px] border-gray-300 [corner-shape:squircle] shadow-[0_1px_2px_rgba(17,17,17,0.04),0_18px_44px_-18px_rgba(17,17,17,0.12)] dark:border-transparent dark:bg-[#171717] dark:shadow-none', )} > {/* Home: a warm terracotta glow that grows in from behind the card From 1f7043a9f8f1a10265c383bd0a8b9547793ee999 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Mon, 24 Aug 2026 11:12:06 -0600 Subject: [PATCH 41/50] style(home): recessed fill for Stack Builder box + align prompt hint Replace the light-mode drop shadow with a subtle recessed fill: the box rests at a warm gray-100 and brightens to white on focus (transition- colors), which reads cleanly without elevation. Dark mode keeps its constant #171717 fill. Also vertically center the top-right prompt hint ("Press Shift + Enter") on the prompt's first line by matching the placeholder's leading-6 box (top-6 h-6), so the hint text and the rotating placeholder suggestion sit on the same line in both themes. Co-Authored-By: Claude Opus 4.8 --- src/components/ApplicationStarter.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/ApplicationStarter.tsx b/src/components/ApplicationStarter.tsx index 00a654cee..f2c70b91e 100644 --- a/src/components/ApplicationStarter.tsx +++ b/src/components/ApplicationStarter.tsx @@ -959,12 +959,12 @@ export function ApplicationStarter({ // Home: 36px full-squircle corners. `overflow-visible` lets the // focus glow (an absolute child at -z-10) bleed out from behind // the card; the inner wrapper below re-clips the card content. - // Light mode has no fill difference from the near-white page, so - // the box is elevated with a soft shadow + slightly stronger - // border to read as a raised panel — the light analog of dark - // mode's lighter #171717 fill (which needs no shadow/border). isHomeStarter && - 'overflow-visible rounded-[36px] border-gray-300 [corner-shape:squircle] shadow-[0_1px_2px_rgba(17,17,17,0.04),0_18px_44px_-18px_rgba(17,17,17,0.12)] dark:border-transparent dark:bg-[#171717] dark:shadow-none', + '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-100'), )} > {/* 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 ? (
{/* features-stack — 478:1742 */} -
    +
      {whyTanStackPrinciples.map((principle, index) => (
    1. Date: Mon, 24 Aug 2026 13:47:35 -0600 Subject: [PATCH 44/50] fix(blog): keep filter-bar buttons flat (outlined) on mobile The DS ghost Button pre-applies its hover treatment below 900px (a touch affordance), which made the filter/RSS toolbar buttons read as filled/ "selected". Keep the DS Button but flatten idle ghost buttons back to a plain outlined pill on mobile (FLAT_ON_MOBILE), so they match the search field. Active facets keep their filled secondary look. Co-Authored-By: Claude Opus 4.8 --- src/components/BlogFilterBar.tsx | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/components/BlogFilterBar.tsx b/src/components/BlogFilterBar.tsx index 4039e748b..76a997ed5 100644 --- a/src/components/BlogFilterBar.tsx +++ b/src/components/BlogFilterBar.tsx @@ -38,6 +38,14 @@ type BlogFilterBarProps = { 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}) @@ -123,7 +131,10 @@ export function BlogFilterBar({ From 2a9eb5ae12a28e10197c3a43b027f70c527beb9a Mon Sep 17 00:00:00 2001 From: Abeuty Date: Mon, 24 Aug 2026 14:02:28 -0600 Subject: [PATCH 45/50] feat(ds): scrollable Dropdown with a subtle scroll indicator Add a maxHeight control to DropdownContent: it caps the menu height and reveals overflow with a thin, low-opacity track-less scrollbar (.ds-scroll-subtle) that reads only as a "more content" indicator, not a grab target. Width/corners stay the canonical min-w-48 rounded-lg. Point the blog filter menus at it (maxHeight="20rem") instead of the ad-hoc max-h-80 overflow-y-auto (which pulled the chunky global scrollbar), and add a Scrollable example to /ds/dropdown. Co-Authored-By: Claude Opus 4.8 --- src/components/BlogFilterBar.tsx | 6 ++--- src/components/ds/ds-nav.ts | 2 +- src/components/ds/ui/index.tsx | 9 +++++++ src/routes/ds.dropdown.tsx | 46 ++++++++++++++++++++++++++++++++ src/styles/app.css | 30 +++++++++++++++++++++ 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/components/BlogFilterBar.tsx b/src/components/BlogFilterBar.tsx index 76a997ed5..04ddbbeeb 100644 --- a/src/components/BlogFilterBar.tsx +++ b/src/components/BlogFilterBar.tsx @@ -150,7 +150,7 @@ export function BlogFilterBar({ - + onLibraryToggle(undefined)} @@ -204,7 +204,7 @@ export function BlogFilterBar({ - + onAuthorChange(undefined)} @@ -241,7 +241,7 @@ export function BlogFilterBar({ - + onYearToggle(undefined)} diff --git a/src/components/ds/ds-nav.ts b/src/components/ds/ds-nav.ts index 4aff0f569..79d6ef680 100644 --- a/src/components/ds/ds-nav.ts +++ b/src/components/ds/ds-nav.ts @@ -144,7 +144,7 @@ export const dsNav: Array = [ { label: 'Dropdown', to: '/ds/dropdown', - sections: ['Basic menu'], + sections: ['Basic menu', 'Scrollable'], }, { label: 'Eyebrow', diff --git a/src/components/ds/ui/index.tsx b/src/components/ds/ui/index.tsx index fb8fae7c1..cf192585f 100644 --- a/src/components/ds/ui/index.tsx +++ b/src/components/ds/ui/index.tsx @@ -759,6 +759,7 @@ export function DropdownContent({ side = 'bottom', sideOffset = 6, collisionPadding = 0, + maxHeight, }: { children: React.ReactNode className?: string @@ -767,7 +768,13 @@ export function DropdownContent({ side?: 'top' | 'right' | 'bottom' | 'left' sideOffset?: number collisionPadding?: number + /** Cap the menu height and reveal overflow with a subtle scroll indicator — + * a thin, low-opacity scrollbar that appears only when the list overflows, + * signalling "more content" without inviting a drag. Accepts any CSS length + * (e.g. '20rem') or a px number. */ + maxHeight?: number | string }) { + const scrollable = maxHeight !== undefined return ( diff --git a/src/routes/ds.dropdown.tsx b/src/routes/ds.dropdown.tsx index a909c37fa..b82d39f2b 100644 --- a/src/routes/ds.dropdown.tsx +++ b/src/routes/ds.dropdown.tsx @@ -26,6 +26,26 @@ export const Route = createFileRoute('/ds/dropdown')({ }), }) +const SCROLL_ITEMS = [ + 'Start', + 'Router', + 'Query', + 'Table', + 'Form', + 'Store', + 'DB', + 'AI', + 'Virtual', + 'Pacer', + 'Ranger', + 'Devtools', + 'Config', + 'CLI', + 'Intent', + 'Highlight', + 'Markdown', +] + function DropdownPage() { return ( + + + + {items.map((label) => ( + {label} + ))} +`} + > + + + + + + {SCROLL_ITEMS.map((label) => ( + {label} + ))} + + + + ) } diff --git a/src/styles/app.css b/src/styles/app.css index eecb18154..fba8872c9 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -1355,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; } From 0c4211a674c99505cbebdf115ce380b3c3c81936 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Mon, 24 Aug 2026 14:39:34 -0600 Subject: [PATCH 46/50] feat(ds): SearchInput reads as a pill; Dropdown menu fills its trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SearchInput: make the fully-rounded pill the default (drop the `pill` prop) so search stays visually distinct from the rounded-corner buttons and dropdown menus. Blog search drops its now-redundant `pill`. DropdownContent: width wraps its content but is never narrower than the trigger it opened from — min-w now uses max(12rem, var(--radix-dropdown-menu-trigger-width, 12rem)). A compact trigger gets a content-hugging menu; a full-width trigger (e.g. the mobile blog facets) gets a menu that fills the same span. Co-Authored-By: Claude Opus 4.8 --- src/components/BlogFilterBar.tsx | 1 - src/components/ds/ui/index.tsx | 26 ++++++++++---------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/components/BlogFilterBar.tsx b/src/components/BlogFilterBar.tsx index 04ddbbeeb..bd3635cd6 100644 --- a/src/components/BlogFilterBar.tsx +++ b/src/components/BlogFilterBar.tsx @@ -287,7 +287,6 @@ export function BlogFilterBar({
      & { size?: 'default' | 'large' progressive?: boolean - /** Fully-rounded pill shape (default is the standard rounded-lg/xl). */ - pill?: boolean /** Node rendered inside the field, right of the input (e.g. an RSS link). */ trailing?: React.ReactNode } export const SearchInput = React.forwardRef( function SearchInput( - { - className, - size = 'default', - progressive = false, - pill = false, - trailing, - ...props - }, + { className, size = 'default', progressive = false, trailing, ...props }, forwardedRef, ) { const [open, setOpen] = React.useState(!progressive) @@ -511,11 +502,10 @@ export const SearchInput = React.forwardRef( return (
      {/* The label wraps only the icon + input so clicking the field @@ -784,7 +774,11 @@ export function DropdownContent({ collisionPadding={collisionPadding} style={scrollable ? { maxHeight } : undefined} className={twMerge( - 'z-[1200] min-w-48 rounded-lg border border-border-default bg-background-elevated p-1.5 shadow-lg', + // Width wraps the content, but never narrower than the trigger it + // opened from (Radix's --radix-dropdown-menu-trigger-width), with a + // 12rem floor. So a compact trigger gets a content-hugging menu, and a + // wide/full-width trigger gets a menu that fills the same span. + 'z-[1200] min-w-[max(12rem,var(--radix-dropdown-menu-trigger-width,12rem))] rounded-lg border border-border-default bg-background-elevated p-1.5 shadow-lg', scrollable && 'overflow-y-auto overscroll-contain ds-scroll-subtle', className, )} From 1ee03568a311d2505184dd9ece81a24ab47d4159 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Mon, 24 Aug 2026 14:53:29 -0600 Subject: [PATCH 47/50] feat(ds): simplify input focus to a single neutral border-color change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the focus ring across all DS inputs (FormInput, FormSelect, SearchInput). Focus now lifts the border to the strong neutral token with no ring or accent — the lightest neutral on dark surfaces. Removes the FormInput `focusRing` prop and its showcase section. Co-Authored-By: Claude Opus 4.8 --- src/components/ds/ds-nav.ts | 2 +- src/components/ds/ui/index.tsx | 30 ++++++++++++------------------ src/routes/ds.inputs.tsx | 19 ++++++++----------- 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/src/components/ds/ds-nav.ts b/src/components/ds/ds-nav.ts index 79d6ef680..7c021296a 100644 --- a/src/components/ds/ds-nav.ts +++ b/src/components/ds/ds-nav.ts @@ -156,7 +156,7 @@ export const dsNav: Array = [ to: '/ds/inputs', sections: [ 'Default', - 'Focus rings', + 'Focus', 'With a label & disabled', 'Progressive search', 'Persistent search', diff --git a/src/components/ds/ui/index.tsx b/src/components/ds/ui/index.tsx index 5653d523e..36ac916a4 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 (