{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "frame",
  "type": "registry:component",
  "title": "Frame Design System",
  "description": "React components and design foundation for AI agent, chat, message and confirmation interfaces.",
  "author": "Beibei Zhang",
  "dependencies": [
    "react@19.2.5",
    "react-dom@19.2.5",
    "lucide-react@1.14.0"
  ],
  "files": [
    {
      "path": "components/chat-message.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/chat-message.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { Sparkles } from 'lucide-react';\nimport { type ReactNode } from 'react';\n\nexport function ChatMessage({ role, content, streaming = false, avatar, showAvatar = true, meta, }: {\n    role: 'user' | 'assistant';\n    content?: ReactNode;\n    streaming?: boolean;\n    avatar?: ReactNode;\n    showAvatar?: boolean;\n    meta?: ReactNode;\n}) {\n    const isUser = role === 'user';\n    const bubbleStyle = isUser\n        ? { background: 'var(--color-selected-bg)', color: 'var(--color-selected-text)' }\n        : undefined;\n    const bubbleClass = isUser\n        ? 'rounded-[16px] rounded-tr-[4px] px-4 py-3 type-body'\n        : 'rounded-[16px] rounded-tl-[4px] px-4 py-3 type-body bg-bg-message text-text-primary';\n    const defaultAvatar = (<div className=\"w-8 h-8 shrink-0 flex items-center justify-center rounded-full bg-bg-message text-text-primary\" aria-hidden>\n      <Sparkles size={16} strokeWidth={1.5}/>\n    </div>);\n    return (<div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>\n      {!isUser && showAvatar && (avatar ?? defaultAvatar)}\n      <div className={`flex flex-col gap-1 max-w-[80%] min-w-0 ${isUser ? 'items-end' : 'items-start'}`}>\n        <div className={bubbleClass} style={bubbleStyle}>\n          {content}\n          {streaming && (<span className={`inline-flex items-center gap-1 align-middle${content ? ' ml-1' : ''}`} role=\"status\" aria-label=\"Assistant is typing\">\n              <span className=\"typing-dot w-1.5 h-1.5 rounded-full bg-current opacity-60\"/>\n              <span className=\"typing-dot w-1.5 h-1.5 rounded-full bg-current opacity-60\"/>\n              <span className=\"typing-dot w-1.5 h-1.5 rounded-full bg-current opacity-60\"/>\n            </span>)}\n        </div>\n        {meta && <div className=\"type-caption text-text-secondary px-1\">{meta}</div>}\n      </div>\n    </div>);\n}\n"
    },
    {
      "path": "components/chat-panel.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/chat-panel.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { type ReactNode, useLayoutEffect, useRef } from 'react';\n\nexport function ChatPanel({ toolbar, header, messages, input, }: {\n    toolbar?: ReactNode;\n    header?: ReactNode;\n    messages: ReactNode;\n    input: ReactNode;\n}) {\n    const scrollRef = useRef<HTMLDivElement>(null);\n    const lastHeightRef = useRef(0);\n    useLayoutEffect(() => {\n        const el = scrollRef.current;\n        if (!el)\n            return;\n        const grew = el.scrollHeight > lastHeightRef.current;\n        const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;\n        if (grew && nearBottom) {\n            el.scrollTop = el.scrollHeight;\n        }\n        lastHeightRef.current = el.scrollHeight;\n    });\n    return (<div className=\"frosted-root flex-1 flex flex-col min-w-0 min-h-0 h-full\">\n      <div ref={scrollRef} className=\"frosted-scroller flex-1 overflow-y-auto min-h-0 scrollbar-autohide\">\n        {(toolbar != null || header) && (<div className=\"frosted-header\">\n            {toolbar}\n            {header && (<div className=\"px-4 sm:px-8 pt-4 pb-3\">\n                <div className=\"max-w-[863px] mx-auto w-full\">{header}</div>\n              </div>)}\n          </div>)}\n        <div className=\"max-w-[863px] mx-auto w-full px-4 sm:px-8 py-6 flex flex-col gap-4\">\n          {messages}\n        </div>\n      </div>\n      <div className=\"shrink-0 px-4 sm:px-8 pt-2 pb-6\">\n        <div className=\"max-w-[863px] mx-auto w-full\">{input}</div>\n      </div>\n    </div>);\n}\n"
    },
    {
      "path": "components/chat-input.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/chat-input.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { ArrowUp, AtSign, AudioLines, Camera, Download, FileText, FileUp, Image as ImageIcon, Loader2, Mic, MicOff, Plus, Square, SquareSlash, User, X } from 'lucide-react';\nimport { type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport { createPortal, flushSync } from 'react-dom';\n\nexport const LIVE_DICTATION_MAX_MS = 300000;\n\nexport interface BrowserRecognizer {\n    lang: string;\n    continuous: boolean;\n    interimResults: boolean;\n    onstart: (() => void) | null;\n    onresult: ((event: {\n        results: ArrayLike<{\n            isFinal: boolean;\n            0: {\n                transcript: string;\n            };\n        }>;\n    }) => void) | null;\n    onerror: ((event: {\n        error: string;\n    }) => void) | null;\n    onend: (() => void) | null;\n    start(): void;\n    stop(): void;\n    abort(): void;\n}\n\nexport function browserRecognitionConstructor(): (new () => BrowserRecognizer) | undefined {\n    if (typeof window === 'undefined')\n        return;\n    const api = window as typeof window & {\n        SpeechRecognition?: new () => BrowserRecognizer;\n        webkitSpeechRecognition?: new () => BrowserRecognizer;\n    };\n    return api.SpeechRecognition ?? api.webkitSpeechRecognition;\n}\n\nexport class BrowserSpeechUnavailable extends Error {\n}\n\nexport function createBrowserDictation(handlers: {\n    onPartial: (id: string, text: string) => void;\n    onFinal: (id: string, text: string) => void;\n    onError: (err: Error) => void;\n    signal?: AbortSignal;\n}, lang?: string): Promise<{\n    stop: () => Promise<void>;\n    cancel: () => void;\n}> {\n    return new Promise((resolve, reject) => {\n        const Constructor = browserRecognitionConstructor();\n        if (!Constructor) {\n            reject(new BrowserSpeechUnavailable('Browser speech is unavailable'));\n            return;\n        }\n        let recognition: BrowserRecognizer | null = null;\n        let timer: ReturnType<typeof setTimeout> | undefined;\n        let ready = false, ended = false, stopping = false, run = 0;\n        let prematureEnds = 0;\n        let finishStop: (() => void) | undefined;\n        let stopPromise: Promise<void> | undefined;\n        const language = lang || navigator.languages?.[0] || navigator.language;\n        let flushPending = () => { };\n        const release = () => {\n            clearTimeout(timer);\n            const current = recognition;\n            recognition = null;\n            if (current)\n                current.onstart = current.onresult = current.onerror = current.onend = null;\n            const flush = flushPending;\n            flushPending = () => { };\n            flush();\n            return current;\n        };\n        const cancel = () => {\n            if (ended)\n                return;\n            ended = true;\n            const current = release();\n            handlers.signal?.removeEventListener('abort', cancel);\n            try {\n                current?.abort();\n            }\n            catch { }\n            if (!ready)\n                reject(new Error('Speech recognition cancelled'));\n            finishStop?.();\n        };\n        const fail = (error: Error) => {\n            const wasReady = ready;\n            if (!wasReady)\n                reject(error);\n            cancel();\n            if (wasReady)\n                handlers.onError(error);\n        };\n        const session = {\n            cancel,\n            stop: () => stopPromise ??= new Promise<void>(done => {\n                if (ended) {\n                    done();\n                    return;\n                }\n                stopping = true;\n                finishStop = done;\n                clearTimeout(timer);\n                timer = setTimeout(cancel, 5000);\n                if (!recognition) {\n                    cancel();\n                    return;\n                }\n                try {\n                    recognition.stop();\n                }\n                catch {\n                    cancel();\n                }\n            }),\n        };\n        const start = () => {\n            if (ended || stopping)\n                return;\n            let current: BrowserRecognizer;\n            try {\n                current = new Constructor();\n            }\n            catch {\n                fail(new BrowserSpeechUnavailable('Browser speech is unavailable'));\n                return;\n            }\n            recognition = current;\n            const segmentPrefix = `browser-${run++}-`;\n            let finalized = 0;\n            let pendingText = '';\n            let started = false;\n            flushPending = () => {\n                const text = pendingText;\n                pendingText = '';\n                if (text)\n                    handlers.onFinal(segmentPrefix + finalized, text);\n            };\n            current.lang = /^zh/i.test(language) ? 'zh-CN' : language;\n            current.continuous = true;\n            current.interimResults = true;\n            current.onstart = () => {\n                if (recognition !== current || ended || stopping)\n                    return;\n                clearTimeout(timer);\n                started = true;\n                prematureEnds = 0;\n                if (!ready) {\n                    ready = true;\n                    resolve(session);\n                }\n            };\n            current.onresult = ({ results }) => {\n                if (recognition !== current || ended)\n                    return;\n                const utterance = Array.from(results).slice(finalized);\n                const text = utterance.map(result => result[0].transcript).join('');\n                if (utterance.length && utterance.every(result => result.isFinal)) {\n                    const id = segmentPrefix + finalized;\n                    finalized = results.length;\n                    pendingText = '';\n                    handlers.onFinal(id, text);\n                }\n                else if (text !== pendingText) {\n                    pendingText = text;\n                    handlers.onPartial(segmentPrefix + finalized, text);\n                }\n            };\n            current.onerror = ({ error }) => {\n                if (recognition !== current || ended)\n                    return;\n                if (error === 'no-speech' || (error === 'aborted' && stopping))\n                    return;\n                const unavailable = !ready && ['service-not-allowed', 'language-not-supported', 'network'].includes(error);\n                fail(unavailable ? new BrowserSpeechUnavailable(error) : new Error(error === 'not-allowed' ? 'Microphone access denied' : `Speech recognition failed (${error})`));\n            };\n            current.onend = () => {\n                if (recognition !== current || ended)\n                    return;\n                if (!ready) {\n                    fail(new BrowserSpeechUnavailable('Speech recognition ended before starting'));\n                    return;\n                }\n                if (!started && ++prematureEnds >= 3) {\n                    fail(new Error('Speech recognition repeatedly ended before starting'));\n                    return;\n                }\n                release();\n                if (stopping) {\n                    cancel();\n                    return;\n                }\n                timer = setTimeout(start, 100);\n            };\n            timer = setTimeout(() => fail(ready\n                ? new Error('Speech recognition start timed out')\n                : new BrowserSpeechUnavailable('Speech recognition start timed out')), 15000);\n            try {\n                current.start();\n            }\n            catch {\n                fail(new BrowserSpeechUnavailable('Browser speech could not start'));\n            }\n        };\n        handlers.signal?.addEventListener('abort', cancel, { once: true });\n        if (handlers.signal?.aborted)\n            cancel();\n        else\n            start();\n    });\n}\n\nexport function useAnchoredAbove(open: boolean, anchorRef: RefObject<HTMLElement | null>, onDismiss: () => void, align: 'stretch' | 'left' | 'right' = 'stretch', gap = 8): CSSProperties | null {\n    return useAnchoredPopover(open, anchorRef, onDismiss, 'above', align, gap);\n}\n\nfunction useAnchoredPopover(open: boolean, anchorRef: RefObject<HTMLElement | null>, onDismiss: () => void, placement: 'above' | 'below', align: 'stretch' | 'left' | 'right', gap: number, menuRef?: RefObject<HTMLDivElement | null>): CSSProperties | null {\n    const [style, setStyle] = useState<CSSProperties | null>(null);\n    const measure = useCallback(() => {\n        const anchor = anchorRef.current;\n        const menu = menuRef?.current;\n        if (!anchor || !menu)\n            return;\n        const r = anchor.getBoundingClientRect();\n        const below = Math.max(0, window.innerHeight - r.bottom - gap - 8);\n        const above = Math.max(0, r.top - gap - 8);\n        const height = Math.max(menu.getBoundingClientRect().height, menu.scrollHeight + menu.offsetHeight - menu.clientHeight);\n        const useAbove = placement === 'above' || (height > below && above > below);\n        const inlineCap = menu.style.maxHeight;\n        menu.style.maxHeight = '';\n        const callerCap = getComputedStyle(menu).maxHeight;\n        menu.style.maxHeight = inlineCap;\n        const available = useAbove ? above : below;\n        const maxHeight = !callerCap || callerCap === 'none' ? available\n            : callerCap.endsWith('px') ? Math.min(available, Number.parseFloat(callerCap))\n                : `min(${available}px, ${callerCap})`;\n        const base: CSSProperties = useAbove\n            ? { position: 'fixed', bottom: window.innerHeight - r.top + gap, maxHeight, zIndex: 1000 }\n            : { position: 'fixed', top: r.bottom + gap, maxHeight, zIndex: 1000 };\n        const next = align === 'stretch' ? { ...base, left: r.left, width: r.width }\n            : align === 'left' ? { ...base, left: r.left }\n                : { ...base, right: window.innerWidth - r.right };\n        setStyle(previous => previous && Object.keys(next).every(key => previous[key as keyof CSSProperties] === next[key as keyof CSSProperties])\n            && Object.keys(previous).length === Object.keys(next).length ? previous : next);\n    }, [anchorRef, menuRef, placement, align, gap]);\n    useLayoutEffect(() => {\n        if (!open) {\n            setStyle(null);\n            return;\n        }\n        const el = anchorRef.current;\n        if (!el)\n            return;\n        const r = el.getBoundingClientRect();\n        const base: CSSProperties = placement === 'above'\n            ? { position: 'fixed', bottom: window.innerHeight - r.top + gap, zIndex: 1000 }\n            : { position: 'fixed', top: r.bottom + gap, maxHeight: window.innerHeight - r.bottom - gap - 8, zIndex: 1000 };\n        setStyle(align === 'stretch' ? { ...base, left: r.left, width: r.width }\n            : align === 'left' ? { ...base, left: r.left }\n                : { ...base, right: window.innerWidth - r.right });\n        const placedAt = { top: r.top, left: r.left, right: r.right };\n        const dismissIfAnchorMoved = () => {\n            const now = anchorRef.current?.getBoundingClientRect();\n            if (!now)\n                return;\n            if (Math.abs(now.top - placedAt.top) < 0.5\n                && Math.abs(now.left - placedAt.left) < 0.5\n                && Math.abs(now.right - placedAt.right) < 0.5)\n                return;\n            onDismiss();\n        };\n        const dismissAlways = () => onDismiss();\n        window.addEventListener('scroll', dismissIfAnchorMoved, true);\n        window.addEventListener('resize', dismissAlways);\n        return () => {\n            window.removeEventListener('scroll', dismissIfAnchorMoved, true);\n            window.removeEventListener('resize', dismissAlways);\n        };\n    }, [open, placement, align, gap, anchorRef]);\n    useLayoutEffect(() => { if (open)\n        measure(); });\n    const placed = open && style !== null;\n    useLayoutEffect(() => {\n        const menu = menuRef?.current;\n        if (!placed || !menu || typeof ResizeObserver === 'undefined')\n            return;\n        const observer = new ResizeObserver(measure);\n        observer.observe(menu);\n        return () => observer.disconnect();\n    }, [placed, menuRef, measure]);\n    return style;\n}\n\nexport function Tooltip({ label, children, className = '', decorative = false, }: {\n    label: string;\n    children: ReactNode;\n    className?: string;\n    decorative?: boolean;\n}) {\n    return (<div className={`relative group ${className}`}>\n      {children}\n      <div role={decorative ? undefined : 'tooltip'} aria-hidden={decorative || undefined} className=\"pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 px-2 py-1 rounded-md bg-tooltip text-white type-footnote whitespace-nowrap opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity z-50\">\n        {label}\n      </div>\n    </div>);\n}\n\nexport function Chip({ label, icon, active, disabled = false, onClick, onRemove, draggable = false, onDragStart, className: extra = '', }: {\n    label: ReactNode;\n    icon?: ReactNode;\n    active?: boolean;\n    disabled?: boolean;\n    onClick?: () => void;\n    onRemove?: () => void;\n    draggable?: boolean;\n    onDragStart?: (e: React.DragEvent) => void;\n    className?: string;\n}) {\n    const isActive = active === true;\n    const ariaPressed = active === undefined ? undefined : active;\n    const stateClass = isActive\n        ? 'border-transparent'\n        : 'border-stroke-outline text-text-primary chip-gradient-hover';\n    const stateStyle = isActive\n        ? { background: 'var(--color-selected-bg)', color: 'var(--color-selected-text)' }\n        : undefined;\n    const cursor = disabled ? 'cursor-not-allowed' : draggable ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer';\n    const base = `flex items-center gap-1 px-4 py-2 md:px-3 md:py-1 rounded-full border type-h2 transition-colors select-none ${cursor} ${disabled ? 'opacity-40' : ''} ${stateClass} ${extra}`;\n    const inner = (<>\n      {icon}\n      <span>{label}</span>\n      {onRemove && (<button type=\"button\" disabled={disabled} onClick={disabled ? undefined : (e) => { e.stopPropagation(); onRemove(); }} className=\"ml-1 -mr-1 flex items-center justify-center rounded-full hover:opacity-70\" style={{ width: 16, height: 16, color: 'inherit' }} aria-label=\"Remove\">\n          <X size={14}/>\n        </button>)}\n    </>);\n    const useDiv = draggable || Boolean(onRemove);\n    if (useDiv) {\n        return (<div role=\"button\" aria-pressed={ariaPressed} aria-disabled={disabled || undefined} tabIndex={disabled ? -1 : 0} draggable={!disabled && draggable ? true : undefined} onDragStart={disabled ? undefined : onDragStart} onClick={disabled ? undefined : onClick} onKeyDown={disabled ? undefined : (e) => { if (e.key === 'Enter' || e.key === ' ') {\n            e.preventDefault();\n            onClick?.();\n        } }} className={base} style={stateStyle}>\n        {inner}\n      </div>);\n    }\n    return (<button type=\"button\" aria-pressed={ariaPressed} disabled={disabled} onClick={disabled ? undefined : onClick} className={base} style={stateStyle}>\n      {inner}\n    </button>);\n}\n\nexport function UtilityChip({ children, icon, onClick, title, ariaLabel, ariaPressed, maxWidth, className: extra = '', }: {\n    children: ReactNode;\n    icon?: ReactNode;\n    onClick?: (e: ReactMouseEvent<HTMLButtonElement>) => void;\n    title?: string;\n    ariaLabel?: string;\n    ariaPressed?: boolean;\n    maxWidth?: number | string;\n    className?: string;\n}) {\n    const style = maxWidth !== undefined ? { maxWidth } : undefined;\n    return (<button type=\"button\" onClick={onClick} aria-label={ariaLabel} aria-pressed={ariaPressed} title={title} style={style} className={`flex items-center gap-1.5 min-w-0 px-3 h-8 rounded-full border border-stroke-outline hover:bg-bg-hover transition-colors text-text-primary ${extra}`}>\n      {icon}\n      <span className=\"font-mono type-caption truncate\">{children}</span>\n    </button>);\n}\n\nexport type ChatInputAttachSource = 'photo' | 'camera' | 'upload';\n\nexport interface DictationDraftState {\n    value: string;\n    draftStart: number;\n    draftEnd: number;\n    segmentId: string | null;\n    frozen: string;\n    frozenEnd?: number;\n    frozenPadding?: number;\n    draftPadding?: number;\n    shown: string;\n    done: string[];\n}\n\nconst DICTATION_CJK = /[\\u2E80-\\u9FFF\\uF900-\\uFAFF\\uFF00-\\uFFEF\\u3000-\\u303F]/;\n\nfunction dictationNeedsSpace(left: string, right: string, leftClosesQuote = false): boolean {\n    if (!left || !right)\n        return false;\n    if (/\\s/.test(left) || /\\s/.test(right))\n        return false;\n    if (DICTATION_CJK.test(left) || DICTATION_CJK.test(right))\n        return false;\n    if (/[.,!?;:)\\]}'\"%]/.test(right))\n        return false;\n    if (/[([{‘“«'\"]/.test(left) && !leftClosesQuote)\n        return false;\n    return true;\n}\n\nconst dictationClamp = (n: number, max: number) => Math.max(0, Math.min(n, max));\n\nexport function beginDictation(value: string, caret: number = value.length): DictationDraftState {\n    const at = dictationClamp(caret, value.length);\n    return { value, draftStart: at, draftEnd: at, segmentId: null, frozen: '', shown: '', done: [] };\n}\n\nexport function adoptUserEdit(state: DictationDraftState, value: string, caret: number = value.length): DictationDraftState {\n    const at = dictationClamp(caret, value.length);\n    const frozenEnd = value === state.value\n        ? (state.frozen === state.shown ? state.frozenEnd : state.draftEnd - (state.draftPadding ?? 0))\n        : undefined;\n    const frozenPadding = value === state.value\n        ? (state.frozen === state.shown ? state.frozenPadding : state.draftPadding)\n        : undefined;\n    return { ...state, value, draftStart: at, draftEnd: at, frozen: state.shown, frozenEnd, frozenPadding, draftPadding: undefined };\n}\n\nexport function mapPosition(before: DictationDraftState, after: DictationDraftState, pos: number): number {\n    if (pos < before.draftStart)\n        return pos;\n    if (pos >= before.draftEnd)\n        return pos + (after.value.length - before.value.length);\n    return Math.min(after.draftStart + (pos - before.draftStart), after.value.length);\n}\n\nfunction placeDictationText(state: DictationDraftState, segmentId: string, text: string, final: boolean): DictationDraftState {\n    if (state.done.includes(segmentId))\n        return state;\n    let s = state;\n    if (s.segmentId !== segmentId) {\n        s = { ...s, draftStart: s.draftEnd, segmentId, frozen: '', frozenEnd: undefined, frozenPadding: undefined, draftPadding: undefined, shown: '', done: retireDictationSegment(s) };\n    }\n    let before = s.value.slice(0, s.draftStart);\n    const after = s.value.slice(s.draftEnd);\n    const remainder = dictationRemainder(s.frozen, text);\n    const paddingBefore = s.frozenEnd !== undefined\n        && s.draftStart === s.frozenEnd + (s.frozenPadding ?? 0) ? (s.frozenPadding ?? 0) : 0;\n    const frozenBefore = paddingBefore ? before.slice(0, -paddingBefore) : before;\n    const joinsFrozenTail = s.frozen && !DICTATION_CJK.test(s.frozen)\n        && s.frozenEnd !== undefined\n        && (s.draftStart === s.frozenEnd || s.draftStart === s.frozenEnd + (s.frozenPadding ?? 0))\n        && frozenBefore.slice(-s.frozen.length).toLowerCase() === s.frozen.toLowerCase() && text.endsWith(remainder)\n        && ((/[\\p{L}\\p{N}']$/u.test(s.frozen)\n            && /[\\p{L}\\p{N}']$/u.test(text.slice(0, text.length - remainder.length))\n            && /^[\\p{L}\\p{N}']/u.test(remainder)) || /^[-/]/.test(remainder));\n    if (joinsFrozenTail && paddingBefore) {\n        before = frozenBefore;\n        s = { ...s, draftStart: before.length };\n    }\n    let body = remainder.replace(/^\\s+/, '');\n    if (final)\n        body = body.replace(/\\s+$/, '');\n    const openingSpace = /^\\s/u.test(remainder) && /^[\"'“‘«]/u.test(body)\n        && before.length > 0 && !/\\s/u.test(before.slice(-1));\n    const closesQuote = (value: string) => {\n        const quote = value.slice(-1);\n        if (/[”’»]/u.test(quote))\n            return true;\n        if (/[“‘«]/u.test(quote))\n            return false;\n        if (quote === \"'\" && /[\\p{L}\\p{N}]'$/u.test(value))\n            return true;\n        if (quote !== '\"' && quote !== \"'\")\n            return false;\n        const delimiters = value.replace(/(?<=[\\p{L}\\p{N}])'(?=[\\p{L}\\p{N}])/gu, '');\n        return (delimiters.split(quote).length - 1) % 2 === 0;\n    };\n    let draftPadding = body && dictationNeedsSpace(body.slice(-1), after[0] ?? '', closesQuote(before + body)) ? 1 : 0;\n    const restoredPadding = !body && s.frozenPadding && s.draftStart === s.frozenEnd\n        && dictationNeedsSpace(before.slice(-1), after[0] ?? '') ? ' ' : '';\n    if (restoredPadding)\n        draftPadding = 1;\n    const insert = body\n        ? `${!joinsFrozenTail && (openingSpace || dictationNeedsSpace(before.slice(-1), body[0], closesQuote(before))) ? ' ' : ''}${body}${draftPadding ? ' ' : ''}`\n        : restoredPadding;\n    const value = before + insert + after;\n    if (final) {\n        const at = before.length + insert.length;\n        return {\n            value,\n            draftStart: at,\n            draftEnd: at,\n            segmentId: null,\n            frozen: '',\n            frozenEnd: undefined,\n            frozenPadding: undefined,\n            draftPadding: undefined,\n            shown: '',\n            done: [...s.done.slice(-31), segmentId],\n        };\n    }\n    return { ...s, value, draftEnd: before.length + insert.length, draftPadding, shown: text };\n}\n\nfunction dictationRemainder(frozen: string, text: string): string {\n    if (!frozen)\n        return text;\n    if (text.startsWith(frozen))\n        return text.slice(frozen.length);\n    if (DICTATION_CJK.test(frozen)) {\n        const body = frozen.trim();\n        for (let n = Math.min(3, body.length); n >= 1; n--) {\n            const tail = body.slice(-n);\n            const expected = body.length - n;\n            for (let at = text.indexOf(tail); at >= 0 && at <= expected + 2; at = text.indexOf(tail, at + 1)) {\n                if (at >= expected - 2)\n                    return text.slice(at + n);\n            }\n        }\n        return text.slice(frozen.length);\n    }\n    const norm = (w: string) => w.toLowerCase().replace(/[^\\p{L}\\p{N}']/gu, '');\n    const tokenize = (input: string) => Array.from(input.matchAll(/[\\p{L}\\p{N}']+|[^\\p{L}\\p{N}']+/gu), (match) => ({\n        raw: match[0], start: match.index!, end: match.index! + match[0].length,\n        word: /^[\\p{L}\\p{N}']/u.test(match[0]),\n    }));\n    const had = tokenize(frozen).filter((token) => token.word);\n    const now = tokenize(text).filter((token) => token.word);\n    const frozenStream = had.map((token) => norm(token.raw)).join('');\n    let stream = '';\n    const ends: number[] = [];\n    const wordAt: number[] = [];\n    now.forEach((token, word) => {\n        let rawEnd = token.start;\n        for (const char of token.raw) {\n            rawEnd += char.length;\n            const normalized = norm(char);\n            stream += normalized;\n            for (let i = 0; i < normalized.length; i++) {\n                ends.push(rawEnd);\n                wordAt.push(word);\n            }\n        }\n    });\n    const remainderAt = (at: number) => {\n        if (/[.,!?;:。，！？；：]\\s*$/u.test(frozen)) {\n            const boundary = text.slice(at).match(/^[\\s.,!?;:。，！？；：]*/u)![0];\n            at += boundary.replace(/\\s+$/, '').length;\n        }\n        return text.slice(at);\n    };\n    if (frozenStream && stream.startsWith(frozenStream)) {\n        const end = frozenStream.length - 1;\n        const at = ends[end];\n        const lastStart = frozenStream.length - norm(had[had.length - 1].raw).length;\n        const complete = at === now[wordAt[end]].end;\n        const partial = wordAt[lastStart] === wordAt[end];\n        if (complete || partial)\n            return remainderAt(at);\n    }\n    const last = had.length ? norm(had[had.length - 1].raw) : '';\n    const expected = had.length - 1;\n    let at = had.length;\n    for (let d = 0; last && d <= 2; d++) {\n        const hit = [expected - d, expected + d].find((i) => i >= 0 && i < now.length && norm(now[i].raw) === last);\n        if (hit !== undefined) {\n            at = hit + 1;\n            break;\n        }\n    }\n    return remainderAt(at ? (now[Math.min(at, now.length) - 1]?.end ?? text.length) : 0);\n}\n\nexport function applyPartial(state: DictationDraftState, segmentId: string, text: string): DictationDraftState {\n    return placeDictationText(state, segmentId, text, false);\n}\n\nexport function applyFinal(state: DictationDraftState, segmentId: string, text: string): DictationDraftState {\n    return placeDictationText(state, segmentId, text, true);\n}\n\nexport function discardDraft(state: DictationDraftState): DictationDraftState {\n    const before = state.value.slice(0, state.draftStart);\n    const after = state.value.slice(state.draftEnd);\n    const padding = state.frozenPadding && state.draftStart === state.frozenEnd\n        && dictationNeedsSpace(before.slice(-1), after[0] ?? '') ? ' ' : '';\n    const value = before + padding + after;\n    const at = state.draftStart + padding.length;\n    return { ...state, value, draftStart: at, draftEnd: at, segmentId: null, frozen: '', frozenEnd: undefined, frozenPadding: undefined, draftPadding: undefined, shown: '', done: retireDictationSegment(state) };\n}\n\nexport function settleDraft(state: DictationDraftState): DictationDraftState {\n    return { ...state, draftStart: state.draftEnd, segmentId: null, frozen: '', frozenEnd: undefined, frozenPadding: undefined, draftPadding: undefined, shown: '', done: retireDictationSegment(state) };\n}\n\nfunction retireDictationSegment(state: DictationDraftState): string[] {\n    return state.segmentId ? [...state.done.slice(-31), state.segmentId] : state.done;\n}\n\nexport interface VoiceInputStreamHandlers {\n    stream: MediaStream;\n    onPartial: (segmentId: string, textSoFar: string) => void;\n    onFinal: (segmentId: string, text: string) => void;\n    onError: (err: Error) => void;\n    onNotice?: (message: string) => void;\n    signal?: AbortSignal;\n}\n\nexport interface VoiceInputStreamSession {\n    stop: () => Promise<void>;\n    cancel: () => void;\n}\n\nexport interface ChatInputAttachment {\n    id: string;\n    name: string;\n    mimeType: string;\n    size: number;\n    kind: 'image' | 'file';\n    dataUrl: string;\n}\n\nexport interface ChatInputActionChip {\n    label: string;\n    action: string;\n}\n\nexport interface ChatInputMentionOption {\n    id: string;\n    label: string;\n    subtitle?: string;\n    avatarUrl?: string;\n    keywords?: string[];\n}\n\nexport interface ChatInputMention {\n    id: string;\n    label: string;\n}\n\nexport interface ChatInputCommandOption {\n    id: string;\n    label: string;\n    subtitle?: string;\n    icon?: ReactNode;\n    keywords?: string[];\n    insertText?: string;\n}\n\nexport interface ChatInputSendMeta {\n    mentions?: ChatInputMention[];\n}\n\nexport function VoiceWaveform({ className = 'flex items-end gap-[2px] h-3', barClassName = 'w-[2px]', }: {\n    className?: string;\n    barClassName?: string;\n}) {\n    const bar = `${barClassName} bg-current rounded-full voice-bar motion-reduce:animate-none`;\n    return (<div className={className} aria-hidden data-voice-waveform=\"\">\n      <div className={`${bar} h-2`} style={{ animationDelay: '0ms' }}/>\n      <div className={`${bar} h-3`} style={{ animationDelay: '188ms' }}/>\n      <div className={`${bar} h-4`} style={{ animationDelay: '375ms' }}/>\n      <div className={`${bar} h-3`} style={{ animationDelay: '188ms' }}/>\n    </div>);\n}\n\nexport interface ChatInputFeatures {\n    voice?: {\n        onActivate: () => void;\n        active: boolean;\n        emphasized?: boolean;\n        session?: {\n            status: 'idle' | 'connecting' | 'active' | 'error';\n            muted: boolean;\n            muteDisabled: boolean;\n            onToggleMute: () => void;\n            onEnd: () => void;\n            onRetry?: () => void;\n            errorText?: string;\n            labels: {\n                muteMicrophone: string;\n                mute: string;\n                unmute: string;\n                endVoiceSession: string;\n                endVoice: string;\n                placeholderConnecting: string;\n                placeholderActive: string;\n                retryVoice?: string;\n            };\n        } | null;\n    };\n    voiceInput?: {\n        onAudioBuffer: (blob: Blob) => Promise<string>;\n        onStream?: (handlers: VoiceInputStreamHandlers) => Promise<VoiceInputStreamSession>;\n        lang?: string;\n        maxDurationMs?: number;\n    };\n    attach?: {\n        attachments: ChatInputAttachment[];\n        onAdd: (files: File[], source: ChatInputAttachSource) => void;\n        onRemove: (id: string) => void;\n        limit?: {\n            maxBytes?: number;\n            maxCount?: number;\n        };\n    };\n    actionChips?: {\n        items?: ChatInputActionChip[];\n        onClick?: (chip: ChatInputActionChip) => void;\n        quickItems?: string[];\n        onQuickClick?: (label: string, index: number) => void;\n        iconMap?: Record<string, string | ReactNode>;\n    };\n    mentions?: {\n        options?: ChatInputMentionOption[];\n        source?: (query: string, signal: AbortSignal) => Promise<ChatInputMentionOption[]>;\n        sourceKey?: string | number;\n        emptyLabel?: string;\n        loadingLabel?: string;\n    };\n    commands?: {\n        source: (query: string, signal: AbortSignal) => Promise<ChatInputCommandOption[]>;\n        sourceKey?: string | number;\n        onPick: (command: ChatInputCommandOption) => void;\n        trigger?: '/';\n        atLineStartOnly?: boolean;\n        emptyLabel?: string;\n        loadingLabel?: string;\n    };\n    forceSendActive?: boolean;\n    isAiResponding?: boolean;\n    voiceModeActive?: boolean;\n    allowSendWhileResponding?: boolean;\n    draft?: {\n        key?: string;\n        initial?: string;\n    };\n}\n\nexport interface ChatInputProps {\n    value?: string;\n    onChange?: (value: string) => void;\n    onSend: (value: string, attachments?: ChatInputAttachment[], meta?: ChatInputSendMeta) => void;\n    placeholder?: string;\n    disabled?: boolean;\n    chips?: ReactNode;\n    fillHeight?: boolean;\n    modeSlot?: ReactNode;\n    features?: ChatInputFeatures;\n    suggestion?: string;\n}\n\nfunction formatBytes(bytes: number): string {\n    if (bytes < 1024)\n        return `${bytes} B`;\n    if (bytes < 1024 * 1024)\n        return `${(bytes / 1024).toFixed(1)} KB`;\n    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nexport function AttachmentChip({ attachment, onRemove, showDownload, maxNameWidth = 160, }: {\n    attachment: ChatInputAttachment;\n    onRemove?: () => void;\n    showDownload?: boolean;\n    maxNameWidth?: number;\n}) {\n    return (<div className=\"relative group inline-flex items-center gap-2 pr-2 py-1.5 rounded-lg bg-bg-message overflow-hidden\">\n      {attachment.kind === 'image' ? (<img src={attachment.dataUrl} alt={attachment.name} className=\"w-12 self-stretch -my-1.5 object-cover shrink-0\"/>) : (<div className=\"w-12 self-stretch -my-1.5 shrink-0 flex items-center justify-center text-text-primary\">\n          <FileText size={22} strokeWidth={1.5}/>\n        </div>)}\n      <div className=\"min-w-0 pr-1\" style={{ maxWidth: maxNameWidth }}>\n        <div className=\"type-detail text-text-primary truncate\">{attachment.name}</div>\n        <div className=\"type-detail text-text-secondary\">{formatBytes(attachment.size)}</div>\n      </div>\n      {showDownload && (<Download size={14} className=\"text-text-secondary shrink-0 ml-1\"/>)}\n      {onRemove && (<button type=\"button\" onClick={onRemove} aria-label={`Remove ${attachment.name}`} className=\"absolute top-1 right-1 w-5 h-5 rounded-full flex items-center justify-center bg-bg-page border border-stroke-outline text-text-primary opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity cursor-pointer\">\n          <X size={12}/>\n        </button>)}\n    </div>);\n}\n\nfunction useChatSuggestion({ suggestion, value, disabled, suppressed, fill, textareaRef, basePlaceholder, }: {\n    suggestion?: string;\n    value: string;\n    disabled: boolean;\n    suppressed?: boolean;\n    fill: (next: string) => void;\n    textareaRef: RefObject<HTMLTextAreaElement | null>;\n    basePlaceholder: string;\n}) {\n    const hintId = useId();\n    const text = suggestion?.trim() ?? '';\n    const active = text !== '' && value === '' && !disabled && !suppressed;\n    const accept = () => {\n        if (!active)\n            return;\n        fill(text);\n        textareaRef.current?.focus();\n    };\n    const handleKey = (e: ReactKeyboardEvent<HTMLTextAreaElement>): boolean => {\n        if (!active || e.nativeEvent.isComposing)\n            return false;\n        if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)\n            return false;\n        if (e.key !== 'Tab' && e.key !== 'ArrowRight')\n            return false;\n        e.preventDefault();\n        accept();\n        return true;\n    };\n    const node = active ? (<>\n      <span aria-hidden data-suggestion-hint=\"\" className=\"type-caption text-text-secondary bg-bg-hover rounded px-1.5 py-0.5 shrink-0 select-none [@media(pointer:coarse)]:hidden\">\n        Tab\n      </span>\n      <UtilityChip onClick={accept} ariaLabel=\"Use suggested reply\" className=\"hidden shrink-0 [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:flex\">\n        <span className=\"type-detail\">Use</span>\n      </UtilityChip>\n      <span id={hintId} className=\"sr-only\">\n        Suggested reply: {text}. Press Tab to use it.\n      </span>\n    </>) : null;\n    return {\n        active,\n        placeholder: active ? text : basePlaceholder,\n        describedBy: active ? hintId : undefined,\n        handleKey,\n        node,\n    };\n}\n\nexport function ChatInput(props: ChatInputProps) {\n    if (props.features === undefined)\n        return <BaselineChatInput {...props}/>;\n    return <RichChatInput {...props}/>;\n}\n\nconst quickChipsContainerClass = (fillHeight?: boolean) => fillHeight\n    ? 'flex flex-wrap content-start gap-2 flex-1 min-h-0 overflow-y-auto p-1'\n    : 'flex flex-wrap gap-2';\n\nfunction isTextareaMultiline(el: HTMLTextAreaElement): boolean {\n    const cs = getComputedStyle(el);\n    const lineHeight = parseFloat(cs.lineHeight) || 22;\n    const padTop = parseFloat(cs.paddingTop) || 0;\n    const padBottom = parseFloat(cs.paddingBottom) || 0;\n    const singleLineHeight = lineHeight + padTop + padBottom;\n    return el.scrollHeight > singleLineHeight + lineHeight * 0.5;\n}\n\nfunction BaselineChatInput({ value = '', onChange, onSend, placeholder = 'Type a message…', disabled = false, chips, fillHeight, modeSlot, suggestion, }: ChatInputProps) {\n    const textareaRef = useRef<HTMLTextAreaElement>(null);\n    const sug = useChatSuggestion({\n        suggestion, value, disabled, textareaRef,\n        fill: (next) => onChange?.(next),\n        basePlaceholder: placeholder,\n    });\n    const [focused, setFocused] = useState(false);\n    const [isMultiline, setIsMultiline] = useState(false);\n    const canSend = !disabled && value.trim().length > 0;\n    const isActive = focused || canSend;\n    const hasText = value.trim().length > 0;\n    const isClickable = canSend;\n    useLayoutEffect(() => {\n        const el = textareaRef.current;\n        if (!el)\n            return;\n        if (value === '') {\n            el.style.height = 'auto';\n            setIsMultiline(false);\n            return;\n        }\n        el.style.height = 'auto';\n        const multiline = isTextareaMultiline(el);\n        el.style.height = `${Math.min(el.scrollHeight, 120)}px`;\n        setIsMultiline(multiline);\n    }, [value]);\n    const submit = () => {\n        if (!isClickable)\n            return;\n        onSend(value, undefined);\n    };\n    return (<div className={`w-full flex flex-col gap-4 ${fillHeight ? 'flex-1 min-h-0' : ''}`}>\n      {chips && (<div className={quickChipsContainerClass(fillHeight)}>\n          {chips}\n        </div>)}\n      <div className={`px-3 py-2 flex gap-2 transition-[border-radius,background-color,box-shadow] duration-200 ease-out ${fillHeight ? 'shrink-0' : ''} ${isMultiline ? 'items-end' : 'items-center'} ${isActive\n            ? `input-gradient-border ${hasText ? 'rounded-lg' : 'rounded-full'}`\n            : 'rounded-full input-gradient-hover'}`} style={{\n            border: '2px solid transparent',\n            backgroundColor: isActive ? 'var(--color-input-bg-active)' : 'var(--color-bg-message)',\n            backgroundClip: 'padding-box',\n        }}>\n        <textarea ref={textareaRef} value={value} onChange={(e) => onChange?.(e.target.value)} onKeyDown={(e) => {\n            if (sug.handleKey(e))\n                return;\n            if (e.key === 'Enter' && !e.shiftKey) {\n                e.preventDefault();\n                submit();\n            }\n        }} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} placeholder={sug.placeholder} disabled={disabled} rows={1} aria-label=\"Chat message — type a message and press Enter to send\" aria-describedby={sug.describedBy} className={`flex-1 min-w-0 resize-none bg-transparent outline-none focus-visible:outline-none type-h2 text-text-primary placeholder-text-tertiary chat-textarea chat-textarea-inset disabled:opacity-50 ${sug.active ? 'placeholder:truncate' : ''}`}/>\n        {sug.node}\n        {isClickable && <div className=\"flex items-center gap-2 shrink-0\">\n          <Tooltip label=\"Send\" decorative>\n            <button type=\"button\" onClick={isClickable ? submit : undefined} aria-label=\"Send message\" aria-disabled={!isClickable} className={`flex items-center justify-center shrink-0 w-12 h-12 md:w-9 md:h-9 rounded-full transition-all ${isClickable ? 'gradient-btn cursor-pointer' : 'cursor-default'}`} style={{ color: isClickable ? '#fff' : 'var(--color-text-primary)' }}>\n              <ArrowUp strokeWidth={2} className=\"w-6 h-6 md:w-[18px] md:h-[18px]\"/>\n            </button>\n          </Tooltip>\n        </div>}\n      </div>\n      {modeSlot && <div className=\"flex items-center\">{modeSlot}</div>}\n    </div>);\n}\n\ntype MentionSearchState = {\n    start: number;\n    query: string;\n};\n\nfunction getActiveMentionSearch(value: string, caret: number): MentionSearchState | null {\n    const beforeCaret = value.slice(0, caret);\n    const match = beforeCaret.match(/(^|\\s)@([^\\s@]*)$/);\n    if (!match)\n        return null;\n    return {\n        start: beforeCaret.length - match[2].length - 1,\n        query: match[2],\n    };\n}\n\nfunction getActiveCommandSearch(value: string, caret: number, trigger: string, atLineStartOnly: boolean): MentionSearchState | null {\n    const beforeCaret = value.slice(0, caret);\n    const t = escapeRegExp(trigger);\n    const match = beforeCaret.match(atLineStartOnly ? new RegExp(`^${t}([^\\\\s]*)$`) : new RegExp(`(^|\\\\s)${t}([^\\\\s]*)$`));\n    if (!match)\n        return null;\n    const query = atLineStartOnly ? match[1] : match[2];\n    return { start: beforeCaret.length - query.length - trigger.length, query };\n}\n\nconst ASYNC_MENU_DEBOUNCE_MS = 120;\n\nconst NO_MENU_ITEMS: never[] = [];\n\nfunction useAsyncMenuOptions<T>(source: ((query: string, signal: AbortSignal) => Promise<T[]>) | undefined, query: string | null, sourceKey?: string | number): {\n    items: T[];\n    loading: boolean;\n} {\n    const [state, setState] = useState<{\n        forQuery: string | null;\n        forKey: string | number | undefined;\n        items: T[];\n    }>({\n        forQuery: null,\n        forKey: undefined,\n        items: [],\n    });\n    const sourceRef = useRef(source);\n    sourceRef.current = source;\n    const seqRef = useRef(0);\n    const hasSource = !!source;\n    useEffect(() => {\n        const seq = ++seqRef.current;\n        if (query === null || !sourceRef.current) {\n            setState((s) => s.forQuery === null && s.forKey === undefined && s.items.length === 0\n                ? s\n                : { forQuery: null, forKey: undefined, items: [] });\n            return;\n        }\n        const controller = new AbortController();\n        const timer = window.setTimeout(() => {\n            const fetcher = sourceRef.current;\n            if (!fetcher)\n                return;\n            const keep = () => seq === seqRef.current && !controller.signal.aborted;\n            let pending: Promise<T[]>;\n            try {\n                pending = Promise.resolve(fetcher(query, controller.signal));\n            }\n            catch {\n                pending = Promise.resolve([]);\n            }\n            pending.then((items) => { if (keep())\n                setState({ forQuery: query, forKey: sourceKey, items: Array.isArray(items) ? items : [] }); }, () => { if (keep())\n                setState({ forQuery: query, forKey: sourceKey, items: [] }); });\n        }, ASYNC_MENU_DEBOUNCE_MS);\n        return () => {\n            window.clearTimeout(timer);\n            controller.abort();\n        };\n    }, [query, hasSource, sourceKey]);\n    const ready = state.forQuery === query && state.forKey === sourceKey;\n    return { items: ready ? state.items : NO_MENU_ITEMS, loading: query !== null && hasSource && !ready };\n}\n\nfunction filterMentionOptions(options: ChatInputMentionOption[], query: string): ChatInputMentionOption[] {\n    const needle = query.trim().toLocaleLowerCase();\n    if (!needle)\n        return options.slice(0, 8);\n    return options\n        .filter((option) => {\n        const haystack = [\n            option.label,\n            option.subtitle,\n            ...(option.keywords ?? []),\n        ]\n            .filter(Boolean)\n            .join(' ')\n            .toLocaleLowerCase();\n        return haystack.includes(needle);\n    })\n        .slice(0, 8);\n}\n\nfunction escapeRegExp(value: string): string {\n    return value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction containsMentionToken(value: string, label: string): boolean {\n    return new RegExp(`(^|\\\\s)@${escapeRegExp(label)}(?=$|[\\\\s.,!?;:)\\\\]}])`).test(value);\n}\n\nfunction resolveMentionMeta(value: string, selected: ChatInputMention[], options: ChatInputMentionOption[]): ChatInputMention[] {\n    const byId = new Map<string, ChatInputMention>();\n    for (const mention of selected) {\n        if (!containsMentionToken(value, mention.label))\n            continue;\n        byId.set(mention.id, mention);\n    }\n    const labelCounts = new Map<string, number>();\n    for (const option of options) {\n        labelCounts.set(option.label, (labelCounts.get(option.label) ?? 0) + 1);\n    }\n    for (const option of options) {\n        if (byId.has(option.id))\n            continue;\n        if (labelCounts.get(option.label) !== 1)\n            continue;\n        if (!containsMentionToken(value, option.label))\n            continue;\n        byId.set(option.id, { id: option.id, label: option.label });\n    }\n    return [...byId.values()];\n}\n\nconst EMPTY_MENTION_OPTIONS: ChatInputMentionOption[] = [];\n\nfunction ChatInputMenuLoading({ label }: {\n    label?: string;\n}) {\n    return (<div className=\"flex items-center gap-2 px-3 py-3 type-detail text-text-secondary\" data-chat-input-menu-loading=\"\">\n      <Loader2 size={14} strokeWidth={2.2} className=\"animate-spin motion-reduce:animate-none shrink-0\"/>\n      <span>{label ?? 'Loading…'}</span>\n    </div>);\n}\n\nfunction RichChatInput({ value: valueProp, onChange: onChangeProp, onSend, placeholder = 'Type a message…', disabled = false, features = {}, fillHeight, modeSlot, suggestion, }: ChatInputProps) {\n    const isControlled = valueProp !== undefined;\n    const [internalValue, setInternalValue] = useState(features.draft?.initial ?? '');\n    const v = isControlled ? valueProp! : internalValue;\n    const setV = (next: string) => {\n        if (isControlled)\n            onChangeProp?.(next);\n        else\n            setInternalValue(next);\n    };\n    const [focused, setFocused] = useState(false);\n    const [showAttachMenu, setShowAttachMenu] = useState(false);\n    const composerRef = useRef<HTMLDivElement>(null);\n    const [isMultiline, setIsMultiline] = useState(false);\n    const [attachError, setAttachError] = useState<string | null>(null);\n    const [recordingState, setRecordingState] = useState<'idle' | 'connecting' | 'recording' | 'processing'>('idle');\n    const [voiceInputError, setVoiceInputError] = useState<string | null>(null);\n    const sendAfterDictationRef = useRef(false);\n    const dictationFailedRef = useRef(false);\n    const [mentionSearch, setMentionSearch] = useState<MentionSearchState | null>(null);\n    const [mentionActiveIndex, setMentionActiveIndex] = useState(0);\n    const [selectedMentions, setSelectedMentions] = useState<ChatInputMention[]>([]);\n    const [commandSearch, setCommandSearch] = useState<MentionSearchState | null>(null);\n    const menuListboxId = useId();\n    const textareaRef = useRef<HTMLTextAreaElement>(null);\n    const attachRef = useRef<HTMLDivElement>(null);\n    const attachMenuRef = useRef<HTMLDivElement>(null);\n    const attachStyle = useAnchoredAbove(showAttachMenu, attachRef, () => setShowAttachMenu(false), 'left');\n    const photoInputRef = useRef<HTMLInputElement>(null);\n    const cameraInputRef = useRef<HTMLInputElement>(null);\n    const fileInputRef = useRef<HTMLInputElement>(null);\n    const sendingRef = useRef(false);\n    const mediaRecorderRef = useRef<MediaRecorder | null>(null);\n    const audioStreamRef = useRef<MediaStream | null>(null);\n    const audioChunksRef = useRef<Blob[]>([]);\n    const recordingTimerRef = useRef<number | null>(null);\n    const recordingCancelledRef = useRef(false);\n    const latestValueRef = useRef('');\n    const dictationRef = useRef<{\n        session: VoiceInputStreamSession | null;\n        draft: DictationDraftState;\n        stream: MediaStream | null;\n        ended: boolean;\n        abort: AbortController;\n        lastSel: [\n            number,\n            number\n        ] | null;\n        held: ((s: DictationDraftState) => DictationDraftState)[];\n    } | null>(null);\n    const composingRef = useRef(false);\n    const pendingSelectionRef = useRef<[\n        number,\n        number\n    ] | null>(null);\n    const backdropRef = useRef<HTMLDivElement>(null);\n    const startingRef = useRef<object | null>(null);\n    const pendingStartRef = useRef<AbortController | null>(null);\n    const blurSelRef = useRef<{\n        value: string;\n        start: number;\n        end: number;\n    } | null>(null);\n    const [draftView, setDraftView] = useState<{\n        start: number;\n        end: number;\n        value: string;\n    } | null>(null);\n    const [liveCaption, setLiveCaption] = useState('');\n    useEffect(() => {\n        if (!features.isAiResponding || features.voiceModeActive || features.allowSendWhileResponding)\n            sendingRef.current = false;\n    }, [features.isAiResponding, features.voiceModeActive, features.allowSendWhileResponding]);\n    useEffect(() => {\n        latestValueRef.current = v;\n    }, [v]);\n    const lastDraftKeyRef = useRef(features.draft?.key);\n    useEffect(() => {\n        if (features.draft?.key === lastDraftKeyRef.current)\n            return;\n        lastDraftKeyRef.current = features.draft?.key;\n        sendAfterDictationRef.current = false;\n        abortPendingStart();\n        const dictating = dictationRef.current;\n        if (dictating && !dictating.ended) {\n            endDictation(dictating, 'discard');\n            dictating.session?.cancel();\n        }\n        if (isControlled)\n            return;\n        setInternalValue(features.draft?.initial ?? '');\n        latestValueRef.current = features.draft?.initial ?? '';\n        setSelectedMentions([]);\n        setMentionSearch(null);\n        setCommandSearch(null);\n    }, [features.draft?.key, features.draft?.initial, isControlled]);\n    useEffect(() => {\n        const ta = textareaRef.current;\n        if (!ta)\n            return;\n        if (v === '') {\n            ta.style.height = 'auto';\n            setIsMultiline(false);\n            return;\n        }\n        ta.style.height = 'auto';\n        const multiline = isTextareaMultiline(ta);\n        const next = Math.min(ta.scrollHeight, 120);\n        ta.style.height = next + 'px';\n        setIsMultiline(multiline);\n    }, [v]);\n    useLayoutEffect(() => {\n        const ta = textareaRef.current;\n        const sel = pendingSelectionRef.current;\n        if (ta && sel && ta.value === v) {\n            pendingSelectionRef.current = null;\n            ta.setSelectionRange(sel[0], sel[1]);\n        }\n        const layer = backdropRef.current;\n        if (ta && layer) {\n            layer.style.top = `${ta.offsetTop}px`;\n            layer.style.left = `${ta.offsetLeft}px`;\n            layer.style.width = `${ta.offsetWidth}px`;\n            layer.style.height = `${ta.offsetHeight}px`;\n            layer.scrollTop = ta.scrollTop;\n        }\n    }, [v, draftView, focused, recordingState]);\n    useEffect(() => () => {\n        pendingStartRef.current?.abort();\n        const d = dictationRef.current;\n        if (!d)\n            return;\n        d.ended = true;\n        d.abort.abort();\n        d.session?.cancel();\n        d.stream?.getTracks().forEach((t) => t.stop());\n    }, []);\n    useEffect(() => {\n        if (!attachError)\n            return;\n        const t = window.setTimeout(() => setAttachError(null), 4000);\n        return () => window.clearTimeout(t);\n    }, [attachError]);\n    useEffect(() => {\n        if (!voiceInputError)\n            return;\n        const t = window.setTimeout(() => setVoiceInputError(null), 4000);\n        return () => window.clearTimeout(t);\n    }, [voiceInputError]);\n    useEffect(() => {\n        if (!showAttachMenu)\n            return;\n        const handleClick = (e: globalThis.MouseEvent) => {\n            const target = e.target as Node;\n            if (attachRef.current?.contains(target))\n                return;\n            if (attachMenuRef.current?.contains(target))\n                return;\n            setShowAttachMenu(false);\n        };\n        const handleKey = (e: globalThis.KeyboardEvent) => {\n            if (e.key === 'Escape')\n                setShowAttachMenu(false);\n        };\n        document.addEventListener('mousedown', handleClick);\n        document.addEventListener('keydown', handleKey);\n        return () => {\n            document.removeEventListener('mousedown', handleClick);\n            document.removeEventListener('keydown', handleKey);\n        };\n    }, [showAttachMenu]);\n    useEffect(() => {\n        return () => {\n            if (audioStreamRef.current) {\n                audioStreamRef.current.getTracks().forEach((t) => t.stop());\n                audioStreamRef.current = null;\n            }\n            if (recordingTimerRef.current !== null) {\n                window.clearTimeout(recordingTimerRef.current);\n                recordingTimerRef.current = null;\n            }\n        };\n    }, []);\n    const attachFeat = features.attach;\n    const voiceInputFeat = features.voiceInput;\n    const voiceFeat = features.voice;\n    const mentionFeat = features.mentions;\n    const mentionOptions = mentionFeat?.options ?? EMPTY_MENTION_OPTIONS;\n    const mentionSource = mentionFeat?.source;\n    const commandFeat = features.commands;\n    const enhancedMenus = !!mentionSource || !!commandFeat;\n    const mentionEnabled = !!mentionFeat && (!!mentionSource || mentionOptions.length > 0);\n    const mentionAsync = useAsyncMenuOptions(mentionSource, mentionSource && mentionSearch ? mentionSearch.query : null, mentionFeat?.sourceKey);\n    const commandAsync = useAsyncMenuOptions(commandFeat?.source, commandFeat && commandSearch ? commandSearch.query : null, commandFeat?.sourceKey);\n    const staticMentionOptions = useMemo(() => filterMentionOptions(mentionOptions, mentionSearch?.query ?? ''), [mentionOptions, mentionSearch?.query]);\n    const visibleMentionOptions = mentionSource ? mentionAsync.items : staticMentionOptions;\n    const showMentionPicker = !!mentionFeat && !!mentionSearch;\n    const showCommandPicker = !!commandFeat && !!commandSearch;\n    const commandStyle = useAnchoredAbove(showCommandPicker, composerRef, () => setCommandSearch(null), 'stretch');\n    const mentionStyle = useAnchoredAbove(showMentionPicker, composerRef, () => setMentionSearch(null), 'stretch');\n    const menuOpen = showMentionPicker || showCommandPicker;\n    const menuLoading = showCommandPicker\n        ? commandAsync.loading\n        : showMentionPicker && !!mentionSource && mentionAsync.loading;\n    const menuItemCount = showCommandPicker ? commandAsync.items.length : visibleMentionOptions.length;\n    useEffect(() => {\n        setMentionActiveIndex(0);\n    }, [mentionSearch?.query]);\n    useEffect(() => {\n        if (!enhancedMenus)\n            return;\n        setMentionActiveIndex(0);\n    }, [enhancedMenus, commandSearch?.query, mentionAsync.items, commandAsync.items]);\n    const handleFileInput = (source: ChatInputAttachSource) => (e: React.ChangeEvent<HTMLInputElement>) => {\n        const files = Array.from(e.target.files ?? []);\n        e.target.value = '';\n        if (!attachFeat || files.length === 0)\n            return;\n        const limit = attachFeat.limit;\n        const maxCount = limit?.maxCount;\n        const maxBytes = limit?.maxBytes;\n        const remaining = maxCount !== undefined ? maxCount - attachFeat.attachments.length : files.length;\n        if (remaining <= 0) {\n            setAttachError(maxCount !== undefined ? `Up to ${maxCount} attachments per message` : 'Attachment limit reached');\n            return;\n        }\n        const toProcess = files.slice(0, remaining);\n        const droppedForLimit = files.length - toProcess.length;\n        const oversized: string[] = [];\n        const accepted: File[] = [];\n        for (const f of toProcess) {\n            if (maxBytes !== undefined && f.size > maxBytes)\n                oversized.push(f.name);\n            else\n                accepted.push(f);\n        }\n        const problems: string[] = [];\n        if (oversized.length)\n            problems.push(`${oversized.length} file${oversized.length > 1 ? 's' : ''}${maxBytes !== undefined ? ` over ${formatBytes(maxBytes)}` : ' rejected'}`);\n        if (droppedForLimit)\n            problems.push(`${droppedForLimit} skipped${maxCount !== undefined ? ` (limit ${maxCount})` : ''}`);\n        setAttachError(problems.length ? problems.join(' • ') : null);\n        if (accepted.length)\n            attachFeat.onAdd(accepted, source);\n    };\n    const openPicker = (source: ChatInputAttachSource) => {\n        setShowAttachMenu(false);\n        const ref = source === 'photo' ? photoInputRef : source === 'camera' ? cameraInputRef : fileInputRef;\n        ref.current?.click();\n    };\n    const refreshMentionSearch = (value: string, caret: number) => {\n        const command = commandFeat\n            ? getActiveCommandSearch(value, caret, commandFeat.trigger ?? '/', commandFeat.atLineStartOnly ?? true)\n            : null;\n        setCommandSearch(command);\n        if (command || !mentionEnabled) {\n            setMentionSearch(null);\n            return;\n        }\n        setMentionSearch(getActiveMentionSearch(value, caret));\n    };\n    const closeMenus = () => {\n        setMentionSearch(null);\n        setCommandSearch(null);\n    };\n    const openMentionPicker = () => {\n        if (!mentionEnabled)\n            return;\n        setShowAttachMenu(false);\n        setCommandSearch(null);\n        const caret = textareaRef.current?.selectionStart ?? v.length;\n        setMentionSearch({ start: caret, query: '' });\n        setMentionActiveIndex(0);\n        window.requestAnimationFrame(() => textareaRef.current?.focus());\n    };\n    const insertMention = (option: ChatInputMentionOption) => {\n        const caret = textareaRef.current?.selectionStart ?? v.length;\n        const search = mentionSearch ?? { start: caret, query: '' };\n        const before = v.slice(0, search.start);\n        const after = v.slice(caret);\n        const prefix = before.length > 0 && !/\\s$/.test(before) ? ' ' : '';\n        const token = `@${option.label} `;\n        const nextValue = `${before}${prefix}${token}${after}`;\n        const nextCursor = before.length + prefix.length + token.length;\n        flushSync(() => setV(nextValue));\n        latestValueRef.current = nextValue;\n        if (textareaRef.current) {\n            textareaRef.current.focus();\n            textareaRef.current.setSelectionRange(nextCursor, nextCursor);\n        }\n        setSelectedMentions((prev) => {\n            if (prev.some((mention) => mention.id === option.id))\n                return prev;\n            return [...prev, { id: option.id, label: option.label }];\n        });\n        setMentionSearch(null);\n    };\n    const pickCommand = (command: ChatInputCommandOption) => {\n        if (!commandFeat)\n            return;\n        const caret = textareaRef.current?.selectionStart ?? v.length;\n        const search = commandSearch ?? { start: caret, query: '' };\n        const before = v.slice(0, search.start);\n        const from = Math.max(caret, search.start);\n        const tokenEnd = from + (v.slice(from).match(/^[^\\s]*/)?.[0].length ?? 0);\n        const after = v.slice(tokenEnd);\n        const insert = command.insertText ?? '';\n        const nextValue = `${before}${insert}${after}`;\n        const nextCursor = before.length + insert.length;\n        flushSync(() => setV(nextValue));\n        latestValueRef.current = nextValue;\n        if (textareaRef.current) {\n            textareaRef.current.focus();\n            textareaRef.current.setSelectionRange(nextCursor, nextCursor);\n        }\n        setCommandSearch(null);\n        commandFeat.onPick(command);\n    };\n    const canSend = v.trim().length > 0 || (attachFeat && attachFeat.attachments.length > 0);\n    const isActive = focused || canSend || !!features.forceSendActive;\n    const hasText = v.trim().length > 0 || !!(attachFeat && attachFeat.attachments.length > 0);\n    const sendActive = canSend || !!features.forceSendActive;\n    const responseLock = !!features.isAiResponding && !features.voiceModeActive && !features.allowSendWhileResponding;\n    const isClickable = sendActive && !responseLock && !disabled;\n    const handleSend = () => {\n        if (responseLock || sendingRef.current || disabled)\n            return;\n        if (!sendActive)\n            return;\n        const trimmed = v.trim();\n        const mentions = mentionFeat\n            ? resolveMentionMeta(v, selectedMentions, mentionOptions)\n            : [];\n        sendingRef.current = true;\n        onSend(trimmed, attachFeat?.attachments, mentions.length ? { mentions } : undefined);\n        setV('');\n        latestValueRef.current = '';\n        const dictating = dictationRef.current;\n        if (dictating && !dictating.ended) {\n            dictating.draft = adoptUserEdit(dictating.draft, '', 0);\n            dictating.lastSel = null;\n            pendingSelectionRef.current = null;\n            setDraftView(null);\n            setLiveCaption('');\n        }\n        setSelectedMentions([]);\n        setMentionSearch(null);\n        setCommandSearch(null);\n        setIsMultiline(false);\n        if (textareaRef.current)\n            textareaRef.current.style.height = 'auto';\n        if (features.voiceModeActive || features.allowSendWhileResponding) {\n            queueMicrotask(() => { sendingRef.current = false; });\n        }\n    };\n    type Dictation = NonNullable<typeof dictationRef.current>;\n    const applyCaption = (d: Dictation, update: (s: DictationDraftState) => DictationDraftState, force = false) => {\n        if (dictationRef.current !== d || d.ended)\n            return;\n        if (composingRef.current && !force) {\n            d.held.push(update);\n            return;\n        }\n        if (d.held.length) {\n            const held = d.held.splice(0);\n            const last = update;\n            update = (s) => last(held.reduce((acc, f) => f(acc), s));\n        }\n        const ta = textareaRef.current;\n        const focused = !!ta && document.activeElement === ta;\n        let current = d.draft;\n        const live = latestValueRef.current;\n        const settled = !!ta && ta.value === live && pendingSelectionRef.current === null;\n        const sel: [\n            number,\n            number\n        ] | null = !focused\n            ? null\n            : pendingSelectionRef.current ?? (settled ? [ta!.selectionStart, ta!.selectionEnd] : null);\n        if (live !== current.value) {\n            current = adoptUserEdit(current, live, sel ? sel[1] : live.length);\n        }\n        else if (settled && sel && (!d.lastSel || sel[0] !== d.lastSel[0] || sel[1] !== d.lastSel[1])) {\n            current = adoptUserEdit(current, live, sel[1]);\n        }\n        const next = update(current);\n        let nextSel = sel;\n        if (next.value !== current.value) {\n            nextSel = sel ? [mapPosition(current, next, sel[0]), mapPosition(current, next, sel[1])] : null;\n            pendingSelectionRef.current = nextSel;\n            setV(next.value);\n            latestValueRef.current = next.value;\n        }\n        d.lastSel = nextSel;\n        d.draft = next;\n        setDraftView({ start: next.draftStart, end: next.draftEnd, value: next.value });\n        setLiveCaption(next.value.slice(next.draftStart, next.draftEnd));\n    };\n    const endDictation = (d: Dictation, mode: 'settle' | 'discard') => {\n        if (d.ended)\n            return;\n        applyCaption(d, mode === 'discard' ? discardDraft : settleDraft, true);\n        d.ended = true;\n        d.abort.abort();\n        if (recordingTimerRef.current !== null) {\n            window.clearTimeout(recordingTimerRef.current);\n            recordingTimerRef.current = null;\n        }\n        d.stream?.getTracks().forEach((t) => t.stop());\n        if (dictationRef.current === d)\n            dictationRef.current = null;\n        setDraftView(null);\n        setLiveCaption('');\n        setRecordingState('idle');\n    };\n    const startStreaming = async (onStream: NonNullable<ChatInputFeatures['voiceInput']>['onStream'], browser = false) => {\n        const abort = new AbortController();\n        pendingStartRef.current = abort;\n        setRecordingState('connecting');\n        let stream: MediaStream | null = null;\n        try {\n            if (!browser)\n                stream = await navigator.mediaDevices.getUserMedia({\n                    audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },\n                });\n        }\n        catch (err) {\n            if (pendingStartRef.current === abort)\n                pendingStartRef.current = null;\n            if (!abort.signal.aborted) {\n                setVoiceInputError(err instanceof Error ? err.message : 'Microphone access denied');\n                setRecordingState('idle');\n            }\n            return;\n        }\n        if (pendingStartRef.current === abort)\n            pendingStartRef.current = null;\n        if (abort.signal.aborted) {\n            stream?.getTracks().forEach((t) => t.stop());\n            return;\n        }\n        const ta = textareaRef.current;\n        const focused = !!ta && document.activeElement === ta;\n        const blurred = blurSelRef.current;\n        const caret = focused\n            ? ta!.selectionEnd\n            : blurred && blurred.value === latestValueRef.current\n                ? blurred.end\n                : latestValueRef.current.length;\n        const d: Dictation = {\n            session: null,\n            draft: beginDictation(latestValueRef.current, caret),\n            stream,\n            ended: false,\n            abort,\n            lastSel: ta && document.activeElement === ta ? [ta.selectionStart, ta.selectionEnd] : null,\n            held: [],\n        };\n        dictationRef.current = d;\n        try {\n            const handlers = {\n                signal: abort.signal,\n                onPartial: (id: string, text: string) => applyCaption(d, (st) => applyPartial(st, id, text)),\n                onFinal: (id: string, text: string) => applyCaption(d, (st) => applyFinal(st, id, text)),\n                onError: (err: Error) => {\n                    dictationFailedRef.current = true;\n                    setVoiceInputError(err.message || 'Live captions stopped');\n                    endDictation(d, 'settle');\n                },\n                onNotice: (message: string) => setVoiceInputError(message),\n            };\n            const session = await (browser\n                ? createBrowserDictation(handlers, voiceInputFeat?.lang)\n                : onStream!({ ...handlers, stream: stream! }));\n            if (d.ended) {\n                session.cancel();\n                return;\n            }\n            d.session = session;\n            setRecordingState('recording');\n            setDraftView({ start: d.draft.draftStart, end: d.draft.draftEnd, value: d.draft.value });\n            const maxMs = voiceInputFeat?.maxDurationMs ?? LIVE_DICTATION_MAX_MS;\n            recordingTimerRef.current = window.setTimeout(() => stopRecording(), maxMs);\n        }\n        catch (err) {\n            const cancelled = d.ended;\n            endDictation(d, 'settle');\n            if (cancelled)\n                return;\n            if (browser) {\n                if (err instanceof BrowserSpeechUnavailable)\n                    await startHostRecording();\n                else\n                    setVoiceInputError(err instanceof Error ? err.message : 'Speech recognition failed');\n                return;\n            }\n            if (!(await startBatchRecording())) {\n                setVoiceInputError(err instanceof Error ? err.message : 'Live captions are unavailable right now');\n            }\n        }\n    };\n    const startRecording = async () => {\n        if (!voiceInputFeat || recordingState !== 'idle' || startingRef.current)\n            return;\n        const token = {};\n        startingRef.current = token;\n        try {\n            await startRecordingNow();\n        }\n        finally {\n            if (startingRef.current === token)\n                startingRef.current = null;\n        }\n    };\n    const startRecordingNow = async () => {\n        if (!voiceInputFeat)\n            return;\n        setVoiceInputError(null);\n        recordingCancelledRef.current = false;\n        if (browserRecognitionConstructor()) {\n            await startStreaming(voiceInputFeat.onStream, true);\n            return;\n        }\n        await startHostRecording();\n    };\n    const startHostRecording = async () => {\n        if (!voiceInputFeat)\n            return;\n        if (!navigator.mediaDevices?.getUserMedia) {\n            setVoiceInputError('Microphone is unavailable in this browser. Try a recent Chrome / Safari over HTTPS.');\n            return;\n        }\n        if (voiceInputFeat.onStream && typeof window.RTCPeerConnection === 'function') {\n            await startStreaming(voiceInputFeat.onStream);\n            return;\n        }\n        await startBatchRecording();\n    };\n    const startBatchRecording = async (): Promise<boolean> => {\n        if (!voiceInputFeat)\n            return false;\n        recordingCancelledRef.current = false;\n        const abort = new AbortController();\n        pendingStartRef.current = abort;\n        setRecordingState('connecting');\n        try {\n            const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n            if (pendingStartRef.current === abort)\n                pendingStartRef.current = null;\n            if (abort.signal.aborted) {\n                stream.getTracks().forEach((t) => t.stop());\n                return true;\n            }\n            audioStreamRef.current = stream;\n            audioChunksRef.current = [];\n            const recorder = new MediaRecorder(stream);\n            mediaRecorderRef.current = recorder;\n            recorder.ondataavailable = (e) => {\n                if (e.data && e.data.size > 0)\n                    audioChunksRef.current.push(e.data);\n            };\n            recorder.onstop = async () => {\n                const stream = audioStreamRef.current;\n                if (stream) {\n                    stream.getTracks().forEach((t) => t.stop());\n                    audioStreamRef.current = null;\n                }\n                if (recordingTimerRef.current !== null) {\n                    window.clearTimeout(recordingTimerRef.current);\n                    recordingTimerRef.current = null;\n                }\n                const cancelled = recordingCancelledRef.current;\n                recordingCancelledRef.current = false;\n                if (cancelled) {\n                    audioChunksRef.current = [];\n                    setRecordingState('idle');\n                    return;\n                }\n                const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || 'audio/webm' });\n                audioChunksRef.current = [];\n                if (blob.size === 0) {\n                    setRecordingState('idle');\n                    return;\n                }\n                setRecordingState('processing');\n                try {\n                    const transcript = await voiceInputFeat.onAudioBuffer(blob);\n                    if (transcript) {\n                        const prev = latestValueRef.current;\n                        setV(prev ? `${prev} ${transcript}` : transcript);\n                    }\n                }\n                catch (err) {\n                    dictationFailedRef.current = true;\n                    setVoiceInputError(err instanceof Error ? err.message : 'Transcription failed');\n                }\n                finally {\n                    setRecordingState('idle');\n                }\n            };\n            recorder.start();\n            setRecordingState('recording');\n            const maxMs = voiceInputFeat.maxDurationMs ?? 60000;\n            recordingTimerRef.current = window.setTimeout(() => {\n                if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {\n                    mediaRecorderRef.current.stop();\n                }\n            }, maxMs);\n            return true;\n        }\n        catch (err) {\n            if (pendingStartRef.current === abort)\n                pendingStartRef.current = null;\n            if (abort.signal.aborted)\n                return true;\n            setVoiceInputError(err instanceof Error ? err.message : 'Microphone access denied');\n            setRecordingState('idle');\n            return false;\n        }\n    };\n    const abortPendingStart = (): boolean => {\n        const pending = pendingStartRef.current;\n        if (!pending)\n            return false;\n        pending.abort();\n        pendingStartRef.current = null;\n        startingRef.current = null;\n        setRecordingState('idle');\n        return true;\n    };\n    const stopRecording = () => {\n        if (abortPendingStart())\n            return;\n        const d = dictationRef.current;\n        if (d) {\n            if (d.ended)\n                return;\n            if (!d.session) {\n                endDictation(d, 'settle');\n                return;\n            }\n            setRecordingState('processing');\n            const session = d.session;\n            void session.stop().finally(() => endDictation(d, 'settle'));\n            return;\n        }\n        if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {\n            mediaRecorderRef.current.stop();\n        }\n    };\n    const cancelRecording = () => {\n        if (abortPendingStart())\n            return;\n        const d = dictationRef.current;\n        if (d) {\n            endDictation(d, 'discard');\n            d.session?.cancel();\n            return;\n        }\n        if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {\n            recordingCancelledRef.current = true;\n            mediaRecorderRef.current.stop();\n        }\n    };\n    const sendAndEndDictation = () => {\n        if (responseLock || disabled)\n            return;\n        sendAfterDictationRef.current = true;\n        dictationFailedRef.current = false;\n        stopRecording();\n    };\n    useEffect(() => {\n        if (recordingState !== 'idle' || !sendAfterDictationRef.current)\n            return;\n        sendAfterDictationRef.current = false;\n        if (dictationFailedRef.current)\n            return;\n        handleSend();\n    }, [recordingState]);\n    const liveMirror = recordingState !== 'idle' && !!draftView && draftView.value === v;\n    const showQuickChips = !!features.actionChips?.quickItems?.length &&\n        v.length === 0 &&\n        !(attachFeat && attachFeat.attachments.length > 0) &&\n        !voiceFeat?.active &&\n        !menuOpen;\n    const showSendInsteadOfVoice = voiceFeat?.active || sendActive;\n    const voiceSession = voiceFeat?.active ? voiceFeat.session ?? null : null;\n    const voiceErrorText = voiceSession?.status === 'error' ? voiceSession.errorText ?? '' : '';\n    const voiceError = voiceSession?.status === 'error' && voiceErrorText !== '';\n    const inputPlaceholder = voiceSession?.status === 'connecting'\n        ? voiceSession.labels.placeholderConnecting\n        : voiceSession?.status === 'active'\n            ? voiceSession.labels.placeholderActive\n            : voiceError\n                ? voiceErrorText\n                : placeholder;\n    const sug = useChatSuggestion({\n        suggestion, value: v, disabled, textareaRef,\n        suppressed: (!!voiceSession && voiceSession.status !== 'idle') || voiceError,\n        fill: (next) => { setV(next); latestValueRef.current = next; },\n        basePlaceholder: inputPlaceholder,\n    });\n    const errorMsg = attachError || voiceInputError;\n    return (<div className={`w-full flex flex-col gap-4 ${fillHeight ? 'flex-1 min-h-0' : ''}`}>\n      {attachFeat && (<>\n          <input ref={photoInputRef} type=\"file\" accept=\"image/*\" multiple className=\"hidden\" onChange={handleFileInput('photo')} aria-label=\"Attach photos\"/>\n          <input ref={cameraInputRef} type=\"file\" accept=\"image/*\" capture=\"environment\" className=\"hidden\" onChange={handleFileInput('camera')} aria-label=\"Take a photo\"/>\n          <input ref={fileInputRef} type=\"file\" multiple className=\"hidden\" onChange={handleFileInput('upload')} aria-label=\"Attach files\"/>\n        </>)}\n\n      {attachFeat && attachFeat.attachments.length > 0 && (<div className=\"flex flex-wrap gap-2\" aria-label=\"Pending attachments\">\n          {attachFeat.attachments.map((att) => (<AttachmentChip key={att.id} attachment={att} onRemove={() => attachFeat.onRemove(att.id)}/>))}\n        </div>)}\n\n      {errorMsg && (<div className=\"type-caption text-error\" role=\"status\">{errorMsg}</div>)}\n\n      {showQuickChips && (<div className={quickChipsContainerClass(fillHeight)}>\n          {features.actionChips!.quickItems!.map((chip, index) => {\n                const ic = features.actionChips?.iconMap?.[chip];\n                return (<Chip key={chip} label={chip} onClick={() => {\n                        const onQuickClick = features.actionChips?.onQuickClick;\n                        if (onQuickClick)\n                            onQuickClick(chip, index);\n                        else\n                            onSend(chip, undefined);\n                    }} icon={ic ? (<div className=\"relative overflow-hidden w-4 h-4 shrink-0 flex items-center justify-center\">\n                      {typeof ic === 'string' ? (<img src={ic} alt=\"\" className=\"max-w-full max-h-full object-contain opacity-70 icon-theme\"/>) : (ic)}\n                    </div>) : undefined}/>);\n            })}\n        </div>)}\n\n      {features.actionChips?.items && features.actionChips.items.length > 0 && (<div className=\"flex gap-2 overflow-x-auto scrollbar-autohide -mx-4 px-4\">\n          {features.actionChips.items.map((chip) => (<Chip key={chip.action} label={chip.label} onClick={() => features.actionChips!.onClick?.(chip)} className=\"shrink-0 whitespace-nowrap min-h-11 md:min-h-0\"/>))}\n        </div>)}\n\n      <div ref={composerRef} className={`relative px-3 py-2 flex gap-2 transition-[border-radius,background-color,box-shadow] duration-200 ease-out ${fillHeight ? 'shrink-0' : ''} ${isMultiline ? 'items-end' : 'items-center'} ${isActive\n            ? `input-gradient-border ${hasText ? 'rounded-lg' : 'rounded-full'}`\n            : 'rounded-full input-gradient-hover'}`} style={{\n            border: '2px solid transparent',\n            backgroundColor: isActive ? 'var(--color-input-bg-active)' : 'var(--color-bg-message)',\n            backgroundClip: 'padding-box',\n        }}>\n        {showCommandPicker && commandStyle && createPortal(<div id={menuListboxId} role=\"listbox\" aria-label=\"Commands\" aria-busy={menuLoading} data-chat-input-menu=\"command\" className=\"overlay-surface panel-border max-h-72 overflow-y-auto rounded-xl\" style={{ ...commandStyle, boxShadow: 'var(--shadow-popup)' }}>\n            {commandAsync.items.length > 0 ? (commandAsync.items.map((command, index) => (<button key={command.id} id={`${menuListboxId}-opt-${index}`} type=\"button\" role=\"option\" aria-selected={index === mentionActiveIndex} onMouseDown={(e) => e.preventDefault()} onClick={() => pickCommand(command)} className={`w-full min-h-11 flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${index === mentionActiveIndex ? 'bg-bg-hover' : 'hover:bg-bg-hover'}`}>\n                  <div className=\"w-8 h-8 rounded-lg bg-bg-message shrink-0 flex items-center justify-center text-text-secondary\">\n                    {command.icon ?? <SquareSlash size={16} strokeWidth={1.75}/>}\n                  </div>\n                  <div className=\"min-w-0 flex-1\">\n                    <div className=\"type-detail-emphasized text-text-primary truncate\">\n                      {command.label}\n                    </div>\n                    {command.subtitle && (<div className=\"type-caption text-text-secondary truncate\">\n                        {command.subtitle}\n                      </div>)}\n                  </div>\n                </button>))) : menuLoading ? (<ChatInputMenuLoading label={commandFeat?.loadingLabel}/>) : (<div className=\"px-3 py-3 type-detail text-text-secondary\">\n                {commandFeat?.emptyLabel ?? 'No commands found'}\n              </div>)}\n          </div>, document.body)}\n        {showMentionPicker && mentionStyle && createPortal(<div role=\"listbox\" aria-label={mentionSource ? 'Mention suggestions' : 'Mention someone'} {...(enhancedMenus\n            ? { id: menuListboxId, 'aria-busy': menuLoading, 'data-chat-input-menu': 'mention' }\n            : {})} className=\"overlay-surface panel-border max-h-72 overflow-y-auto rounded-xl\" style={{ ...mentionStyle, boxShadow: 'var(--shadow-popup)' }}>\n            {visibleMentionOptions.length > 0 ? (visibleMentionOptions.map((option, index) => (<button key={option.id} {...(enhancedMenus ? { id: `${menuListboxId}-opt-${index}` } : {})} type=\"button\" role=\"option\" aria-selected={index === mentionActiveIndex} onMouseDown={(e) => e.preventDefault()} onClick={() => insertMention(option)} className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${index === mentionActiveIndex ? 'bg-bg-hover' : 'hover:bg-bg-hover'}`}>\n                  <div className=\"w-8 h-8 rounded-full overflow-hidden bg-bg-message shrink-0 flex items-center justify-center text-text-secondary\">\n                    {option.avatarUrl ? (<img src={option.avatarUrl} alt=\"\" className=\"w-full h-full object-cover\"/>) : (<User size={16} strokeWidth={1.75}/>)}\n                  </div>\n                  <div className=\"min-w-0 flex-1\">\n                    <div className=\"type-detail-emphasized text-text-primary truncate\">\n                      {option.label}\n                    </div>\n                    {option.subtitle && (<div className=\"type-caption text-text-secondary truncate\">\n                        {option.subtitle}\n                      </div>)}\n                  </div>\n                </button>))) : menuLoading ? (<ChatInputMenuLoading label={mentionFeat?.loadingLabel}/>) : (<div className=\"px-3 py-3 type-detail text-text-secondary\">\n                {mentionFeat?.emptyLabel ?? 'No matches found'}\n              </div>)}\n          </div>, document.body)}\n        {attachFeat && (<div ref={attachRef} className=\"relative shrink-0\">\n            <Tooltip label=\"Attach\" decorative>\n              <button type=\"button\" onClick={() => setShowAttachMenu((s) => !s)} aria-label=\"Attach\" className=\"flex items-center justify-center shrink-0 w-12 h-12 md:w-9 md:h-9 rounded-full transition-all cursor-pointer hover:bg-bg-hover text-text-primary\">\n                <Plus strokeWidth={2} className=\"w-6 h-6 md:w-5 md:h-5\"/>\n              </button>\n            </Tooltip>\n            {showAttachMenu && attachStyle && createPortal(<div ref={attachMenuRef} role=\"menu\" className=\"overlay-surface panel-border min-w-[220px] py-1 rounded-xl overflow-hidden\" style={{ ...attachStyle, boxShadow: 'var(--shadow-popup)' }}>\n                {([\n                    ...(mentionEnabled\n                        ? [{ Icon: AtSign, label: 'Mention', action: 'mention' as const }]\n                        : []),\n                    { Icon: ImageIcon, label: 'Photo', action: 'photo' as const },\n                    { Icon: Camera, label: 'Camera', action: 'camera' as const },\n                    { Icon: FileUp, label: 'Upload File', action: 'upload' as const },\n                ]).map(({ Icon, label, action }) => (<button key={label} type=\"button\" role=\"menuitem\" onClick={() => {\n                        if (action === 'mention') {\n                            openMentionPicker();\n                            return;\n                        }\n                        openPicker(action);\n                    }} className=\"w-full flex items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-bg-hover\">\n                    <Icon size={16} strokeWidth={2} className=\"shrink-0 text-text-primary\"/>\n                    <span className=\"type-detail text-text-primary\">{label}</span>\n                  </button>))}\n              </div>, document.body)}\n          </div>)}\n\n        {liveMirror && draftView && (<div ref={backdropRef} aria-hidden data-dictation-draft=\"\" className=\"pointer-events-none absolute overflow-hidden whitespace-pre-wrap break-words type-h2 text-text-primary chat-textarea-inset\">\n              {v.slice(0, draftView.start)}\n              <span data-dictation-draft-text=\"\" className=\"text-text-secondary\">{v.slice(draftView.start, draftView.end)}</span>\n              {v.slice(draftView.end)}\n              {'\\n'}\n            </div>)}\n        {voiceSession && voiceSession.status === 'connecting' && v === '' ? (<span className=\"flex items-center h-6 shrink-0 text-text-tertiary pointer-events-none\" aria-hidden data-voice-spinner=\"\">\n              <Loader2 size={14} strokeWidth={2.2} className=\"animate-spin motion-reduce:animate-none\"/>\n            </span>) : voiceSession && voiceSession.status === 'active' && v === '' ? (<VoiceWaveform className=\"flex items-center gap-[2.5px] h-[30px] [zoom:0.8] shrink-0 text-text-tertiary pointer-events-none\" barClassName=\"w-[2.5px]\"/>) : voiceError && v === '' ? (<span className=\"flex items-center h-6 shrink-0 text-error pointer-events-none\" aria-hidden data-voice-error-icon=\"\">\n              <X size={14} strokeWidth={2.5}/>\n            </span>) : null}\n\n        <textarea ref={textareaRef} value={v} onScroll={(e) => {\n            if (backdropRef.current)\n                backdropRef.current.scrollTop = e.currentTarget.scrollTop;\n        }} onChange={(e) => {\n            const nextValue = e.target.value;\n            setV(nextValue);\n            latestValueRef.current = nextValue;\n            refreshMentionSearch(nextValue, e.target.selectionStart ?? nextValue.length);\n        }} onKeyDown={(e) => {\n            if (composingRef.current || e.nativeEvent.isComposing || e.keyCode === 229)\n                return;\n            if (e.key === 'Enter' && !e.shiftKey && recordingState !== 'idle') {\n                e.preventDefault();\n                if (recordingState === 'recording' || recordingState === 'connecting')\n                    stopRecording();\n                return;\n            }\n            if (sug.handleKey(e))\n                return;\n            if (menuOpen) {\n                if (e.key === 'Escape') {\n                    e.preventDefault();\n                    closeMenus();\n                    return;\n                }\n                if (menuItemCount > 0) {\n                    if (e.key === 'ArrowDown') {\n                        e.preventDefault();\n                        setMentionActiveIndex((index) => (index + 1) % menuItemCount);\n                        return;\n                    }\n                    if (e.key === 'ArrowUp') {\n                        e.preventDefault();\n                        setMentionActiveIndex((index) => (index - 1 + menuItemCount) % menuItemCount);\n                        return;\n                    }\n                    if (e.key === 'Enter' || e.key === 'Tab') {\n                        e.preventDefault();\n                        const pick = Math.min(mentionActiveIndex, menuItemCount - 1);\n                        if (showCommandPicker)\n                            pickCommand(commandAsync.items[pick]);\n                        else\n                            insertMention(visibleMentionOptions[pick]);\n                        return;\n                    }\n                }\n                else if (menuLoading && e.key === 'Enter' && !e.shiftKey) {\n                    e.preventDefault();\n                    return;\n                }\n            }\n            if (e.key === 'Enter' && !e.shiftKey) {\n                e.preventDefault();\n                handleSend();\n            }\n        }} onPaste={(e) => {\n            if (!attachFeat)\n                return;\n            const files = Array.from(e.clipboardData?.files ?? []);\n            if (files.length === 0)\n                return;\n            e.preventDefault();\n            const dt = new DataTransfer();\n            files.forEach((f) => dt.items.add(f));\n            const synthetic = { target: { files: dt.files, value: '' } } as unknown as React.ChangeEvent<HTMLInputElement>;\n            handleFileInput('upload')(synthetic);\n        }} onFocus={() => setFocused(true)} onCompositionStart={() => { composingRef.current = true; }} onCompositionEnd={() => {\n            composingRef.current = false;\n            window.setTimeout(() => {\n                const d = dictationRef.current;\n                if (d && d.held.length && !composingRef.current)\n                    applyCaption(d, (st) => st);\n            }, 0);\n        }} onBlur={(e) => {\n            blurSelRef.current = {\n                value: e.currentTarget.value,\n                start: e.currentTarget.selectionStart,\n                end: e.currentTarget.selectionEnd,\n            };\n            setFocused(false);\n            window.setTimeout(() => {\n                if (document.activeElement !== textareaRef.current) {\n                    setMentionSearch(null);\n                    setCommandSearch(null);\n                }\n            }, 120);\n        }} onClick={(e) => {\n            refreshMentionSearch(v, e.currentTarget.selectionStart ?? v.length);\n        }} onKeyUp={(e) => {\n            if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {\n                refreshMentionSearch(v, e.currentTarget.selectionStart ?? v.length);\n            }\n        }} placeholder={sug.placeholder} disabled={disabled} rows={1} aria-label={recordingState === 'idle'\n            ? 'Chat message — type a message and press Enter to send'\n            : 'Chat message — type a message'} aria-describedby={sug.describedBy} {...(enhancedMenus\n        ? {\n            'aria-expanded': menuOpen,\n            'aria-controls': menuOpen ? menuListboxId : undefined,\n            'aria-activedescendant': menuOpen && menuItemCount > 0\n                ? `${menuListboxId}-opt-${Math.min(mentionActiveIndex, menuItemCount - 1)}`\n                : undefined,\n            'aria-autocomplete': 'list' as const,\n        }\n        : {})} className={`relative flex-1 min-w-0 bg-transparent resize-none outline-none focus-visible:outline-none type-h2 ${liveMirror ? 'text-text-hidden caret-text-primary' : 'text-text-primary'} ${voiceError ? 'placeholder-error' : 'placeholder-text-tertiary'} chat-textarea chat-textarea-inset disabled:opacity-50 ${voiceSession || sug.active ? 'placeholder:truncate' : ''}`}/>\n        {sug.node}\n\n        {<span aria-live=\"polite\" className=\"sr-only\" data-dictation-live=\"\">\n            {liveCaption}\n          </span>}\n\n        {<span role=\"status\" aria-live=\"polite\" className=\"sr-only\" data-voice-status=\"\">\n            {voiceSession?.status === 'connecting'\n                ? voiceSession.labels.placeholderConnecting\n                : voiceSession?.status === 'active'\n                    ? voiceSession.labels.placeholderActive\n                    : ''}\n          </span>}\n        {voiceError && (<span role=\"alert\" className=\"sr-only\" data-voice-error=\"\">{voiceErrorText}</span>)}\n\n        <div className=\"flex items-center gap-2 shrink-0\">\n          {recordingState !== 'idle' ? (<RichRecordingControls state={recordingState} onCancel={cancelRecording} onEnd={stopRecording} onSend={sendAndEndDictation} canSend={isClickable}/>) : (<>\n              {voiceSession && voiceSession.status === 'error' && voiceSession.onRetry ? (<Tooltip label={voiceSession.labels.retryVoice ?? 'Retry voice'} decorative>\n                  <button type=\"button\" onClick={voiceSession.onRetry} aria-label={voiceSession.labels.retryVoice ?? 'Retry voice'} data-voice-retry=\"\" className=\"flex items-center justify-center shrink-0 w-12 h-12 md:w-9 md:h-9 rounded-full transition-all cursor-pointer hover:bg-bg-hover text-text-primary\">\n                    <Mic strokeWidth={2} className=\"w-6 h-6 md:w-[18px] md:h-[18px]\"/>\n                  </button>\n                </Tooltip>) : voiceSession ? (<Tooltip label={voiceSession.muted ? voiceSession.labels.unmute : voiceSession.labels.mute} decorative>\n                  <button type=\"button\" onClick={voiceSession.onToggleMute} disabled={voiceSession.muteDisabled} aria-label={voiceSession.labels.muteMicrophone} aria-pressed={voiceSession.muted} className={`flex items-center justify-center shrink-0 w-12 h-12 md:w-9 md:h-9 rounded-full transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default ${voiceSession.muted ? 'bg-bg-hover text-error' : 'hover:bg-bg-hover text-text-primary'}`}>\n                    {voiceSession.muted\n                    ? <MicOff strokeWidth={2} className=\"w-6 h-6 md:w-[18px] md:h-[18px]\"/>\n                    : <Mic strokeWidth={2} className=\"w-6 h-6 md:w-[18px] md:h-[18px]\"/>}\n                  </button>\n                </Tooltip>) : voiceInputFeat && (<Tooltip label=\"Voice input\" decorative>\n                  <button type=\"button\" onClick={startRecording} aria-label=\"Start voice input\" className=\"flex items-center justify-center shrink-0 w-12 h-12 md:w-9 md:h-9 rounded-full transition-all cursor-pointer hover:bg-bg-hover text-text-primary\">\n                    <Mic strokeWidth={2} className=\"w-6 h-6 md:w-[18px] md:h-[18px]\"/>\n                  </button>\n                </Tooltip>)}\n              {(voiceSession ? sendActive : showSendInsteadOfVoice) ? (<Tooltip label=\"Send\" decorative>\n                  <button type=\"button\" onClick={isClickable ? handleSend : undefined} aria-label=\"Send message\" aria-disabled={!isClickable} className={`flex items-center justify-center shrink-0 w-12 h-12 md:w-9 md:h-9 rounded-full transition-all ${isClickable ? 'gradient-btn cursor-pointer' : 'cursor-default'}`} style={{ color: isClickable ? '#fff' : 'var(--color-text-primary)' }}>\n                    <ArrowUp strokeWidth={2} className=\"w-6 h-6 md:w-[18px] md:h-[18px]\"/>\n                  </button>\n                </Tooltip>) : voiceSession ? (<Tooltip label={voiceSession.labels.endVoice} decorative>\n                  <button type=\"button\" onClick={voiceSession.onEnd} aria-label={voiceSession.labels.endVoiceSession} className=\"flex items-center justify-center shrink-0 w-12 h-12 md:w-9 md:h-9 rounded-full transition-all cursor-pointer bg-bg-hover text-text-primary\">\n                    <Square strokeWidth={0} fill=\"currentColor\" className=\"w-4 h-4 md:w-3.5 md:h-3.5\"/>\n                  </button>\n                </Tooltip>) : voiceFeat ? (<Tooltip label=\"Voice mode\" decorative>\n                  <button type=\"button\" onClick={voiceFeat.onActivate} aria-label=\"Start voice mode\" className={`flex items-center justify-center shrink-0 w-12 h-12 md:w-9 md:h-9 rounded-full transition-all cursor-pointer ${voiceFeat.emphasized ? 'gradient-btn' : 'hover:bg-bg-hover text-text-primary'}`}>\n                    <AudioLines strokeWidth={2} className=\"w-6 h-6 md:w-[18px] md:h-[18px]\"/>\n                  </button>\n                </Tooltip>) : null}\n            </>)}\n        </div>\n      </div>\n\n      {modeSlot && <div className=\"flex items-center\">{modeSlot}</div>}\n    </div>);\n}\n\nfunction RichRecordingControls({ state, onCancel, onEnd, onSend, canSend }: {\n    state: 'connecting' | 'recording' | 'processing';\n    onCancel: () => void;\n    onEnd: () => void;\n    onSend: () => void;\n    canSend: boolean;\n}) {\n    if (state === 'processing') {\n        return (<span className=\"inline-flex items-center gap-1.5 type-caption text-text-secondary px-2\">\n        <Loader2 size={14} className=\"animate-spin\"/>\n        Transcribing…\n      </span>);\n    }\n    const connecting = state === 'connecting';\n    return (<div className=\"flex items-center gap-2\">\n      <span aria-live=\"polite\" className=\"sr-only\" data-recording-status=\"\">\n        {connecting ? 'Connecting…' : 'Recording'}\n      </span>\n      {!connecting && (<span className=\"inline-block w-2 h-2 rounded-full bg-accent-blue animate-pulse motion-reduce:animate-none\" data-recording-indicator=\"recording\" aria-hidden/>)}\n      <Tooltip label={connecting ? 'Cancel' : 'End voice input'} decorative>\n        <button type=\"button\" onClick={connecting ? onCancel : onEnd} aria-label={connecting ? 'Cancel voice input' : 'End voice input'} data-recording-indicator={connecting ? 'connecting' : undefined} className=\"flex items-center justify-center shrink-0 w-12 h-12 md:w-9 md:h-9 rounded-full transition-all cursor-pointer hover:bg-bg-hover text-text-primary\">\n          {connecting\n            ? <Loader2 strokeWidth={2} className=\"w-6 h-6 md:w-[18px] md:h-[18px] animate-spin motion-reduce:animate-none\"/>\n            : <X strokeWidth={2} className=\"w-6 h-6 md:w-[18px] md:h-[18px]\"/>}\n        </button>\n      </Tooltip>\n      <Tooltip label=\"Send\" decorative>\n        <button type=\"button\" onClick={canSend ? onSend : undefined} aria-label=\"Send message\" aria-disabled={!canSend} className={`flex items-center justify-center shrink-0 w-12 h-12 md:w-9 md:h-9 rounded-full transition-all ${canSend ? 'gradient-btn cursor-pointer' : 'cursor-default text-text-primary'}`}>\n          <ArrowUp strokeWidth={2} className=\"w-6 h-6 md:w-[18px] md:h-[18px]\"/>\n        </button>\n      </Tooltip>\n    </div>);\n}\n"
    },
    {
      "path": "components/message-card.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/message-card.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { AlertTriangle, BadgeCheck, Info, type LucideIcon } from 'lucide-react';\nimport { type ReactNode } from 'react';\n\ntype MessageCardTone = 'info' | 'success' | 'warning';\n\nconst MESSAGE_CARD_TONE: Record<MessageCardTone, {\n    accent: string;\n    icon: LucideIcon;\n}> = {\n    info: { accent: 'var(--color-accent-blue)', icon: Info },\n    success: { accent: 'var(--color-accent-green)', icon: BadgeCheck },\n    warning: { accent: 'var(--color-warning)', icon: AlertTriangle },\n};\n\nexport function MessageCard({ tone = 'info', title, body, action, }: {\n    tone?: MessageCardTone;\n    title?: ReactNode;\n    body: ReactNode;\n    action?: ReactNode;\n}) {\n    const { accent, icon: Icon } = MESSAGE_CARD_TONE[tone];\n    return (<div role=\"status\" className=\"panel-border rounded-[12px] overflow-hidden flex\" style={{ background: 'var(--color-card-panel-bg)' }}>\n      <div className=\"w-1 shrink-0\" style={{ background: accent }} aria-hidden/>\n      <div className=\"flex-1 min-w-0 flex items-start gap-3 p-4\">\n        <span className=\"shrink-0 mt-0.5\" style={{ color: accent }} aria-hidden>\n          <Icon size={16} strokeWidth={1.75}/>\n        </span>\n        <div className=\"flex-1 min-w-0\">\n          {title && (<p className=\"type-detail-emphasized text-text-primary\">{title}</p>)}\n          <div className={`type-detail text-text-secondary ${title ? 'mt-1' : ''}`}>\n            {body}\n          </div>\n          {action && <div className=\"mt-3\">{action}</div>}\n        </div>\n      </div>\n    </div>);\n}\n"
    },
    {
      "path": "components/status-tag.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/status-tag.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { AlertTriangle, BadgeCheck, Clock, type LucideIcon, Send, Smile, XCircle } from 'lucide-react';\nimport { type ReactNode } from 'react';\n\nexport type StatusVariant = 'pending' | 'in-progress' | 'submitted' | 'in-review' | 'success' | 'failed' | 'expired' | 'neutral';\n\nconst STATUS_ICONS: Record<StatusVariant, LucideIcon> = {\n    'pending': AlertTriangle,\n    'in-progress': Clock,\n    'submitted': Send,\n    'in-review': Smile,\n    'success': BadgeCheck,\n    'failed': XCircle,\n    'expired': Clock,\n    'neutral': Clock,\n};\n\nexport function StatusTag({ variant, label, showIcon = true, size = 'md', icon, outline = false, tooltip, }: {\n    variant: StatusVariant;\n    label: ReactNode;\n    showIcon?: boolean;\n    size?: 'sm' | 'md';\n    icon?: LucideIcon;\n    outline?: boolean;\n    tooltip?: string;\n}) {\n    const Icon = icon ?? STATUS_ICONS[variant];\n    const isSmall = size === 'sm';\n    const surface = outline\n        ? { background: 'var(--color-bg-page)', color: 'var(--status-fg)', border: '1px solid var(--color-stroke-outline)' }\n        : { background: 'var(--status-bg)', color: 'var(--status-fg)' };\n    return (<span className={`status-${variant} inline-flex items-center whitespace-nowrap shrink-0 rounded-[4px] tracking-[-0.3px] font-normal ${isSmall ? 'gap-1 px-2 py-0.5 type-caption' : 'gap-1.5 px-3 py-1 type-detail'} ${tooltip ? 'cursor-help' : ''}`} style={surface} title={tooltip} tabIndex={tooltip ? 0 : undefined} role={tooltip ? 'note' : undefined}>\n      {showIcon && <Icon size={isSmall ? 12 : 14} strokeWidth={2}/>}\n      {label}\n    </span>);\n}\n"
    },
    {
      "path": "components/tag.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/tag.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { AlertTriangle, BadgeCheck, Clock, type LucideIcon, Send, Smile, XCircle } from 'lucide-react';\nimport { type ReactNode } from 'react';\n\nexport type StatusVariant = 'pending' | 'in-progress' | 'submitted' | 'in-review' | 'success' | 'failed' | 'expired' | 'neutral';\n\nconst STATUS_ICONS: Record<StatusVariant, LucideIcon> = {\n    'pending': AlertTriangle,\n    'in-progress': Clock,\n    'submitted': Send,\n    'in-review': Smile,\n    'success': BadgeCheck,\n    'failed': XCircle,\n    'expired': Clock,\n    'neutral': Clock,\n};\n\nexport function StatusTag({ variant, label, showIcon = true, size = 'md', icon, outline = false, tooltip, }: {\n    variant: StatusVariant;\n    label: ReactNode;\n    showIcon?: boolean;\n    size?: 'sm' | 'md';\n    icon?: LucideIcon;\n    outline?: boolean;\n    tooltip?: string;\n}) {\n    const Icon = icon ?? STATUS_ICONS[variant];\n    const isSmall = size === 'sm';\n    const surface = outline\n        ? { background: 'var(--color-bg-page)', color: 'var(--status-fg)', border: '1px solid var(--color-stroke-outline)' }\n        : { background: 'var(--status-bg)', color: 'var(--status-fg)' };\n    return (<span className={`status-${variant} inline-flex items-center whitespace-nowrap shrink-0 rounded-[4px] tracking-[-0.3px] font-normal ${isSmall ? 'gap-1 px-2 py-0.5 type-caption' : 'gap-1.5 px-3 py-1 type-detail'} ${tooltip ? 'cursor-help' : ''}`} style={surface} title={tooltip} tabIndex={tooltip ? 0 : undefined} role={tooltip ? 'note' : undefined}>\n      {showIcon && <Icon size={isSmall ? 12 : 14} strokeWidth={2}/>}\n      {label}\n    </span>);\n}\n\nexport function Tag({ children, outline = false, tooltip, }: {\n    children: ReactNode;\n    outline?: boolean;\n    tooltip?: string;\n}) {\n    return (<StatusTag variant=\"neutral\" label={children} size=\"sm\" showIcon={false} outline={outline} tooltip={tooltip}/>);\n}\n"
    },
    {
      "path": "components/side-panel-body.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/side-panel-body.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { type ReactNode } from 'react';\n\nexport function SidePanelBody({ children }: {\n    children: ReactNode;\n}) {\n    return (<div className=\"flex-1 overflow-y-auto min-h-0 px-3 pb-6 flex flex-col gap-4 scrollbar-autohide\">\n      {children}\n    </div>);\n}\n"
    },
    {
      "path": "components/section-count.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/section-count.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\n\n\nexport function SectionCount({ n }: {\n    n: number;\n}) {\n    return (<span className=\"type-caption px-2 py-0.5 rounded-[4px] bg-bg-hover text-text-primary\">\n      {n}\n    </span>);\n}\n"
    },
    {
      "path": "components/time-pill.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/time-pill.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { AlertTriangle, BadgeCheck, Clock, type LucideIcon, Send, Smile, User, XCircle } from 'lucide-react';\nimport { type ReactNode } from 'react';\n\nexport function TimePill({ time, tooltip }: {\n    time: string;\n    tooltip?: string;\n}) {\n    return <StatusTag variant=\"in-progress\" label={time} size=\"sm\" icon={User} tooltip={tooltip}/>;\n}\n\nexport type StatusVariant = 'pending' | 'in-progress' | 'submitted' | 'in-review' | 'success' | 'failed' | 'expired' | 'neutral';\n\nconst STATUS_ICONS: Record<StatusVariant, LucideIcon> = {\n    'pending': AlertTriangle,\n    'in-progress': Clock,\n    'submitted': Send,\n    'in-review': Smile,\n    'success': BadgeCheck,\n    'failed': XCircle,\n    'expired': Clock,\n    'neutral': Clock,\n};\n\nexport function StatusTag({ variant, label, showIcon = true, size = 'md', icon, outline = false, tooltip, }: {\n    variant: StatusVariant;\n    label: ReactNode;\n    showIcon?: boolean;\n    size?: 'sm' | 'md';\n    icon?: LucideIcon;\n    outline?: boolean;\n    tooltip?: string;\n}) {\n    const Icon = icon ?? STATUS_ICONS[variant];\n    const isSmall = size === 'sm';\n    const surface = outline\n        ? { background: 'var(--color-bg-page)', color: 'var(--status-fg)', border: '1px solid var(--color-stroke-outline)' }\n        : { background: 'var(--status-bg)', color: 'var(--status-fg)' };\n    return (<span className={`status-${variant} inline-flex items-center whitespace-nowrap shrink-0 rounded-[4px] tracking-[-0.3px] font-normal ${isSmall ? 'gap-1 px-2 py-0.5 type-caption' : 'gap-1.5 px-3 py-1 type-detail'} ${tooltip ? 'cursor-help' : ''}`} style={surface} title={tooltip} tabIndex={tooltip ? 0 : undefined} role={tooltip ? 'note' : undefined}>\n      {showIcon && <Icon size={isSmall ? 12 : 14} strokeWidth={2}/>}\n      {label}\n    </span>);\n}\n"
    },
    {
      "path": "components/step-indicator.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/step-indicator.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { Check } from 'lucide-react';\n\nexport function StepIndicator({ status }: {\n    status: 'done' | 'in-progress' | 'pending';\n}) {\n    if (status === 'done') {\n        return <Check size={14} className=\"text-text-primary shrink-0\"/>;\n    }\n    if (status === 'in-progress') {\n        return (<div className=\"w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center shrink-0\" style={{ borderColor: 'var(--color-text-primary)' }}>\n        <div className=\"w-1.5 h-1.5 rounded-full\" style={{ background: 'var(--color-text-primary)' }}/>\n      </div>);\n    }\n    return <div className=\"w-3.5 h-3.5 rounded-full border-2 shrink-0\" style={{ borderColor: 'var(--color-stroke-outline)' }}/>;\n}\n"
    },
    {
      "path": "components/secondary-button.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/secondary-button.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { type ReactNode } from 'react';\n\nexport function SecondaryButton({ children, onClick, disabled, type = 'button', className: extra = '', fullWidth, }: {\n    children: ReactNode;\n    onClick?: () => void;\n    disabled?: boolean;\n    type?: 'button' | 'submit' | 'reset';\n    className?: string;\n    fullWidth?: boolean;\n}) {\n    return (<button type={type} onClick={onClick} disabled={disabled} className={`inverted-btn h-[48px] flex items-center justify-center px-5 rounded-[4px] type-detail-emphasized cursor-pointer disabled:opacity-40 ${fullWidth ? 'w-full' : ''} ${extra}`}>\n      {children}\n    </button>);\n}\n"
    },
    {
      "path": "components/tertiary-button.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/tertiary-button.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { type ReactNode } from 'react';\n\nexport function TertiaryButton({ children, onClick, disabled, type = 'button', className: extra = '', fullWidth, size = 'md', }: {\n    children: ReactNode;\n    onClick?: () => void;\n    disabled?: boolean;\n    type?: 'button' | 'submit' | 'reset';\n    className?: string;\n    fullWidth?: boolean;\n    size?: 'md' | 'lg';\n}) {\n    const sizeClass = size === 'md' ? 'h-[48px] type-detail-emphasized' : 'h-[52px] type-h1';\n    return (<button type={type} onClick={onClick} disabled={disabled} className={`${sizeClass} flex items-center justify-center px-5 rounded-[4px] border border-stroke-outline text-text-primary cursor-pointer chip-gradient-hover transition-colors disabled:opacity-40 ${fullWidth ? 'w-full' : ''} ${extra}`}>\n      {children}\n    </button>);\n}\n"
    },
    {
      "path": "components/ghost-pill-button.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/ghost-pill-button.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { type ReactNode } from 'react';\n\nexport function GhostPillButton({ onClick, icon, children, ariaLabel, disabled, size = 'md', ariaExpanded, ariaControls, className: extra = '', }: {\n    onClick?: () => void;\n    icon?: ReactNode;\n    children: ReactNode;\n    ariaLabel?: string;\n    disabled?: boolean;\n    size?: 'md' | 'sm';\n    ariaExpanded?: boolean;\n    ariaControls?: string;\n    className?: string;\n}) {\n    const sizeClass = size === 'sm' ? 'px-3 py-1 gap-1.5' : 'h-10 px-4 gap-2';\n    return (<button type=\"button\" onClick={onClick} aria-label={ariaLabel} aria-expanded={ariaExpanded} aria-controls={ariaControls} disabled={disabled} className={`${sizeClass} flex items-center rounded-full border border-stroke-outline enabled:hover:bg-bg-hover transition-colors text-text-primary type-detail cursor-pointer disabled:opacity-40 ${extra}`}>\n      {icon}\n      {children}\n    </button>);\n}\n"
    },
    {
      "path": "components/header-icon-button.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/header-icon-button.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { type ReactNode } from 'react';\n\nexport function HeaderIconButton({ onClick, ariaLabel, title, children, className: extra = '', }: {\n    onClick?: () => void;\n    ariaLabel: string;\n    title?: string;\n    children: ReactNode;\n    className?: string;\n}) {\n    return (<button type=\"button\" onClick={onClick} aria-label={ariaLabel} title={title} className={`w-11 h-11 md:w-10 md:h-10 flex items-center justify-center rounded-xl hover:bg-bg-hover transition-colors shrink-0 text-text-primary cursor-pointer ${extra}`}>\n      {children}\n    </button>);\n}\n"
    },
    {
      "path": "components/menu-row.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/menu-row.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { type ReactNode } from 'react';\n\nexport function MenuRow({ icon, label, sublabel, onClick, trailing, active = false, muted = false, className: extra = '', }: {\n    icon?: ReactNode;\n    label: ReactNode;\n    sublabel?: ReactNode;\n    onClick?: () => void;\n    trailing?: ReactNode;\n    active?: boolean;\n    muted?: boolean;\n    className?: string;\n}) {\n    return (<button type=\"button\" onClick={onClick} aria-current={active ? true : undefined} className={`flex ${sublabel ? 'items-start' : 'items-center'} gap-2.5 w-full px-3 py-2 rounded-lg hover:bg-bg-hover transition-colors text-left ${active ? 'bg-bg-hover' : ''} ${extra}`}>\n      {icon}\n      <span className=\"flex flex-col flex-1 min-w-0\">\n        <span className={`type-h2 truncate ${muted ? 'text-text-tertiary' : 'text-text-primary'}`}>{label}</span>\n        {sublabel && <span className=\"type-detail text-text-secondary\">{sublabel}</span>}\n      </span>\n      {trailing}\n    </button>);\n}\n"
    },
    {
      "path": "components/icon-badge.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/icon-badge.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { type ReactNode } from 'react';\n\nexport function IconBadge({ children, size = 40, className: extra = '', }: {\n    children: ReactNode;\n    size?: number;\n    className?: string;\n}) {\n    return (<span aria-hidden=\"true\" className={`inline-flex items-center justify-center shrink-0 rounded-full bg-bg-hover text-text-primary ${extra}`} style={{ width: size, height: size }}>\n      {children}\n    </span>);\n}\n"
    },
    {
      "path": "components/text-button.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/text-button.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { type ReactNode, type Ref } from 'react';\n\nexport function TextButton({ children, onClick, tone = 'secondary', disabled, className: extra = '', ariaExpanded, ariaControls, buttonRef, }: {\n    children: ReactNode;\n    onClick?: () => void;\n    tone?: 'secondary' | 'primary';\n    disabled?: boolean;\n    className?: string;\n    ariaExpanded?: boolean;\n    ariaControls?: string;\n    buttonRef?: Ref<HTMLButtonElement>;\n}) {\n    const toneClass = tone === 'primary'\n        ? 'text-text-primary hover:opacity-70'\n        : 'text-text-secondary hover:text-text-primary';\n    return (<button type=\"button\" ref={buttonRef} aria-expanded={ariaExpanded} aria-controls={ariaControls} onClick={onClick} disabled={disabled} className={`type-detail transition-colors cursor-pointer disabled:opacity-40 ${toneClass} ${extra}`}>\n      {children}\n    </button>);\n}\n"
    },
    {
      "path": "components/utility-chip.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/utility-chip.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';\n\nexport function UtilityChip({ children, icon, onClick, title, ariaLabel, ariaPressed, maxWidth, className: extra = '', }: {\n    children: ReactNode;\n    icon?: ReactNode;\n    onClick?: (e: ReactMouseEvent<HTMLButtonElement>) => void;\n    title?: string;\n    ariaLabel?: string;\n    ariaPressed?: boolean;\n    maxWidth?: number | string;\n    className?: string;\n}) {\n    const style = maxWidth !== undefined ? { maxWidth } : undefined;\n    return (<button type=\"button\" onClick={onClick} aria-label={ariaLabel} aria-pressed={ariaPressed} title={title} style={style} className={`flex items-center gap-1.5 min-w-0 px-3 h-8 rounded-full border border-stroke-outline hover:bg-bg-hover transition-colors text-text-primary ${extra}`}>\n      {icon}\n      <span className=\"font-mono type-caption truncate\">{children}</span>\n    </button>);\n}\n"
    },
    {
      "path": "components/summary-footer.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/summary-footer.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { Clock } from 'lucide-react';\nimport { type ReactNode } from 'react';\n\nexport function SummaryFooter({ children }: {\n    children: ReactNode;\n}) {\n    return (<div className=\"flex items-center justify-end gap-1.5 py-1.5 type-detail text-text-primary\">\n      <Clock size={12}/>\n      <span>{children}</span>\n    </div>);\n}\n"
    },
    {
      "path": "components/choice-card.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/choice-card.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { type ReactNode } from 'react';\n\nexport function ChoiceCard({ icon, title, description, active = false, onClick, className: extra = '', }: {\n    icon: ReactNode;\n    title: string;\n    description: string;\n    active?: boolean;\n    onClick?: () => void;\n    className?: string;\n}) {\n    return (<button type=\"button\" onClick={onClick} aria-pressed={active} className={`text-left rounded-xl border p-4 flex flex-col gap-2 transition-colors cursor-pointer ${active ? 'border-transparent' : 'chip-gradient-hover border-stroke-outline'} ${extra}`} style={active\n            ? { background: 'var(--color-selected-bg)', color: 'var(--color-selected-text)' }\n            : undefined}>\n      <span className=\"flex items-center justify-center w-8 h-8 rounded-full\" style={{ background: active ? 'var(--color-selected-icon-tint)' : 'var(--color-bg-hover)' }}>\n        {icon}\n      </span>\n      <span className=\"type-h2-emphasized\">{title}</span>\n      <span className={`type-detail ${active ? '' : 'text-text-secondary'}`}>{description}</span>\n    </button>);\n}\n"
    },
    {
      "path": "components/review-item-card.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/review-item-card.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { AlertTriangle, ChevronRight, FileText, type LucideIcon, Mail, Ticket } from 'lucide-react';\n\nconst REVIEW_TYPE_ICONS: Record<string, LucideIcon> = {\n    Document: FileText,\n    Tickets: Ticket,\n    Email: Mail,\n    Error: AlertTriangle,\n};\n\nexport function ReviewItemCard({ title, type, time, done = false, icon, interactive = false, }: {\n    title: string;\n    source?: string;\n    type: string;\n    time: string;\n    done?: boolean;\n    icon?: LucideIcon;\n    interactive?: boolean;\n}) {\n    const Icon = icon ?? REVIEW_TYPE_ICONS[type] ?? FileText;\n    return (<div className={`px-5 py-4 flex items-center gap-3.5 transition-all ${done ? 'opacity-50' : ''}`}>\n      <Icon size={22} strokeWidth={1.75} className=\"text-text-primary shrink-0 icon-theme\"/>\n      <div className=\"flex-1 min-w-0\">\n        <div className={`type-detail-emphasized text-text-primary ${done ? 'line-through' : ''}`}>\n          {title}\n        </div>\n        <div className=\"type-detail text-text-primary mt-1\">\n          {type} · {time}\n        </div>\n      </div>\n      {interactive && <ChevronRight size={16} className=\"text-text-primary shrink-0\"/>}\n    </div>);\n}\n"
    },
    {
      "path": "components/empty-state.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/empty-state.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { type LucideIcon } from 'lucide-react';\n\nexport function EmptyState({ icon: Icon, title, description, className = '', }: {\n    icon: LucideIcon;\n    title: string;\n    description?: string;\n    className?: string;\n}) {\n    return (<div className={`flex flex-col items-center justify-center gap-2 py-8 px-4 text-center ${className}`}>\n      <div className=\"w-10 h-10 flex items-center justify-center rounded-full bg-bg-hover text-text-primary\">\n        <Icon size={20} strokeWidth={1.5}/>\n      </div>\n      <div className=\"type-detail-emphasized text-text-primary\">{title}</div>\n      {description && (<div className=\"type-detail text-text-secondary max-w-[280px]\">{description}</div>)}\n    </div>);\n}\n"
    },
    {
      "path": "components/version-row.tsx",
      "type": "registry:file",
      "target": "~/src/components/frame/version-row.tsx",
      "content": "\"use client\";\n\n// Frame component source. Keep the accompanying LICENSE and frame.css.\nimport { AlertTriangle, BadgeCheck, Clock, Loader2, type LucideIcon, Send, Smile, XCircle } from 'lucide-react';\nimport { type ReactNode } from 'react';\n\nexport type StatusVariant = 'pending' | 'in-progress' | 'submitted' | 'in-review' | 'success' | 'failed' | 'expired' | 'neutral';\n\nconst STATUS_ICONS: Record<StatusVariant, LucideIcon> = {\n    'pending': AlertTriangle,\n    'in-progress': Clock,\n    'submitted': Send,\n    'in-review': Smile,\n    'success': BadgeCheck,\n    'failed': XCircle,\n    'expired': Clock,\n    'neutral': Clock,\n};\n\nexport function StatusTag({ variant, label, showIcon = true, size = 'md', icon, outline = false, tooltip, }: {\n    variant: StatusVariant;\n    label: ReactNode;\n    showIcon?: boolean;\n    size?: 'sm' | 'md';\n    icon?: LucideIcon;\n    outline?: boolean;\n    tooltip?: string;\n}) {\n    const Icon = icon ?? STATUS_ICONS[variant];\n    const isSmall = size === 'sm';\n    const surface = outline\n        ? { background: 'var(--color-bg-page)', color: 'var(--status-fg)', border: '1px solid var(--color-stroke-outline)' }\n        : { background: 'var(--status-bg)', color: 'var(--status-fg)' };\n    return (<span className={`status-${variant} inline-flex items-center whitespace-nowrap shrink-0 rounded-[4px] tracking-[-0.3px] font-normal ${isSmall ? 'gap-1 px-2 py-0.5 type-caption' : 'gap-1.5 px-3 py-1 type-detail'} ${tooltip ? 'cursor-help' : ''}`} style={surface} title={tooltip} tabIndex={tooltip ? 0 : undefined} role={tooltip ? 'note' : undefined}>\n      {showIcon && <Icon size={isSmall ? 12 : 14} strokeWidth={2}/>}\n      {label}\n    </span>);\n}\n\nexport function VersionRow({ label, current, latest, status, error, loading, }: {\n    label: string;\n    current: string;\n    latest: string | null;\n    status: 'up-to-date' | 'update-available' | 'unknown';\n    error?: string;\n    loading?: boolean;\n}) {\n    const statusVariant = status === 'up-to-date' ? 'success' : status === 'update-available' ? 'pending' : 'neutral';\n    const statusLabel = status === 'up-to-date' ? 'Up to date' : status === 'update-available' ? 'Update available' : 'Unknown';\n    return (<div className=\"panel-border rounded-[12px] p-4\">\n      <div className=\"flex items-center gap-3\">\n        <div className=\"flex-1 min-w-0\">\n          <p className=\"type-detail-emphasized text-text-primary\">{label}</p>\n          <p className=\"type-caption text-text-secondary truncate mt-0.5\">\n            {current}\n            {latest && latest !== current && (<span className=\"text-text-tertiary\"> → {latest}</span>)}\n          </p>\n        </div>\n        <div className=\"shrink-0\">\n          {loading ? (<Loader2 size={16} className=\"animate-spin text-text-secondary\"/>) : (<StatusTag variant={statusVariant} label={statusLabel} size=\"sm\"/>)}\n        </div>\n      </div>\n      {error && (<p className=\"type-caption text-text-tertiary mt-2\">{error}</p>)}\n    </div>);\n}\n"
    },
    {
      "path": "styles/frame.css",
      "type": "registry:file",
      "target": "~/src/components/frame/frame.css",
      "content": ".frame-root *, .frame-root ::before, .frame-root ::after {\n  --tw-border-spacing-x: 0;\n  --tw-border-spacing-y: 0;\n  --tw-translate-x: 0;\n  --tw-translate-y: 0;\n  --tw-rotate: 0;\n  --tw-skew-x: 0;\n  --tw-skew-y: 0;\n  --tw-scale-x: 1;\n  --tw-scale-y: 1;\n  --tw-pan-x:  ;\n  --tw-pan-y:  ;\n  --tw-pinch-zoom:  ;\n  --tw-scroll-snap-strictness: proximity;\n  --tw-gradient-from-position:  ;\n  --tw-gradient-via-position:  ;\n  --tw-gradient-to-position:  ;\n  --tw-ordinal:  ;\n  --tw-slashed-zero:  ;\n  --tw-numeric-figure:  ;\n  --tw-numeric-spacing:  ;\n  --tw-numeric-fraction:  ;\n  --tw-ring-inset:  ;\n  --tw-ring-offset-width: 0px;\n  --tw-ring-offset-color: #fff;\n  --tw-ring-color: rgb(59 130 246 / 0.5);\n  --tw-ring-offset-shadow: 0 0 #0000;\n  --tw-ring-shadow: 0 0 #0000;\n  --tw-shadow: 0 0 #0000;\n  --tw-shadow-colored: 0 0 #0000;\n  --tw-blur:  ;\n  --tw-brightness:  ;\n  --tw-contrast:  ;\n  --tw-grayscale:  ;\n  --tw-hue-rotate:  ;\n  --tw-invert:  ;\n  --tw-saturate:  ;\n  --tw-sepia:  ;\n  --tw-drop-shadow:  ;\n  --tw-backdrop-blur:  ;\n  --tw-backdrop-brightness:  ;\n  --tw-backdrop-contrast:  ;\n  --tw-backdrop-grayscale:  ;\n  --tw-backdrop-hue-rotate:  ;\n  --tw-backdrop-invert:  ;\n  --tw-backdrop-opacity:  ;\n  --tw-backdrop-saturate:  ;\n  --tw-backdrop-sepia:  ;\n  --tw-contain-size:  ;\n  --tw-contain-layout:  ;\n  --tw-contain-paint:  ;\n  --tw-contain-style:  ;\n}\n\n.frame-root ::backdrop {\n  --tw-border-spacing-x: 0;\n  --tw-border-spacing-y: 0;\n  --tw-translate-x: 0;\n  --tw-translate-y: 0;\n  --tw-rotate: 0;\n  --tw-skew-x: 0;\n  --tw-skew-y: 0;\n  --tw-scale-x: 1;\n  --tw-scale-y: 1;\n  --tw-pan-x:  ;\n  --tw-pan-y:  ;\n  --tw-pinch-zoom:  ;\n  --tw-scroll-snap-strictness: proximity;\n  --tw-gradient-from-position:  ;\n  --tw-gradient-via-position:  ;\n  --tw-gradient-to-position:  ;\n  --tw-ordinal:  ;\n  --tw-slashed-zero:  ;\n  --tw-numeric-figure:  ;\n  --tw-numeric-spacing:  ;\n  --tw-numeric-fraction:  ;\n  --tw-ring-inset:  ;\n  --tw-ring-offset-width: 0px;\n  --tw-ring-offset-color: #fff;\n  --tw-ring-color: rgb(59 130 246 / 0.5);\n  --tw-ring-offset-shadow: 0 0 #0000;\n  --tw-ring-shadow: 0 0 #0000;\n  --tw-shadow: 0 0 #0000;\n  --tw-shadow-colored: 0 0 #0000;\n  --tw-blur:  ;\n  --tw-brightness:  ;\n  --tw-contrast:  ;\n  --tw-grayscale:  ;\n  --tw-hue-rotate:  ;\n  --tw-invert:  ;\n  --tw-saturate:  ;\n  --tw-sepia:  ;\n  --tw-drop-shadow:  ;\n  --tw-backdrop-blur:  ;\n  --tw-backdrop-brightness:  ;\n  --tw-backdrop-contrast:  ;\n  --tw-backdrop-grayscale:  ;\n  --tw-backdrop-hue-rotate:  ;\n  --tw-backdrop-invert:  ;\n  --tw-backdrop-opacity:  ;\n  --tw-backdrop-saturate:  ;\n  --tw-backdrop-sepia:  ;\n  --tw-contain-size:  ;\n  --tw-contain-layout:  ;\n  --tw-contain-paint:  ;\n  --tw-contain-style:  ;\n}/*\n! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com\n*/\n\n.frame-root *, .frame-root ::before, .frame-root ::after {\n  box-sizing: border-box;\n  border-width: 0;\n  border-style: solid;\n  border-color: #e5e7eb;\n}\n\n.frame-root ::before, .frame-root ::after {\n  --tw-content: '';\n}\n\n.frame-root, .frame-root {\n  line-height: 1.5;\n  -webkit-text-size-adjust: 100%;\n  -moz-tab-size: 4;\n  tab-size: 4;\n  font-family: -apple-system, BlinkMacSystemFont, SF Pro, Inter, sans-serif;\n  font-feature-settings: normal;\n  font-variation-settings: normal;\n  -webkit-tap-highlight-color: transparent;\n}\n\n.frame-root {\n  margin: 0;\n  line-height: inherit;\n}\n\n.frame-root hr {\n  height: 0;\n  color: inherit;\n  border-top-width: 1px;\n}\n\n.frame-root abbr:where([title]) {\n  text-decoration: underline dotted;\n}\n\n.frame-root h1, .frame-root h2, .frame-root h3, .frame-root h4, .frame-root h5, .frame-root h6 {\n  font-size: inherit;\n  font-weight: inherit;\n}\n\n.frame-root a {\n  color: inherit;\n  text-decoration: inherit;\n}\n\n.frame-root b, .frame-root strong {\n  font-weight: bolder;\n}\n\n.frame-root code, .frame-root kbd, .frame-root samp, .frame-root pre {\n  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n  font-feature-settings: normal;\n  font-variation-settings: normal;\n  font-size: 1em;\n}\n\n.frame-root small {\n  font-size: 80%;\n}\n\n.frame-root sub, .frame-root sup {\n  font-size: 75%;\n  line-height: 0;\n  position: relative;\n  vertical-align: baseline;\n}\n\n.frame-root sub {\n  bottom: -0.25em;\n}\n\n.frame-root sup {\n  top: -0.5em;\n}\n\n.frame-root table {\n  text-indent: 0;\n  border-color: inherit;\n  border-collapse: collapse;\n}\n\n.frame-root button, .frame-root input, .frame-root optgroup, .frame-root select, .frame-root textarea {\n  font-family: inherit;\n  font-feature-settings: inherit;\n  font-variation-settings: inherit;\n  font-size: 100%;\n  font-weight: inherit;\n  line-height: inherit;\n  letter-spacing: inherit;\n  color: inherit;\n  margin: 0;\n  padding: 0;\n}\n\n.frame-root button, .frame-root select {\n  text-transform: none;\n}\n\n.frame-root button, .frame-root input:where([type='button']), .frame-root input:where([type='reset']), .frame-root input:where([type='submit']) {\n  -webkit-appearance: button;\n  background-color: transparent;\n  background-image: none;\n}\n\n.frame-root :-moz-focusring {\n  outline: auto;\n}\n\n.frame-root :-moz-ui-invalid {\n  box-shadow: none;\n}\n\n.frame-root progress {\n  vertical-align: baseline;\n}\n\n.frame-root ::-webkit-inner-spin-button, .frame-root ::-webkit-outer-spin-button {\n  height: auto;\n}\n\n.frame-root [type='search'] {\n  -webkit-appearance: textfield;\n  outline-offset: -2px;\n}\n\n.frame-root ::-webkit-search-decoration {\n  -webkit-appearance: none;\n}\n\n.frame-root ::-webkit-file-upload-button {\n  -webkit-appearance: button;\n  font: inherit;\n}\n\n.frame-root summary {\n  display: list-item;\n}\n\n.frame-root blockquote, .frame-root dl, .frame-root dd, .frame-root h1, .frame-root h2, .frame-root h3, .frame-root h4, .frame-root h5, .frame-root h6, .frame-root hr, .frame-root figure, .frame-root p, .frame-root pre {\n  margin: 0;\n}\n\n.frame-root fieldset {\n  margin: 0;\n  padding: 0;\n}\n\n.frame-root legend {\n  padding: 0;\n}\n\n.frame-root ol, .frame-root ul, .frame-root menu {\n  list-style: none;\n  margin: 0;\n  padding: 0;\n}\n.frame-root dialog {\n  padding: 0;\n}\n\n.frame-root textarea {\n  resize: vertical;\n}\n\n.frame-root input::placeholder, .frame-root textarea::placeholder {\n  opacity: 1;\n  color: #9ca3af;\n}\n\n.frame-root button, .frame-root [role=\"button\"] {\n  cursor: pointer;\n}\n.frame-root :disabled {\n  cursor: default;\n}\n\n.frame-root img, .frame-root svg, .frame-root video, .frame-root canvas, .frame-root audio, .frame-root iframe, .frame-root embed, .frame-root object {\n  display: block;\n  vertical-align: middle;\n}\n\n.frame-root img, .frame-root video {\n  max-width: 100%;\n  height: auto;\n}\n.frame-root [hidden]:where(:not([hidden=\"until-found\"])) {\n  display: none;\n}\n.frame-root {\n  --white: #FFFFFF;\n  --black: #000000;\n  --navy: #142740;\n  --navy-deep: #001424;\n  --ice: #E2F3FF;\n  --gray-100: #F3F4F5;\n  --neutral-900: #1A1A1A;\n  --blue-500: #3171FF;\n  --blue-300: #73B2FF;\n  --green-500: #028901;\n  --error-700: #B42318;\n  --error-300: #F97066;\n  --warning-500: #F79009;\n  --warning-300: #FDB022;\n\n  --color-text-primary: var(--navy);\n  --color-text-secondary: color-mix(in srgb, var(--navy) 60%, transparent);\n  --color-text-tertiary: color-mix(in srgb, var(--navy) 40%, transparent);\n  --color-bg-page: var(--gray-100);\n  --color-bg-message: color-mix(in srgb, var(--navy) 5%, transparent);\n  --color-bg-hover: color-mix(in srgb, var(--navy) 5%, transparent);\n  --color-stroke-outline: color-mix(in srgb, var(--navy) 10%, transparent);\n  --color-sidebar-bg: var(--gray-100);\n  --color-overlay-surface-bg: var(--color-sidebar-bg);\n  --color-selected-bg: color-mix(in srgb, var(--blue-500) 10%, transparent);\n  --color-selected-text: var(--blue-500);\n  --color-card-panel-bg: var(--white);\n  --color-input-bg-active: var(--white);\n  --color-accent-blue: var(--blue-500);\n  --color-accent-green: var(--green-500);\n\n  --color-warning: var(--warning-500);\n  --color-error: var(--error-700);\n  --color-tooltip-bg: var(--neutral-900);\n  --color-focus-ring: var(--blue-500);\n  --color-text-hidden: transparent;\n  --color-selected-icon-tint: rgba(255, 255, 255, 0.15);\n\n  --font-h2-size: 16px;\n  --font-h2-lh: 22px;\n  --font-h2-weight: 400;\n  --font-h2-emphasized-weight: 700;\n  --font-h2-tracking: 0px;\n\n  --font-body-size: 16px;\n  --font-body-lh: 32px;\n  --font-body-weight: 400;\n  --font-body-emphasized-weight: 700;\n  --font-body-tracking: 0px;\n  --font-detail-size: 16px;\n  --font-detail-lh: 22px;\n  --font-detail-weight: 400;\n  --font-detail-emphasized-lh: 18px;\n  --font-detail-emphasized-weight: 700;\n  --font-detail-tracking: 0px;\n  --font-caption-size: 12px;\n  --font-caption-lh: 16px;\n  --font-caption-weight: 400;\n  --font-caption-tracking: 0px;\n  --font-footnote-size: 11px;\n  --font-footnote-lh: 1;\n  --font-footnote-weight: 400;\n  --font-footnote-tracking: 0px;\n  --motion-duration-fast: 150ms;\n  --motion-duration-slow: 300ms;\n  --motion-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);\n}\n.dark .frame-root, .frame-root.dark {\n  --color-text-primary: var(--white);\n  --color-text-secondary: color-mix(in srgb, var(--ice) 80%, transparent);\n  --color-text-tertiary: color-mix(in srgb, var(--ice) 40%, transparent);\n  --color-bg-page: var(--navy-deep);\n  --color-bg-message: color-mix(in srgb, var(--ice) 10%, transparent);\n  --color-bg-hover: color-mix(in srgb, var(--ice) 10%, transparent);\n  --color-stroke-outline: color-mix(in srgb, var(--blue-300) 20%, transparent);\n  --color-sidebar-bg: var(--navy-deep);\n  --color-overlay-surface-bg: rgba(0, 0, 0, 0.5);\n  --overlay-surface-blur: 10px;\n  --color-selected-bg: var(--blue-500);\n  --color-selected-text: var(--white);\n  --color-card-panel-bg: color-mix(in srgb, var(--ice) 10%, transparent);\n  --color-input-bg-active: color-mix(in srgb, var(--black) 20%, transparent);\n\n  --color-warning: var(--warning-300);\n  --color-error: var(--error-300);\n  --color-focus-ring: var(--blue-300);\n}\n@media (min-width: 768px) {\n  .frame-root, .dark .frame-root, .frame-root.dark {\n    --font-detail-size: 14px;\n  }\n}\n.frame-root {\n  --shadow-popup: 0px 5px 8px 0px rgba(3, 65, 133, 0.17);\n}\n.dark .frame-root, .frame-root.dark {\n  --shadow-popup: none;\n}\n.frame-root .sr-only {\n    position: absolute;\n    width: 1px;\n    height: 1px;\n    padding: 0;\n    margin: -1px;\n    overflow: hidden;\n    clip: rect(0, 0, 0, 0);\n    white-space: nowrap;\n    border-width: 0\n}\n.frame-root .pointer-events-none {\n    pointer-events: none\n}\n.frame-root .fixed {\n    position: fixed\n}\n.frame-root .absolute {\n    position: absolute\n}\n.frame-root .relative {\n    position: relative\n}\n.frame-root .bottom-full {\n    bottom: 100%\n}\n.frame-root .left-1\\/2 {\n    left: 50%\n}\n.frame-root .right-1 {\n    right: 0.25rem\n}\n.frame-root .top-1 {\n    top: 0.25rem\n}\n.frame-root .z-50 {\n    z-index: 50\n}\n.frame-root .-mx-4 {\n    margin-left: -1rem;\n    margin-right: -1rem\n}\n.frame-root .-my-1\\.5 {\n    margin-top: -0.375rem;\n    margin-bottom: -0.375rem\n}\n.frame-root .mx-auto {\n    margin-left: auto;\n    margin-right: auto\n}\n.frame-root .-mr-1 {\n    margin-right: -0.25rem\n}\n.frame-root .mb-1\\.5 {\n    margin-bottom: 0.375rem\n}\n.frame-root .ml-1 {\n    margin-left: 0.25rem\n}\n.frame-root .mt-0\\.5 {\n    margin-top: 0.125rem\n}\n.frame-root .mt-1 {\n    margin-top: 0.25rem\n}\n.frame-root .mt-2 {\n    margin-top: 0.5rem\n}\n.frame-root .mt-3 {\n    margin-top: 0.75rem\n}\n.frame-root .inline-block {\n    display: inline-block\n}\n.frame-root .flex {\n    display: flex\n}\n.frame-root .inline-flex {\n    display: inline-flex\n}\n.frame-root .hidden {\n    display: none\n}\n.frame-root .h-1\\.5 {\n    height: 0.375rem\n}\n.frame-root .h-10 {\n    height: 2.5rem\n}\n.frame-root .h-11 {\n    height: 2.75rem\n}\n.frame-root .h-12 {\n    height: 3rem\n}\n.frame-root .h-2 {\n    height: 0.5rem\n}\n.frame-root .h-3 {\n    height: 0.75rem\n}\n.frame-root .h-3\\.5 {\n    height: 0.875rem\n}\n.frame-root .h-4 {\n    height: 1rem\n}\n.frame-root .h-5 {\n    height: 1.25rem\n}\n.frame-root .h-6 {\n    height: 1.5rem\n}\n.frame-root .h-8 {\n    height: 2rem\n}\n.frame-root .h-\\[30px\\] {\n    height: 30px\n}\n.frame-root .h-\\[48px\\] {\n    height: 48px\n}\n.frame-root .h-\\[52px\\] {\n    height: 52px\n}\n.frame-root .h-full {\n    height: 100%\n}\n.frame-root .max-h-72 {\n    max-height: 18rem\n}\n.frame-root .max-h-full {\n    max-height: 100%\n}\n.frame-root .min-h-0 {\n    min-height: 0px\n}\n.frame-root .min-h-11 {\n    min-height: 2.75rem\n}\n.frame-root .w-1 {\n    width: 0.25rem\n}\n.frame-root .w-1\\.5 {\n    width: 0.375rem\n}\n.frame-root .w-10 {\n    width: 2.5rem\n}\n.frame-root .w-11 {\n    width: 2.75rem\n}\n.frame-root .w-12 {\n    width: 3rem\n}\n.frame-root .w-2 {\n    width: 0.5rem\n}\n.frame-root .w-3\\.5 {\n    width: 0.875rem\n}\n.frame-root .w-4 {\n    width: 1rem\n}\n.frame-root .w-5 {\n    width: 1.25rem\n}\n.frame-root .w-6 {\n    width: 1.5rem\n}\n.frame-root .w-8 {\n    width: 2rem\n}\n.frame-root .w-\\[2\\.5px\\] {\n    width: 2.5px\n}\n.frame-root .w-\\[2px\\] {\n    width: 2px\n}\n.frame-root .w-full {\n    width: 100%\n}\n.frame-root .min-w-0 {\n    min-width: 0px\n}\n.frame-root .min-w-11 {\n    min-width: 2.75rem\n}\n.frame-root .min-w-\\[220px\\] {\n    min-width: 220px\n}\n.frame-root .max-w-\\[280px\\] {\n    max-width: 280px\n}\n.frame-root .max-w-\\[80\\%\\] {\n    max-width: 80%\n}\n.frame-root .max-w-\\[863px\\] {\n    max-width: 863px\n}\n.frame-root .max-w-full {\n    max-width: 100%\n}\n.frame-root .flex-1 {\n    flex: 1 1 0%\n}\n.frame-root .shrink-0 {\n    flex-shrink: 0\n}\n.frame-root .-translate-x-1\\/2 {\n    --tw-translate-x: -50%;\n    transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))\n}\n@keyframes frame-pulse {\n    50% {\n        opacity: .5\n    }\n}\n.frame-root .animate-pulse {\n    animation: frame-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite\n}\n@keyframes frame-spin {\n    to {\n        transform: rotate(360deg)\n    }\n}\n.frame-root .animate-spin {\n    animation: frame-spin 1s linear infinite\n}\n.frame-root .cursor-default {\n    cursor: default\n}\n.frame-root .cursor-grab {\n    cursor: grab\n}\n.frame-root .cursor-help {\n    cursor: help\n}\n.frame-root .cursor-not-allowed {\n    cursor: not-allowed\n}\n.frame-root .cursor-pointer {\n    cursor: pointer\n}\n.frame-root .select-none {\n    user-select: none\n}\n.frame-root .resize-none {\n    resize: none\n}\n.frame-root .resize {\n    resize: both\n}\n.frame-root .flex-row {\n    flex-direction: row\n}\n.frame-root .flex-row-reverse {\n    flex-direction: row-reverse\n}\n.frame-root .flex-col {\n    flex-direction: column\n}\n.frame-root .flex-wrap {\n    flex-wrap: wrap\n}\n.frame-root .content-start {\n    align-content: flex-start\n}\n.frame-root .items-start {\n    align-items: flex-start\n}\n.frame-root .items-end {\n    align-items: flex-end\n}\n.frame-root .items-center {\n    align-items: center\n}\n.frame-root .justify-end {\n    justify-content: flex-end\n}\n.frame-root .justify-center {\n    justify-content: center\n}\n.frame-root .gap-1 {\n    gap: 0.25rem\n}\n.frame-root .gap-1\\.5 {\n    gap: 0.375rem\n}\n.frame-root .gap-2 {\n    gap: 0.5rem\n}\n.frame-root .gap-2\\.5 {\n    gap: 0.625rem\n}\n.frame-root .gap-3 {\n    gap: 0.75rem\n}\n.frame-root .gap-3\\.5 {\n    gap: 0.875rem\n}\n.frame-root .gap-4 {\n    gap: 1rem\n}\n.frame-root .gap-\\[2\\.5px\\] {\n    gap: 2.5px\n}\n.frame-root .gap-\\[2px\\] {\n    gap: 2px\n}\n.frame-root .self-stretch {\n    align-self: stretch\n}\n.frame-root .overflow-hidden {\n    overflow: hidden\n}\n.frame-root .overflow-x-auto {\n    overflow-x: auto\n}\n.frame-root .overflow-y-auto {\n    overflow-y: auto\n}\n.frame-root .truncate {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap\n}\n.frame-root .whitespace-nowrap {\n    white-space: nowrap\n}\n.frame-root .whitespace-pre-wrap {\n    white-space: pre-wrap\n}\n.frame-root .break-words {\n    overflow-wrap: break-word\n}\n.frame-root .rounded {\n    border-radius: 0.25rem\n}\n.frame-root .rounded-\\[12px\\] {\n    border-radius: 12px\n}\n.frame-root .rounded-\\[16px\\] {\n    border-radius: 16px\n}\n.frame-root .rounded-\\[4px\\] {\n    border-radius: 4px\n}\n.frame-root .rounded-full {\n    border-radius: 9999px\n}\n.frame-root .rounded-lg {\n    border-radius: 0.5rem\n}\n.frame-root .rounded-md {\n    border-radius: 0.375rem\n}\n.frame-root .rounded-xl {\n    border-radius: 0.75rem\n}\n.frame-root .rounded-tl-\\[4px\\] {\n    border-top-left-radius: 4px\n}\n.frame-root .rounded-tr-\\[4px\\] {\n    border-top-right-radius: 4px\n}\n.frame-root .border {\n    border-width: 1px\n}\n.frame-root .border-2 {\n    border-width: 2px\n}\n.frame-root .border-stroke-outline {\n    border-color: var(--color-stroke-outline)\n}\n.frame-root .border-transparent {\n    border-color: transparent\n}\n.frame-root .bg-accent-blue {\n    background-color: var(--color-accent-blue)\n}\n.frame-root .bg-bg-hover {\n    background-color: var(--color-bg-hover)\n}\n.frame-root .bg-bg-message {\n    background-color: var(--color-bg-message)\n}\n.frame-root .bg-bg-page {\n    background-color: var(--color-bg-page)\n}\n.frame-root .bg-current {\n    background-color: currentColor\n}\n.frame-root .bg-tooltip {\n    background-color: var(--color-tooltip-bg)\n}\n.frame-root .bg-transparent {\n    background-color: transparent\n}\n.frame-root .object-contain {\n    object-fit: contain\n}\n.frame-root .object-cover {\n    object-fit: cover\n}\n.frame-root .p-1 {\n    padding: 0.25rem\n}\n.frame-root .p-4 {\n    padding: 1rem\n}\n.frame-root .px-1 {\n    padding-left: 0.25rem;\n    padding-right: 0.25rem\n}\n.frame-root .px-1\\.5 {\n    padding-left: 0.375rem;\n    padding-right: 0.375rem\n}\n.frame-root .px-2 {\n    padding-left: 0.5rem;\n    padding-right: 0.5rem\n}\n.frame-root .px-3 {\n    padding-left: 0.75rem;\n    padding-right: 0.75rem\n}\n.frame-root .px-4 {\n    padding-left: 1rem;\n    padding-right: 1rem\n}\n.frame-root .px-5 {\n    padding-left: 1.25rem;\n    padding-right: 1.25rem\n}\n.frame-root .py-0\\.5 {\n    padding-top: 0.125rem;\n    padding-bottom: 0.125rem\n}\n.frame-root .py-1 {\n    padding-top: 0.25rem;\n    padding-bottom: 0.25rem\n}\n.frame-root .py-1\\.5 {\n    padding-top: 0.375rem;\n    padding-bottom: 0.375rem\n}\n.frame-root .py-2 {\n    padding-top: 0.5rem;\n    padding-bottom: 0.5rem\n}\n.frame-root .py-2\\.5 {\n    padding-top: 0.625rem;\n    padding-bottom: 0.625rem\n}\n.frame-root .py-3 {\n    padding-top: 0.75rem;\n    padding-bottom: 0.75rem\n}\n.frame-root .py-4 {\n    padding-top: 1rem;\n    padding-bottom: 1rem\n}\n.frame-root .py-6 {\n    padding-top: 1.5rem;\n    padding-bottom: 1.5rem\n}\n.frame-root .py-8 {\n    padding-top: 2rem;\n    padding-bottom: 2rem\n}\n.frame-root .pb-3 {\n    padding-bottom: 0.75rem\n}\n.frame-root .pb-6 {\n    padding-bottom: 1.5rem\n}\n.frame-root .pr-1 {\n    padding-right: 0.25rem\n}\n.frame-root .pr-2 {\n    padding-right: 0.5rem\n}\n.frame-root .pt-2 {\n    padding-top: 0.5rem\n}\n.frame-root .pt-4 {\n    padding-top: 1rem\n}\n.frame-root .text-left {\n    text-align: left\n}\n.frame-root .text-center {\n    text-align: center\n}\n.frame-root .align-middle {\n    vertical-align: middle\n}\n.frame-root .font-mono {\n    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace\n}\n.frame-root .font-normal {\n    font-weight: 400\n}\n.frame-root .tracking-\\[-0\\.3px\\] {\n    letter-spacing: -0.3px\n}\n.frame-root .text-error {\n    color: var(--color-error)\n}\n.frame-root .text-text-hidden {\n    color: var(--color-text-hidden)\n}\n.frame-root .text-text-primary {\n    color: var(--color-text-primary)\n}\n.frame-root .text-text-secondary {\n    color: var(--color-text-secondary)\n}\n.frame-root .text-text-tertiary {\n    color: var(--color-text-tertiary)\n}\n.frame-root .text-white {\n    --tw-text-opacity: 1;\n    color: rgb(255 255 255 / var(--tw-text-opacity, 1))\n}\n.frame-root .line-through {\n    text-decoration-line: line-through\n}\n.frame-root .placeholder-error::placeholder {\n    color: var(--color-error)\n}\n.frame-root .placeholder-text-tertiary::placeholder {\n    color: var(--color-text-tertiary)\n}\n.frame-root .caret-text-primary {\n    caret-color: var(--color-text-primary)\n}\n.frame-root .opacity-0 {\n    opacity: 0\n}\n.frame-root .opacity-40 {\n    opacity: 0.4\n}\n.frame-root .opacity-50 {\n    opacity: 0.5\n}\n.frame-root .opacity-60 {\n    opacity: 0.6\n}\n.frame-root .opacity-70 {\n    opacity: 0.7\n}\n.frame-root .outline-none {\n    outline: 2px solid transparent;\n    outline-offset: 2px\n}\n.frame-root .outline {\n    outline-style: solid\n}\n.frame-root .filter {\n    filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)\n}\n.frame-root .transition-\\[border-radius\\2c background-color\\2c box-shadow\\] {\n    transition-property: border-radius,background-color,box-shadow;\n    transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n    transition-duration: 150ms\n}\n.frame-root .transition-all {\n    transition-property: all;\n    transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n    transition-duration: 150ms\n}\n.frame-root .transition-colors {\n    transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;\n    transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n    transition-duration: 150ms\n}\n.frame-root .transition-opacity {\n    transition-property: opacity;\n    transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n    transition-duration: 150ms\n}\n.frame-root .duration-200 {\n    transition-duration: 200ms\n}\n.frame-root .ease-out {\n    transition-timing-function: cubic-bezier(0, 0, 0.2, 1)\n}\n.frame-root .\\[zoom\\:0\\.8\\] {\n    zoom: 0.8\n}\n.frame-root .placeholder\\:truncate::placeholder {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap\n}\n.frame-root .hover\\:bg-bg-hover:hover {\n    background-color: var(--color-bg-hover)\n}\n.frame-root .hover\\:text-text-primary:hover {\n    color: var(--color-text-primary)\n}\n.frame-root .hover\\:opacity-70:hover {\n    opacity: 0.7\n}\n.frame-root .focus\\:opacity-100:focus {\n    opacity: 1\n}\n.frame-root .focus-visible\\:outline-none:focus-visible {\n    outline: 2px solid transparent;\n    outline-offset: 2px\n}\n.frame-root .active\\:cursor-grabbing:active {\n    cursor: grabbing\n}\n.frame-root .enabled\\:hover\\:bg-bg-hover:hover:enabled {\n    background-color: var(--color-bg-hover)\n}\n.frame-root .disabled\\:cursor-default:disabled {\n    cursor: default\n}\n.frame-root .disabled\\:opacity-40:disabled {\n    opacity: 0.4\n}\n.frame-root .disabled\\:opacity-50:disabled {\n    opacity: 0.5\n}\n.frame-root .group:focus-within .group-focus-within\\:opacity-100 {\n    opacity: 1\n}\n.frame-root .group:hover .group-hover\\:opacity-100 {\n    opacity: 1\n}\n@media (prefers-reduced-motion: reduce) {\n    .frame-root .motion-reduce\\:animate-none {\n        animation: none\n    }\n}\n@media (min-width: 640px) {\n    .frame-root .sm\\:px-8 {\n        padding-left: 2rem;\n        padding-right: 2rem\n    }\n}\n@media (min-width: 768px) {\n    .frame-root .md\\:h-10 {\n        height: 2.5rem\n    }\n    .frame-root .md\\:h-3\\.5 {\n        height: 0.875rem\n    }\n    .frame-root .md\\:h-5 {\n        height: 1.25rem\n    }\n    .frame-root .md\\:h-9 {\n        height: 2.25rem\n    }\n    .frame-root .md\\:h-\\[18px\\] {\n        height: 18px\n    }\n    .frame-root .md\\:min-h-0 {\n        min-height: 0px\n    }\n    .frame-root .md\\:w-10 {\n        width: 2.5rem\n    }\n    .frame-root .md\\:w-3\\.5 {\n        width: 0.875rem\n    }\n    .frame-root .md\\:w-5 {\n        width: 1.25rem\n    }\n    .frame-root .md\\:w-9 {\n        width: 2.25rem\n    }\n    .frame-root .md\\:w-\\[18px\\] {\n        width: 18px\n    }\n    .frame-root .md\\:px-3 {\n        padding-left: 0.75rem;\n        padding-right: 0.75rem\n    }\n    .frame-root .md\\:py-1 {\n        padding-top: 0.25rem;\n        padding-bottom: 0.25rem\n    }\n}\n@media(pointer:coarse) {\n    .frame-root .\\[\\@media\\(pointer\\:coarse\\)\\]\\:flex {\n        display: flex\n    }\n    .frame-root .\\[\\@media\\(pointer\\:coarse\\)\\]\\:hidden {\n        display: none\n    }\n    .frame-root .\\[\\@media\\(pointer\\:coarse\\)\\]\\:h-11 {\n        height: 2.75rem\n    }\n}\n.frame-root * {\n  box-sizing: border-box;\n  margin: 0;\n  padding: 0;\n}\n.frame-root strong, .frame-root b {\n  font-weight: var(--font-body-emphasized-weight, 700);\n}\n.frame-root *:focus-visible {\n  outline: 2px solid var(--color-focus-ring);\n  outline-offset: 2px;\n}\n.frame-root *:focus:not(:focus-visible) {\n  outline: none;\n}\n@media (prefers-reduced-motion: reduce) {\n  .frame-root *, .frame-root *::before, .frame-root *::after {\n    animation-duration: 0.01ms !important;\n    animation-iteration-count: 1 !important;\n    transition-duration: 0.01ms !important;\n    scroll-behavior: auto !important;\n  }\n}\n.frame-root ::-webkit-scrollbar {\n  width: 4px;\n}\n.frame-root ::-webkit-scrollbar-track {\n  background: transparent;\n}\n.frame-root ::-webkit-scrollbar-thumb {\n  background: rgba(0, 0, 0, 0.15);\n  border-radius: 4px;\n}\n.dark .frame-root ::-webkit-scrollbar-thumb, .frame-root.dark ::-webkit-scrollbar-thumb {\n  background: rgba(255, 255, 255, 0.15);\n}\n.frame-root .scrollbar-autohide {\n  scrollbar-width: thin;\n  scrollbar-color: transparent transparent;\n}\n.frame-root .scrollbar-autohide::-webkit-scrollbar-thumb {\n  background: transparent;\n  transition: background var(--motion-duration-slow) var(--motion-ease-standard);\n}\n.frame-root .scrollbar-autohide:hover::-webkit-scrollbar-thumb, .frame-root .scrollbar-autohide:active::-webkit-scrollbar-thumb {\n  background: rgba(0, 0, 0, 0.15);\n}\n.dark .frame-root .scrollbar-autohide:hover::-webkit-scrollbar-thumb, .frame-root.dark .scrollbar-autohide:hover::-webkit-scrollbar-thumb, .dark .frame-root .scrollbar-autohide:active::-webkit-scrollbar-thumb, .frame-root.dark .scrollbar-autohide:active::-webkit-scrollbar-thumb {\n  background: rgba(255, 255, 255, 0.15);\n}\n.frame-root .scrollbar-autohide:hover {\n  scrollbar-color: rgba(0, 0, 0, 0.15) transparent;\n}\n.dark .frame-root .scrollbar-autohide:hover, .frame-root.dark .scrollbar-autohide:hover {\n  scrollbar-color: rgba(255, 255, 255, 0.15) transparent;\n}\n.dark .frame-root .icon-theme, .frame-root.dark .icon-theme {\n  filter: brightness(0) invert(1);\n}\n.frame-root .gradient-btn {\n  background: linear-gradient(35.85deg, #7652B9 0%, #B46470 51.92%, #CA9D8C 100%);\n  color: #FFFFFF;\n  box-shadow:\n    inset 0 0 0 1px rgba(255, 255, 255, 0.12),\n    0px 8px 11.7px 0px rgba(20, 39, 64, 0.17);\n  transition: box-shadow var(--motion-duration-fast) var(--motion-ease-standard);\n}\n.frame-root .gradient-btn svg { color: #FFFFFF; }\n.frame-root .gradient-btn:not(:disabled):hover {\n  box-shadow:\n    inset 0 0 0 1px rgba(255, 255, 255, 0.12),\n    0px 12px 20.1px 0px rgba(20, 39, 64, 0.22);\n}\n.frame-root .gradient-btn:not(:disabled):active {\n  box-shadow:\n    inset 0 0 0 1px rgba(255, 255, 255, 0.12),\n    0px 4px 8px 0px rgba(20, 39, 64, 0.2);\n}\n.dark .frame-root .gradient-btn:not(:disabled):hover, .frame-root.dark .gradient-btn:not(:disabled):hover {\n  box-shadow:\n    inset 0 0 0 1px rgba(255, 255, 255, 0.30),\n    0px 12px 20.1px 0px rgba(255, 255, 255, 0.20);\n}\n.dark .frame-root .gradient-btn:not(:disabled):active, .frame-root.dark .gradient-btn:not(:disabled):active {\n  box-shadow:\n    inset 0 0 0 1px rgba(255, 255, 255, 0.30),\n    0px 4px 8px 0px rgba(255, 255, 255, 0.24);\n}\n.frame-root .inverted-btn {\n  background: var(--color-text-primary);\n  color: var(--color-bg-page);\n  transition: box-shadow var(--motion-duration-fast) var(--motion-ease-standard);\n}\n.frame-root .inverted-btn:not(:disabled):hover {\n  box-shadow: 0px 8px 11.7px 0px rgba(20, 39, 64, 0.17);\n}\n.frame-root .inverted-btn:not(:disabled):active {\n  box-shadow: 0px 4px 8px 0px rgba(20, 39, 64, 0.2);\n}\n.dark .frame-root .inverted-btn:not(:disabled):hover, .frame-root.dark .inverted-btn:not(:disabled):hover {\n  box-shadow: 0px 8px 11.7px 0px rgba(255, 255, 255, 0.18);\n}\n.dark .frame-root .inverted-btn:not(:disabled):active, .frame-root.dark .inverted-btn:not(:disabled):active {\n  box-shadow: 0px 4px 8px 0px rgba(255, 255, 255, 0.24);\n}\n.frame-root .typing-dot {\n  animation: frame-typingBounce 1.4s infinite ease-in-out;\n}\n.frame-root .typing-dot:nth-child(1) { animation-delay: 0s; }\n.frame-root .typing-dot:nth-child(2) { animation-delay: 0.2s; }\n.frame-root .typing-dot:nth-child(3) { animation-delay: 0.4s; }\n.frame-root .chip-gradient-hover, .frame-root .input-gradient-hover {\n  position: relative;\n}\n.frame-root .chip-gradient-hover:not(:disabled):hover, .frame-root .input-gradient-hover:not(:disabled):hover {\n  border-color: transparent !important;\n}\n.frame-root .chip-gradient-hover:not(:disabled):hover::before, .frame-root .input-gradient-hover:not(:disabled):hover::before {\n  content: '';\n  position: absolute;\n  border-radius: inherit;\n  padding: 1px;\n  background: linear-gradient(74deg, #7652B9 0%, #B46470 52%, #CA9D8C 100%);\n  -webkit-mask:\n    linear-gradient(#000 0 0) content-box,\n    linear-gradient(#000 0 0);\n  -webkit-mask-composite: xor;\n  mask:\n    linear-gradient(#000 0 0) content-box,\n    linear-gradient(#000 0 0);\n  mask-composite: exclude;\n  pointer-events: none;\n}\n.frame-root .chip-gradient-hover:not(:disabled):hover::before {\n  inset: -1px;\n}\n.frame-root .input-gradient-hover:not(:disabled):hover::before {\n  inset: -2px;\n}\n.frame-root .input-gradient-border {\n  position: relative;\n}\n.frame-root .input-gradient-border::before {\n  content: '';\n  position: absolute;\n  inset: -2px;\n  border-radius: inherit;\n  padding: 2px;\n  background: linear-gradient(74deg, #7652B9 0%, #B46470 52%, #CA9D8C 100%);\n  -webkit-mask:\n    linear-gradient(#000 0 0) content-box,\n    linear-gradient(#000 0 0);\n  -webkit-mask-composite: xor;\n  mask:\n    linear-gradient(#000 0 0) content-box,\n    linear-gradient(#000 0 0);\n  mask-composite: exclude;\n  pointer-events: none;\n}\n.frame-root .type-h2 {\n  font-size: var(--font-h2-size);\n  line-height: var(--font-h2-lh);\n  font-weight: var(--font-h2-weight);\n  letter-spacing: var(--font-h2-tracking);\n}\n.frame-root .type-h2-emphasized {\n  font-size: var(--font-h2-size);\n  line-height: var(--font-h2-lh);\n  font-weight: var(--font-h2-emphasized-weight);\n  letter-spacing: var(--font-h2-tracking);\n}\n.frame-root .type-body {\n  font-size: var(--font-body-size);\n  line-height: var(--font-body-lh);\n  font-weight: var(--font-body-weight);\n  letter-spacing: var(--font-body-tracking);\n}\n.frame-root .type-detail {\n  font-size: var(--font-detail-size);\n  line-height: var(--font-detail-lh);\n  font-weight: var(--font-detail-weight);\n  letter-spacing: var(--font-detail-tracking);\n}\n.frame-root .type-detail-emphasized {\n  font-size: var(--font-detail-size);\n  line-height: var(--font-detail-emphasized-lh);\n  font-weight: var(--font-detail-emphasized-weight);\n  letter-spacing: var(--font-detail-tracking);\n}\n.frame-root .type-caption {\n  font-size: var(--font-caption-size);\n  line-height: var(--font-caption-lh);\n  font-weight: var(--font-caption-weight);\n  letter-spacing: var(--font-caption-tracking);\n}\n.frame-root .type-footnote {\n  font-size: var(--font-footnote-size);\n  line-height: var(--font-footnote-lh);\n  font-weight: var(--font-footnote-weight);\n  letter-spacing: var(--font-footnote-tracking);\n}\n.frame-root .panel-border {\n  border: 1px solid var(--color-card-panel-bg);\n}\n.dark .frame-root .panel-border, .frame-root.dark .panel-border {\n  border-color: var(--color-stroke-outline);\n}\n.frame-root .overlay-surface {\n  background: var(--color-overlay-surface-bg);\n}\n.dark .frame-root .overlay-surface, .frame-root.dark .overlay-surface {\n  -webkit-backdrop-filter: blur(var(--overlay-surface-blur));\n  backdrop-filter: blur(var(--overlay-surface-blur));\n}\n.frame-root .status-pending     { --status-bg: rgba(245,158,11,0.15);  --status-fg: #B8541A; }\n.frame-root .status-in-progress { --status-bg: rgba(49,113,255,0.1);   --status-fg: #3171FF; }\n.frame-root .status-submitted   { --status-bg: rgba(118,82,185,0.15);  --status-fg: #6B54E6; }\n.frame-root .status-in-review   { --status-bg: rgba(234,179,8,0.18);   --status-fg: #A87725; }\n.frame-root .status-success     { --status-bg: rgba(2,137,1,0.1);      --status-fg: #028901; }\n.frame-root .status-failed      { --status-bg: rgba(220,38,38,0.12);   --status-fg: #C93838; }\n.frame-root .status-expired     { --status-bg: rgba(107,114,128,0.15); --status-fg: #6B7280; }\n.frame-root .status-neutral     { --status-bg: var(--color-bg-hover);  --status-fg: var(--color-text-primary); }\n.dark .frame-root .status-pending, .frame-root.dark .status-pending     { --status-bg: #F59E0B; --status-fg: #FFFFFF; }\n.dark .frame-root .status-in-progress, .frame-root.dark .status-in-progress { --status-bg: #3171FF; --status-fg: #FFFFFF; }\n.dark .frame-root .status-submitted, .frame-root.dark .status-submitted   { --status-bg: #7652B9; --status-fg: #FFFFFF; }\n.dark .frame-root .status-in-review, .frame-root.dark .status-in-review   { --status-bg: #EAB308; --status-fg: #FFFFFF; }\n.dark .frame-root .status-success, .frame-root.dark .status-success     { --status-bg: #028901; --status-fg: #FFFFFF; }\n.dark .frame-root .status-failed, .frame-root.dark .status-failed      { --status-bg: #DC2626; --status-fg: #FFFFFF; }\n.dark .frame-root .status-expired, .frame-root.dark .status-expired     { --status-bg: #6B7280; --status-fg: #FFFFFF; }\n.frame-root .chat-textarea {\n  scrollbar-width: none;\n}\n.frame-root .chat-textarea-inset {\n  padding-top: 11px;\n  padding-bottom: 11px;\n}\n.frame-root .chat-textarea::-webkit-scrollbar {\n  display: none;\n  width: 0;\n  height: 0;\n}\n@keyframes frame-typingBounce {\n  0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }\n  30% { transform: translateY(-5px); opacity: 1; }\n}\n"
    },
    {
      "path": "FRAME_AI_GUIDE.md",
      "type": "registry:file",
      "target": "~/src/components/frame/FRAME_AI_GUIDE.md",
      "content": "# Frame Design System — AI Implementation Guide\n\nUse Frame's public design foundation and published components to build AI product interfaces. Import ./frame.css once, wrap Frame content in frame-root, reuse the installed components before creating new UI, and treat automated checks as support rather than a replacement for human review.\n\n## Public references\n\n- Plugin-independent setup: https://raw.githubusercontent.com/BeibeiZhang/frame-design-system/main/guides/getting-started.md\n- Portable checker (separate install): https://raw.githubusercontent.com/BeibeiZhang/frame-design-system/main/checks/README.md\n- Design principles: https://system.beibeidesign.com/#principles\n- AI interaction patterns: https://system.beibeidesign.com/#ai-patterns\n- Operating guide: https://system.beibeidesign.com/#operating-guide\n\n## Installed components\n\n- ChatMessage: User and assistant message presentation with a visual streaming state.\n- ChatPanel: Conversation layout with message and composer slots and near-bottom scrolling.\n- ChatInput: Prompt composer with callback-based sending, optional attachments and mentions. No model provider is included.\n- MessageCard: Inline event notice with information, success or warning tone and an optional action slot.\n- StatusTag: Semantic status pill for pending, processing, review and completed states.\n- Tag: Compact neutral label with an optional outline and tooltip.\n- SidePanelBody: Scrollable conversation inspector body with spacing and an automatic scrollbar.\n- SectionCount: Compact count label for grouped messages, references or workflow steps.\n- TimePill: Compact time or effort label using the shared status presentation.\n- StepIndicator: Visual pending, active and completed workflow step marker; pair with a text label.\n- SecondaryButton: Secondary action button for prompt or workflow decisions.\n- TertiaryButton: Outlined action button for quieter conversation and workflow actions.\n- GhostPillButton: Pill-shaped secondary action with optional icon and disabled state.\n- HeaderIconButton: Named icon action for conversation headers; size the mobile hit area to at least 44 pixels.\n- MenuRow: Workflow or conversation selection row with optional secondary text and current state.\n- IconBadge: Decorative icon badge for message context or workflow groups; not an interactive control.\n- TextButton: Quiet text action with disabled and disclosure attributes.\n- UtilityChip: Compact context action with optional pressed state for prompt toolbars.\n- SummaryFooter: Compact elapsed-time or summary line beneath a conversation result.\n- ChoiceCard: Selectable title and description card for choosing a prompt or workflow option.\n- ReviewItemCard: Presentational review-result row with type, time and completed state; does not implement a review workflow.\n- EmptyState: Icon and explanation for an empty message, result or reference area.\n- VersionRow: Version comparison and update-status presentation without a network or update client.\n"
    }
  ],
  "docs": "React 19, ReactDOM 19, TypeScript and lucide-react 1.14.0. Import the accompanying frame.css once and wrap Frame content in a div with className=\"frame-root\". Set the wrapper background to var(--color-bg-page) and color to var(--color-text-primary), or supply a matching host surface. Add dark to an ancestor or that wrapper for dark mode. CSS is generated from Tailwind 3 and scoped; it does not replace your Tailwind config or global theme. Optional microphone and voice features are off in examples. The full install includes every component currently marked published plus FRAME_AI_GUIDE.md. Import ./frame.css once. Review LICENSE at the source repository."
}
