diff --git a/package.json b/package.json index d5901a78d..6426a014a 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "drizzle-orm": "^0.45.2", "encoding-sniffer": "^0.2.1", "esbuild-wasm": "0.27.4", + "figma-squircle": "^1.1.0", "gifenc": "^1.0.3", "gray-matter": "^4.0.3", "iconv-lite": "^0.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02d2eec6e..1ed76f9c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: esbuild-wasm: specifier: 0.27.4 version: 0.27.4 + figma-squircle: + specifier: ^1.1.0 + version: 1.1.0 gifenc: specifier: ^1.0.3 version: 1.0.3 @@ -5045,6 +5048,9 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figma-squircle@1.1.0: + resolution: {integrity: sha512-3aa6bOurrsybsuYo1TMRuasBpXgVjgnmOUtZ40il6e1dJF2lhTRq89z7oy1uzKDspbH3630rLZD2UvJqXDiktA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -11617,6 +11623,8 @@ snapshots: fflate@0.8.2: {} + figma-squircle@1.1.0: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 diff --git a/src/components/ApplicationStarter.tsx b/src/components/ApplicationStarter.tsx index c4ec2c9b7..9cf5da81b 100644 --- a/src/components/ApplicationStarter.tsx +++ b/src/components/ApplicationStarter.tsx @@ -18,11 +18,7 @@ import type { ApplicationStarterContext, ApplicationStarterResult, } from '~/utils/application-starter' -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '~/components/Collapsible' +import { Panel, PanelContent, PanelTrigger } from '~/components/Panel' import { GeneratedPromptPreviewBody, GeneratedPromptPreviewHeader, @@ -868,8 +864,8 @@ export function ApplicationStarter({ ) : null} - - + + {showOptionsSection ? (
@@ -946,8 +942,8 @@ export function ApplicationStarter({
) : null} -
-
+ + ) : ( <> @@ -1170,8 +1166,8 @@ export function ApplicationStarter({ ) : null} - - + ) : null} - - + + - - + ) : null} - - + + {showPromptPreview && hasGeneratedPrompt ? (
@@ -1571,9 +1567,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..f89d25cf6 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 (