From acdf56f84a686641b76355e75fc2451213d0d93d Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Wed, 26 Nov 2025 06:33:08 -0800 Subject: [PATCH 01/16] feat: add ai library --- ...x-dda29b85-f08f-4ce1-b965-d51785ae7745.png | 0 ...x-2c8f7094-d201-4ab3-afa5-9ecf58ab5f04.png | 0 ...x-33a67107-e929-42e2-ac73-a2400d7f7bdc.png | 0 ...x-7a125c5a-bd61-489b-80c1-778afa7f1d60.png | 0 ...x-26649416-4a92-40f2-91aa-f274d0125803.png | 0 ...x-1b21df04-8f56-4c02-b17d-5f5711879ea1.png | 0 ...x-7d8e8d4c-ef93-45d9-966a-14bfb795b8b2.png | 0 src/components/AILibraryHero.tsx | 545 ++++++++++++++++++ src/components/Navbar.tsx | 1 + src/libraries/ai.tsx | 82 +++ src/libraries/index.tsx | 4 + src/routeTree.gen.ts | 21 + src/routes/_libraries/ai.$version.index.tsx | 99 ++++ src/routes/_libraries/partners.tsx | 3 + 14 files changed, 755 insertions(+) create mode 100644 assets/CleanShot_2025-11-25_at_16.54.48_2x-dda29b85-f08f-4ce1-b965-d51785ae7745.png create mode 100644 assets/CleanShot_2025-11-25_at_17.03.50_2x-2c8f7094-d201-4ab3-afa5-9ecf58ab5f04.png create mode 100644 assets/CleanShot_2025-11-25_at_17.05.19_2x-33a67107-e929-42e2-ac73-a2400d7f7bdc.png create mode 100644 assets/CleanShot_2025-11-25_at_17.06.20_2x-7a125c5a-bd61-489b-80c1-778afa7f1d60.png create mode 100644 assets/CleanShot_2025-11-25_at_17.47.09_2x-26649416-4a92-40f2-91aa-f274d0125803.png create mode 100644 assets/CleanShot_2025-11-25_at_17.58.25_2x-1b21df04-8f56-4c02-b17d-5f5711879ea1.png create mode 100644 assets/CleanShot_2025-11-25_at_18.01.21_2x-7d8e8d4c-ef93-45d9-966a-14bfb795b8b2.png create mode 100644 src/components/AILibraryHero.tsx create mode 100644 src/libraries/ai.tsx create mode 100644 src/routes/_libraries/ai.$version.index.tsx diff --git a/assets/CleanShot_2025-11-25_at_16.54.48_2x-dda29b85-f08f-4ce1-b965-d51785ae7745.png b/assets/CleanShot_2025-11-25_at_16.54.48_2x-dda29b85-f08f-4ce1-b965-d51785ae7745.png new file mode 100644 index 000000000..e69de29bb diff --git a/assets/CleanShot_2025-11-25_at_17.03.50_2x-2c8f7094-d201-4ab3-afa5-9ecf58ab5f04.png b/assets/CleanShot_2025-11-25_at_17.03.50_2x-2c8f7094-d201-4ab3-afa5-9ecf58ab5f04.png new file mode 100644 index 000000000..e69de29bb diff --git a/assets/CleanShot_2025-11-25_at_17.05.19_2x-33a67107-e929-42e2-ac73-a2400d7f7bdc.png b/assets/CleanShot_2025-11-25_at_17.05.19_2x-33a67107-e929-42e2-ac73-a2400d7f7bdc.png new file mode 100644 index 000000000..e69de29bb diff --git a/assets/CleanShot_2025-11-25_at_17.06.20_2x-7a125c5a-bd61-489b-80c1-778afa7f1d60.png b/assets/CleanShot_2025-11-25_at_17.06.20_2x-7a125c5a-bd61-489b-80c1-778afa7f1d60.png new file mode 100644 index 000000000..e69de29bb diff --git a/assets/CleanShot_2025-11-25_at_17.47.09_2x-26649416-4a92-40f2-91aa-f274d0125803.png b/assets/CleanShot_2025-11-25_at_17.47.09_2x-26649416-4a92-40f2-91aa-f274d0125803.png new file mode 100644 index 000000000..e69de29bb diff --git a/assets/CleanShot_2025-11-25_at_17.58.25_2x-1b21df04-8f56-4c02-b17d-5f5711879ea1.png b/assets/CleanShot_2025-11-25_at_17.58.25_2x-1b21df04-8f56-4c02-b17d-5f5711879ea1.png new file mode 100644 index 000000000..e69de29bb diff --git a/assets/CleanShot_2025-11-25_at_18.01.21_2x-7d8e8d4c-ef93-45d9-966a-14bfb795b8b2.png b/assets/CleanShot_2025-11-25_at_18.01.21_2x-7d8e8d4c-ef93-45d9-966a-14bfb795b8b2.png new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/AILibraryHero.tsx b/src/components/AILibraryHero.tsx new file mode 100644 index 000000000..771b80f62 --- /dev/null +++ b/src/components/AILibraryHero.tsx @@ -0,0 +1,545 @@ +import * as React from 'react' +import { twMerge } from 'tailwind-merge' +import { Link, LinkProps } from '@tanstack/react-router' +import type { Library } from '~/libraries' +import { useIsDark } from '~/hooks/useIsDark' + +type AILibraryHeroProps = { + project: Library + cta?: { + linkProps: LinkProps + label: string + className?: string + } + actions?: React.ReactNode +} + +export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { + const [hoveredBox, setHoveredBox] = React.useState(null) + const isDark = useIsDark() + const strokeColor = isDark ? '#ffffff' : '#000000' + const fillColor = isDark ? '#121212' : '#ffffff' + const textColor = isDark ? '#ffffff' : '#000000' + + return ( +
+ {/* Background dimmed text */} +
+

+ TANSTACK +

+
+ + {/* SVG Diagram */} +
+ + {/* Lines from frameworks to ai-client */} + + + + + + {/* Line from ai-client to @tanstack/ai */} + + + {/* Lines from @tanstack/ai to providers */} + + + + + + {/* Top layer: Frameworks */} + setHoveredBox('vanilla')} + onMouseLeave={() => setHoveredBox(null)} + className="cursor-pointer transition-transform duration-300" + > + + + Vanilla + + + + setHoveredBox('react')} + onMouseLeave={() => setHoveredBox(null)} + className="cursor-pointer transition-transform duration-300" + > + + + React + + + + setHoveredBox('solid')} + onMouseLeave={() => setHoveredBox(null)} + className="cursor-pointer transition-all" + style={{ + transform: hoveredBox === 'solid' ? 'scale(1.05)' : 'scale(1)', + transformOrigin: '380px 30px', + }} + /> + + Solid + + + setHoveredBox('future')} + onMouseLeave={() => setHoveredBox(null)} + className="cursor-pointer transition-all" + style={{ + transform: hoveredBox === 'future' ? 'scale(1.05)' : 'scale(1)', + transformOrigin: '540px 30px', + }} + /> + + ? + + + {/* @tanstack/ai-client box */} + setHoveredBox('ai-client')} + onMouseLeave={() => setHoveredBox(null)} + className="cursor-pointer transition-all" + style={{ + transform: + hoveredBox === 'ai-client' ? 'scale(1.05)' : 'scale(1)', + transformOrigin: '295px 130px', + }} + /> + + @tanstack/ai-client + + + {/* Large @tanstack/ai container box */} + + + {/* @tanstack/ai label */} + + @tanstack/ai + + + {/* Provider layer */} + setHoveredBox('ollama')} + onMouseLeave={() => setHoveredBox(null)} + className="cursor-pointer transition-all" + style={{ + transform: hoveredBox === 'ollama' ? 'scale(1.05)' : 'scale(1)', + transformOrigin: '170px 280px', + }} + /> + + Ollama + + + setHoveredBox('openai')} + onMouseLeave={() => setHoveredBox(null)} + className="cursor-pointer transition-all" + style={{ + transform: hoveredBox === 'openai' ? 'scale(1.05)' : 'scale(1)', + transformOrigin: '300px 280px', + }} + /> + + OpenAI + + + setHoveredBox('anthropic')} + onMouseLeave={() => setHoveredBox(null)} + className="cursor-pointer transition-all" + style={{ + transform: + hoveredBox === 'anthropic' ? 'scale(1.05)' : 'scale(1)', + transformOrigin: '435px 280px', + }} + /> + + Anthropic + + + setHoveredBox('gemini')} + onMouseLeave={() => setHoveredBox(null)} + className="cursor-pointer transition-all" + style={{ + transform: hoveredBox === 'gemini' ? 'scale(1.05)' : 'scale(1)', + transformOrigin: '570px 280px', + }} + /> + + Gemini + + + {/* Server layer */} + setHoveredBox('typescript')} + onMouseLeave={() => setHoveredBox(null)} + className="cursor-pointer transition-all" + style={{ + transform: + hoveredBox === 'typescript' ? 'scale(1.05)' : 'scale(1)', + transformOrigin: '60px 400px', + }} + /> + + TypeScript + + + setHoveredBox('php')} + onMouseLeave={() => setHoveredBox(null)} + className="cursor-pointer transition-all" + style={{ + transform: hoveredBox === 'php' ? 'scale(1.05)' : 'scale(1)', + transformOrigin: '220px 400px', + }} + /> + + PHP + + + setHoveredBox('python')} + onMouseLeave={() => setHoveredBox(null)} + className="cursor-pointer transition-all" + style={{ + transform: hoveredBox === 'python' ? 'scale(1.05)' : 'scale(1)', + transformOrigin: '380px 400px', + }} + /> + + Python + + + setHoveredBox('future-server')} + onMouseLeave={() => setHoveredBox(null)} + className="cursor-pointer transition-all" + style={{ + transform: + hoveredBox === 'future-server' ? 'scale(1.05)' : 'scale(1)', + transformOrigin: '540px 400px', + }} + /> + + ? + + +
+ + {/* Content overlay */} +
+

+ {project.tagline} +

+ {project.description ? ( +

+ {project.description} +

+ ) : null} + {actions ? ( +
{actions}
+ ) : cta ? ( + + {cta.label} + + ) : null} +
+
+ ) +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index ee31f9712..19a73d7c2 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -234,6 +234,7 @@ export function Navbar({ children }: { children: React.ReactNode }) { 'table', 'form', 'db', + 'ai', 'virtual', 'pacer', 'store', diff --git a/src/libraries/ai.tsx b/src/libraries/ai.tsx new file mode 100644 index 000000000..9c98711e0 --- /dev/null +++ b/src/libraries/ai.tsx @@ -0,0 +1,82 @@ +import { VscPreview } from 'react-icons/vsc' +import { Library } from '.' +import { FaGithub, FaBolt, FaCogs } from 'react-icons/fa' +import { BiBookAlt } from 'react-icons/bi' +import { twMerge } from 'tailwind-merge' +import { FaPlug } from 'react-icons/fa6' + +const repo = 'tanstack/ai' + +const textStyles = `text-pink-600 dark:text-pink-500` + +export const aiProject = { + id: 'ai', + name: 'TanStack AI', + cardStyles: `shadow-xl shadow-pink-700/20 dark:shadow-lg dark:shadow-pink-500/20 text-pink-500 dark:text-pink-400 border-2 border-transparent hover:border-current`, + to: '/ai', + tagline: `A powerful, open-source AI SDK with a unified interface across multiple providers`, + description: `A powerful, open-source AI SDK with a unified interface across multiple providers. No vendor lock-in, no proprietary formats, just clean TypeScript and honest open source.`, + ogImage: 'https://github.com/tanstack/ai/raw/main/media/repo-header.png', + badge: 'alpha', + bgStyle: `bg-pink-700`, + textStyle: `text-pink-500`, + repo, + latestBranch: 'main', + latestVersion: 'v0', + availableVersions: ['v0'], + bgRadial: 'from-pink-500 via-pink-700/50 to-transparent', + colorFrom: `from-pink-500`, + colorTo: `to-pink-700`, + textColor: `text-pink-700`, + frameworks: ['react', 'solid', 'vanilla'], + scarfId: undefined, + defaultDocs: 'overview', + menu: [ + { + icon: , + label: 'Docs', + to: '/ai/latest/docs', + }, + { + icon: , + label: 'Github', + to: `https://github.com/${repo}`, + }, + ], + featureHighlights: [ + { + title: 'Multi-Provider Support', + icon: , + description: ( +
+ Support for OpenAI, Anthropic, Ollama, and Google Gemini. Switch + providers at runtime without code changes. No vendor lock-in, just + clean TypeScript. +
+ ), + }, + { + title: 'Unified API', + icon: , + description: ( +
+ Same interface across all providers. Standalone functions with + automatic type inference from adapters. Framework-agnostic client for + any JavaScript environment. +
+ ), + }, + { + title: 'Tool/Function Calling', + icon: , + description: ( +
+ Automatic execution loop with no manual tool management needed. + Type-safe tool definitions with structured outputs and streaming + support. +
+ ), + }, + ], +} satisfies Library + diff --git a/src/libraries/index.tsx b/src/libraries/index.tsx index 58ed6c67a..525043c7f 100644 --- a/src/libraries/index.tsx +++ b/src/libraries/index.tsx @@ -18,6 +18,7 @@ import { rangerProject } from './ranger' import { storeProject } from './store' import { pacerProject } from './pacer' import { dbProject } from './db' +import { aiProject } from './ai' import { devtoolsProject } from './devtools' export const frameworkOptions = [ @@ -56,6 +57,7 @@ export type Library = { | 'store' | 'pacer' | 'db' + | 'ai' | 'config' | 'devtools' | 'react-charts' @@ -112,6 +114,7 @@ export const libraries = [ tableProject, formProject, dbProject, + aiProject, virtualProject, pacerProject, storeProject, @@ -136,6 +139,7 @@ export const librariesByGroup = { routerProject, queryProject, dbProject, + aiProject, storeProject, pacerProject, ], diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 1ac8b5deb..aaa65f600 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -53,6 +53,7 @@ import { Route as LibrariesFormVersionIndexRouteImport } from './routes/_librari import { Route as LibrariesDevtoolsVersionIndexRouteImport } from './routes/_libraries/devtools.$version.index' import { Route as LibrariesDbVersionIndexRouteImport } from './routes/_libraries/db.$version.index' import { Route as LibrariesConfigVersionIndexRouteImport } from './routes/_libraries/config.$version.index' +import { Route as LibrariesAiVersionIndexRouteImport } from './routes/_libraries/ai.$version.index' import { Route as LibraryIdVersionDocsIndexRouteImport } from './routes/$libraryId/$version.docs.index' import { Route as LibraryIdVersionDocsChar123Char125DotmdRouteImport } from './routes/$libraryId/$version.docs.{$}[.]md' import { Route as LibraryIdVersionDocsContributorsRouteImport } from './routes/$libraryId/$version.docs.contributors' @@ -294,6 +295,11 @@ const LibrariesConfigVersionIndexRoute = path: '/config/$version/', getParentRoute: () => LibrariesRouteRoute, } as any) +const LibrariesAiVersionIndexRoute = LibrariesAiVersionIndexRouteImport.update({ + id: '/ai/$version/', + path: '/ai/$version/', + getParentRoute: () => LibrariesRouteRoute, +} as any) const LibraryIdVersionDocsIndexRoute = LibraryIdVersionDocsIndexRouteImport.update({ id: '/', @@ -392,6 +398,7 @@ export interface FileRoutesByFullPath { '/$libraryId/$version/docs/contributors': typeof LibraryIdVersionDocsContributorsRoute '/$libraryId/$version/docs/{$}.md': typeof LibraryIdVersionDocsChar123Char125DotmdRoute '/$libraryId/$version/docs/': typeof LibraryIdVersionDocsIndexRoute + '/ai/$version': typeof LibrariesAiVersionIndexRoute '/config/$version': typeof LibrariesConfigVersionIndexRoute '/db/$version': typeof LibrariesDbVersionIndexRoute '/devtools/$version': typeof LibrariesDevtoolsVersionIndexRoute @@ -443,6 +450,7 @@ export interface FileRoutesByTo { '/$libraryId/$version/docs/contributors': typeof LibraryIdVersionDocsContributorsRoute '/$libraryId/$version/docs/{$}.md': typeof LibraryIdVersionDocsChar123Char125DotmdRoute '/$libraryId/$version/docs': typeof LibraryIdVersionDocsIndexRoute + '/ai/$version': typeof LibrariesAiVersionIndexRoute '/config/$version': typeof LibrariesConfigVersionIndexRoute '/db/$version': typeof LibrariesDbVersionIndexRoute '/devtools/$version': typeof LibrariesDevtoolsVersionIndexRoute @@ -500,6 +508,7 @@ export interface FileRoutesById { '/$libraryId/$version/docs/contributors': typeof LibraryIdVersionDocsContributorsRoute '/$libraryId/$version/docs/{$}.md': typeof LibraryIdVersionDocsChar123Char125DotmdRoute '/$libraryId/$version/docs/': typeof LibraryIdVersionDocsIndexRoute + '/_libraries/ai/$version/': typeof LibrariesAiVersionIndexRoute '/_libraries/config/$version/': typeof LibrariesConfigVersionIndexRoute '/_libraries/db/$version/': typeof LibrariesDbVersionIndexRoute '/_libraries/devtools/$version/': typeof LibrariesDevtoolsVersionIndexRoute @@ -557,6 +566,7 @@ export interface FileRouteTypes { | '/$libraryId/$version/docs/contributors' | '/$libraryId/$version/docs/{$}.md' | '/$libraryId/$version/docs/' + | '/ai/$version' | '/config/$version' | '/db/$version' | '/devtools/$version' @@ -608,6 +618,7 @@ export interface FileRouteTypes { | '/$libraryId/$version/docs/contributors' | '/$libraryId/$version/docs/{$}.md' | '/$libraryId/$version/docs' + | '/ai/$version' | '/config/$version' | '/db/$version' | '/devtools/$version' @@ -664,6 +675,7 @@ export interface FileRouteTypes { | '/$libraryId/$version/docs/contributors' | '/$libraryId/$version/docs/{$}.md' | '/$libraryId/$version/docs/' + | '/_libraries/ai/$version/' | '/_libraries/config/$version/' | '/_libraries/db/$version/' | '/_libraries/devtools/$version/' @@ -1007,6 +1019,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LibrariesConfigVersionIndexRouteImport parentRoute: typeof LibrariesRouteRoute } + '/_libraries/ai/$version/': { + id: '/_libraries/ai/$version/' + path: '/ai/$version' + fullPath: '/ai/$version' + preLoaderRoute: typeof LibrariesAiVersionIndexRouteImport + parentRoute: typeof LibrariesRouteRoute + } '/$libraryId/$version/docs/': { id: '/$libraryId/$version/docs/' path: '/' @@ -1170,6 +1189,7 @@ interface LibrariesRouteRouteChildren { LibrariesSupportRoute: typeof LibrariesSupportRoute LibrariesTermsRoute: typeof LibrariesTermsRoute LibrariesIndexRoute: typeof LibrariesIndexRoute + LibrariesAiVersionIndexRoute: typeof LibrariesAiVersionIndexRoute LibrariesConfigVersionIndexRoute: typeof LibrariesConfigVersionIndexRoute LibrariesDbVersionIndexRoute: typeof LibrariesDbVersionIndexRoute LibrariesDevtoolsVersionIndexRoute: typeof LibrariesDevtoolsVersionIndexRoute @@ -1199,6 +1219,7 @@ const LibrariesRouteRouteChildren: LibrariesRouteRouteChildren = { LibrariesSupportRoute: LibrariesSupportRoute, LibrariesTermsRoute: LibrariesTermsRoute, LibrariesIndexRoute: LibrariesIndexRoute, + LibrariesAiVersionIndexRoute: LibrariesAiVersionIndexRoute, LibrariesConfigVersionIndexRoute: LibrariesConfigVersionIndexRoute, LibrariesDbVersionIndexRoute: LibrariesDbVersionIndexRoute, LibrariesDevtoolsVersionIndexRoute: LibrariesDevtoolsVersionIndexRoute, diff --git a/src/routes/_libraries/ai.$version.index.tsx b/src/routes/_libraries/ai.$version.index.tsx new file mode 100644 index 000000000..3f59f2559 --- /dev/null +++ b/src/routes/_libraries/ai.$version.index.tsx @@ -0,0 +1,99 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Footer } from '~/components/Footer' +import { LazySponsorSection } from '~/components/LazySponsorSection' +import { PartnersSection } from '~/components/PartnersSection' +import { BottomCTA } from '~/components/BottomCTA' +import { aiProject } from '~/libraries/ai' +import { seo } from '~/utils/seo' +import { AILibraryHero } from '~/components/AILibraryHero' +import { getLibrary } from '~/libraries' +import { LibraryFeatureHighlights } from '~/components/LibraryFeatureHighlights' +import LandingPageGad from '~/components/LandingPageGad' +import OpenSourceStats, { ossStatsQuery } from '~/components/OpenSourceStats' + +const library = getLibrary('ai') + +export const Route = createFileRoute('/_libraries/ai/$version/')({ + component: AIVersionIndex, + head: () => ({ + meta: seo({ + title: aiProject.name, + description: aiProject.description, + }), + }), + loader: async ({ context: { queryClient } }) => { + await queryClient.ensureQueryData(ossStatsQuery({ library })) + }, +}) + +function AIVersionIndex() { + // sponsorsPromise no longer needed - using lazy loading + const { version } = Route.useParams() + + return ( + <> +
+ +
+ +
+ +
+
+

+ No vendor lock-in, just clean TypeScript +

+

+ TanStack AI provides a unified interface across multiple AI + providers. Switch between OpenAI, Anthropic, Ollama, and Google + Gemini at runtime without code changes. Built with TypeScript + first principles and zero lock-in. +

+
+
+
+

🔌 Multi-Provider Support

+

OpenAI, Anthropic, Ollama, and Google Gemini.

+
+
+

⚡ Automatic Tool Execution

+

No manual tool management needed.

+
+
+

🎯 Type-Safe by Default

+

Full type inference from adapters.

+
+
+

🌟 Framework Agnostic

+

Works with React, Solid, and vanilla JS.

+
+
+
+ + + + +
+
+ + ) +} diff --git a/src/routes/_libraries/partners.tsx b/src/routes/_libraries/partners.tsx index 344312bbe..54fabc4d8 100644 --- a/src/routes/_libraries/partners.tsx +++ b/src/routes/_libraries/partners.tsx @@ -13,6 +13,7 @@ import { queryProject } from '~/libraries/query' import { tableProject } from '~/libraries/table' import { configProject } from '~/libraries/config' import { dbProject } from '~/libraries/db' +import { aiProject } from '~/libraries/ai' import { formProject } from '~/libraries/form' import { pacerProject } from '~/libraries/pacer' import { rangerProject } from '~/libraries/ranger' @@ -30,6 +31,7 @@ const availableLibraries = [ storeProject, pacerProject, dbProject, + aiProject, configProject, ] @@ -44,6 +46,7 @@ const librarySchema = z.enum([ 'store', 'pacer', 'db', + 'ai', 'config', 'react-charts', 'devtools', From ce8d2f9cd401d0b7f91caf0de8808420697be132 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Wed, 26 Nov 2025 06:33:26 -0800 Subject: [PATCH 02/16] feat: add ai library --- ...-11-25_at_16.54.48_2x-dda29b85-f08f-4ce1-b965-d51785ae7745.png | 0 ...-11-25_at_17.03.50_2x-2c8f7094-d201-4ab3-afa5-9ecf58ab5f04.png | 0 ...-11-25_at_17.05.19_2x-33a67107-e929-42e2-ac73-a2400d7f7bdc.png | 0 ...-11-25_at_17.06.20_2x-7a125c5a-bd61-489b-80c1-778afa7f1d60.png | 0 ...-11-25_at_17.47.09_2x-26649416-4a92-40f2-91aa-f274d0125803.png | 0 ...-11-25_at_17.58.25_2x-1b21df04-8f56-4c02-b17d-5f5711879ea1.png | 0 ...-11-25_at_18.01.21_2x-7d8e8d4c-ef93-45d9-966a-14bfb795b8b2.png | 0 7 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 assets/CleanShot_2025-11-25_at_16.54.48_2x-dda29b85-f08f-4ce1-b965-d51785ae7745.png delete mode 100644 assets/CleanShot_2025-11-25_at_17.03.50_2x-2c8f7094-d201-4ab3-afa5-9ecf58ab5f04.png delete mode 100644 assets/CleanShot_2025-11-25_at_17.05.19_2x-33a67107-e929-42e2-ac73-a2400d7f7bdc.png delete mode 100644 assets/CleanShot_2025-11-25_at_17.06.20_2x-7a125c5a-bd61-489b-80c1-778afa7f1d60.png delete mode 100644 assets/CleanShot_2025-11-25_at_17.47.09_2x-26649416-4a92-40f2-91aa-f274d0125803.png delete mode 100644 assets/CleanShot_2025-11-25_at_17.58.25_2x-1b21df04-8f56-4c02-b17d-5f5711879ea1.png delete mode 100644 assets/CleanShot_2025-11-25_at_18.01.21_2x-7d8e8d4c-ef93-45d9-966a-14bfb795b8b2.png diff --git a/assets/CleanShot_2025-11-25_at_16.54.48_2x-dda29b85-f08f-4ce1-b965-d51785ae7745.png b/assets/CleanShot_2025-11-25_at_16.54.48_2x-dda29b85-f08f-4ce1-b965-d51785ae7745.png deleted file mode 100644 index e69de29bb..000000000 diff --git a/assets/CleanShot_2025-11-25_at_17.03.50_2x-2c8f7094-d201-4ab3-afa5-9ecf58ab5f04.png b/assets/CleanShot_2025-11-25_at_17.03.50_2x-2c8f7094-d201-4ab3-afa5-9ecf58ab5f04.png deleted file mode 100644 index e69de29bb..000000000 diff --git a/assets/CleanShot_2025-11-25_at_17.05.19_2x-33a67107-e929-42e2-ac73-a2400d7f7bdc.png b/assets/CleanShot_2025-11-25_at_17.05.19_2x-33a67107-e929-42e2-ac73-a2400d7f7bdc.png deleted file mode 100644 index e69de29bb..000000000 diff --git a/assets/CleanShot_2025-11-25_at_17.06.20_2x-7a125c5a-bd61-489b-80c1-778afa7f1d60.png b/assets/CleanShot_2025-11-25_at_17.06.20_2x-7a125c5a-bd61-489b-80c1-778afa7f1d60.png deleted file mode 100644 index e69de29bb..000000000 diff --git a/assets/CleanShot_2025-11-25_at_17.47.09_2x-26649416-4a92-40f2-91aa-f274d0125803.png b/assets/CleanShot_2025-11-25_at_17.47.09_2x-26649416-4a92-40f2-91aa-f274d0125803.png deleted file mode 100644 index e69de29bb..000000000 diff --git a/assets/CleanShot_2025-11-25_at_17.58.25_2x-1b21df04-8f56-4c02-b17d-5f5711879ea1.png b/assets/CleanShot_2025-11-25_at_17.58.25_2x-1b21df04-8f56-4c02-b17d-5f5711879ea1.png deleted file mode 100644 index e69de29bb..000000000 diff --git a/assets/CleanShot_2025-11-25_at_18.01.21_2x-7d8e8d4c-ef93-45d9-966a-14bfb795b8b2.png b/assets/CleanShot_2025-11-25_at_18.01.21_2x-7d8e8d4c-ef93-45d9-966a-14bfb795b8b2.png deleted file mode 100644 index e69de29bb..000000000 From 4082ac2203c8c58e42cf57f94c795c41c8a5cde3 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Wed, 26 Nov 2025 07:12:13 -0800 Subject: [PATCH 03/16] cleanups and better styling --- src/components/AILibraryHero.tsx | 411 +++++++++++++++++-------------- 1 file changed, 226 insertions(+), 185 deletions(-) diff --git a/src/components/AILibraryHero.tsx b/src/components/AILibraryHero.tsx index 771b80f62..fcbd3bd2e 100644 --- a/src/components/AILibraryHero.tsx +++ b/src/components/AILibraryHero.tsx @@ -15,10 +15,8 @@ type AILibraryHeroProps = { } export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { - const [hoveredBox, setHoveredBox] = React.useState(null) const isDark = useIsDark() - const strokeColor = isDark ? '#ffffff' : '#000000' - const fillColor = isDark ? '#121212' : '#ffffff' + const strokeColor = isDark ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.6)' const textColor = isDark ? '#ffffff' : '#000000' return ( @@ -37,43 +35,129 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { className="w-full h-auto" viewBox="0 0 632 432" > + + {/* Glass effect filter with blur and opacity */} + + + + + + + {/* Subtle glow for lines */} + + + + + + + + + {/* Glass gradient */} + + + + + + {/* Glass gradient for larger boxes */} + + + + + + {/* Lines from frameworks to ai-client */} - - {/* Line from ai-client to @tanstack/ai */} - {/* Lines from @tanstack/ai to providers */} @@ -81,95 +165,87 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { d="M 60 370 Q 60 350 151.26 350 Q 242.52 350 242.5 320" fill="none" stroke={strokeColor} - strokeWidth="2" + strokeWidth="1.5" strokeMiterlimit="10" + filter="url(#lineGlow)" + opacity="0.7" /> {/* Top layer: Frameworks */} - setHoveredBox('vanilla')} - onMouseLeave={() => setHoveredBox(null)} - className="cursor-pointer transition-transform duration-300" + + - - - Vanilla - - + Vanilla + - setHoveredBox('react')} - onMouseLeave={() => setHoveredBox(null)} - className="cursor-pointer transition-transform duration-300" + + - - - React - - + React + setHoveredBox('solid')} - onMouseLeave={() => setHoveredBox(null)} - className="cursor-pointer transition-all" - style={{ - transform: hoveredBox === 'solid' ? 'scale(1.05)' : 'scale(1)', - transformOrigin: '380px 30px', - }} + strokeWidth="1.5" + filter="url(#glass)" + opacity="0.9" /> Solid @@ -205,17 +277,12 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { width="120" height="60" rx="9" - fill={fillColor} + fill="url(#glassGradient)" stroke={strokeColor} - strokeWidth="2" + strokeWidth="1.5" strokeDasharray="8 8" - onMouseEnter={() => setHoveredBox('future')} - onMouseLeave={() => setHoveredBox(null)} - className="cursor-pointer transition-all" - style={{ - transform: hoveredBox === 'future' ? 'scale(1.05)' : 'scale(1)', - transformOrigin: '540px 30px', - }} + filter="url(#glass)" + opacity="0.9" /> ? @@ -235,17 +303,11 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { width="210" height="60" rx="9" - fill={fillColor} + fill="url(#glassGradientLarge)" stroke={strokeColor} - strokeWidth="5" - onMouseEnter={() => setHoveredBox('ai-client')} - onMouseLeave={() => setHoveredBox(null)} - className="cursor-pointer transition-all" - style={{ - transform: - hoveredBox === 'ai-client' ? 'scale(1.05)' : 'scale(1)', - transformOrigin: '295px 130px', - }} + strokeWidth="3" + filter="url(#glass)" + opacity="0.9" /> @tanstack/ai-client @@ -266,9 +329,21 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { width="210" height="110" rx="16.5" - fill={fillColor} + fill="url(#glassGradientLarge)" stroke={strokeColor} - strokeWidth="5" + strokeWidth="3" + filter="url(#glass)" + opacity="0.85" + /> + + {/* Line from ai-client to @tanstack/ai - drawn after boxes to be on top */} + {/* @tanstack/ai label */} @@ -280,6 +355,7 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { fontSize="17" fontWeight="bold" textAnchor="middle" + opacity="0.95" > @tanstack/ai @@ -291,16 +367,11 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { width="120" height="40" rx="6" - fill={fillColor} + fill="url(#glassGradient)" stroke={strokeColor} - strokeWidth="2" - onMouseEnter={() => setHoveredBox('ollama')} - onMouseLeave={() => setHoveredBox(null)} - className="cursor-pointer transition-all" - style={{ - transform: hoveredBox === 'ollama' ? 'scale(1.05)' : 'scale(1)', - transformOrigin: '170px 280px', - }} + strokeWidth="1.5" + filter="url(#glass)" + opacity="0.9" /> Ollama @@ -319,16 +391,11 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { width="120" height="40" rx="6" - fill={fillColor} + fill="url(#glassGradient)" stroke={strokeColor} - strokeWidth="2" - onMouseEnter={() => setHoveredBox('openai')} - onMouseLeave={() => setHoveredBox(null)} - className="cursor-pointer transition-all" - style={{ - transform: hoveredBox === 'openai' ? 'scale(1.05)' : 'scale(1)', - transformOrigin: '300px 280px', - }} + strokeWidth="1.5" + filter="url(#glass)" + opacity="0.9" /> OpenAI @@ -347,17 +415,11 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { width="120" height="40" rx="6" - fill={fillColor} + fill="url(#glassGradient)" stroke={strokeColor} - strokeWidth="2" - onMouseEnter={() => setHoveredBox('anthropic')} - onMouseLeave={() => setHoveredBox(null)} - className="cursor-pointer transition-all" - style={{ - transform: - hoveredBox === 'anthropic' ? 'scale(1.05)' : 'scale(1)', - transformOrigin: '435px 280px', - }} + strokeWidth="1.5" + filter="url(#glass)" + opacity="0.9" /> Anthropic @@ -376,16 +439,11 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { width="120" height="40" rx="6" - fill={fillColor} + fill="url(#glassGradient)" stroke={strokeColor} - strokeWidth="2" - onMouseEnter={() => setHoveredBox('gemini')} - onMouseLeave={() => setHoveredBox(null)} - className="cursor-pointer transition-all" - style={{ - transform: hoveredBox === 'gemini' ? 'scale(1.05)' : 'scale(1)', - transformOrigin: '570px 280px', - }} + strokeWidth="1.5" + filter="url(#glass)" + opacity="0.9" /> Gemini @@ -405,17 +464,11 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { width="120" height="60" rx="9" - fill={fillColor} + fill="url(#glassGradient)" stroke={strokeColor} - strokeWidth="2" - onMouseEnter={() => setHoveredBox('typescript')} - onMouseLeave={() => setHoveredBox(null)} - className="cursor-pointer transition-all" - style={{ - transform: - hoveredBox === 'typescript' ? 'scale(1.05)' : 'scale(1)', - transformOrigin: '60px 400px', - }} + strokeWidth="1.5" + filter="url(#glass)" + opacity="0.9" /> TypeScript @@ -434,16 +488,11 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { width="120" height="60" rx="9" - fill={fillColor} + fill="url(#glassGradient)" stroke={strokeColor} - strokeWidth="2" - onMouseEnter={() => setHoveredBox('php')} - onMouseLeave={() => setHoveredBox(null)} - className="cursor-pointer transition-all" - style={{ - transform: hoveredBox === 'php' ? 'scale(1.05)' : 'scale(1)', - transformOrigin: '220px 400px', - }} + strokeWidth="1.5" + filter="url(#glass)" + opacity="0.9" /> PHP @@ -462,16 +512,11 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { width="120" height="60" rx="9" - fill={fillColor} + fill="url(#glassGradient)" stroke={strokeColor} - strokeWidth="2" - onMouseEnter={() => setHoveredBox('python')} - onMouseLeave={() => setHoveredBox(null)} - className="cursor-pointer transition-all" - style={{ - transform: hoveredBox === 'python' ? 'scale(1.05)' : 'scale(1)', - transformOrigin: '380px 400px', - }} + strokeWidth="1.5" + filter="url(#glass)" + opacity="0.9" /> Python @@ -490,18 +536,12 @@ export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { width="120" height="60" rx="9" - fill={fillColor} + fill="url(#glassGradient)" stroke={strokeColor} - strokeWidth="2" + strokeWidth="1.5" strokeDasharray="8 8" - onMouseEnter={() => setHoveredBox('future-server')} - onMouseLeave={() => setHoveredBox(null)} - className="cursor-pointer transition-all" - style={{ - transform: - hoveredBox === 'future-server' ? 'scale(1.05)' : 'scale(1)', - transformOrigin: '540px 400px', - }} + filter="url(#glass)" + opacity="0.9" /> ? From e0310cbf6fdaae115fa623d15bc92fa4a9f797c7 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Wed, 26 Nov 2025 07:33:42 -0800 Subject: [PATCH 04/16] animation, decent first take --- ...x-ae1830cf-b89c-4017-9a91-6d43735b0c3c.png | 0 src/components/AILibraryHero.tsx | 1578 +++++++++++------ src/components/ChatPanel.tsx | 58 + 3 files changed, 1088 insertions(+), 548 deletions(-) create mode 100644 assets/CleanShot_2025-11-26_at_07.10.01_2x-ae1830cf-b89c-4017-9a91-6d43735b0c3c.png create mode 100644 src/components/ChatPanel.tsx diff --git a/assets/CleanShot_2025-11-26_at_07.10.01_2x-ae1830cf-b89c-4017-9a91-6d43735b0c3c.png b/assets/CleanShot_2025-11-26_at_07.10.01_2x-ae1830cf-b89c-4017-9a91-6d43735b0c3c.png new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/AILibraryHero.tsx b/src/components/AILibraryHero.tsx index fcbd3bd2e..6d5bd2590 100644 --- a/src/components/AILibraryHero.tsx +++ b/src/components/AILibraryHero.tsx @@ -3,6 +3,7 @@ import { twMerge } from 'tailwind-merge' import { Link, LinkProps } from '@tanstack/react-router' import type { Library } from '~/libraries' import { useIsDark } from '~/hooks/useIsDark' +import { ChatPanel } from './ChatPanel' type AILibraryHeroProps = { project: Library @@ -14,573 +15,1054 @@ type AILibraryHeroProps = { actions?: React.ReactNode } +enum AnimationPhase { + STARTING = 'STARTING', + DESELECTING = 'DESELECTING', + SELECTING_FRAMEWORK = 'SELECTING_FRAMEWORK', + SELECTING_SERVICE = 'SELECTING_SERVICE', + SELECTING_SERVER = 'SELECTING_SERVER', + SHOWING_CHAT = 'SHOWING_CHAT', + PULSING_CONNECTIONS = 'PULSING_CONNECTIONS', + STREAMING_RESPONSE = 'STREAMING_RESPONSE', + HOLDING = 'HOLDING', +} + +const FRAMEWORKS = ['vanilla', 'react', 'solid', '?'] as const +const SERVICES = ['ollama', 'openai', 'anthropic', 'gemini'] as const +const SERVERS = ['typescript', 'php', 'python', '?'] as const + +const USER_MESSAGES = [ + 'Hello!', + 'How does this work?', + 'Show me an example', + 'What can you do?', + 'Tell me more', +] + +const ASSISTANT_MESSAGES = [ + 'I can help you integrate AI into your application using TanStack AI. The selected framework connects to the client, which communicates with the service, and finally reaches your server.', + 'TanStack AI provides a unified interface across multiple AI providers. You can switch between providers at runtime without code changes.', + 'The architecture is framework-agnostic, working with React, Solid, or vanilla JavaScript. Choose your preferred stack and start building!', + 'With TanStack AI, you get type-safe AI interactions, automatic tool execution, and zero vendor lock-in. Perfect for modern applications.', +] + export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) { const isDark = useIsDark() const strokeColor = isDark ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.6)' const textColor = isDark ? '#ffffff' : '#000000' + const [phase, setPhase] = React.useState( + AnimationPhase.STARTING + ) + const [selectedFramework, setSelectedFramework] = React.useState(1) // React + const [selectedService, setSelectedService] = React.useState(1) // OpenAI + const [selectedServer, setSelectedServer] = React.useState(0) // TypeScript + const [rotatingFramework, setRotatingFramework] = React.useState< + number | null + >(null) + const [rotatingServer, setRotatingServer] = React.useState( + null + ) + const [rotatingService, setRotatingService] = React.useState( + null + ) + const [serviceOffset, setServiceOffset] = React.useState(0) + const [userMessage, setUserMessage] = React.useState(null) + const [assistantMessage, setAssistantMessage] = React.useState( + null + ) + const [isStreaming, setIsStreaming] = React.useState(false) + const [connectionPulseDirection, setConnectionPulseDirection] = + React.useState<'down' | 'up'>('down') + + const timeoutRefs = React.useRef([]) + + React.useEffect(() => { + const clearTimeouts = () => { + timeoutRefs.current.forEach(clearTimeout) + timeoutRefs.current = [] + } + + const addTimeout = (fn: () => void, delay: number) => { + const timeout = setTimeout(fn, delay) + timeoutRefs.current.push(timeout) + return timeout + } + + const getRandomIndex = (length: number, exclude?: number) => { + let index + do { + index = Math.floor(Math.random() * length) + } while (exclude !== undefined && index === exclude) + return index + } + + const startAnimationSequence = () => { + // Phase 1: STARTING (initial state) + setPhase(AnimationPhase.STARTING) + addTimeout(() => { + // Phase 2: DESELECTING + setPhase(AnimationPhase.DESELECTING) + addTimeout(() => { + // Phase 3: SELECTING_FRAMEWORK + setPhase(AnimationPhase.SELECTING_FRAMEWORK) + const targetFramework = getRandomIndex(FRAMEWORKS.length) + let currentIndex = Math.floor(Math.random() * FRAMEWORKS.length) + const rotationCount = 8 + Math.floor(Math.random() * 4) // 8-11 rotations + + const rotateFramework = (iteration: number) => { + if (iteration < rotationCount - 1) { + setRotatingFramework(currentIndex) + currentIndex = (currentIndex + 1) % FRAMEWORKS.length + const delay = + iteration < rotationCount - 4 + ? 100 + : 150 + (iteration - (rotationCount - 4)) * 50 + addTimeout(() => rotateFramework(iteration + 1), delay) + } else { + // Final iteration - ensure we land on target + setRotatingFramework(targetFramework) + addTimeout(() => { + setSelectedFramework(targetFramework) + setRotatingFramework(null) + addTimeout(() => { + // Phase 4: SELECTING_SERVICE + setPhase(AnimationPhase.SELECTING_SERVICE) + const targetService = getRandomIndex(SERVICES.length) + let currentServiceIndex = Math.floor( + Math.random() * SERVICES.length + ) + const serviceRotationCount = 6 + Math.floor(Math.random() * 3) + + const rotateService = (iteration: number) => { + if (iteration < serviceRotationCount - 1) { + setRotatingService(currentServiceIndex) + currentServiceIndex = + (currentServiceIndex + 1) % SERVICES.length + const delay = + iteration < serviceRotationCount - 3 + ? 120 + : 180 + (iteration - (serviceRotationCount - 3)) * 60 + addTimeout(() => rotateService(iteration + 1), delay) + } else { + // Final iteration - ensure we land on target + setRotatingService(targetService) + addTimeout(() => { + setSelectedService(targetService) + setRotatingService(null) + // Calculate offset to center the selected service + const centerX = 300 // Center position in SVG + const servicePositions = [170, 300, 435, 570] // x positions of services + const targetX = servicePositions[targetService] + setServiceOffset(centerX - targetX) + + addTimeout(() => { + // Phase 5: SELECTING_SERVER + setPhase(AnimationPhase.SELECTING_SERVER) + const targetServer = getRandomIndex(SERVERS.length) + let currentServerIndex = Math.floor( + Math.random() * SERVERS.length + ) + const serverRotationCount = + 8 + Math.floor(Math.random() * 4) + + const rotateServer = (iteration: number) => { + if (iteration < serverRotationCount - 1) { + setRotatingServer(currentServerIndex) + currentServerIndex = + (currentServerIndex + 1) % SERVERS.length + const delay = + iteration < serverRotationCount - 4 + ? 100 + : 150 + + (iteration - (serverRotationCount - 4)) * 50 + addTimeout( + () => rotateServer(iteration + 1), + delay + ) + } else { + // Final iteration - ensure we land on target + setRotatingServer(targetServer) + addTimeout(() => { + setSelectedServer(targetServer) + setRotatingServer(null) + addTimeout(() => { + // Phase 6: SHOWING_CHAT + setPhase(AnimationPhase.SHOWING_CHAT) + const randomUserMessage = + USER_MESSAGES[ + Math.floor( + Math.random() * USER_MESSAGES.length + ) + ] + setUserMessage(randomUserMessage) + addTimeout(() => { + // Phase 7: PULSING_CONNECTIONS + setPhase(AnimationPhase.PULSING_CONNECTIONS) + setConnectionPulseDirection('down') + addTimeout(() => { + // Phase 8: STREAMING_RESPONSE + setPhase( + AnimationPhase.STREAMING_RESPONSE + ) + setConnectionPulseDirection('up') + const randomAssistantMessage = + ASSISTANT_MESSAGES[ + Math.floor( + Math.random() * + ASSISTANT_MESSAGES.length + ) + ] + setIsStreaming(true) + let charIndex = 0 + + const streamChar = () => { + if ( + charIndex < + randomAssistantMessage.length + ) { + setAssistantMessage( + randomAssistantMessage.slice( + 0, + charIndex + 1 + ) + ) + charIndex++ + addTimeout(streamChar, 30) + } else { + setIsStreaming(false) + addTimeout(() => { + // Phase 9: HOLDING + setPhase(AnimationPhase.HOLDING) + addTimeout(() => { + // Reset and loop + setUserMessage(null) + setAssistantMessage(null) + setServiceOffset(0) + setConnectionPulseDirection( + 'down' + ) + startAnimationSequence() + }, 5000) + }, 500) + } + } + streamChar() + }, 2000) + }, 500) + }, 800) + }, 1000) + } + } + rotateServer(0) + }, 1000) + }, 800) + } + } + rotateService(0) + }, 800) + }, 500) + } + } + rotateFramework(0) + }, 500) + }, 1000) + } + + startAnimationSequence() + + return () => { + clearTimeouts() + } + }, []) + + const getOpacity = ( + index: number, + selectedIndex: number, + rotatingIndex: number | null + ) => { + if (rotatingIndex !== null && rotatingIndex === index) { + return 1.0 + } + if (selectedIndex === index) { + return 1.0 + } + return phase === AnimationPhase.STARTING ? 0.9 : 0.3 + } + + const getServiceOpacity = (index: number) => { + if (rotatingService !== null && rotatingService === index) { + return 1.0 + } + if (selectedService === index) { + return 1.0 + } + return phase === AnimationPhase.STARTING ? 0.9 : 0.3 + } + + const getConnectionOpacity = ( + frameworkIndex: number, + serverIndex: number + ) => { + const isFrameworkSelected = selectedFramework === frameworkIndex + const isServerSelected = selectedServer === serverIndex + const isHighlighting = + phase === AnimationPhase.SHOWING_CHAT || + phase === AnimationPhase.PULSING_CONNECTIONS || + phase === AnimationPhase.STREAMING_RESPONSE + + if (isHighlighting && isFrameworkSelected && isServerSelected) { + return 1.0 + } + if (isFrameworkSelected || isServerSelected) { + return 0.85 + } + return 0.7 + } + + const getConnectionPulse = () => { + if ( + phase === AnimationPhase.PULSING_CONNECTIONS || + phase === AnimationPhase.STREAMING_RESPONSE + ) { + return connectionPulseDirection === 'down' ? 'down' : 'up' + } + return null + } + return ( -
- {/* Background dimmed text */} -
-

- TANSTACK -

-
+ <> +