chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
'use client'
import { useRef, useState } from 'react'
import { cn, getAssetUrl } from '@/lib/utils'
import { Lightbox } from './lightbox'
interface ActionImageProps {
src: string
alt: string
enableLightbox?: boolean
}
interface ActionVideoProps {
src: string
alt: string
enableLightbox?: boolean
}
export function ActionImage({ src, alt, enableLightbox = true }: ActionImageProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const openLightbox = () => setIsLightboxOpen(true)
const image = (
<img
src={src}
alt={alt}
className={cn(
'inline-block w-full max-w-[200px] rounded-lg border border-[var(--border-1)]',
enableLightbox && 'transition-opacity group-hover:opacity-90'
)}
/>
)
return (
<>
{enableLightbox ? (
<button
type='button'
onClick={openLightbox}
aria-label={`Open ${alt} in media viewer`}
className='group inline-block cursor-pointer rounded p-0 text-left'
>
{image}
</button>
) : (
image
)}
{enableLightbox && (
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={alt}
type='image'
/>
)}
</>
)
}
export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const startTimeRef = useRef(0)
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const resolvedSrc = getAssetUrl(src)
const openLightbox = () => {
startTimeRef.current = videoRef.current?.currentTime ?? 0
setIsLightboxOpen(true)
}
const video = (
<video
ref={videoRef}
src={resolvedSrc}
autoPlay
loop
muted
playsInline
className={cn(
'inline-block w-full max-w-[200px] rounded-lg border border-[var(--border-1)]',
enableLightbox && 'transition-opacity group-hover:opacity-90'
)}
/>
)
return (
<>
{enableLightbox ? (
<button
type='button'
onClick={openLightbox}
aria-label={`Open ${alt} in media viewer`}
className='group inline-block cursor-pointer rounded p-0 text-left'
>
{video}
</button>
) : (
video
)}
{enableLightbox && (
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={alt}
type='video'
startTime={startTimeRef.current}
/>
)}
</>
)
}
@@ -0,0 +1,61 @@
'use client'
import type * as React from 'react'
import { blockTypeToIconMap } from '@/components/ui/icon-mapping'
interface BlockInfoCardProps {
type: string
color: string
icon?: React.ComponentType<{ className?: string }>
}
/**
* Brightness above which a tile background is "clearly light" and a white
* foreground icon would wash out. Mirrors apps/sim's LIGHT_TILE_THRESHOLD
* (blocks/icon-color.ts) so monochrome `currentColor` icons (e.g. Daytona,
* Notion) stay legible on white/pale tiles instead of white-on-white.
*/
const LIGHT_TILE_THRESHOLD = 0.75
function isLightTileColor(color: string): boolean {
const hex = color.trim().replace('#', '').toLowerCase()
let r: number
let g: number
let b: number
if (/^[0-9a-f]{3}$/.test(hex)) {
r = Number.parseInt(hex[0] + hex[0], 16)
g = Number.parseInt(hex[1] + hex[1], 16)
b = Number.parseInt(hex[2] + hex[2], 16)
} else if (/^[0-9a-f]{6}$/.test(hex)) {
r = Number.parseInt(hex.slice(0, 2), 16)
g = Number.parseInt(hex.slice(2, 4), 16)
b = Number.parseInt(hex.slice(4, 6), 16)
} else {
return false
}
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > LIGHT_TILE_THRESHOLD
}
export function BlockInfoCard({
type,
color,
icon: IconComponent,
}: BlockInfoCardProps): React.ReactNode {
const ResolvedIcon = IconComponent || blockTypeToIconMap[type] || null
const iconColorClass = isLightTileColor(color) ? 'text-black' : 'text-white'
return (
<div
className='mb-6 flex items-center justify-center overflow-hidden rounded-lg p-8'
style={{ background: color }}
>
{ResolvedIcon ? (
<ResolvedIcon className={`size-10 ${iconColorClass}`} />
) : (
<div className={`font-mono text-xl opacity-70 ${iconColorClass}`}>
{type.substring(0, 2)}
</div>
)}
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
'use client'
import { useState } from 'react'
import { CodeBlock as FumadocsCodeBlock } from 'fumadocs-ui/components/codeblock'
import { Check, Copy } from 'lucide-react'
import { cn } from '@/lib/utils'
export function CodeBlock(props: React.ComponentProps<typeof FumadocsCodeBlock>) {
const [copied, setCopied] = useState(false)
const handleCopy = async (text: string) => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<FumadocsCodeBlock
{...props}
className={cn('!border !border-[var(--border)] !shadow-none', props.className)}
Actions={({ className }) => (
<div className={cn('empty:hidden', className)}>
<button
type='button'
aria-label={copied ? 'Copied Text' : 'Copy Text'}
onClick={(e) => {
const pre = (e.currentTarget as HTMLElement).closest('figure')?.querySelector('pre')
if (pre) handleCopy(pre.textContent || '')
}}
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-[var(--brand-accent)]' />
) : (
<Copy size={16} className='text-[var(--text-muted)]' />
)}
</span>
</button>
</div>
)}
/>
)
}
+99
View File
@@ -0,0 +1,99 @@
'use client'
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { serializeJsonLd } from '@/lib/json-ld'
import { cn } from '@/lib/utils'
interface FAQItem {
question: string
answer: string
}
interface FAQProps {
items: FAQItem[]
title?: string
}
function FAQItemRow({
item,
isOpen,
onToggle,
}: {
item: FAQItem
isOpen: boolean
onToggle: () => void
}) {
return (
<div>
<button
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-[var(--text-body)] transition-colors hover:bg-[var(--surface-3)]'
>
<ChevronRight
className={cn(
'size-[14px] shrink-0 text-[var(--text-icon)] transition-transform duration-200',
isOpen && 'rotate-90'
)}
/>
{item.question}
</button>
<div
className='grid transition-[grid-template-rows,opacity] duration-200 ease-in-out'
style={{
gridTemplateRows: isOpen ? '1fr' : '0fr',
opacity: isOpen ? 1 : 0,
}}
>
<div className='overflow-hidden'>
<div className='px-4 pt-2 pb-2.5 pl-11 text-[0.875rem] text-[var(--text-secondary)] leading-relaxed'>
{item.answer}
</div>
</div>
</div>
</div>
)
}
export function FAQ({ items, title = 'Common Questions' }: FAQProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null)
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
}
return (
<div className='mt-12'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: serializeJsonLd(faqSchema) }}
/>
<h2 className='mb-4 font-[500] text-xl'>{title}</h2>
<div className='border-[var(--border)] border-t border-b'>
{items.map((item, index) => (
<div
key={item.question}
className={cn(index !== items.length - 1 && 'border-[var(--border)] border-b')}
>
<FAQItemRow
item={item}
isOpen={openIndex === index}
onToggle={() => setOpenIndex(openIndex === index ? null : index)}
/>
</div>
))}
</div>
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
'use client'
import { type ComponentPropsWithoutRef, useState } from 'react'
import { Check, Link } from 'lucide-react'
import { cn } from '@/lib/utils'
type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
interface HeadingProps extends ComponentPropsWithoutRef<'h1'> {
as?: HeadingTag
}
export function Heading({ as, className, ...props }: HeadingProps) {
const [copied, setCopied] = useState(false)
const As = as ?? 'h1'
if (!props.id) {
return <As className={className} {...props} />
}
const copyHeadingLink = async (e: React.MouseEvent) => {
e.preventDefault()
const url = `${window.location.origin}${window.location.pathname}#${props.id}`
try {
await navigator.clipboard.writeText(url)
setCopied(true)
// Update URL hash without scrolling
window.history.pushState(null, '', `#${props.id}`)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback: just navigate to the anchor
window.location.hash = props.id as string
}
}
return (
<As className={cn('group flex scroll-m-28 flex-row items-center gap-2', className)} {...props}>
<a href={`#${props.id}`} className='peer' onClick={copyHeadingLink}>
{props.children}
</a>
{copied ? (
<Check
aria-hidden
className='size-[14px] shrink-0 text-[var(--brand-accent)] opacity-100 transition-opacity'
/>
) : (
<Link
aria-hidden
className='size-[14px] shrink-0 text-[var(--text-icon)] opacity-0 transition-opacity group-hover:opacity-100 peer-hover:opacity-100'
/>
)}
</As>
)
}
+506
View File
@@ -0,0 +1,506 @@
// Auto-generated file - do not edit manually
// Generated by scripts/generate-docs.ts
// Maps block types to their icon component references
import type { ComponentType, SVGProps } from 'react'
import {
AgentMailIcon,
AgentPhoneIcon,
AgiloftIcon,
AhrefsIcon,
AirtableIcon,
AirweaveIcon,
AlgoliaIcon,
AmplitudeIcon,
ApifyIcon,
ApolloIcon,
AppConfigIcon,
ArxivIcon,
AsanaIcon,
AshbyIcon,
AthenaIcon,
AttioIcon,
AzureIcon,
BoxCompanyIcon,
BrainIcon,
BrandfetchIcon,
BrexIcon,
BrightDataIcon,
BrowserUseIcon,
CalComIcon,
CalendlyIcon,
CirclebackIcon,
ClayIcon,
ClerkIcon,
ClickHouseIcon,
CloudFormationIcon,
CloudflareIcon,
CloudWatchIcon,
CodePipelineIcon,
ConfluenceIcon,
ContextDevIcon,
ConvexIcon,
CrowdStrikeIcon,
CursorIcon,
DagsterIcon,
DatabricksIcon,
DatadogIcon,
DatagmaIcon,
DaytonaIcon,
DevinIcon,
DiscordIcon,
DocumentIcon,
DocuSignIcon,
DowndetectorIcon,
DropboxIcon,
DropcontactIcon,
DsPyIcon,
DubIcon,
DuckDuckGoIcon,
DynamoDBIcon,
ElasticsearchIcon,
ElevenLabsIcon,
EmailBisonIcon,
EnrichmentIcon,
EnrichSoIcon,
EnrowIcon,
EvernoteIcon,
ExaAIIcon,
ExtendIcon,
EyeIcon,
FathomIcon,
FindymailIcon,
FirecrawlIcon,
FirefliesIcon,
GammaIcon,
GithubIcon,
GitLabIcon,
GmailIcon,
GongIcon,
GoogleAdsIcon,
GoogleAppsheetIcon,
GoogleBigQueryIcon,
GoogleBooksIcon,
GoogleCalendarIcon,
GoogleContactsIcon,
GoogleDocsIcon,
GoogleDriveIcon,
GoogleFormsIcon,
GoogleGroupsIcon,
GoogleIcon,
GoogleMapsIcon,
GoogleMeetIcon,
GooglePagespeedIcon,
GoogleSheetsIcon,
GoogleSlidesIcon,
GoogleTasksIcon,
GoogleTranslateIcon,
GoogleVaultIcon,
GrafanaIcon,
GrainIcon,
GranolaIcon,
GreenhouseIcon,
GreptileIcon,
HexIcon,
HubspotIcon,
HuggingFaceIcon,
HunterIOIcon,
IAMIcon,
IcypeasIcon,
IdentityCenterIcon,
IncidentioIcon,
InfisicalIcon,
InstantlyIcon,
IntercomIcon,
JinaAIIcon,
JiraIcon,
JiraServiceManagementIcon,
JupyterIcon,
KalshiIcon,
KetchIcon,
LangsmithIcon,
LatexIcon,
LaunchDarklyIcon,
LeadMagicIcon,
LemlistIcon,
LinearIcon,
LinkedInIcon,
LinkupIcon,
LinqIcon,
LoopsIcon,
LumaIcon,
MailchimpIcon,
MailgunIcon,
MailServerIcon,
Mem0Icon,
MicrosoftDataverseIcon,
MicrosoftExcelIcon,
MicrosoftOneDriveIcon,
MicrosoftPlannerIcon,
MicrosoftSharepointIcon,
MicrosoftTeamsIcon,
MillionVerifierIcon,
MistralIcon,
MondayIcon,
MongoDBIcon,
MySQLIcon,
Neo4jIcon,
NeverBounceIcon,
NewRelicIcon,
NotionIcon,
ObsidianIcon,
OktaIcon,
OnePasswordIcon,
OpenAIIcon,
OutlookIcon,
PackageSearchIcon,
PagerDutyIcon,
ParallelIcon,
PeopleDataLabsIcon,
PerplexityIcon,
PersonaIcon,
PineconeIcon,
PipedriveIcon,
PolymarketIcon,
PostgresIcon,
PosthogIcon,
ProfoundIcon,
ProspeoIcon,
PulseIcon,
QdrantIcon,
QuartrIcon,
QuiverIcon,
RailwayIcon,
RB2BIcon,
RDSIcon,
RedditIcon,
RedisIcon,
ReductoIcon,
ResendIcon,
RevenueCatIcon,
RipplingIcon,
RootlyIcon,
S3Icon,
SalesforceIcon,
SapConcurIcon,
SapS4HanaIcon,
SESIcon,
SecretsManagerIcon,
SendblueIcon,
SendgridIcon,
SentryIcon,
SerperIcon,
ServiceNowIcon,
SftpIcon,
ShopifyIcon,
SimilarwebIcon,
SimTriggerIcon,
SixtyfourIcon,
SlackIcon,
SmtpIcon,
SportmonksIcon,
SQSIcon,
SquareIcon,
SshIcon,
STSIcon,
STTIcon,
StagehandIcon,
StripeIcon,
SupabaseIcon,
TailscaleIcon,
TavilyIcon,
TelegramIcon,
TemporalIcon,
TextractIcon,
ThriveIcon,
TinybirdIcon,
TrelloIcon,
TriggerDevIcon,
TwilioIcon,
TypeformIcon,
UpstashIcon,
UptimeRobotIcon,
VantaIcon,
VercelIcon,
VideoIcon,
WealthboxIcon,
WebflowIcon,
WhatsAppIcon,
WikipediaIcon,
WizaIcon,
WordpressIcon,
WorkdayIcon,
xIcon,
YouTubeIcon,
ZendeskIcon,
ZepIcon,
ZeroBounceIcon,
ZoomIcon,
ZoomInfoIcon,
} from '@/components/icons'
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
agentmail: AgentMailIcon,
agentphone: AgentPhoneIcon,
agiloft: AgiloftIcon,
ahrefs: AhrefsIcon,
airtable: AirtableIcon,
airweave: AirweaveIcon,
algolia: AlgoliaIcon,
amplitude: AmplitudeIcon,
apify: ApifyIcon,
apollo: ApolloIcon,
appconfig: AppConfigIcon,
arxiv: ArxivIcon,
asana: AsanaIcon,
ashby: AshbyIcon,
athena: AthenaIcon,
attio: AttioIcon,
azure_devops: AzureIcon,
box: BoxCompanyIcon,
brandfetch: BrandfetchIcon,
brex: BrexIcon,
brightdata: BrightDataIcon,
browser_use: BrowserUseIcon,
calcom: CalComIcon,
calendly: CalendlyIcon,
circleback: CirclebackIcon,
clay: ClayIcon,
clerk: ClerkIcon,
clickhouse: ClickHouseIcon,
cloudflare: CloudflareIcon,
cloudformation: CloudFormationIcon,
cloudwatch: CloudWatchIcon,
codepipeline: CodePipelineIcon,
confluence: ConfluenceIcon,
confluence_v2: ConfluenceIcon,
context_dev: ContextDevIcon,
convex: ConvexIcon,
crowdstrike: CrowdStrikeIcon,
cursor: CursorIcon,
cursor_v2: CursorIcon,
dagster: DagsterIcon,
databricks: DatabricksIcon,
datadog: DatadogIcon,
datagma: DatagmaIcon,
daytona: DaytonaIcon,
devin: DevinIcon,
discord: DiscordIcon,
docusign: DocuSignIcon,
downdetector: DowndetectorIcon,
dropbox: DropboxIcon,
dropcontact: DropcontactIcon,
dspy: DsPyIcon,
dub: DubIcon,
duckduckgo: DuckDuckGoIcon,
dynamodb: DynamoDBIcon,
elasticsearch: ElasticsearchIcon,
elevenlabs: ElevenLabsIcon,
emailbison: EmailBisonIcon,
enrich: EnrichSoIcon,
enrichment: EnrichmentIcon,
enrow: EnrowIcon,
evernote: EvernoteIcon,
exa: ExaAIIcon,
extend: ExtendIcon,
extend_v2: ExtendIcon,
fathom: FathomIcon,
file: DocumentIcon,
file_v2: DocumentIcon,
file_v4: DocumentIcon,
file_v5: DocumentIcon,
findymail: FindymailIcon,
firecrawl: FirecrawlIcon,
fireflies: FirefliesIcon,
fireflies_v2: FirefliesIcon,
gamma: GammaIcon,
github: GithubIcon,
github_v2: GithubIcon,
gitlab: GitLabIcon,
gmail: GmailIcon,
gmail_v2: GmailIcon,
gong: GongIcon,
google_ads: GoogleAdsIcon,
google_appsheet: GoogleAppsheetIcon,
google_bigquery: GoogleBigQueryIcon,
google_books: GoogleBooksIcon,
google_calendar: GoogleCalendarIcon,
google_calendar_v2: GoogleCalendarIcon,
google_contacts: GoogleContactsIcon,
google_docs: GoogleDocsIcon,
google_drive: GoogleDriveIcon,
google_forms: GoogleFormsIcon,
google_groups: GoogleGroupsIcon,
google_maps: GoogleMapsIcon,
google_meet: GoogleMeetIcon,
google_pagespeed: GooglePagespeedIcon,
google_search: GoogleIcon,
google_sheets: GoogleSheetsIcon,
google_sheets_v2: GoogleSheetsIcon,
google_slides: GoogleSlidesIcon,
google_slides_v2: GoogleSlidesIcon,
google_tasks: GoogleTasksIcon,
google_translate: GoogleTranslateIcon,
google_vault: GoogleVaultIcon,
grafana: GrafanaIcon,
grain: GrainIcon,
granola: GranolaIcon,
greenhouse: GreenhouseIcon,
greptile: GreptileIcon,
hex: HexIcon,
hubspot: HubspotIcon,
huggingface: HuggingFaceIcon,
hunter: HunterIOIcon,
iam: IAMIcon,
icypeas: IcypeasIcon,
identity_center: IdentityCenterIcon,
imap: MailServerIcon,
incidentio: IncidentioIcon,
infisical: InfisicalIcon,
instantly: InstantlyIcon,
intercom: IntercomIcon,
intercom_v2: IntercomIcon,
jina: JinaAIIcon,
jira: JiraIcon,
jira_service_management: JiraServiceManagementIcon,
jupyter: JupyterIcon,
kalshi: KalshiIcon,
kalshi_v2: KalshiIcon,
ketch: KetchIcon,
knowledge: PackageSearchIcon,
langsmith: LangsmithIcon,
latex: LatexIcon,
launchdarkly: LaunchDarklyIcon,
leadmagic: LeadMagicIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linear_v2: LinearIcon,
linkedin: LinkedInIcon,
linkup: LinkupIcon,
linq: LinqIcon,
loops: LoopsIcon,
luma: LumaIcon,
mailchimp: MailchimpIcon,
mailgun: MailgunIcon,
mem0: Mem0Icon,
memory: BrainIcon,
microsoft_ad: AzureIcon,
microsoft_dataverse: MicrosoftDataverseIcon,
microsoft_excel: MicrosoftExcelIcon,
microsoft_excel_v2: MicrosoftExcelIcon,
microsoft_planner: MicrosoftPlannerIcon,
microsoft_teams: MicrosoftTeamsIcon,
millionverifier: MillionVerifierIcon,
mistral_parse: MistralIcon,
mistral_parse_v2: MistralIcon,
mistral_parse_v3: MistralIcon,
monday: MondayIcon,
mongodb: MongoDBIcon,
mysql: MySQLIcon,
neo4j: Neo4jIcon,
neverbounce: NeverBounceIcon,
new_relic: NewRelicIcon,
notion: NotionIcon,
notion_v2: NotionIcon,
obsidian: ObsidianIcon,
okta: OktaIcon,
onedrive: MicrosoftOneDriveIcon,
onepassword: OnePasswordIcon,
openai: OpenAIIcon,
outlook: OutlookIcon,
pagerduty: PagerDutyIcon,
parallel_ai: ParallelIcon,
peopledatalabs: PeopleDataLabsIcon,
perplexity: PerplexityIcon,
persona: PersonaIcon,
pinecone: PineconeIcon,
pipedrive: PipedriveIcon,
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
posthog: PosthogIcon,
profound: ProfoundIcon,
prospeo: ProspeoIcon,
pulse: PulseIcon,
pulse_v2: PulseIcon,
qdrant: QdrantIcon,
quartr: QuartrIcon,
quiver: QuiverIcon,
railway: RailwayIcon,
rb2b: RB2BIcon,
rds: RDSIcon,
reddit: RedditIcon,
redis: RedisIcon,
reducto: ReductoIcon,
reducto_v2: ReductoIcon,
resend: ResendIcon,
revenuecat: RevenueCatIcon,
rippling: RipplingIcon,
rootly: RootlyIcon,
s3: S3Icon,
salesforce: SalesforceIcon,
sap_concur: SapConcurIcon,
sap_s4hana: SapS4HanaIcon,
secrets_manager: SecretsManagerIcon,
sendblue: SendblueIcon,
sendgrid: SendgridIcon,
sentry: SentryIcon,
serper: SerperIcon,
servicenow: ServiceNowIcon,
ses: SESIcon,
sftp: SftpIcon,
sharepoint: MicrosoftSharepointIcon,
sharepoint_v2: MicrosoftSharepointIcon,
shopify: ShopifyIcon,
sim_workspace_event: SimTriggerIcon,
similarweb: SimilarwebIcon,
sixtyfour: SixtyfourIcon,
slack: SlackIcon,
smtp: SmtpIcon,
sportmonks: SportmonksIcon,
sqs: SQSIcon,
square: SquareIcon,
ssh: SshIcon,
stagehand: StagehandIcon,
stripe: StripeIcon,
sts: STSIcon,
stt: STTIcon,
stt_v2: STTIcon,
supabase: SupabaseIcon,
tailscale: TailscaleIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
temporal: TemporalIcon,
textract: TextractIcon,
textract_v2: TextractIcon,
thrive: ThriveIcon,
tinybird: TinybirdIcon,
trello: TrelloIcon,
trigger_dev: TriggerDevIcon,
twilio_sms: TwilioIcon,
twilio_voice: TwilioIcon,
typeform: TypeformIcon,
upstash: UpstashIcon,
uptimerobot: UptimeRobotIcon,
vanta: VantaIcon,
vercel: VercelIcon,
video_generator: VideoIcon,
video_generator_v2: VideoIcon,
vision: EyeIcon,
vision_v2: EyeIcon,
wealthbox: WealthboxIcon,
webflow: WebflowIcon,
whatsapp: WhatsAppIcon,
wikipedia: WikipediaIcon,
wiza: WizaIcon,
wordpress: WordpressIcon,
workday: WorkdayIcon,
x: xIcon,
youtube: YouTubeIcon,
zendesk: ZendeskIcon,
zep: ZepIcon,
zerobounce: ZeroBounceIcon,
zoom: ZoomIcon,
zoominfo: ZoomInfoIcon,
}
+63
View File
@@ -0,0 +1,63 @@
'use client'
import { useState } from 'react'
import NextImage, { type ImageProps as NextImageProps } from 'next/image'
import { Lightbox } from '@/components/ui/lightbox'
import { cn } from '@/lib/utils'
interface ImageProps extends Omit<NextImageProps, 'className'> {
className?: string
enableLightbox?: boolean
}
export function Image({
className = 'w-full',
enableLightbox = true,
alt = '',
src,
...props
}: ImageProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const openLightbox = () => setIsLightboxOpen(true)
const image = (
<NextImage
className={cn(
'overflow-hidden rounded-xl border border-[var(--border)] object-cover',
enableLightbox && 'cursor-pointer transition-opacity group-hover:opacity-95',
className
)}
alt={alt}
src={src}
{...props}
/>
)
return (
<>
{enableLightbox ? (
<button
type='button'
onClick={openLightbox}
aria-label={`Open ${alt} in media viewer`}
className='group contents'
>
{image}
</button>
) : (
image
)}
{enableLightbox && (
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={typeof src === 'string' ? src : String(src)}
alt={alt}
type='image'
/>
)}
</>
)
}
@@ -0,0 +1,59 @@
'use client'
import { ChipDropdown } from '@sim/emcn'
import { useParams, usePathname, useRouter } from 'next/navigation'
const languages = {
en: { name: 'English', flag: '🇺🇸' },
es: { name: 'Español', flag: '🇪🇸' },
fr: { name: 'Français', flag: '🇫🇷' },
de: { name: 'Deutsch', flag: '🇩🇪' },
ja: { name: '日本語', flag: '🇯🇵' },
zh: { name: '简体中文', flag: '🇨🇳' },
}
export function LanguageDropdown() {
const pathname = usePathname()
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'
const handleLanguageChange = (locale: string) => {
if (locale === currentLang) return
const segments = pathname.split('/').filter(Boolean)
if (segments[0] && Object.keys(languages).includes(segments[0])) {
segments.shift()
}
let newPath = ''
if (locale === 'en') {
newPath = segments.length > 0 ? `/${segments.join('/')}` : '/introduction'
} else {
newPath = `/${locale}${segments.length > 0 ? `/${segments.join('/')}` : '/introduction'}`
}
push(newPath)
}
return (
<ChipDropdown
value={currentLang}
onChange={handleLanguageChange}
options={languageOptions}
align='end'
matchTriggerWidth={false}
contentClassName='min-w-[160px]'
/>
)
}
+96
View File
@@ -0,0 +1,96 @@
'use client'
import { useEffect, useEffectEvent, useLayoutEffect, useRef } from 'react'
import { getAssetUrl } from '@/lib/utils'
interface LightboxProps {
isOpen: boolean
onClose: () => void
src: string
alt: string
type: 'image' | 'video'
startTime?: number
}
export function Lightbox({ isOpen, onClose, src, alt, type, startTime }: LightboxProps) {
const overlayRef = useRef<HTMLDivElement>(null)
const mediaButtonRef = useRef<HTMLButtonElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const closeLightbox = useEffectEvent(onClose)
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeLightbox()
}
}
const handleClickOutside = (event: MouseEvent) => {
if (overlayRef.current && event.target === overlayRef.current) {
closeLightbox()
}
}
const previousOverflow = document.body.style.overflow
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('click', handleClickOutside)
document.body.style.overflow = 'hidden'
mediaButtonRef.current?.focus()
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('click', handleClickOutside)
document.body.style.overflow = previousOverflow
}
}, [isOpen])
useLayoutEffect(() => {
if (isOpen && type === 'video' && videoRef.current && startTime != null && startTime > 0) {
videoRef.current.currentTime = startTime
}
}, [isOpen, startTime, type])
if (!isOpen) return null
return (
<div
ref={overlayRef}
className='fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-12 backdrop-blur-sm'
role='dialog'
aria-modal='true'
aria-label='Media viewer'
>
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl'>
<button
ref={mediaButtonRef}
type='button'
onClick={onClose}
aria-label='Close media viewer'
className='block cursor-pointer rounded-xl p-0 outline-none focus-visible:ring-2 focus-visible:ring-white/70'
>
{type === 'image' ? (
<img
src={src}
alt={alt}
className='max-h-[75vh] max-w-[75vw] rounded-xl object-contain'
loading='lazy'
/>
) : (
<video
ref={videoRef}
src={getAssetUrl(src)}
autoPlay
loop
muted
playsInline
className='max-h-[75vh] max-w-[75vw] rounded-xl outline-none focus:outline-none'
/>
)}
</button>
</div>
</div>
)
}
@@ -0,0 +1,169 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ResponseSectionProps {
children: React.ReactNode
}
export function ResponseSection({ children }: ResponseSectionProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [statusCodes, setStatusCodes] = useState<string[]>([])
const [selectedCode, setSelectedCode] = useState<string>('')
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
function getAccordionItems() {
const root = containerRef.current?.querySelector('[data-orientation="vertical"]')
if (!root) return []
return Array.from(root.children).filter(
(el) => el.getAttribute('data-state') !== null
) as HTMLElement[]
}
function showStatusCode(code: string) {
const items = getAccordionItems()
for (const item of items) {
const triggerBtn = item.querySelector('h3 button') as HTMLButtonElement | null
const text = triggerBtn?.textContent?.trim() ?? ''
const itemCode = text.match(/^\d{3}/)?.[0]
if (itemCode === code) {
item.style.display = ''
if (item.getAttribute('data-state') === 'closed' && triggerBtn) {
triggerBtn.click()
}
} else {
item.style.display = 'none'
if (item.getAttribute('data-state') === 'open' && triggerBtn) {
triggerBtn.click()
}
}
}
}
/**
* Detect when the fumadocs accordion children mount via MutationObserver,
* then extract status codes and show the first one.
* Replaces the previous approach that used `children` as a dependency
* (which triggered on every render since children is a new object each time).
*/
useEffect(() => {
const container = containerRef.current
if (!container) return
const initialize = () => {
const items = getAccordionItems()
if (items.length === 0) return false
const codes: string[] = []
const seen = new Set<string>()
for (const item of items) {
const triggerBtn = item.querySelector('h3 button')
if (triggerBtn) {
const text = triggerBtn.textContent?.trim() ?? ''
const code = text.match(/^\d{3}/)?.[0]
if (code && !seen.has(code)) {
seen.add(code)
codes.push(code)
}
}
}
if (codes.length > 0) {
setStatusCodes(codes)
setSelectedCode(codes[0])
showStatusCode(codes[0])
return true
}
return false
}
if (initialize()) return
const observer = new MutationObserver(() => {
if (initialize()) {
observer.disconnect()
}
})
observer.observe(container, { childList: true, subtree: true })
return () => observer.disconnect()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
function handleSelectCode(code: string) {
setSelectedCode(code)
setIsOpen(false)
showStatusCode(code)
}
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
return (
<div ref={containerRef} className='response-section-wrapper'>
{statusCodes.length > 0 && (
<div className='response-section-header'>
<h2 className='response-section-title'>Response</h2>
<div className='response-section-meta'>
<div ref={dropdownRef} className='response-section-dropdown-wrapper'>
<button
type='button'
className='response-section-dropdown-trigger'
onClick={() => setIsOpen(!isOpen)}
>
<span>{selectedCode}</span>
<ChevronDown
className={cn(
'response-section-chevron',
isOpen && 'response-section-chevron-open'
)}
/>
</button>
{isOpen && (
<div className='response-section-dropdown-menu'>
{statusCodes.map((code) => (
<button
key={code}
type='button'
className={cn(
'response-section-dropdown-item',
code === selectedCode && 'response-section-dropdown-item-selected'
)}
onClick={() => handleSelectCode(code)}
>
<span>{code}</span>
{code === selectedCode && (
<svg
className='response-section-check'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
>
<polyline points='20 6 9 17 4 12' />
</svg>
)}
</button>
))}
</div>
)}
</div>
<span className='response-section-content-type'>application/json</span>
</div>
</div>
)}
<div className='response-section-content'>{children}</div>
</div>
)
}
@@ -0,0 +1,42 @@
'use client'
import {
chipContentIconClass,
chipFilledFillTokens,
chipGeometryClass,
TRIGGER_BORDER_CLASS,
} from '@sim/emcn'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
export function SearchTrigger() {
const openSearchDialog = () => {
const event = new KeyboardEvent('keydown', {
key: 'k',
metaKey: true,
bubbles: true,
})
document.dispatchEvent(event)
}
return (
<button
type='button'
data-search-trigger
className={cn(
chipGeometryClass,
chipFilledFillTokens,
TRIGGER_BORDER_CLASS,
'flex w-[360px] cursor-pointer font-season text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-active)]'
)}
onClick={openSearchDialog}
>
<Search className={chipContentIconClass} />
<span>Search&hellip;</span>
<kbd className='ml-auto flex items-center'>
<span className='text-[15px]'></span>
<span className='text-[12px]'>K</span>
</kbd>
</button>
)
}
+138
View File
@@ -0,0 +1,138 @@
'use client'
import { useId } from 'react'
import { cn } from '@/lib/utils'
interface SimLogoProps {
className?: string
}
/**
* Inline "sim" wordmark, no separate icon mark — same paths as the landing
* footer's `SimWordmark` (`apps/sim/app/(landing)/components/navbar/components/sim-wordmark`),
* ported here so the docs footer can match it exactly. Filled with
* `var(--text-body)` so it reads as one solid ink matching surrounding text.
*/
export function SimWordmark({ className }: SimLogoProps) {
return (
<svg
viewBox='0 0 441 212'
width={37}
height={18}
fill='none'
aria-hidden='true'
className={cn('-translate-y-[1.5px] h-[18px] w-auto', className)}
>
<g fill='var(--text-body)'>
<path d='M0 160.9H29.51C29.51 169.08 32.46 175.61 38.37 180.48C44.27 185.12 52.25 187.44 62.31 187.44C73.24 187.44 81.65 185.34 87.56 181.14C93.46 176.71 96.41 170.85 96.41 163.55C96.41 158.24 94.77 153.82 91.49 150.28C88.43 146.74 82.75 143.86 74.44 141.65L46.24 135.01C32.03 131.47 21.42 126.05 14.43 118.75C7.65 111.45 4.26 101.83 4.26 89.88C4.26 79.93 6.78 71.3 11.81 64C17.05 56.7 24.16 51.06 33.12 47.08C42.3 43.09 52.8 41.1 64.6 41.1C76.41 41.1 86.57 43.2 95.1 47.41C103.84 51.61 110.62 57.47 115.43 64.99C120.46 72.52 123.08 81.48 123.3 91.87H93.79C93.57 83.47 90.84 76.94 85.59 72.3C80.34 67.65 73.02 65.33 63.62 65.33C54 65.33 46.57 67.43 41.32 71.63C36.07 75.83 33.45 81.59 33.45 88.89C33.45 99.73 41.32 107.14 57.06 111.12L85.26 118.09C98.81 121.19 108.98 126.28 115.76 133.35C122.53 140.21 125.92 149.61 125.92 161.56C125.92 171.74 123.19 180.7 117.73 188.44C112.26 195.96 104.72 201.82 95.1 206.03C85.7 210.01 74.55 212 61.65 212C42.85 212 27.87 207.35 16.72 198.06C5.57 188.77 0 176.38 0 160.9Z' />
<path d='M232.8 212H202.13L202.13 49.76H229.54V77.39C232.8 68.34 239.11 60.66 247.81 54.7C256.73 48.52 267.5 45.43 280.12 45.43C294.26 45.43 306.01 49.29 315.36 57.02C324.72 64.75 330.81 75.01 333.64 87.82H328.09C330.27 75.01 336.25 64.75 346.04 57.02C355.83 49.29 367.9 45.43 382.26 45.43C400.54 45.43 414.89 50.84 425.34 61.66C435.78 72.47 441 87.26 441 106.03V212H410.98V113.65C410.98 100.84 407.71 91.02 401.19 84.17C394.88 77.11 386.29 73.58 375.41 73.58C367.79 73.58 361.05 75.34 355.17 78.88C349.52 82.19 345.06 87.04 341.8 93.45C338.53 99.85 336.9 107.36 336.9 115.97V212H306.55V113.32C306.55 100.51 303.4 90.8 297.09 84.17C290.78 77.33 282.19 73.91 271.31 73.91C263.69 73.91 256.95 75.67 251.08 79.21C245.42 82.52 240.96 87.38 237.7 93.78C234.43 99.96 232.8 107.36 232.8 115.97V212Z' />
<path d='M184.83 20.55C184.83 31.9 175.64 41.1 164.29 41.1C152.95 41.1 143.76 31.9 143.76 20.55C143.76 9.2 152.95 0 164.29 0C175.64 0 184.83 9.2 184.83 20.55Z' />
<path d='M179.43 212H149.16V49.76C153.76 51.91 158.88 53.12 164.29 53.12C169.7 53.12 174.83 51.91 179.43 49.76V212Z' />
</g>
</svg>
)
}
/**
* Icon-only Sim mark, no wordmark text. Same brandbook icon geometry as
* {@link SimLogoFull}'s icon, at its native square viewBox.
*/
export function SimLogoIcon({ className }: SimLogoProps) {
const gradientId = `sim-logo-icon-gradient-${useId()}`
return (
<svg
viewBox='0 0 222 222'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={cn('size-7', className)}
aria-label='Sim'
>
<defs>
<linearGradient
id={gradientId}
gradientUnits='userSpaceOnUse'
x1='129.434'
y1='129.266'
x2='185.629'
y2='185.33'
>
<stop offset='0' />
<stop offset='1' stopOpacity='0' />
</linearGradient>
</defs>
<path
clipRule='evenodd'
d='m107.822 93.7612c0 3.5869-1.419 7.0308-3.938 9.5668l-.361.364c-2.517 2.544-5.9375 3.966-9.4994 3.966h-80.5781c-7.42094 0-13.4455 6.06-13.4455 13.533v87.141c0 7.474 6.02456 13.534 13.4455 13.534h86.5167c7.4208 0 13.4378-6.06 13.4378-13.534v-81.587c0-3.326 1.31-6.517 3.647-8.871 2.33-2.347 5.499-3.667 8.802-3.667h81.928c7.421 0 13.437-6.059 13.437-13.533v-87.1407c0-7.47374-6.016-13.5333-13.437-13.5333h-86.517c-7.421 0-13.438 6.05956-13.438 13.5333zm26.256-75.2112h60.874c4.337 0 7.844 3.5393 7.844 7.9003v61.3071c0 4.3604-3.507 7.9003-7.844 7.9003h-60.874c-4.33 0-7.845-3.5399-7.845-7.9003v-61.3071c0-4.361 3.515-7.9003 7.845-7.9003z'
fill='#33C482'
fillRule='evenodd'
/>
<path
d='m207.878 129.57h-64.324c-7.798 0-14.12 6.367-14.12 14.221v63.993c0 7.854 6.322 14.221 14.12 14.221h64.324c7.799 0 14.121-6.367 14.121-14.221v-63.993c0-7.854-6.322-14.221-14.121-14.221z'
fill='#33C482'
/>
<path
d='m207.878 129.266h-64.324c-7.798 0-14.12 6.366-14.12 14.221v63.992c0 7.854 6.322 14.22 14.12 14.22h64.324c7.799 0 14.121-6.366 14.121-14.22v-63.992c0-7.855-6.322-14.221-14.121-14.221z'
fill={`url(#${gradientId})`}
fillOpacity='0.2'
/>
</svg>
)
}
/**
* Full Sim logo with icon and "Sim" text.
* Uses the same SVG source as the landing page navbar for exact visual alignment.
* The icon stays green (#33C482), text adapts to light/dark mode.
*/
export function SimLogoFull({ className }: SimLogoProps) {
const gradientId = `sim-logo-full-gradient-${useId()}`
return (
<svg
viewBox='0 0 71 22'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={cn('h-7 w-auto', className)}
aria-label='Sim'
>
<defs>
<linearGradient
id={gradientId}
gradientUnits='userSpaceOnUse'
x1='171.406'
y1='171.18'
x2='245.831'
y2='245.428'
>
<stop offset='0' />
<stop offset='1' stopOpacity='0' />
</linearGradient>
</defs>
{/* Green icon — scaled to match landing logo proportions */}
<g transform='scale(.07483)'>
<path
clipRule='evenodd'
d='m142.79 124.17c0 4.75-1.88 9.31-5.22 12.67l-.48.48c-3.33 3.37-7.86 5.25-12.58 5.25h-106.71c-9.83 0-17.81 8.03-17.81 17.92v115.41c0 9.9 7.98 17.92 17.81 17.92h114.58c9.83 0 17.8-8.03 17.8-17.92v-108.05c0-4.41 1.74-8.63 4.83-11.75 3.09-3.11 7.28-4.86 11.66-4.86h108.5c9.83 0 17.8-8.02 17.8-17.92v-115.41c0-9.9-7.97-17.92-17.8-17.92h-114.58c-9.83 0-17.8 8.03-17.8 17.92zm34.77-99.61h80.62c5.74 0 10.39 4.69 10.39 10.46v81.19c0 5.77-4.64 10.46-10.39 10.46h-80.62c-5.73 0-10.39-4.69-10.39-10.46v-81.19c0-5.78 4.66-10.46 10.39-10.46z'
fill='#33C482'
fillRule='evenodd'
/>
<path
d='m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z'
fill='#33C482'
/>
<path
d='m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z'
fill={`url(#${gradientId})`}
fillOpacity='0.2'
/>
</g>
{/* "Sim" text — adapts to light/dark mode */}
<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' />
</g>
</svg>
)
}
+66
View File
@@ -0,0 +1,66 @@
'use client'
import type { SVGProps } from 'react'
import { useTheme } from 'next-themes'
function SunIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='16'
height='16'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<circle cx='12' cy='12' r='4' />
<path d='M12 2v2' />
<path d='M12 20v2' />
<path d='m4.93 4.93 1.41 1.41' />
<path d='m17.66 17.66 1.41 1.41' />
<path d='M2 12h2' />
<path d='M20 12h2' />
<path d='m6.34 17.66-1.41 1.41' />
<path d='m19.07 4.93-1.41 1.41' />
</svg>
)
}
function MoonIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='16'
height='16'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<path d='M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z' />
</svg>
)
}
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme()
return (
<button
type='button'
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
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 size-[14px] dark:hidden' />
<MoonIcon className='hidden size-[14px] dark:block' />
</button>
)
}
@@ -0,0 +1,73 @@
'use client'
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
/** Parse a chapter timestamp ("M:SS" or "H:MM:SS") into seconds. */
function parseTime(time: string): number {
const parts = time.split(':').map(Number)
if (parts.some(Number.isNaN)) return 0
return parts.reduce((acc, n) => acc * 60 + n, 0)
}
interface Chapter {
/** Chapter label. */
title: string
/** Timestamp, e.g. "0:45". */
time?: string
}
interface VideoChaptersProps {
/** Panel heading. Defaults to "Chapters". */
title?: string
chapters: Chapter[]
className?: string
}
/**
* Right-rail list of the current video's chapters — flat and borderless to
* match the docs' "On this page" TOC (small muted label, hover-highlighted
* rows). Rows are skip-to controls; they activate once the lesson's video is
* recorded.
*/
export function VideoChapters({ title = 'Chapters', chapters, className }: VideoChaptersProps) {
// Chapters only seek when a VideoPlaceholder with a real video is on the page.
// Handshake so the rows stay inert (not falsely clickable) on video-less lessons.
const [hasVideo, setHasVideo] = useState(false)
useEffect(() => {
const onReady = () => setHasVideo(true)
window.addEventListener('academy:video-ready', onReady)
window.dispatchEvent(new Event('academy:video-query'))
return () => window.removeEventListener('academy:video-ready', onReady)
}, [])
return (
<aside className={cn('not-prose', className)}>
<p className='mb-2 px-2.5 font-medium text-[0.8125rem] text-[var(--text-muted)]'>{title}</p>
<ul className='m-0 flex list-none flex-col gap-0.5 p-0'>
{chapters.map((chapter) => (
<li key={chapter.title}>
<button
type='button'
disabled={!hasVideo || chapter.time == null}
onClick={() => {
if (chapter.time == null) return
window.dispatchEvent(
new CustomEvent('academy:seek', { detail: { time: parseTime(chapter.time) } })
)
}}
className='flex w-full cursor-pointer items-baseline gap-3 rounded-lg px-2.5 py-2 text-left text-[var(--text-secondary)] text-sm transition-colors hover:bg-[var(--surface-active)] disabled:cursor-default disabled:hover:bg-transparent'
>
<span className='min-w-0 flex-1 break-words'>{chapter.title}</span>
{chapter.time && (
<span className='shrink-0 text-[var(--text-muted)] text-xs tabular-nums'>
{chapter.time}
</span>
)}
</button>
</li>
))}
</ul>
</aside>
)
}
@@ -0,0 +1,206 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { cn, getAssetUrl } from '@/lib/utils'
interface VideoPlaceholderProps {
/** Large title shown on the hero. */
title?: string
/** Small italic eyebrow above the title, e.g. a module name. */
eyebrow?: string
/** Pill in the top-right corner. Defaults to "Coming soon" (shown only until a video is set). */
label?: string
/**
* Self-hosted video source. Accepts an absolute URL, a root-relative path
* (`/static/...`), or a bare asset name resolved through the Blob CDN. When
* set, the play button loads the video; otherwise the card is "coming soon".
*/
src?: string
className?: string
}
/** Resolve a video source: pass absolute/root-relative through, send bare names to the Blob CDN. */
function resolveVideoSrc(src: string): string {
if (/^https?:\/\//.test(src) || src.startsWith('/')) return src
return getAssetUrl(src)
}
/** The sim logotype, drawn with currentColor so the theme can tint it. */
function SimWordmark({ className }: { className?: string }) {
return (
<svg viewBox='0 0 816 392' fill='currentColor' aria-label='Sim' className={className}>
<path d='M 0 297.507 L 54.609 297.507 C 54.609 312.642 60.07 324.71 70.992 333.709 C 81.914 342.299 96.679 346.594 115.287 346.594 C 135.512 346.594 151.086 342.707 162.008 334.936 C 172.93 326.754 178.391 315.915 178.391 302.415 C 178.391 292.598 175.357 284.417 169.289 277.871 C 163.627 271.326 153.109 266.009 137.737 261.918 L 85.555 249.646 C 59.261 243.102 39.642 233.08 26.698 219.581 C 14.158 206.082 7.888 188.287 7.888 166.198 C 7.888 147.79 12.54 131.837 21.844 118.338 C 31.552 104.838 44.699 94.408 61.284 87.045 C 78.274 79.682 97.69 76 119.534 76 C 141.378 76 160.187 79.886 175.964 87.658 C 192.144 95.43 204.684 106.271 213.584 120.179 C 222.888 134.086 227.742 150.654 228.146 169.88 L 173.536 169.88 C 173.132 154.335 168.076 142.267 158.368 133.678 C 148.659 125.087 135.108 120.792 117.714 120.792 C 99.915 120.792 86.162 124.678 76.453 132.451 C 66.745 140.223 61.891 150.858 61.891 164.357 C 61.891 184.402 76.453 198.105 105.579 205.468 L 157.76 218.354 C 182.841 224.08 201.651 233.489 214.191 246.579 C 226.73 259.26 233 276.644 233 298.734 C 233 317.55 227.943 334.118 217.831 348.435 C 207.718 362.343 193.762 373.183 175.964 380.955 C 158.57 388.318 137.939 392 114.073 392 C 79.285 392 51.576 383.409 30.945 366.229 C 10.315 349.048 0 326.141 0 297.507 Z' />
<path d='M 430.759 392 L 374 392 L 374 92 L 424.721 92 L 424.721 143.095 C 430.76 126.357 442.433 112.167 458.535 101.145 C 475.039 89.715 494.966 84 518.314 84 C 544.48 84 566.217 91.144 583.527 105.431 C 600.837 119.719 612.108 138.701 617.342 162.378 L 607.076 162.378 C 611.102 138.701 622.172 119.719 640.287 105.431 C 658.401 91.144 680.743 84 707.311 84 C 741.126 84 767.694 94.001 787.017 114.004 C 806.339 134.006 816 161.357 816 196.056 L 816 392 L 760.448 392 L 760.448 210.139 C 760.448 186.462 754.41 168.297 742.333 155.643 C 730.66 142.579 714.758 136.048 694.631 136.048 C 680.542 136.048 668.062 139.314 657.194 145.845 C 646.728 151.968 638.475 160.949 632.437 172.787 C 626.398 184.625 623.38 198.505 623.38 214.425 L 623.38 392 L 567.223 392 L 567.223 209.527 C 567.223 185.85 561.387 167.888 549.713 155.643 C 538.039 142.988 522.138 136.66 502.01 136.66 C 487.921 136.66 475.442 139.926 464.574 146.457 C 454.108 152.58 445.855 161.562 439.817 173.4 C 433.778 184.83 430.759 198.505 430.759 214.425 L 430.759 392 Z' />
<path d='M 342 38 C 342 58.987 324.987 76 304 76 C 283.013 76 266 58.987 266 38 C 266 17.013 283.013 0 304 0 C 324.987 0 342 17.013 342 38 Z' />
<path d='M 332 392 L 276 392 L 276 92 C 284.5 95.988 293.99 98.218 304 98.218 C 314.01 98.218 323.5 95.988 332 92 L 332 392 Z' />
</svg>
)
}
/**
* A 16:9 lesson hero used across the Academy. Always shows the design-system
* video card (title, blueprint grid, theme-aware dark/light). When a `src` is
* provided the play button loads the self-hosted video inline; otherwise the
* card reads "Coming soon" and the play button is muted.
*/
export function VideoPlaceholder({
title,
eyebrow,
label = 'Coming soon',
src,
className,
}: VideoPlaceholderProps) {
const hasVideo = Boolean(src)
const [playing, setPlaying] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const pendingSeek = useRef<number | null>(null)
// Chapter rows (VideoChapters) dispatch `academy:seek` with a time in seconds.
// Start the video if it isn't playing yet, then jump there. We also announce
// that a video exists (and answer a chapters-side query) so the chapter rows
// only become interactive when there's actually something to seek.
useEffect(() => {
if (!src) return
const onSeek = (e: Event) => {
const time = (e as CustomEvent<{ time: number }>).detail?.time
if (typeof time !== 'number') return
const video = videoRef.current
if (video) {
video.currentTime = time
void video.play()
} else {
pendingSeek.current = time
setPlaying(true)
}
}
const announce = () => window.dispatchEvent(new Event('academy:video-ready'))
window.addEventListener('academy:seek', onSeek)
window.addEventListener('academy:video-query', announce)
announce()
return () => {
window.removeEventListener('academy:seek', onSeek)
window.removeEventListener('academy:video-query', announce)
}
}, [src])
if (playing && src) {
return (
<div
className={cn(
'not-prose my-6 aspect-video w-full overflow-hidden rounded-[20px] bg-black',
className
)}
>
{/* biome-ignore lint/a11y/useMediaCaption: lesson videos have no caption track yet */}
<video
ref={videoRef}
src={resolveVideoSrc(src)}
title={title ?? 'Lesson video'}
controls
autoPlay
playsInline
onLoadedMetadata={() => {
if (pendingSeek.current != null && videoRef.current) {
videoRef.current.currentTime = pendingSeek.current
void videoRef.current.play()
pendingSeek.current = null
}
}}
className='h-full w-full border-0'
/>
</div>
)
}
return (
<div
className={cn(
'not-prose group relative my-6 aspect-video w-full select-none overflow-hidden rounded-[20px] font-season transition-transform duration-200 [container-type:inline-size]',
'shadow-[inset_0_0_0_1px_#E6E6E6] [background:radial-gradient(130%_130%_at_50%_14%,#ffffff_0%,#f6f6f6_55%,#ececec_100%)]',
'dark:shadow-none dark:[background:radial-gradient(130%_130%_at_50%_18%,#1c1c1c_0%,#121212_45%,#0a0a0a_100%)]',
className
)}
>
{/* Blueprint grid — faint, fading to atmosphere at the edges */}
<div
aria-hidden
className='pointer-events-none absolute inset-0 [background-image:linear-gradient(rgba(18,18,18,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(18,18,18,0.05)_1px,transparent_1px)] [background-size:64px_64px] [mask-image:radial-gradient(120%_90%_at_50%_35%,#000_30%,transparent_100%)] dark:[background-image:linear-gradient(rgba(255,255,255,0.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.06)_1px,transparent_1px)]'
/>
{/* Corner plus-marks, 20px inset */}
{['top-5 left-5', 'top-5 right-5', 'bottom-5 left-5', 'right-5 bottom-5'].map((pos) => (
<span
key={pos}
aria-hidden
className={cn(
'absolute font-mono text-[20px] text-[rgba(18,18,18,0.22)] leading-none dark:text-[rgba(255,255,255,0.28)]',
pos
)}
>
+
</span>
))}
{/* Top-right status pill — only until a video is wired up */}
{!hasVideo && (
<span className='absolute top-6 right-6 z-10 inline-flex items-center gap-2 rounded-full border border-[var(--border-1)] bg-[var(--surface-2)] px-4 py-2 font-medium text-[12px] text-[var(--text-secondary)] uppercase tracking-[0.14em] md:top-8 md:right-8 dark:bg-[var(--surface-1)]'>
<span className='size-1.5 rounded-full bg-[var(--brand-accent)]' />
{label}
</span>
)}
{/* Heading: eyebrow + title, bottom-left (design: left:40 bottom:40) */}
<div className='absolute bottom-10 left-10 z-10 max-w-[80%]'>
{eyebrow && (
<span className='mb-[14px] block font-normal text-[#5F5F5F] text-[clamp(15px,2cqi,22px)] italic tracking-[-0.01em] dark:text-[#B4B4B4]'>
{eyebrow}
</span>
)}
{title && (
<span className='block font-semibold text-[#121212] text-[clamp(2.5rem,9.5cqi,5.5rem)] leading-[0.96] tracking-[-0.035em] dark:text-[#F8F8F8]'>
{title}
</span>
)}
</div>
{/* Wordmark, bottom-right (design: right:40 bottom:40, svg height 22) */}
<span className='absolute right-10 bottom-10 z-10 text-[#121212] dark:text-white/90'>
<SimWordmark className='block h-[22px] w-auto' />
</span>
{/* Centered play button — active when a video is wired, muted otherwise */}
<div className='absolute inset-0 z-10 grid place-items-center'>
{hasVideo ? (
<button
type='button'
onClick={() => setPlaying(true)}
aria-label={title ? `Play ${title}` : 'Play video'}
className='grid h-12 w-16 cursor-pointer place-items-center rounded-[14px] bg-[rgba(255,255,255,0.78)] shadow-[0_1px_3px_rgba(18,18,18,0.12),inset_0_0_0_1px_#E6E6E6] backdrop-blur-[4px] transition-transform duration-200 hover:scale-105 active:scale-95 dark:bg-[rgba(10,10,10,0.72)] dark:shadow-none'
>
<svg
width='18'
height='20'
viewBox='0 0 18 20'
aria-hidden
className='translate-x-[1px] text-[#121212] dark:text-white'
>
<path d='M0 0l18 10L0 20z' fill='currentColor' />
</svg>
</button>
) : (
<span className='grid h-12 w-16 place-items-center rounded-[14px] bg-[rgba(255,255,255,0.78)] opacity-60 shadow-[0_1px_3px_rgba(18,18,18,0.12),inset_0_0_0_1px_#E6E6E6] backdrop-blur-[4px] dark:bg-[rgba(10,10,10,0.72)] dark:shadow-none'>
<svg
width='18'
height='20'
viewBox='0 0 18 20'
aria-hidden
className='translate-x-[1px] text-[#121212] dark:text-white'
>
<path d='M0 0l18 10L0 20z' fill='currentColor' />
</svg>
</span>
)}
</div>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
'use client'
import { useRef, useState } from 'react'
import { cn, getAssetUrl } from '@/lib/utils'
import { Lightbox } from './lightbox'
interface VideoProps {
src: string
className?: string
autoPlay?: boolean
loop?: boolean
muted?: boolean
playsInline?: boolean
enableLightbox?: boolean
width?: number
height?: number
}
export function Video({
src,
className = 'w-full rounded-xl border border-[var(--border)] overflow-hidden outline-none focus:outline-none',
autoPlay = true,
loop = true,
muted = true,
playsInline = true,
enableLightbox = true,
width,
height,
}: VideoProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const startTimeRef = useRef(0)
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const openLightbox = () => {
startTimeRef.current = videoRef.current?.currentTime ?? 0
setIsLightboxOpen(true)
}
const video = (
<video
ref={videoRef}
autoPlay={autoPlay}
loop={loop}
muted={muted}
playsInline={playsInline}
width={width}
height={height}
className={cn(
className,
enableLightbox && 'cursor-pointer transition-opacity group-hover:opacity-[0.97]'
)}
src={getAssetUrl(src)}
/>
)
return (
<>
{enableLightbox ? (
<button
type='button'
onClick={openLightbox}
aria-label={`Open ${src} in media viewer`}
className='group contents'
>
{video}
</button>
) : (
video
)}
{enableLightbox && (
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={`Video: ${src}`}
type='video'
startTime={startTimeRef.current}
/>
)}
</>
)
}
@@ -0,0 +1,35 @@
import { cn } from '@/lib/utils'
interface LearnItem {
title: string
body: string
}
interface WhatYouWillLearnProps {
items: LearnItem[]
className?: string
}
/**
* "What you will learn" — a flat callout matching the docs' flat/divider
* language. A quiet muted label (like the TOC heading) sits above the
* takeaways; dividers fall only between items, so the label reads as a marker
* rather than an underlined heading and never competes with the item titles.
*/
export function WhatYouWillLearn({ items, className }: WhatYouWillLearnProps) {
return (
<div className={cn('not-prose', className)}>
<p className='mb-3 font-medium text-[0.8125rem] text-[var(--text-muted)]'>
What you will learn
</p>
<div className='divide-y divide-[var(--border)]'>
{items.map((item) => (
<div key={item.title} className='py-3.5 first:pt-0 last:pb-0'>
<p className='mb-1 font-medium text-[var(--text-primary)] text-sm'>{item.title}</p>
<p className='m-0 text-[var(--text-secondary)] text-sm leading-relaxed'>{item.body}</p>
</div>
))}
</div>
</div>
)
}