improvement(docs): align docs UI with the platform emcn design system (#4962)

* improvement(docs): align docs UI with the platform emcn design system

* improvement(docs): build language dropdown options in render scope
This commit is contained in:
Waleed
2026-06-10 19:40:31 -07:00
committed by GitHub
parent e54daf798d
commit aa39974d7b
23 changed files with 706 additions and 648 deletions
@@ -33,19 +33,17 @@ const SOCIAL_LINKS = [
export function PageFooter({ previous, next }: PageFooterProps) {
return (
<div className='mt-12'>
<div className='h-px w-full bg-[rgba(0,0,0,0.08)] dark:bg-[rgba(255,255,255,0.08)]' />
<div className='h-px w-full bg-[var(--border)]' />
{(previous || next) && (
<div className='flex'>
<div className='flex gap-2 py-3'>
{previous ? (
<Link
href={previous.url}
className='group flex flex-1 flex-col gap-1 px-6 py-5 transition-colors hover:bg-[rgba(0,0,0,0.02)] dark:hover:bg-[rgba(255,255,255,0.03)]'
className='group flex flex-1 flex-col gap-1 rounded-lg px-3 py-3 transition-colors hover:bg-[var(--surface-3)]'
>
<span className='text-[rgba(0,0,0,0.4)] text-xs dark:text-[rgba(255,255,255,0.35)]'>
Previous
</span>
<span className='flex items-center gap-1.5 font-[470] text-[rgba(0,0,0,0.7)] text-sm transition-colors group-hover:text-[rgba(0,0,0,0.88)] dark:text-[rgba(255,255,255,0.7)] dark:group-hover:text-[rgba(255,255,255,0.92)]'>
<span className='text-[var(--text-muted)] text-xs'>Previous</span>
<span className='flex items-center gap-1.5 text-[var(--text-body)] text-sm transition-colors group-hover:text-[var(--text-primary)]'>
<ChevronLeft className='size-3.5 shrink-0' />
{previous.name}
</span>
@@ -54,19 +52,13 @@ export function PageFooter({ previous, next }: PageFooterProps) {
<div className='flex-1' />
)}
{previous && next && (
<div className='w-px bg-[rgba(0,0,0,0.08)] dark:bg-[rgba(255,255,255,0.08)]' />
)}
{next ? (
<Link
href={next.url}
className='group flex flex-1 flex-col items-end gap-1 px-6 py-5 transition-colors hover:bg-[rgba(0,0,0,0.02)] dark:hover:bg-[rgba(255,255,255,0.03)]'
className='group flex flex-1 flex-col items-end gap-1 rounded-lg px-3 py-3 transition-colors hover:bg-[var(--surface-3)]'
>
<span className='text-[rgba(0,0,0,0.4)] text-xs dark:text-[rgba(255,255,255,0.35)]'>
Next
</span>
<span className='flex items-center gap-1.5 font-[470] text-[rgba(0,0,0,0.7)] text-sm transition-colors group-hover:text-[rgba(0,0,0,0.88)] dark:text-[rgba(255,255,255,0.7)] dark:group-hover:text-[rgba(255,255,255,0.92)]'>
<span className='text-[var(--text-muted)] text-xs'>Next</span>
<span className='flex items-center gap-1.5 text-[var(--text-body)] text-sm transition-colors group-hover:text-[var(--text-primary)]'>
{next.name}
<ChevronRight className='size-3.5 shrink-0' />
</span>
@@ -77,9 +69,7 @@ export function PageFooter({ previous, next }: PageFooterProps) {
</div>
)}
<div className='h-px w-full bg-[rgba(0,0,0,0.08)] dark:bg-[rgba(255,255,255,0.08)]' />
<div className='flex items-center gap-4 pt-10 pb-6'>
<div className='flex items-center gap-4 pt-4 pb-6'>
{SOCIAL_LINKS.map((link) => (
<Link
key={link.label}
@@ -90,7 +80,7 @@ export function PageFooter({ previous, next }: PageFooterProps) {
>
<svg
viewBox='0 0 24 24'
className='size-5 fill-neutral-400 transition-colors hover:fill-neutral-500 dark:fill-neutral-500 dark:hover:fill-neutral-400'
className='size-[16px] fill-[var(--text-muted)] transition-colors hover:fill-[var(--text-icon)]'
>
<path d={link.icon} />
</svg>
@@ -12,6 +12,9 @@ interface PageNavigationArrowsProps {
}
}
const ARROW_LINK_CLASS =
'flex size-[30px] items-center justify-center rounded-lg text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)]'
export function PageNavigationArrows({ previous, next }: PageNavigationArrowsProps) {
if (!previous && !next) return null
@@ -20,21 +23,16 @@ export function PageNavigationArrows({ previous, next }: PageNavigationArrowsPro
{previous && (
<Link
href={previous.url}
className='inline-flex items-center justify-center rounded-[5px] border border-transparent px-2.5 py-2 text-foreground/40 transition-colors duration-200 hover:border-border/40 hover:bg-neutral-100 hover:text-foreground/70 dark:hover:bg-neutral-800'
className={ARROW_LINK_CLASS}
aria-label='Previous page'
title='Previous page'
>
<ChevronLeft className='size-4' />
<ChevronLeft className='size-[16px]' />
</Link>
)}
{next && (
<Link
href={next.url}
className='inline-flex items-center justify-center rounded-[5px] border border-transparent px-2.5 py-2 text-foreground/40 transition-colors duration-200 hover:border-border/40 hover:bg-neutral-100 hover:text-foreground/70 dark:hover:bg-neutral-800'
aria-label='Next page'
title='Next page'
>
<ChevronRight className='size-4' />
<Link href={next.url} className={ARROW_LINK_CLASS} aria-label='Next page' title='Next page'>
<ChevronRight className='size-[16px]' />
</Link>
)}
</div>
@@ -51,20 +51,18 @@ function isActive(url: string, pathname: string, nested = true): boolean {
}
const ITEM_BASE =
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors text-fd-muted-foreground hover:bg-fd-accent/50 hover:text-fd-accent-foreground'
const ITEM_ACTIVE_MOBILE = 'bg-fd-primary/10 font-medium text-fd-primary'
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[var(--text-muted)] text-sm transition-colors hover:bg-[var(--surface-active)] hover:text-[var(--text-body)]'
const ITEM_ACTIVE_MOBILE = 'bg-[var(--surface-active)] font-medium text-[var(--text-primary)]'
const ITEM_DESKTOP =
'lg:mb-[0.0625rem] lg:block lg:rounded-lg lg:px-2.5 lg:py-1.5 lg:font-normal lg:text-[13px] lg:leading-tight'
const ITEM_TEXT = 'lg:text-[#3b3b3b] lg:dark:text-[#cdcdcd]'
const ITEM_HOVER = 'lg:hover:bg-[#f2f2f2] lg:dark:hover:bg-[#262626]'
const ITEM_ACTIVE =
'lg:bg-[#ececec] lg:font-normal lg:text-[#3b3b3b] lg:dark:bg-[#2c2c2c] lg:dark:text-[#cdcdcd]'
const ITEM_TEXT = 'lg:text-[var(--text-body)]'
const ITEM_HOVER = 'lg:hover:bg-[var(--surface-3)]'
const ITEM_ACTIVE = 'lg:bg-[var(--surface-active)] lg:font-normal lg:text-[var(--text-body)]'
const FOLDER_TEXT = 'lg:text-[#3b3b3b] lg:font-medium lg:dark:text-[#cdcdcd]'
const FOLDER_HOVER = 'lg:hover:bg-[#f2f2f2] lg:dark:hover:bg-[#262626]'
const FOLDER_ACTIVE =
'lg:bg-[#ececec] lg:text-[#3b3b3b] lg:dark:bg-[#2c2c2c] lg:dark:text-[#cdcdcd]'
const FOLDER_TEXT = 'lg:text-[var(--text-body)] lg:font-medium'
const FOLDER_HOVER = 'lg:hover:bg-[var(--surface-3)]'
const FOLDER_ACTIVE = 'lg:bg-[var(--surface-active)] lg:text-[var(--text-body)]'
export function SidebarItem({ item }: { item: Item }) {
const pathname = usePathname()
@@ -138,7 +136,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
data-active={active}
className={cn(
'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
'text-fd-muted-foreground hover:bg-fd-accent/50 hover:text-fd-accent-foreground',
'text-[var(--text-muted)] hover:bg-[var(--surface-active)] hover:text-[var(--text-body)]',
active && ITEM_ACTIVE_MOBILE,
'lg:block lg:flex-1 lg:rounded-lg lg:px-2.5 lg:py-1.5 lg:text-[13px] lg:leading-tight',
FOLDER_TEXT,
@@ -152,12 +150,12 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
<button
onClick={toggleOpen}
className={cn(
'rounded p-1 hover:bg-fd-accent/50',
'lg:cursor-pointer lg:rounded lg:p-1 lg:transition-colors lg:hover:bg-[#f2f2f2] lg:dark:hover:bg-[#262626]'
'rounded-md p-1 hover:bg-[var(--surface-active)]',
'lg:cursor-pointer lg:rounded-md lg:p-1 lg:transition-colors lg:hover:bg-[var(--surface-3)]'
)}
aria-label={open ? 'Collapse' : 'Expand'}
>
<SidebarChevron open={open} className='text-[#5e5e5e] dark:text-[#939393]' />
<SidebarChevron open={open} className='text-[var(--text-icon)]' />
</button>
)}
</>
@@ -166,14 +164,14 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
onClick={toggleOpen}
className={cn(
'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
'text-fd-muted-foreground hover:bg-fd-accent/50',
'text-[var(--text-muted)] hover:bg-[var(--surface-active)]',
'lg:flex lg:w-full lg:cursor-pointer lg:items-center lg:justify-between lg:rounded-lg lg:px-2.5 lg:py-1.5 lg:text-left lg:text-[13px] lg:leading-tight',
FOLDER_TEXT,
FOLDER_HOVER
)}
>
<span>{item.name}</span>
<SidebarChevron open={open} className='ml-auto text-[#5e5e5e] dark:text-[#939393]' />
<SidebarChevron open={open} className='ml-auto text-[var(--text-icon)]' />
</button>
)}
</div>
@@ -186,7 +184,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
>
<div className='overflow-hidden'>
<div className='ml-4 flex flex-col gap-0.5 lg:hidden'>{children}</div>
<ul className='mt-0.5 ml-2 hidden space-y-[0.0625rem] border-[#ececec] border-l pl-2.5 lg:block dark:border-[#2c2c2c]'>
<ul className='mt-0.5 ml-2 hidden space-y-[0.0625rem] border-[var(--surface-active)] border-l pl-2.5 lg:block'>
{children}
</ul>
</div>
@@ -204,13 +202,13 @@ export function SidebarSeparator({ item }: { item: Separator }) {
>
<div className='separator-divider hidden'>
<div className='h-[20px]' />
<div className='h-px bg-[#ececec] dark:bg-[#2c2c2c]' />
<div className='h-px bg-[var(--surface-active)]' />
<div className='h-[20px]' />
</div>
<p
className={cn(
'font-medium text-fd-muted-foreground text-xs',
'lg:font-semibold lg:text-[#5e5e5e] lg:text-[10px] lg:uppercase lg:tracking-[0.06em] lg:dark:text-[#939393]'
'font-medium text-[var(--text-muted)] text-xs',
'lg:font-semibold lg:text-[10px] lg:text-[var(--text-muted)] lg:uppercase lg:tracking-[0.06em]'
)}
>
{item.name}
+7 -9
View File
@@ -2,6 +2,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { ChipLink } from '@/components/ui/chip'
import { LanguageDropdown } from '@/components/ui/language-dropdown'
import { SearchTrigger } from '@/components/ui/search-trigger'
import { SimLogoFull } from '@/components/ui/sim-logo'
@@ -27,7 +28,7 @@ export function Navbar() {
const pathname = usePathname()
return (
<nav className='sticky top-0 z-50 bg-background/80 backdrop-blur-md backdrop-saturate-150'>
<nav className='sticky top-0 z-50 bg-[var(--bg)]/80 backdrop-blur-md backdrop-saturate-150'>
<div className='hidden w-full flex-col lg:flex'>
{/* Top row: logo, search, controls */}
<div
@@ -48,18 +49,15 @@ export function Navbar() {
<div className='flex items-center gap-1'>
<LanguageDropdown />
<ThemeToggle />
<Link
href='https://sim.ai'
className='ml-1 flex items-center rounded-[8px] bg-[#33c482] px-2.5 py-1.5 text-[13px] text-white transition-colors duration-200 hover:bg-[#2DAC72]'
>
<ChipLink href='https://sim.ai' variant='brand' className='ml-1'>
Get started
</Link>
</ChipLink>
</div>
</div>
{/* Bottom row: navigation tabs — border on row, tabs overlap it */}
<div
className='flex h-[40px] items-stretch gap-6 border-border/20 border-b'
className='flex h-[40px] items-stretch gap-6 border-[var(--border)]/20 border-b'
style={{
paddingLeft: 'calc(var(--sidebar-offset) + var(--nav-inset))',
}}
@@ -74,8 +72,8 @@ export function Navbar() {
className={cn(
'-mb-px relative flex items-center border-b text-[14px] tracking-[-0.01em] transition-colors',
isActive
? 'border-neutral-400 font-[480] text-neutral-800 dark:border-neutral-500 dark:text-neutral-200'
: 'border-transparent font-[430] text-neutral-500 hover:border-neutral-300 hover:text-neutral-600 dark:text-neutral-400 dark:hover:border-neutral-600 dark:hover:text-neutral-300'
? 'border-[var(--text-muted)] font-[480] text-[var(--text-primary)]'
: 'border-transparent font-[430] text-[var(--text-muted)] hover:border-[var(--border-1)] hover:text-[var(--text-secondary)]'
)}
>
{/* Invisible bold text reserves width to prevent layout shift */}
+5 -14
View File
@@ -2,27 +2,18 @@
import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button'
import { Check, Copy } from 'lucide-react'
import { Chip } from '@/components/ui/chip'
export function LLMCopyButton({ content }: { content: string }) {
const [checked, onClick] = useCopyButton(() => navigator.clipboard.writeText(content))
return (
<button
<Chip
onClick={onClick}
className='flex cursor-pointer items-center gap-1.5 rounded-[5px] border border-transparent px-2.5 py-2 text-[13px] text-foreground/40 leading-none transition-colors duration-200 hover:border-border/40 hover:bg-neutral-100 hover:text-foreground/70 dark:hover:bg-neutral-800'
leftIcon={checked ? Check : Copy}
aria-label={checked ? 'Copied to clipboard' : 'Copy page content'}
>
{checked ? (
<>
<Check className='size-3.5' />
<span>Copied</span>
</>
) : (
<>
<Copy className='size-3.5' />
<span>Copy page</span>
</>
)}
</button>
{checked ? 'Copied' : 'Copy page'}
</Chip>
)
}
+14 -5
View File
@@ -1,19 +1,27 @@
import type { DocsPageType } from '@/lib/source'
import { cn } from '@/lib/utils'
/**
* Status-color mapping mirrored from the emcn `Badge` variants
* (`apps/sim/components/emcn/components/badge/badge.tsx`) — `green`, `blue`,
* `purple`, and `amber` over the shared `--badge-*` tokens.
*/
const CONFIG = {
tutorial: { label: 'Tutorial', className: 'text-[#33c482] border-[#33c482]/30 bg-[#33c482]/10' },
tutorial: {
label: 'Tutorial',
className: 'bg-[var(--badge-success-bg)] text-[var(--badge-success-text)]',
},
guide: {
label: 'Guide',
className: 'text-blue-600 border-blue-500/30 bg-blue-500/10 dark:text-blue-400',
className: 'bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)]',
},
reference: {
label: 'Reference',
className: 'text-violet-600 border-violet-500/30 bg-violet-500/10 dark:text-violet-400',
className: 'bg-[var(--badge-purple-bg)] text-[var(--badge-purple-text)]',
},
concept: {
label: 'Concept',
className: 'text-amber-600 border-amber-500/30 bg-amber-500/10 dark:text-amber-400',
className: 'bg-[var(--badge-amber-bg)] text-[var(--badge-amber-text)]',
},
} as const satisfies Record<DocsPageType, { label: string; className: string }>
@@ -25,6 +33,7 @@ interface PageTypeBadgeProps {
/**
* Small label that tells the reader which Diátaxis mode a page is — learning,
* task, lookup, or understanding. Rendered only when a page declares `type`.
* Chrome matches the emcn `Badge` status variants (md size).
*/
export function PageTypeBadge({ type, className }: PageTypeBadgeProps) {
const config = CONFIG[type]
@@ -33,7 +42,7 @@ export function PageTypeBadge({ type, className }: PageTypeBadgeProps) {
return (
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 font-medium text-[11px] uppercase tracking-[0.04em]',
'inline-flex items-center gap-1.5 rounded-md px-[9px] py-0.5 font-medium font-season text-[12px] transition-colors',
config.className,
className
)}
+2 -2
View File
@@ -26,7 +26,7 @@ export function ActionImage({ src, alt, enableLightbox = true }: ActionImageProp
src={src}
alt={alt}
className={cn(
'inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700',
'inline-block w-full max-w-[200px] rounded-lg border border-[var(--border-1)]',
enableLightbox && 'transition-opacity group-hover:opacity-90'
)}
/>
@@ -79,7 +79,7 @@ export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProp
muted
playsInline
className={cn(
'inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700',
'inline-block w-full max-w-[200px] rounded-lg border border-[var(--border-1)]',
enableLightbox && 'transition-opacity group-hover:opacity-90'
)}
/>
+140
View File
@@ -0,0 +1,140 @@
'use client'
import { type ComponentType, forwardRef, type ReactNode } from 'react'
import { Check } from 'lucide-react'
import { ChipChevronDown, chipVariants, TRIGGER_BORDER_CLASS } from '@/components/ui/chip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
type ChipIcon = ComponentType<{ className?: string }>
/**
* Single option rendered inside a {@link ChipDropdown}.
*/
interface ChipDropdownOption {
/** Stable identifier returned via `onChange`. */
value: string
/** Visual label rendered both inside the trigger (when selected) and in the menu. */
label: ReactNode
/** Optional leading icon rendered inside the menu item. */
icon?: ChipIcon
/** Pre-rendered leading element (e.g. a flag) — takes precedence over `icon`. */
iconElement?: ReactNode
}
interface ChipDropdownProps {
/** Options to render in the menu. */
options: ReadonlyArray<ChipDropdownOption>
/** Currently selected value. */
value?: string
/** Called when the user picks a different option from the menu. */
onChange?: (value: string) => void
/** Shown in the trigger when nothing is selected. */
placeholder?: string
/** Aligns the dropdown popover relative to the trigger. */
align?: 'start' | 'center' | 'end'
/**
* Whether the menu width should match the trigger's. When `true` (default),
* the menu pins to `--radix-dropdown-menu-trigger-width`. Set `false` to let
* the menu size to its widest item.
*/
matchTriggerWidth?: boolean
/** Forwarded to the inner `DropdownMenuContent`. */
contentClassName?: string
/** Disables the trigger. */
disabled?: boolean
/** Optional icon rendered before the label. */
leftIcon?: ChipIcon
/** Forwarded class for the trigger button — layout/sizing only. */
className?: string
}
/**
* Docs-local mirror of the emcn `ChipDropdown`
* (`apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx`) — a
* 30px filled chip pill that opens a menu of options. The trigger reuses
* `chipVariants` for visual parity with `Chip`; the chevron is owned by the
* component and rendered in a 16px slot so the label's trailing gap matches a
* leading icon's.
*
* @example
* <ChipDropdown
* value={currentLang}
* onChange={setLanguage}
* options={LANGUAGE_OPTIONS}
* align='end'
* matchTriggerWidth={false}
* />
*/
const ChipDropdown = forwardRef<HTMLButtonElement, ChipDropdownProps>(function ChipDropdown(
{
options,
value,
onChange,
placeholder,
align = 'end',
matchTriggerWidth = true,
contentClassName,
disabled,
leftIcon: LeftIcon,
className,
},
ref
) {
const selected = options.find((option) => option.value === value)
const displayLabel = selected?.label ?? placeholder ?? 'Select...'
const isPlaceholder = selected == null
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild disabled={disabled}>
<button
ref={ref}
type='button'
disabled={disabled}
className={cn(chipVariants({ variant: 'filled' }), TRIGGER_BORDER_CLASS, className)}
>
{LeftIcon ? (
<LeftIcon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
) : null}
<span
className={cn(
'min-w-0 flex-1 truncate text-sm',
isPlaceholder ? 'text-[var(--text-muted)]' : 'text-[var(--text-body)]'
)}
>
{displayLabel}
</span>
<ChipChevronDown />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
className={cn(
matchTriggerWidth && 'w-[var(--radix-dropdown-menu-trigger-width)]',
contentClassName
)}
>
{options.map((option) => {
const isSelected = option.value === value
const OptionIcon = option.icon
return (
<DropdownMenuItem key={option.value} onSelect={() => onChange?.(option.value)}>
{option.iconElement ?? (OptionIcon ? <OptionIcon /> : null)}
<span>{option.label}</span>
{isSelected ? <Check className='!ml-auto !size-[16px]' /> : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
})
export { ChipDropdown }
export type { ChipDropdownOption, ChipDropdownProps }
+172
View File
@@ -0,0 +1,172 @@
'use client'
import {
type AnchorHTMLAttributes,
type ButtonHTMLAttributes,
type ComponentType,
forwardRef,
type ReactNode,
} from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import Link, { type LinkProps } from 'next/link'
import { cn } from '@/lib/utils'
/**
* Docs-local mirror of the platform chip chrome
* (`apps/sim/components/emcn/components/chip/chip-chrome.ts`). The docs app
* cannot import from `apps/sim`, so the canonical tokens are replicated here —
* keep them in sync with the emcn source.
*/
export const chipContentGap = 'gap-1.5'
/** Chip pill geometry — height, centering, gap, radius, padding, text size. */
export const chipGeometryClass = `h-[30px] items-center ${chipContentGap} rounded-lg px-2 text-left text-sm`
/** Chip-content icon (non-inverse): 16px, non-shrinking, `--text-icon`. */
export const chipContentIconClass = 'size-[16px] flex-shrink-0 text-[var(--text-icon)]'
/** Chip-content label (non-inverse): truncating `--text-body` at `text-sm`. */
export const chipContentLabelClass = 'min-w-0 truncate text-[var(--text-body)] text-sm'
/** The filled FILL (surface only, no border) — `--surface-5` light / `--surface-4` dark. */
export const chipFilledFillTokens = 'bg-[var(--surface-5)] dark:bg-[var(--surface-4)]'
/** 1px `--border-1` border applied to chip triggers so they read as controls. */
export const TRIGGER_BORDER_CLASS = 'border border-[var(--border-1)]'
/**
* 30px pill — the platform's most common chrome pattern, mirrored for the docs.
*
* @remarks
* The implicit default variant is the bare pill — transparent, `--surface-active`
* on hover. `filled` adds the filled surface; `brand` is the green CTA surface
* (`--brand-accent`) used for the "Get started" link.
*/
const chipVariants = cva(
`group cursor-pointer font-season ${chipGeometryClass} transition-colors disabled:cursor-not-allowed disabled:opacity-60`,
{
variants: {
variant: {
default: 'hover:bg-[var(--surface-active)]',
filled: `${chipFilledFillTokens} hover:bg-[var(--surface-active)]`,
brand:
'bg-[var(--brand-accent)] text-white hover:bg-[var(--brand-accent-hover)] hover:text-white',
},
fullWidth: { true: 'flex', false: 'inline-flex' },
},
defaultVariants: { variant: 'default', fullWidth: false },
}
)
type ChipIcon = ComponentType<{ className?: string }>
type ChipVariant = VariantProps<typeof chipVariants>['variant']
interface ChipBaseProps extends VariantProps<typeof chipVariants> {
/** Icon component rendered before the label. */
leftIcon?: ChipIcon
/** Icon component rendered after the label. */
rightIcon?: ChipIcon
children?: ReactNode
}
/**
* `brand` sets text color on the chip itself — its icon and label inherit via
* `currentColor`. The default and `filled` chips use explicit icon
* (`--text-icon`) and label (`--text-body`) colors.
*/
function ChipContent({
variant,
leftIcon: LeftIcon,
rightIcon: RightIcon,
children,
}: ChipBaseProps) {
const isInverse = variant === 'brand'
const iconClass = cn(chipContentIconClass, isInverse && 'text-current')
const labelClass = cn(chipContentLabelClass, 'flex-1', isInverse && 'text-current')
return (
<>
{LeftIcon ? <LeftIcon className={iconClass} /> : null}
{children != null && children !== false ? (
<span className={labelClass}>{children}</span>
) : null}
{RightIcon ? <RightIcon className={iconClass} /> : null}
</>
)
}
interface ChipProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'>,
ChipBaseProps {}
/**
* @example <Chip leftIcon={Copy} onClick={copyPage}>Copy page</Chip>
*/
const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
{ className, variant, fullWidth, leftIcon, rightIcon, children, type, ...props },
ref
) {
return (
<button
ref={ref}
type={type ?? 'button'}
className={cn(chipVariants({ variant, fullWidth }), className)}
{...props}
>
<ChipContent variant={variant} leftIcon={leftIcon} rightIcon={rightIcon}>
{children}
</ChipContent>
</button>
)
})
interface ChipLinkProps
extends Omit<LinkProps, 'children'>,
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps | 'children'>,
ChipBaseProps {}
/**
* @example <ChipLink href='https://sim.ai' variant='brand'>Get started</ChipLink>
*/
const ChipLink = forwardRef<HTMLAnchorElement, ChipLinkProps>(function ChipLink(
{ className, variant, fullWidth, leftIcon, rightIcon, children, ...props },
ref
) {
return (
<Link ref={ref} className={cn(chipVariants({ variant, fullWidth }), className)} {...props}>
<ChipContent variant={variant} leftIcon={leftIcon} rightIcon={rightIcon}>
{children}
</ChipContent>
</Link>
)
})
interface ChipChevronDownProps {
/** Layout-only extras (e.g. `ml-auto` to push the chevron flush right). Never chrome. */
className?: string
}
/**
* Canonical trailing chevron adornment for chip-style dropdown triggers — a
* 16px hidden-from-AT slot centering the 10×6 chevron glyph in `--text-icon`,
* matching the chevron `ChipDropdown` owns in the main app.
*/
export function ChipChevronDown({ className }: ChipChevronDownProps) {
return (
<span
aria-hidden
className={cn(
'inline-flex size-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]',
className
)}
>
<svg width='10' height='6' viewBox='0 0 10 6' fill='none'>
<path
d='M1 1L5 5L9 1'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
/>
</svg>
</span>
)
}
export { Chip, ChipLink, chipVariants }
export type { ChipLinkProps, ChipProps, ChipVariant }
+4 -4
View File
@@ -17,7 +17,7 @@ export function CodeBlock(props: React.ComponentProps<typeof FumadocsCodeBlock>)
return (
<FumadocsCodeBlock
{...props}
className={cn('!border !border-fd-border !shadow-none', props.className)}
className={cn('!border !border-[var(--border)] !shadow-none', props.className)}
Actions={({ className }) => (
<div className={cn('empty:hidden', className)}>
<button
@@ -27,13 +27,13 @@ export function CodeBlock(props: React.ComponentProps<typeof FumadocsCodeBlock>)
const pre = (e.currentTarget as HTMLElement).closest('figure')?.querySelector('pre')
if (pre) handleCopy(pre.textContent || '')
}}
className='cursor-pointer rounded-md p-2 text-muted-foreground transition-colors hover:text-foreground'
className='cursor-pointer rounded-md p-2 text-[var(--text-muted)] transition-colors hover:text-[var(--text-icon)]'
>
<span className='flex items-center justify-center'>
{copied ? (
<Check size={16} className='text-green-600 dark:text-green-400' />
<Check size={16} className='text-[var(--brand-accent)]' />
) : (
<Copy size={16} className='text-muted-foreground' />
<Copy size={16} className='text-[var(--text-muted)]' />
)}
</span>
</button>
+11 -2
View File
@@ -7,6 +7,14 @@ import { cn } from '@/lib/utils'
const ANIMATION_CLASSES =
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none'
/**
* Menu surface mirrored from the emcn `DropdownMenu`
* (`apps/sim/components/emcn/components/dropdown-menu/dropdown-menu.tsx`) —
* `--bg` surface, `--border` outline, `rounded-xl`, `p-1.5`.
*/
const CONTENT_BASE_CLASSES =
'z-50 max-h-[240px] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-y-auto overflow-x-hidden overscroll-none border border-[var(--border)] bg-[var(--bg)] p-1.5 font-season text-[var(--text-body)] shadow-sm'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
@@ -23,7 +31,8 @@ function DropdownMenuContent({
sideOffset={sideOffset}
className={cn(
ANIMATION_CLASSES,
'z-50 max-h-[240px] min-w-[8rem] max-w-[220px] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-lg border border-neutral-200 bg-white p-1.5 shadow-sm dark:border-neutral-800 dark:bg-neutral-900',
CONTENT_BASE_CLASSES,
'max-w-[220px] rounded-xl',
className
)}
{...props}
@@ -41,7 +50,7 @@ function DropdownMenuItem({
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 font-medium text-[13px] text-neutral-700 outline-none transition-colors focus:bg-neutral-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:text-neutral-300 dark:focus:bg-neutral-800',
'relative flex h-[30px] min-w-0 cursor-pointer select-none items-center gap-2 rounded-lg px-2 text-[13px] text-[var(--text-body)] outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
className
)}
{...props}
+5 -8
View File
@@ -30,11 +30,11 @@ function FAQItemRow({
type='button'
onClick={onToggle}
aria-expanded={isOpen}
className='flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left font-[470] text-[0.875rem] text-[rgba(0,0,0,0.8)] transition-colors hover:bg-[rgba(0,0,0,0.02)] dark:text-[rgba(255,255,255,0.85)] dark:hover:bg-[rgba(255,255,255,0.03)]'
className='flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left font-[470] text-[0.875rem] text-[var(--text-body)] transition-colors hover:bg-[var(--surface-3)]'
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 text-[rgba(0,0,0,0.3)] transition-transform duration-200 dark:text-[rgba(255,255,255,0.3)]',
'size-[14px] shrink-0 text-[var(--text-icon)] transition-transform duration-200',
isOpen && 'rotate-90'
)}
/>
@@ -48,7 +48,7 @@ function FAQItemRow({
}}
>
<div className='overflow-hidden'>
<div className='px-4 pt-2 pb-2.5 pl-11 text-[0.875rem] text-[rgba(0,0,0,0.7)] leading-relaxed dark:text-[rgba(255,255,255,0.7)]'>
<div className='px-4 pt-2 pb-2.5 pl-11 text-[0.875rem] text-[var(--text-secondary)] leading-relaxed'>
{item.answer}
</div>
</div>
@@ -80,14 +80,11 @@ export function FAQ({ items, title = 'Common Questions' }: FAQProps) {
dangerouslySetInnerHTML={{ __html: serializeJsonLd(faqSchema) }}
/>
<h2 className='mb-4 font-[500] text-xl'>{title}</h2>
<div className='border-[rgba(0,0,0,0.08)] border-t border-b dark:border-[rgba(255,255,255,0.08)]'>
<div className='border-[var(--border)] border-t border-b'>
{items.map((item, index) => (
<div
key={item.question}
className={cn(
index !== items.length - 1 &&
'border-[rgba(0,0,0,0.08)] border-b dark:border-[rgba(255,255,255,0.08)]'
)}
className={cn(index !== items.length - 1 && 'border-[var(--border)] border-b')}
>
<FAQItemRow
item={item}
+2 -2
View File
@@ -45,12 +45,12 @@ export function Heading({ as, className, ...props }: HeadingProps) {
{copied ? (
<Check
aria-hidden
className='size-3.5 shrink-0 text-green-500 opacity-100 transition-opacity'
className='size-[14px] shrink-0 text-[var(--brand-accent)] opacity-100 transition-opacity'
/>
) : (
<Link
aria-hidden
className='size-3.5 shrink-0 text-fd-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 peer-hover:opacity-100'
className='size-[14px] shrink-0 text-[var(--text-icon)] opacity-0 transition-opacity group-hover:opacity-100 peer-hover:opacity-100'
/>
)}
</As>
+1 -1
View File
@@ -24,7 +24,7 @@ export function Image({
const image = (
<NextImage
className={cn(
'overflow-hidden rounded-xl border border-border object-cover',
'overflow-hidden rounded-xl border border-[var(--border)] object-cover',
enableLightbox && 'cursor-pointer transition-opacity group-hover:opacity-95',
className
)}
+15 -46
View File
@@ -1,14 +1,7 @@
'use client'
import { Check } from 'lucide-react'
import { useParams, usePathname, useRouter } from 'next/navigation'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { ChipDropdown } from '@/components/ui/chip-dropdown'
const languages = {
en: { name: 'English', flag: '🇺🇸' },
@@ -24,6 +17,12 @@ export function LanguageDropdown() {
const params = useParams()
const { push } = useRouter()
const languageOptions = Object.entries(languages).map(([code, lang]) => ({
value: code,
label: lang.name,
iconElement: <span className='text-[13px]'>{lang.flag}</span>,
}))
const langFromParams = params?.lang as string
const currentLang =
langFromParams && Object.keys(languages).includes(langFromParams) ? langFromParams : 'en'
@@ -47,44 +46,14 @@ export function LanguageDropdown() {
push(newPath)
}
const languageEntries = Object.entries(languages)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className='flex cursor-pointer items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 text-[13px] text-foreground/50 transition-colors duration-200 hover:bg-neutral-100 hover:text-foreground/70 focus:outline-none dark:hover:bg-neutral-800 dark:hover:text-foreground/70'>
<span>{languages[currentLang as keyof typeof languages]?.name}</span>
<svg width='8' height='5' viewBox='0 0 10 6' fill='none' className='flex-shrink-0'>
<path
d='M1 1L5 5L9 1'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
/>
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' sideOffset={6} className='min-w-[160px]'>
{languageEntries.map(([code, lang]) => {
const isSelected = currentLang === code
return (
<DropdownMenuItem
key={code}
onClick={() => handleLanguageChange(code)}
className={cn(
'flex cursor-pointer items-center gap-2 text-[13px]',
isSelected && 'font-medium'
)}
>
<span className='text-[13px]'>{lang.flag}</span>
<span className='flex-1'>{lang.name}</span>
{isSelected && <Check className='ml-auto size-3.5' />}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<ChipDropdown
value={currentLang}
onChange={handleLanguageChange}
options={languageOptions}
align='end'
matchTriggerWidth={false}
contentClassName='min-w-[160px]'
/>
)
}
+3 -3
View File
@@ -16,12 +16,12 @@ export function SearchTrigger() {
<button
type='button'
data-search-trigger
className='flex h-8 w-[360px] cursor-pointer items-center gap-2 rounded-lg border border-border/50 bg-fd-muted/50 px-3 text-[13px] text-fd-muted-foreground transition-colors hover:bg-fd-muted'
className='flex h-[30px] w-[360px] cursor-pointer items-center gap-1.5 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-season text-[var(--text-muted)] text-sm transition-colors hover:bg-[var(--surface-active)] dark:bg-[var(--surface-4)]'
onClick={openSearchDialog}
>
<Search className='size-3.5' />
<Search className='size-[14px] text-[var(--text-icon)]' />
<span>Search&hellip;</span>
<kbd className='ml-auto flex items-center font-medium'>
<kbd className='ml-auto flex items-center'>
<span className='text-[15px]'>⌘</span>
<span className='text-[12px]'>K</span>
</kbd>
+1 -1
View File
@@ -52,7 +52,7 @@ export function SimLogoFull({ className }: SimLogoProps) {
/>
</g>
{/* "Sim" text — adapts to light/dark mode */}
<g className='fill-neutral-900 dark:fill-white'>
<g className='fill-[var(--text-primary)]'>
<path d='M31.57 15.85h2.59c0 .71.26 1.28.78 1.71.52.41 1.22.61 2.1.61.96 0 1.7-.18 2.21-.55.52-.39.78-.9.78-1.53 0-.46-.14-.85-.43-1.16-.27-.31-.77-.56-1.49-.75l-2.47-.58c-1.25-.31-2.17-.78-2.79-1.42-.59-.64-.89-1.48-.89-2.52 0-.87.22-1.62.66-2.26.46-.64 1.08-1.13 1.87-1.48.8-.35 1.72-.52 2.76-.52s1.93.18 2.67.55c.77.37 1.36.88 1.78 1.53.44.66.67 1.44.69 2.35h-2.59c-.02-.73-.26-1.3-.72-1.71-.46-.41-1.1-.61-1.93-.61-.84 0-1.49.18-1.95.55-.46.37-.69.87-.69 1.51 0 .95.69 1.59 2.07 1.94l2.47.61c1.19.27 2.08.71 2.67 1.33.59.6.89 1.42.89 2.46 0 .89-.24 1.67-.72 2.35-.48.66-1.14 1.17-1.98 1.53-.82.35-1.8.52-2.93.52-1.65 0-2.96-.41-3.94-1.22-.98-.81-1.47-1.89-1.47-3.24z' />
<path d='M44.51 19.96v-14.16c1.08.39 1.55.39 2.7 0v14.16zm1.32-15.09c-.48 0-.9-.17-1.26-.52-.34-.37-.52-.79-.52-1.27 0-.5.17-.93.52-1.27.36-.35.79-.52 1.26-.52.5 0 .92.17 1.26.52s.52.77.52 1.27c0 .48-.17.91-.52 1.27-.34.35-.77.52-1.26.52z' />
<path d='M51.98 19.96h-2.7v-14.16h2.41v2.39c.29-.79.84-1.46 1.61-1.98.79-.54 1.73-.81 2.85-.81 1.25 0 2.28.34 3.1 1.01.82.68 1.36 1.57 1.61 2.69h-.49c.19-1.12.72-2.02 1.58-2.69.86-.68 1.93-1.01 3.19-1.01 1.61 0 2.87.47 3.79 1.42.92.95 1.38 2.24 1.38 3.88v9.26h-2.64v-8.6c0-1.12-.29-1.98-.86-2.58-.56-.62-1.31-.93-2.27-.93-.67 0-1.26.15-1.78.46-.5.29-.89.71-1.18 1.27-.29.56-.43 1.22-.43 1.97v8.4h-2.67v-8.63c0-1.12-.28-1.97-.83-2.55-.56-.6-1.31-.9-2.27-.9-.67 0-1.26.15-1.78.46-.5.29-.89.71-1.18 1.27-.29.54-.43 1.19-.43 1.94z' />
+3 -3
View File
@@ -56,11 +56,11 @@ export function ThemeToggle() {
<button
type='button'
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
className='flex size-[30px] cursor-pointer items-center justify-center rounded-full text-foreground/40 transition-colors duration-200 hover:bg-neutral-100 hover:text-foreground/70 dark:hover:bg-neutral-800 dark:hover:text-foreground/70'
className='flex size-[30px] cursor-pointer items-center justify-center rounded-lg text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)]'
aria-label='Toggle theme'
>
<SunIcon className='block dark:hidden' />
<MoonIcon className='hidden dark:block' />
<SunIcon className='block size-[14px] dark:hidden' />
<MoonIcon className='hidden size-[14px] dark:block' />
</button>
)
}
+1 -1
View File
@@ -18,7 +18,7 @@ interface VideoProps {
export function Video({
src,
className = 'w-full rounded-xl border border-border overflow-hidden outline-none focus:outline-none',
className = 'w-full rounded-xl border border-[var(--border)] overflow-hidden outline-none focus:outline-none',
autoPlay = true,
loop = true,
muted = true,