mirror of
https://github.com/vercel/chat.git
synced 2026-09-14 18:32:29 +08:00
3ae34b3ed1
Third repo in the design sync, after `vercel/geistdocs#216`/`#218` and `vercel/flags#457`. Upgrades the docs site to `@vercel/geistdocs@1.19.2` and brings the homepage onto the Geist design system. `apps/docs` is `private: true`, so no changeset. ## Dependency `1.19.2` peers on `next: ^16.2.11` and the app pinned `16.2.6`, so **next moves to `16.2.12`** alongside it — without that pnpm reports an unmet peer. Installed via `pnpm add --save-exact` per AGENTS.md. All 20 geistdocs subpaths this app imports still exist in 1.19.2; no API breakage. The footer needed no work: 1.16 already shipped the prop-less Vercel-directory `<Footer />`. ## Layout — `home-grid.css` is gone Deleted `app/styles/home-grid.css` (368 lines) and its `global.css` import, and rebuilt each section on `grid-cols-12` / `col-span-*`: | section | before (CSS) | after | |---|---|---| | OSS stats | 2×2 → 4×1 @768 | `col-span-6 min-[768px]:col-span-3` | | Features | 2-up + full-width 3rd → 3×1 @961 | `col-span-12 sm:col-span-6 lg:col-span-4` | | Code | stacked → sidebar 1/3 + code 2/3 @961 | `lg:col-span-8` / `lg:col-span-4`, pinned with `col-start` + `row-start` | | Integrations | 1×5 → tall left + 2×2 @961 | `lg:col-span-4 lg:row-span-2` + four `lg:col-span-4` | The code section needs explicit `col-start`/`row-start` because the sidebar follows the code in the DOM but sits left of it from `lg`. Other layout changes: - **Single gutter at the page root** (`mx-auto w-full max-w-[1448px] px-4 sm:px-6`); removed the per-section horizontal padding that duplicated it, so every section's content lands on the navbar/footer content edge. - **Content widened 1114px → 1400px**, the navbar's content span (1448 − 2×24). - **Bottom gap above the footer trimmed ~320px → ~176px** — layout `pb-32` → `pb-16` and page `pb-24 sm:pb-36` → `pb-12 sm:pb-16`. Three paddings were stacking. The 768px stats breakpoint is preserved with `min-[768px]:` — there's no Tailwind equivalent here (`md`=601, `lg`=961) and four KPI columns at 601px would be ~140px each. ## Design — ported from vercel.com/ai-sdk Read off the flagged source in `front/apps/vercel-marketing/.../ai-sdk`, not the live site. - **Code showcase tabs** → the `SlidingTabs` primitive: pill labels with an animated indicator, full keyboard nav (arrows/Home/End, roving tabindex), and an invisible-bold label so the tab doesn't shift width when it bolds. Four tabs per group with dot pagination for the rest, tabs above the code block. Copied into `components/ui/sliding-tabs.tsx` with `cn` rewired and the `no-scrollbar` utility inlined (geistdocs doesn't define it). - **"Scale with confidence"** → heading and paragraph on one bottom-aligned row (cols 1–4 / 8–12), then four bordered cards `col-span-12 md:col-span-6 lg:col-span-3`. Type mapped from their primitives: `SectionHeading size="48"` → `text-heading-40 lg:text-heading-48`, `SectionParagraph size="18"` → `text-copy-16 lg:text-copy-18`. - **Feature row** → icon + muted eyebrow over a prominent statement. Note this **inverts the previous emphasis**: the heading is now the small muted label and the description the larger line, matching the reference. Icons come from geistdocs' own set so they match Geist's line weight: `IconLinked`, `IconWorkflow`, `IconAcronymTs`. - **Get-started install snippet** → the shared `CommandPrompt`, with its buttons on one row from `lg`. - Remaining headings converted to `text-heading-*`. - Navbar logo drops `height={22}` to take `LogoChatSdk`'s new 18px default (renders 106.9×22 → 87.4×18). ## Two fixes worth calling out **`lib/utils.ts` — `cn` was silently dropping typography.** Geist's `text-copy-*`/`text-heading-*` share the `text-` prefix with colour utilities, so stock `tailwind-merge` classifies them as colours and drops the size whenever both appear in one `cn()` call. geistdocs ships a `cn` that registers them as `font-size` for exactly this reason but doesn't export it, so the config is replicated here. This was a latent bug across the app, not just the new code. **`Analytics`/`SpeedInsights` moved out of the `"use client"` provider** into the server layout. Both emit `<script>`, and scripts rendered inside a client tree never execute — so analytics wasn't firing on client navigations. React 19.2.7 (pulled in by this bump) now warns about it; the bug predates it. ## Verification - `pnpm --filter docs build` passes (270 pages), `tsc --noEmit` clean, `biome check` clean. - Rendered output spot-checked for the tab strip, dot pagination, card classes, and feature icons. **`pnpm validate` could not be run** — it needs Node ≥20.19 and this machine is on v20.11.1 (`pnpm check` dies on `styleText` from `node:util`). Biome, tsc and build were run directly instead, but the knip and test legs are unrun and should be confirmed in CI. Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com>
463 lines
11 KiB
TypeScript
463 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useId,
|
|
useRef,
|
|
useState,
|
|
type ButtonHTMLAttributes,
|
|
type CSSProperties,
|
|
type HTMLAttributes,
|
|
type JSX,
|
|
type KeyboardEvent,
|
|
type MutableRefObject,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
export type SlidingTabsOrientation = "horizontal" | "vertical";
|
|
|
|
interface SlidingTabsContextValue {
|
|
baseId: string;
|
|
onValueChange: (value: string) => void;
|
|
orientation: SlidingTabsOrientation;
|
|
value: string;
|
|
}
|
|
|
|
interface SlidingTabsListContextValue {
|
|
listRef: MutableRefObject<HTMLDivElement | null>;
|
|
}
|
|
|
|
const SlidingTabsContext = createContext<SlidingTabsContextValue | null>(null);
|
|
const SlidingTabsListContext =
|
|
createContext<SlidingTabsListContextValue | null>(null);
|
|
|
|
export interface SlidingTabsLabelProps extends HTMLAttributes<HTMLSpanElement> {
|
|
active?: boolean;
|
|
activeBackground?: boolean;
|
|
children: ReactNode;
|
|
}
|
|
|
|
function SlidingTabsLabel({
|
|
active,
|
|
activeBackground,
|
|
children,
|
|
className,
|
|
...props
|
|
}: SlidingTabsLabelProps): JSX.Element {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"relative inline-flex items-center justify-center rounded-[6px] px-3.5 py-1.5 text-center text-copy-14 transition-colors hover:text-gray-1000",
|
|
active ? "text-gray-1000" : "text-gray-900",
|
|
active && activeBackground ? "bg-gray-200" : null,
|
|
className,
|
|
)}
|
|
{...props}
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className="invisible flex items-center justify-center gap-1 font-medium"
|
|
>
|
|
{children}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"pointer-events-none absolute inset-0 flex items-center justify-center gap-1",
|
|
{
|
|
"font-medium": active,
|
|
},
|
|
)}
|
|
>
|
|
{children}
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function useSlidingTabsContext(componentName: string): SlidingTabsContextValue {
|
|
const context = useContext(SlidingTabsContext);
|
|
if (!context) {
|
|
throw new Error(`${componentName} must be used within SlidingTabs.Root.`);
|
|
}
|
|
return context;
|
|
}
|
|
|
|
function useSlidingTabsListContext(
|
|
componentName: string,
|
|
): SlidingTabsListContextValue {
|
|
const context = useContext(SlidingTabsListContext);
|
|
if (!context) {
|
|
throw new Error(`${componentName} must be used within SlidingTabs.List.`);
|
|
}
|
|
return context;
|
|
}
|
|
|
|
export interface SlidingTabsRootProps<
|
|
TValue extends string = string,
|
|
> extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
onValueChange: (value: TValue) => void;
|
|
orientation?: SlidingTabsOrientation;
|
|
value: TValue;
|
|
}
|
|
|
|
function SlidingTabsRoot<TValue extends string = string>({
|
|
children,
|
|
onValueChange,
|
|
orientation = "horizontal",
|
|
value,
|
|
...props
|
|
}: SlidingTabsRootProps<TValue>): JSX.Element {
|
|
const baseId = useId();
|
|
|
|
return (
|
|
<SlidingTabsContext.Provider
|
|
value={{
|
|
baseId,
|
|
onValueChange: (nextValue) => onValueChange(nextValue as TValue),
|
|
orientation,
|
|
value,
|
|
}}
|
|
>
|
|
<div data-orientation={orientation} {...props}>
|
|
{children}
|
|
</div>
|
|
</SlidingTabsContext.Provider>
|
|
);
|
|
}
|
|
|
|
export interface SlidingTabsListProps extends HTMLAttributes<HTMLDivElement> {
|
|
activateOnFocus?: boolean;
|
|
loopFocus?: boolean;
|
|
}
|
|
|
|
function getEnabledTabs(list: HTMLDivElement | null): HTMLButtonElement[] {
|
|
if (!list) return [];
|
|
return Array.from(
|
|
list.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)'),
|
|
);
|
|
}
|
|
|
|
function getNextIndex({
|
|
currentIndex,
|
|
direction,
|
|
loopFocus,
|
|
tabCount,
|
|
}: {
|
|
currentIndex: number;
|
|
direction: -1 | 1;
|
|
loopFocus: boolean;
|
|
tabCount: number;
|
|
}): number {
|
|
const nextIndex = currentIndex + direction;
|
|
|
|
if (loopFocus) {
|
|
return (nextIndex + tabCount) % tabCount;
|
|
}
|
|
|
|
return Math.min(Math.max(nextIndex, 0), tabCount - 1);
|
|
}
|
|
|
|
function SlidingTabsList({
|
|
activateOnFocus = true,
|
|
children,
|
|
className,
|
|
loopFocus = true,
|
|
onKeyDown,
|
|
...props
|
|
}: SlidingTabsListProps): JSX.Element {
|
|
const { onValueChange, orientation } =
|
|
useSlidingTabsContext("SlidingTabs.List");
|
|
const listRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
function selectTab(tab: HTMLButtonElement): void {
|
|
const value = tab.dataset.value;
|
|
if (!value) return;
|
|
|
|
tab.focus();
|
|
if (activateOnFocus) {
|
|
onValueChange(value);
|
|
}
|
|
}
|
|
|
|
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
|
|
onKeyDown?.(event);
|
|
if (event.defaultPrevented) return;
|
|
|
|
const tabs = getEnabledTabs(listRef.current);
|
|
if (tabs.length === 0) return;
|
|
|
|
const currentIndex = Math.max(
|
|
0,
|
|
tabs.indexOf(document.activeElement as HTMLButtonElement),
|
|
);
|
|
|
|
if (event.key === "Home") {
|
|
event.preventDefault();
|
|
selectTab(tabs[0]);
|
|
return;
|
|
}
|
|
|
|
if (event.key === "End") {
|
|
event.preventDefault();
|
|
selectTab(tabs[tabs.length - 1]);
|
|
return;
|
|
}
|
|
|
|
const previousKeys =
|
|
orientation === "horizontal"
|
|
? ["ArrowLeft", "ArrowUp"]
|
|
: ["ArrowUp", "ArrowLeft"];
|
|
const nextKeys =
|
|
orientation === "horizontal"
|
|
? ["ArrowRight", "ArrowDown"]
|
|
: ["ArrowDown", "ArrowRight"];
|
|
|
|
if (previousKeys.includes(event.key)) {
|
|
event.preventDefault();
|
|
selectTab(
|
|
tabs[
|
|
getNextIndex({
|
|
currentIndex,
|
|
direction: -1,
|
|
loopFocus,
|
|
tabCount: tabs.length,
|
|
})
|
|
],
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (nextKeys.includes(event.key)) {
|
|
event.preventDefault();
|
|
selectTab(
|
|
tabs[
|
|
getNextIndex({
|
|
currentIndex,
|
|
direction: 1,
|
|
loopFocus,
|
|
tabCount: tabs.length,
|
|
})
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<SlidingTabsListContext.Provider value={{ listRef }}>
|
|
<div
|
|
aria-orientation={orientation}
|
|
className={cn(
|
|
"relative inline-flex gap-1 p-1",
|
|
orientation === "horizontal"
|
|
? "w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] @lg:w-fit @lg:max-w-none [&::-webkit-scrollbar]:hidden"
|
|
: "w-full flex-col",
|
|
className,
|
|
)}
|
|
data-orientation={orientation}
|
|
onKeyDown={handleKeyDown}
|
|
ref={listRef}
|
|
role="tablist"
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
</SlidingTabsListContext.Provider>
|
|
);
|
|
}
|
|
|
|
export interface SlidingTabsTabProps<
|
|
TValue extends string = string,
|
|
> extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value"> {
|
|
panelId?: string;
|
|
value: TValue;
|
|
}
|
|
|
|
function SlidingTabsTab<TValue extends string = string>({
|
|
children,
|
|
className,
|
|
disabled,
|
|
id,
|
|
onClick,
|
|
panelId,
|
|
type = "button",
|
|
value,
|
|
...props
|
|
}: SlidingTabsTabProps<TValue>): JSX.Element {
|
|
const {
|
|
baseId,
|
|
onValueChange,
|
|
orientation,
|
|
value: activeValue,
|
|
} = useSlidingTabsContext("SlidingTabs.Tab");
|
|
const tabId = id ?? `${baseId}-${value}`;
|
|
const isActive = activeValue === value;
|
|
|
|
return (
|
|
<button
|
|
aria-controls={panelId}
|
|
aria-selected={isActive}
|
|
className={cn(
|
|
"relative z-10 rounded-[6px] focus-visible:outline-none focus-visible:shadow-[var(--ds-focus-ring)]",
|
|
orientation === "horizontal"
|
|
? "min-w-max flex-none whitespace-nowrap"
|
|
: "w-full",
|
|
className,
|
|
)}
|
|
data-active={isActive ? "" : undefined}
|
|
data-disabled={disabled ? "" : undefined}
|
|
data-orientation={orientation}
|
|
data-value={value}
|
|
disabled={disabled}
|
|
id={tabId}
|
|
onClick={(event) => {
|
|
onClick?.(event);
|
|
if (!event.defaultPrevented && !disabled) {
|
|
onValueChange(value);
|
|
}
|
|
}}
|
|
role="tab"
|
|
tabIndex={isActive ? 0 : -1}
|
|
type={type}
|
|
{...props}
|
|
>
|
|
<SlidingTabsLabel
|
|
active={isActive}
|
|
className={cn(orientation === "vertical" ? "w-full" : null)}
|
|
>
|
|
{children}
|
|
</SlidingTabsLabel>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export type SlidingTabsIndicatorProps = HTMLAttributes<HTMLSpanElement>;
|
|
|
|
function SlidingTabsIndicator({
|
|
className,
|
|
style,
|
|
...props
|
|
}: SlidingTabsIndicatorProps): JSX.Element | null {
|
|
const { orientation, value } = useSlidingTabsContext("SlidingTabs.Indicator");
|
|
const { listRef } = useSlidingTabsListContext("SlidingTabs.Indicator");
|
|
const [indicator, setIndicator] = useState<{
|
|
height: number;
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
} | null>(null);
|
|
|
|
const measure = useCallback(() => {
|
|
const activeTab = listRef.current?.querySelector<HTMLElement>(
|
|
'[role="tab"][data-active]',
|
|
);
|
|
if (!activeTab) return;
|
|
|
|
setIndicator({
|
|
height: activeTab.offsetHeight,
|
|
left: activeTab.offsetLeft,
|
|
top: activeTab.offsetTop,
|
|
width: activeTab.offsetWidth,
|
|
});
|
|
}, [listRef]);
|
|
|
|
useEffect(() => {
|
|
measure();
|
|
|
|
const tabList = listRef.current;
|
|
const activeTab = tabList?.querySelector<HTMLElement>(
|
|
'[role="tab"][data-active]',
|
|
);
|
|
const observer =
|
|
typeof ResizeObserver !== "undefined"
|
|
? new ResizeObserver(measure)
|
|
: null;
|
|
|
|
if (tabList) observer?.observe(tabList);
|
|
if (activeTab) observer?.observe(activeTab);
|
|
window.addEventListener("resize", measure);
|
|
|
|
return () => {
|
|
observer?.disconnect();
|
|
window.removeEventListener("resize", measure);
|
|
};
|
|
}, [listRef, measure, value]);
|
|
|
|
if (!indicator) return null;
|
|
|
|
const cssVariables = {
|
|
"--active-tab-height": `${indicator.height}px`,
|
|
"--active-tab-left": `${indicator.left}px`,
|
|
"--active-tab-top": `${indicator.top}px`,
|
|
"--active-tab-width": `${indicator.width}px`,
|
|
} as CSSProperties;
|
|
|
|
return (
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"pointer-events-none absolute z-0 rounded-[6px] bg-gray-200 transition-[left,width,top,height] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none",
|
|
orientation === "horizontal" ? "top-1 bottom-1" : "left-1 right-1",
|
|
className,
|
|
)}
|
|
data-orientation={orientation}
|
|
style={{
|
|
...cssVariables,
|
|
...(orientation === "horizontal"
|
|
? { left: indicator.left, width: indicator.width }
|
|
: { height: indicator.height, top: indicator.top }),
|
|
...style,
|
|
}}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export interface SlidingTabsPanelProps<
|
|
TValue extends string = string,
|
|
> extends HTMLAttributes<HTMLDivElement> {
|
|
keepMounted?: boolean;
|
|
value: TValue;
|
|
}
|
|
|
|
function SlidingTabsPanel<TValue extends string = string>({
|
|
children,
|
|
keepMounted = false,
|
|
value,
|
|
...props
|
|
}: SlidingTabsPanelProps<TValue>): JSX.Element | null {
|
|
const {
|
|
baseId,
|
|
orientation,
|
|
value: activeValue,
|
|
} = useSlidingTabsContext("SlidingTabs.Panel");
|
|
const isActive = activeValue === value;
|
|
|
|
if (!keepMounted && !isActive) return null;
|
|
|
|
return (
|
|
<div
|
|
aria-labelledby={`${baseId}-${value}`}
|
|
data-hidden={isActive ? undefined : ""}
|
|
data-orientation={orientation}
|
|
hidden={!isActive}
|
|
role="tabpanel"
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const SlidingTabs = {
|
|
Indicator: SlidingTabsIndicator,
|
|
List: SlidingTabsList,
|
|
Panel: SlidingTabsPanel,
|
|
Root: SlidingTabsRoot,
|
|
Tab: SlidingTabsTab,
|
|
Label: SlidingTabsLabel,
|
|
};
|