diff --git a/README.md b/README.md index 0857a08..b4f16db 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ primitives for React — and the documentation site and playground at The package ships behavior, state, and wire formats with no styling of its own: a contenteditable composer with commands and chips, a thread with scroll auto-follow, message part segmentation, tool-call -timelines. This is the Base UI model applied to chat — the package owns behavior, you own every class. +timelines, and the app-shell parts around them — a collapsible sidebar, a nav tree, a closable tab +collection. This is the Base UI model applied to chat — the package owns behavior, you own every class. ## Layout diff --git a/bun.lock b/bun.lock index 6fcf283..8fad39a 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@ai-sdk/react": "^3.0.220", "@base-ui/react": "^1.6.0", "@intentface/chat": "workspace:*", + "@tabler/icons-react": "^3.46.0", "ai": "^6.0.218", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -46,7 +47,7 @@ }, "packages/chat": { "name": "@intentface/chat", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@floating-ui/dom": "^1.7.6", "nanoid": "^5.1.11", @@ -342,6 +343,10 @@ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@tabler/icons": ["@tabler/icons@3.46.0", "", {}, "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A=="], + + "@tabler/icons-react": ["@tabler/icons-react@3.46.0", "", { "dependencies": { "@tabler/icons": "3.46.0" }, "peerDependencies": { "react": ">= 16" } }, "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="], diff --git a/components/ai/ask-user.tsx b/components/ai/ask-user.tsx index 5ab4aec..df42d8f 100644 --- a/components/ai/ask-user.tsx +++ b/components/ai/ask-user.tsx @@ -6,12 +6,14 @@ import { useAskUserOption, useAskUserOptions, } from "@intentface/chat/ask-user"; -import { CheckIcon } from "lucide-react"; +import { + IconCheck, + IconChevronDown, + IconChevronLeft, + IconChevronRight, + IconHelpCircle, +} from "@tabler/icons-react"; import { type ComponentProps, type RefObject, useState } from "react"; -import { ChevronDownIcon } from "@/components/icons/chevron-down"; -import { ChevronLeftMediumIcon } from "@/components/icons/chevron-left-medium"; -import { ChevronRightMediumIcon } from "@/components/icons/chevron-right-medium"; -import { CircleQuestionmarkIcon } from "@/components/icons/circle-questionmark"; import { Collapsible } from "@/components/ui/collapsible"; import type { AskUserQuestion } from "@/lib/ai/types"; import { cn } from "@/lib/utils"; @@ -60,7 +62,7 @@ type AskUserPreviousProps = ComponentProps; const AskUserPrevious = ({ className, ...props }: AskUserPreviousProps) => ( - + ); @@ -69,7 +71,7 @@ type AskUserNextProps = ComponentProps; const AskUserNext = ({ className, ...props }: AskUserNextProps) => ( - + ); @@ -138,7 +140,7 @@ const AskUserOptionCheckIndicator = () => { aria-hidden="true" className={cn(INDICATOR_CLASS, option.selected && INDICATOR_SELECTED_CLASS)} > - {option.selected && } + {option.selected && } ); }; @@ -206,7 +208,7 @@ const AskUserSummary = ({ questions, answers, className, ...props }: AskUserSumm Answered {count} {count === 1 ? "question" : "questions"} - (
- +
{q.question} diff --git a/components/ai/attachments.tsx b/components/ai/attachments.tsx index f019bf1..1c54d1f 100644 --- a/components/ai/attachments.tsx +++ b/components/ai/attachments.tsx @@ -1,7 +1,7 @@ "use client"; import type { AttachmentErrorCode, AttachmentItem } from "@intentface/chat/attachments"; -import { PaperclipIcon } from "lucide-react"; +import { IconFile, IconPaperclip, IconX } from "@tabler/icons-react"; import { AnimatePresence, motion } from "motion/react"; import Image from "next/image"; import type { ComponentProps, ReactNode } from "react"; @@ -10,9 +10,6 @@ import { createPortal } from "react-dom"; import { IconButton } from "@/components/ui/icon-button"; import { formatFileSize, isImageAttachment, isPdfAttachment } from "@/lib/ai/attachments"; import { cn } from "@/lib/utils"; -import { CrossMediumIcon } from "../icons/cross-medium"; -import { FileBendIcon } from "../icons/file-bend"; -import { PaperClipIcon } from "../icons/paperclip"; // Re-export the attachment surface so consumers import everything from this // module: the generic mechanics from the headless package, and this app's @@ -67,8 +64,8 @@ type AttachmentsItemProps = { }; const getFileIcon = (mediaType: string) => { - if (isPdfAttachment(mediaType)) return FileBendIcon; - return PaperclipIcon; + if (isPdfAttachment(mediaType)) return IconFile; + return IconPaperclip; }; const AttachmentsItem = ({ item, children, className }: AttachmentsItemProps) => { @@ -138,7 +135,7 @@ const AttachmentsRemove = ({ onRemove, filename, className }: AttachmentsRemoveP size="2xs" type="button" > - + ); @@ -224,7 +221,7 @@ type AttachmentsTriggerProps = ComponentProps; const AttachmentsTrigger = ({ children, className, ...props }: AttachmentsTriggerProps) => { return ( - {children ?? } + {children ?? } ); }; diff --git a/components/ai/composer.tsx b/components/ai/composer.tsx index c308342..4679665 100644 --- a/components/ai/composer.tsx +++ b/components/ai/composer.tsx @@ -1,4 +1,5 @@ "use client"; +import { IconArrowUp, IconPlayerStop } from "@tabler/icons-react"; // Styled composer — thin wrappers over @intentface/chat/composer. Behavior // (editor, store, machines, command plumbing) lives in the package; this file @@ -20,8 +21,6 @@ import { Children, type ComponentProps, type ReactNode, useMemo, useRef } from " import { AskUser } from "@/components/ai/ask-user"; import { Attachments } from "@/components/ai/attachments"; import { Chip } from "@/components/ai/chip"; -import { SendIcon } from "@/components/icons/send"; -import { StopIcon } from "@/components/icons/stop"; import Button from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; import { Kbd } from "@/components/ui/kbd"; @@ -336,7 +335,7 @@ const ComposerSubmit = ({ transition={{ duration: 0.15 }} className="flex items-center justify-center" > - {isGenerating ? : (children ?? )} + {isGenerating ? : (children ?? )} diff --git a/components/ai/message.tsx b/components/ai/message.tsx index aa526d0..c203f83 100644 --- a/components/ai/message.tsx +++ b/components/ai/message.tsx @@ -7,7 +7,14 @@ import { useMessageSelection, useMessageSelectionScope, } from "@intentface/chat/message"; -import { PaperclipIcon } from "lucide-react"; +import { + IconCheck, + IconCopy, + IconFile, + IconMessage, + IconPaperclip, + IconPlayerStop, +} from "@tabler/icons-react"; import { motion } from "motion/react"; import Image from "next/image"; import { type ComponentProps, useMemo } from "react"; @@ -21,11 +28,6 @@ import { useCopy } from "@/hooks/use-copy"; import { isImageAttachment, isPdfAttachment } from "@/lib/ai/attachments"; import { CHIP_ICONS, isChipIconKey } from "@/lib/ai/chip-icons"; import { cn } from "@/lib/utils"; -import { Bubble5Icon } from "../icons/bubble-5"; -import { CheckMarkMediumIcon } from "../icons/check-mark-medium"; -import { CopyIcon } from "../icons/copy"; -import { FileBendIcon } from "../icons/file-bend"; -import { StopIcon } from "../icons/stop"; type MessageRootProps = ComponentProps; @@ -220,7 +222,7 @@ const MessageStopped = ({ className, ...props }: ComponentProps<"div">) => ( {...props} > - + Stopped
@@ -256,7 +258,7 @@ const MessageCopy = ({ className={className} {...props} > - {isCopied ? : } + {isCopied ? : } } /> @@ -283,7 +285,7 @@ type MessageAttachmentProps = { const MessageAttachment = ({ attachment, className, ...props }: MessageAttachmentProps) => { const mediaType = attachment.mediaType ?? ""; const filename = attachment.filename ?? "Attachment"; - const Icon = isPdfAttachment(mediaType) ? FileBendIcon : PaperclipIcon; + const Icon = isPdfAttachment(mediaType) ? IconFile : IconPaperclip; return (
{ window.getSelection()?.removeAllRanges(); }} > - + Add to chat diff --git a/components/ai/reasoning.tsx b/components/ai/reasoning.tsx index 11eba2b..06c6425 100644 --- a/components/ai/reasoning.tsx +++ b/components/ai/reasoning.tsx @@ -1,13 +1,12 @@ "use client"; import { Reasoning as ReasoningPrimitive, useReasoning } from "@intentface/chat/reasoning"; +import { IconBrain, IconChevronDown } from "@tabler/icons-react"; import { AnimatePresence, motion } from "motion/react"; import { type ComponentProps, memo, type ReactNode } from "react"; -import { ChevronDownIcon } from "@/components/icons/chevron-down"; import { Markdown } from "@/components/ui/markdown"; import { splitReasoningByHeaders } from "@/lib/ai/message-info"; import { cn } from "@/lib/utils"; -import { BrainIcon } from "../icons/brain"; import { TextShimmer } from "../ui/text-shimmer"; export { useReasoning }; @@ -76,7 +75,7 @@ const ReasoningTrigger = memo( {children ?? ( <>
- +
- diff --git a/components/ai/thread.tsx b/components/ai/thread.tsx index a5915b8..7a5e4fc 100644 --- a/components/ai/thread.tsx +++ b/components/ai/thread.tsx @@ -7,13 +7,13 @@ import { useThread, useThreadVisibility, } from "@intentface/chat/thread"; +import { IconArrowDown } from "@tabler/icons-react"; import { AnimatePresence, motion } from "motion/react"; import type { ComponentProps } from "react"; import { memo, useCallback } from "react"; import Button from "@/components/ui/button"; import { ProgressiveBlur } from "@/components/ui/progressive-blur"; import { cn } from "@/lib/utils"; -import { ArrowDownIcon } from "../icons/arrow-down"; export { useThread, useThreadVisibility }; export type { ThreadAutoScrollMode, ThreadVisibilityState }; @@ -148,7 +148,7 @@ const ThreadScrollButton = ({ className, ...props }: ThreadScrollButtonProps) => onClick={handleScrollToBottom} className="rounded-full shadow-xs" > - + Latest diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 00fc546..843b7df 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -1,17 +1,18 @@ "use client"; - +import { + IconBook, + IconBrandGithub, + IconBrandNpm, + IconDotsVertical, + IconPlus, + IconTrash, +} from "@tabler/icons-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { IntentfaceLogo } from "@/components/icons/intentface-logo"; -import { PlusMediumIcon } from "@/components/icons/plus-medium"; -import { TrashIcon } from "@/components/icons/trash"; import { Sidebar } from "@/components/ui/sidebar"; import { deleteChatInstance } from "@/lib/chat-instance"; import { useChatStore } from "@/lib/store/chat"; -import { BookIcon } from "./icons/book"; -import { DotGrid1x3VerticalTightIcon } from "./icons/dot-grid-1x3-vertical-tight"; -import { GitHubIcon } from "./icons/github"; -import { NpmIcon } from "./icons/npm"; import DropdownMenu from "./ui/dropdown-menu"; export const AppSidebar = () => { @@ -43,7 +44,7 @@ export const AppSidebar = () => { isActive={pathname === "/playground"} render={ - + New Chat } @@ -66,7 +67,7 @@ export const AppSidebar = () => { - + Delete } @@ -76,7 +77,7 @@ export const AppSidebar = () => { onClick={() => handleDelete(chat.id)} aria-label="Delete" > - + Delete Delete @@ -97,7 +98,7 @@ export const AppSidebar = () => { - + Docs } @@ -111,7 +112,7 @@ export const AppSidebar = () => { target="_blank" rel="noreferrer" > - + GitHub } @@ -125,7 +126,7 @@ export const AppSidebar = () => { target="_blank" rel="noreferrer" > - + npm } diff --git a/components/chat.tsx b/components/chat.tsx index 1aa1243..c92c696 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -9,8 +9,19 @@ import { type MessageSegment, } from "@intentface/chat/message-utils"; import { isToolPart, type ToolPart, type UnknownPart } from "@intentface/chat/types"; +import { + IconAlertTriangle, + IconBrain, + IconCheck, + IconChevronDown, + IconCircle, + IconHelpCircle, + IconQuote, + IconRefresh, + IconSandbox, + IconX, +} from "@tabler/icons-react"; import type { ChatStatus } from "ai"; -import { CircleIcon } from "lucide-react"; import { AnimatePresence, motion, stagger } from "motion/react"; import { useRouter } from "next/navigation"; import { @@ -37,15 +48,6 @@ import { Steps } from "@/components/ai/steps"; import { Thread } from "@/components/ai/thread"; import { ActiveTools, ToolsMenu } from "@/components/composer-tools"; import { Header } from "@/components/header"; -import { BrainIcon } from "@/components/icons/brain"; -import { CheckMarkMediumIcon } from "@/components/icons/check-mark-medium"; -import { ChevronDownIcon } from "@/components/icons/chevron-down"; -import { CircleQuestionmarkIcon } from "@/components/icons/circle-questionmark"; -import { CrossMediumIcon } from "@/components/icons/cross-medium"; -import { ExclamationTriangleIcon } from "@/components/icons/exclamation-triangle"; -import { OpenQuote2Icon } from "@/components/icons/open-quote-2"; -import { PlaygroundIcon } from "@/components/icons/playground"; -import { RefreshIcon } from "@/components/icons/refresh"; import { ModelSelector } from "@/components/model-selector"; import { PlaygroundSettings } from "@/components/playground-settings"; import { Markdown } from "@/components/ui/markdown"; @@ -128,10 +130,10 @@ export const useChatMessages = (): ChatMessagesValue => { type IconComponent = React.ComponentType<{ className?: string }>; const statusIcons: Record = { - complete: CheckMarkMediumIcon, - active: CircleIcon, - pending: CircleIcon, - error: ExclamationTriangleIcon, + complete: IconCheck, + active: IconCircle, + pending: IconCircle, + error: IconAlertTriangle, }; // A timeline row: static when it has no detail, collapsible (icon morphs to a @@ -186,7 +188,7 @@ const TimelineStep = ({ - + {label} @@ -222,7 +224,7 @@ const TimelineAskUser = ({ part }: { part: ToolPart }) => { const { label, status, questions, answers, isComplete } = getAskUserStepInfo(part); return ( - +
{questions.map((q) => (
@@ -358,7 +360,7 @@ const InterleavedSteps = ({ {header} - + {segments.map((seg, i) => { @@ -374,7 +376,7 @@ const InterleavedSteps = ({ key={`r-${i}-${j}`} label={section.header ?? "Thinking"} status={streaming && j === sections.length - 1 ? "active" : "complete"} - icon={BrainIcon} + icon={IconBrain} > {section.body && ( @@ -523,7 +525,7 @@ const ChatMessageItem = memo( onClick={() => regenerate({ messageId: message.id })} tooltip="Regenerate" > - + )} @@ -610,7 +612,7 @@ const MENTION_ITEMS: CommandItemData[] = [ { value: "quarterly-report", label: "Q4 Quarterly Report", - icon: "fileText", + icon: "fileChart", }, { value: "meeting-notes", @@ -620,7 +622,7 @@ const MENTION_ITEMS: CommandItemData[] = [ { value: "product-roadmap", label: "Product Roadmap", - icon: "spreadsheet", + icon: "map", }, { value: "brand-guidelines", @@ -630,7 +632,7 @@ const MENTION_ITEMS: CommandItemData[] = [ { value: "api-documentation", label: "API Documentation", - icon: "fileChart", + icon: "code", }, { value: "screenshot-dashboard", @@ -966,13 +968,13 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { "inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-ink-secondary", )} > - + Playground )} {selections.length > 0 && (
- + {selections.length} selection{selections.length === 1 ? "" : "s"} @@ -982,7 +984,7 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { className="cursor-pointer rounded-full p-0.5 hover:bg-primary-bg-hover hover:text-ink-primary" onClick={clearSelections} > - +
)} diff --git a/components/composer-tools.tsx b/components/composer-tools.tsx index 25ed054..a5ddf8c 100644 --- a/components/composer-tools.tsx +++ b/components/composer-tools.tsx @@ -1,11 +1,7 @@ "use client"; +import { IconBrain, IconPaperclip, IconPlus, IconWorld, IconX } from "@tabler/icons-react"; import { useComposer } from "@/components/ai/composer"; -import { BrainIcon } from "@/components/icons/brain"; -import { CrossMediumIcon } from "@/components/icons/cross-medium"; -import { GlobeIcon } from "@/components/icons/globe"; -import { PaperClipIcon } from "@/components/icons/paperclip"; -import { PlusMediumIcon } from "@/components/icons/plus-medium"; import Button from "@/components/ui/button"; import DropdownMenu from "@/components/ui/dropdown-menu"; import { IconButton } from "@/components/ui/icon-button"; @@ -28,8 +24,8 @@ export const ActiveTools = ({ tools, onToolsChange }: ToolToggleProps) => { onClick={() => onToolsChange({ ...tools, webSearch: false })} > - - + + Web Search @@ -43,8 +39,8 @@ export const ActiveTools = ({ tools, onToolsChange }: ToolToggleProps) => { onClick={() => onToolsChange({ ...tools, thinking: false })} > - - + + Thinking @@ -61,13 +57,13 @@ export const ToolsMenu = ({ tools, onToolsChange }: ToolToggleProps) => { - + } /> attachments.openFileDialog()}> - + Attach files @@ -75,13 +71,13 @@ export const ToolsMenu = ({ tools, onToolsChange }: ToolToggleProps) => { checked={tools.webSearch ?? false} onCheckedChange={(checked) => onToolsChange({ ...tools, webSearch: checked })} > - Web Search + Web Search onToolsChange({ ...tools, thinking: checked })} > - Thinking + Thinking diff --git a/components/docs/attributes-table.tsx b/components/docs/attributes-table.tsx index d314dc3..a6d51ab 100644 --- a/components/docs/attributes-table.tsx +++ b/components/docs/attributes-table.tsx @@ -1,4 +1,9 @@ -import type { ReactNode } from "react"; +"use client"; + +import { IconChevronDown } from "@tabler/icons-react"; +import { motion } from "motion/react"; +import { type ReactNode, useState } from "react"; +import { cn } from "@/lib/utils"; export type AttributeRow = { attribute: string; @@ -11,10 +16,9 @@ type AttributesTableProps = { }; // Hand-authored data-attribute reference — the styling hooks a part emits. -// Fixed-width columns match PropsTable so the two stay visually aligned down the -// page. Not collapsible: the description is the essential content here (no -// "default" to defer), so it stays inline. The Values column is omitted when no -// row provides one (e.g. keyboard shortcut tables). +// Built exactly like PropsTable: collapsed shows the attribute and its values, +// expanding reveals the description. The two sit next to each other in every +// API reference, so they read as one control rather than two. export const AttributesTable = ({ rows }: AttributesTableProps) => { const hasValues = rows.some((row) => row.values); @@ -23,40 +27,104 @@ export const AttributesTable = ({ rows }: AttributesTableProps) => { - {hasValues ? : null} - + + - + - {hasValues ? ( - - ) : null} - + + {rows.map((row) => ( - - - {hasValues ? ( - - ) : null} - - + ))}
AttributeValuesDescriptionValues + Details +
- {row.attribute} - - {row.values ? ( - - {row.values} - - ) : ( - — - )} - {row.description}
); }; + +const AttributeTableRow = ({ row, hasValues }: { row: AttributeRow; hasValues: boolean }) => { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen((current) => !current)} + > + + {row.attribute} + + + {row.values ? ( + {row.values} + ) : ( + {hasValues ? "—" : ""} + )} + + +
+ +
+ + + + + +
+ + {row.attribute} + + + {row.description} + + + {row.values ? ( + {row.values} + ) : ( + — + )} + +
+
+ + + + ); +}; + +// Mirrors PropsTable's detail grid, so an expanded attribute and an expanded +// prop line up with each other down the page. +const DetailRow = ({ label, children }: { label: string; children: ReactNode }) => ( +
+
{label}
+
{children}
+
+); diff --git a/components/docs/component-preview-frame.tsx b/components/docs/component-preview-frame.tsx index 658b68e..5b7e1e9 100644 --- a/components/docs/component-preview-frame.tsx +++ b/components/docs/component-preview-frame.tsx @@ -15,7 +15,7 @@ export const ComponentPreviewFrame = ({ preview, code }: ComponentPreviewFramePr return (
-
+
{(["preview", "code"] as const).map((value) => (
{current.code}
diff --git a/components/docs/keys-table.tsx b/components/docs/keys-table.tsx index 1f82176..3b5ea43 100644 --- a/components/docs/keys-table.tsx +++ b/components/docs/keys-table.tsx @@ -22,7 +22,7 @@ export const KeysTable = ({ rows }: KeysTableProps) => ( - + Key Description @@ -31,7 +31,7 @@ export const KeysTable = ({ rows }: KeysTableProps) => ( {rows.map((row) => ( - + {row.keys} diff --git a/components/docs/page-actions.tsx b/components/docs/page-actions.tsx index 4e69f29..43e1cee 100644 --- a/components/docs/page-actions.tsx +++ b/components/docs/page-actions.tsx @@ -1,5 +1,4 @@ -import { GitHubIcon } from "@/components/icons/github"; -import { MarkdownIcon } from "@/components/icons/markdown"; +import { IconBrandGithub, IconMarkdown } from "@tabler/icons-react"; import Button from "@/components/ui/button"; const REPO = "https://github.com/Intentface/intentface-chat"; @@ -27,7 +26,7 @@ export const PageActions = ({ slug, source }: PageActionsProps) => ( className={actionButtonClass} render={ - + View as Markdown } @@ -43,7 +42,7 @@ export const PageActions = ({ slug, source }: PageActionsProps) => ( target="_blank" rel="noreferrer" > - + Primitive source } diff --git a/components/docs/props-table.tsx b/components/docs/props-table.tsx index 02b9440..980240e 100644 --- a/components/docs/props-table.tsx +++ b/components/docs/props-table.tsx @@ -1,8 +1,8 @@ "use client"; +import { IconChevronDown } from "@tabler/icons-react"; import { motion } from "motion/react"; import { type ReactNode, useState } from "react"; -import { ChevronDownIcon } from "@/components/icons/chevron-down"; import { cn } from "@/lib/utils"; export type PropRow = { @@ -29,7 +29,7 @@ export const PropsTable = ({ rows }: PropsTableProps) => ( - + Prop Type Default @@ -76,7 +76,7 @@ const PropsRow = ({ row }: { row: PropRow }) => { setOpen((current) => !current); }} > - @@ -97,15 +97,7 @@ const PropsRow = ({ row }: { row: PropRow }) => { }} className="overflow-hidden" > - +
{row.name} @@ -124,7 +116,7 @@ const PropsRow = ({ row }: { row: PropRow }) => { — )} - +
diff --git a/components/docs/values-table.tsx b/components/docs/values-table.tsx index 8b69c59..9f5fce3 100644 --- a/components/docs/values-table.tsx +++ b/components/docs/values-table.tsx @@ -22,7 +22,7 @@ export const ValuesTable = ({ rows }: ValuesTableProps) => ( - + Value Description @@ -34,7 +34,7 @@ export const ValuesTable = ({ rows }: ValuesTableProps) => (
{row.value} {row.default ? ( - + default ) : null} diff --git a/components/icons/appearance.tsx b/components/icons/appearance.tsx deleted file mode 100644 index e89e995..0000000 --- a/components/icons/appearance.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { SVGProps } from "react"; - -export const AppearanceIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/arrow-down.tsx b/components/icons/arrow-down.tsx deleted file mode 100644 index 6b6f474..0000000 --- a/components/icons/arrow-down.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export const ArrowDownIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/arrow-triangle-right.tsx b/components/icons/arrow-triangle-right.tsx deleted file mode 100644 index 862b8ed..0000000 --- a/components/icons/arrow-triangle-right.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const ArrowTriangleRightIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/arrow-up.tsx b/components/icons/arrow-up.tsx deleted file mode 100644 index 53536ba..0000000 --- a/components/icons/arrow-up.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export function ArrowUpIcon(props: SVGProps) { - return ( - - ); -} diff --git a/components/icons/arrows-all-sides-2.tsx b/components/icons/arrows-all-sides-2.tsx deleted file mode 100644 index 7ddc64d..0000000 --- a/components/icons/arrows-all-sides-2.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { SVGProps } from "react"; - -export const ArrowsAllSides2Icon = (props: SVGProps) => ( - -); diff --git a/components/icons/book.tsx b/components/icons/book.tsx deleted file mode 100644 index e065071..0000000 --- a/components/icons/book.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const BookIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/brain.tsx b/components/icons/brain.tsx deleted file mode 100644 index 206110e..0000000 --- a/components/icons/brain.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { SVGProps } from "react"; - -export const BrainIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/bubble-5.tsx b/components/icons/bubble-5.tsx deleted file mode 100644 index 67052f8..0000000 --- a/components/icons/bubble-5.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const Bubble5Icon = (props: SVGProps) => ( - -); diff --git a/components/icons/bubble-wide-sparkle.tsx b/components/icons/bubble-wide-sparkle.tsx deleted file mode 100644 index 867a528..0000000 --- a/components/icons/bubble-wide-sparkle.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { SVGProps } from "react"; - -export const BubbleWideSparkleIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/bug.tsx b/components/icons/bug.tsx deleted file mode 100644 index c9a1bfd..0000000 --- a/components/icons/bug.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export const BugIcon = (props: SVGProps) => { - return ( - - - - ); -}; diff --git a/components/icons/chain-link.tsx b/components/icons/chain-link.tsx deleted file mode 100644 index ccbc12c..0000000 --- a/components/icons/chain-link.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChainLinkIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/check-mark-medium.tsx b/components/icons/check-mark-medium.tsx deleted file mode 100644 index 9a1e335..0000000 --- a/components/icons/check-mark-medium.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { SVGProps } from "react"; - -export const CheckMarkMediumIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/check.tsx b/components/icons/check.tsx deleted file mode 100644 index 4e83252..0000000 --- a/components/icons/check.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export function CheckIcon(props: SVGProps) { - return ( - - ); -} diff --git a/components/icons/chevron-down-medium.tsx b/components/icons/chevron-down-medium.tsx deleted file mode 100644 index 3d211c3..0000000 --- a/components/icons/chevron-down-medium.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChevronDownMediumIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/chevron-down.tsx b/components/icons/chevron-down.tsx deleted file mode 100644 index 4f9f0d9..0000000 --- a/components/icons/chevron-down.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChevronDownIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/chevron-grabber-vertical.tsx b/components/icons/chevron-grabber-vertical.tsx deleted file mode 100644 index 165bc59..0000000 --- a/components/icons/chevron-grabber-vertical.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChevronGrabberVerticalIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/chevron-left-medium.tsx b/components/icons/chevron-left-medium.tsx deleted file mode 100644 index 51f59e9..0000000 --- a/components/icons/chevron-left-medium.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChevronLeftMediumIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/chevron-right-medium.tsx b/components/icons/chevron-right-medium.tsx deleted file mode 100644 index 8d1b2c7..0000000 --- a/components/icons/chevron-right-medium.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChevronRightMediumIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/chevron-top-medium.tsx b/components/icons/chevron-top-medium.tsx deleted file mode 100644 index 521270b..0000000 --- a/components/icons/chevron-top-medium.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChevronTopMediumIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/circle-questionmark.tsx b/components/icons/circle-questionmark.tsx deleted file mode 100644 index 0ffdab7..0000000 --- a/components/icons/circle-questionmark.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const CircleQuestionmarkIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/circle.tsx b/components/icons/circle.tsx deleted file mode 100644 index 844e089..0000000 --- a/components/icons/circle.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { ComponentProps } from "react"; - -export const CircleIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/components/icons/code.tsx b/components/icons/code.tsx deleted file mode 100644 index 6fd2a72..0000000 --- a/components/icons/code.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { SVGProps } from "react"; - -export const CodeIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/color-swatch.tsx b/components/icons/color-swatch.tsx deleted file mode 100644 index 9b2e947..0000000 --- a/components/icons/color-swatch.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const ColorSwatchIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/copy.tsx b/components/icons/copy.tsx deleted file mode 100644 index 5439ad7..0000000 --- a/components/icons/copy.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { SVGProps } from "react"; - -export const CopyIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/cross-medium.tsx b/components/icons/cross-medium.tsx deleted file mode 100644 index fdef40f..0000000 --- a/components/icons/cross-medium.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const CrossMediumIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/dot-grid-1x3-horizontal-tight.tsx b/components/icons/dot-grid-1x3-horizontal-tight.tsx deleted file mode 100644 index 4eb4aef..0000000 --- a/components/icons/dot-grid-1x3-horizontal-tight.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { SVGProps } from "react"; - -export const DotGrid1x3HorizontalTightIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/dot-grid-1x3-vertical-tight.tsx b/components/icons/dot-grid-1x3-vertical-tight.tsx deleted file mode 100644 index 8829c37..0000000 --- a/components/icons/dot-grid-1x3-vertical-tight.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { SVGProps } from "react"; - -export const DotGrid1x3VerticalTightIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/edit.tsx b/components/icons/edit.tsx deleted file mode 100644 index a4e8b7c..0000000 --- a/components/icons/edit.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { SVGProps } from "react"; - -export const EditIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/exclamation-triangle.tsx b/components/icons/exclamation-triangle.tsx deleted file mode 100644 index 334d12d..0000000 --- a/components/icons/exclamation-triangle.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const ExclamationTriangleIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/eyedropper.tsx b/components/icons/eyedropper.tsx deleted file mode 100644 index 68c0a89..0000000 --- a/components/icons/eyedropper.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export const EyedropperIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/file-bend.tsx b/components/icons/file-bend.tsx deleted file mode 100644 index 884479b..0000000 --- a/components/icons/file-bend.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export const FileBendIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/file-chart.tsx b/components/icons/file-chart.tsx deleted file mode 100644 index 4db3623..0000000 --- a/components/icons/file-chart.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { SVGProps } from "react"; - -export const FileChartIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/file-text.tsx b/components/icons/file-text.tsx deleted file mode 100644 index 237ed21..0000000 --- a/components/icons/file-text.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { SVGProps } from "react"; - -export const FileTextIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/github.tsx b/components/icons/github.tsx deleted file mode 100644 index 1c4e6f0..0000000 --- a/components/icons/github.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const GitHubIcon = (props: SVGProps) => { - return ( - - - - ); -}; diff --git a/components/icons/globe.tsx b/components/icons/globe.tsx deleted file mode 100644 index 4791664..0000000 --- a/components/icons/globe.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { SVGProps } from "react"; - -export const GlobeIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/image-alt.tsx b/components/icons/image-alt.tsx deleted file mode 100644 index 8a3cd6a..0000000 --- a/components/icons/image-alt.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { SVGProps } from "react"; - -export const ImageAltIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/input-form.tsx b/components/icons/input-form.tsx deleted file mode 100644 index 71980e9..0000000 --- a/components/icons/input-form.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { SVGProps } from "react"; - -export const InputFormIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/markdown.tsx b/components/icons/markdown.tsx deleted file mode 100644 index 5d46e66..0000000 --- a/components/icons/markdown.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export const MarkdownIcon = (props: SVGProps) => { - return ( - - - - ); -}; diff --git a/components/icons/moon.tsx b/components/icons/moon.tsx deleted file mode 100644 index c75368c..0000000 --- a/components/icons/moon.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const MoonIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/npm.tsx b/components/icons/npm.tsx deleted file mode 100644 index 976615e..0000000 --- a/components/icons/npm.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { SVGProps } from "react"; - -export const NpmIcon = (props: SVGProps) => { - return ( - - - - ); -}; diff --git a/components/icons/open-quote-2.tsx b/components/icons/open-quote-2.tsx deleted file mode 100644 index 5df51fc..0000000 --- a/components/icons/open-quote-2.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export const OpenQuote2Icon = (props: SVGProps) => ( - -); diff --git a/components/icons/paper-plane.tsx b/components/icons/paper-plane.tsx deleted file mode 100644 index f9c9553..0000000 --- a/components/icons/paper-plane.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const PaperPlaneIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/paperclip.tsx b/components/icons/paperclip.tsx deleted file mode 100644 index bed101c..0000000 --- a/components/icons/paperclip.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export function PaperClipIcon(props: SVGProps) { - return ( - - ); -} diff --git a/components/icons/playground.tsx b/components/icons/playground.tsx deleted file mode 100644 index 9f90473..0000000 --- a/components/icons/playground.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { SVGProps } from "react"; - -export const PlaygroundIcon = (props: SVGProps) => { - return ( - - - - - - ); -}; diff --git a/components/icons/plus-medium.tsx b/components/icons/plus-medium.tsx deleted file mode 100644 index a67d2e9..0000000 --- a/components/icons/plus-medium.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const PlusMediumIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/refresh.tsx b/components/icons/refresh.tsx deleted file mode 100644 index 14c2e29..0000000 --- a/components/icons/refresh.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { SVGProps } from "react"; - -export const RefreshIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/search-line-sparkle.tsx b/components/icons/search-line-sparkle.tsx deleted file mode 100644 index 540f241..0000000 --- a/components/icons/search-line-sparkle.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { SVGProps } from "react"; - -export const SearchLineSparkleIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/send.tsx b/components/icons/send.tsx deleted file mode 100644 index fcaa5c8..0000000 --- a/components/icons/send.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export function SendIcon(props: SVGProps) { - return ( - - ); -} diff --git a/components/icons/settings-slider-three.tsx b/components/icons/settings-slider-three.tsx deleted file mode 100644 index 41c094f..0000000 --- a/components/icons/settings-slider-three.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const SettingsSliderThreeIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/settings.tsx b/components/icons/settings.tsx deleted file mode 100644 index ef4999d..0000000 --- a/components/icons/settings.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import type { ComponentProps } from "react"; - -export const SettingsIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/components/icons/sidebar.tsx b/components/icons/sidebar.tsx deleted file mode 100644 index cc2cf3a..0000000 --- a/components/icons/sidebar.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { SVGProps } from "react"; - -export const SidebarIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/spreadsheet.tsx b/components/icons/spreadsheet.tsx deleted file mode 100644 index 0f7abd6..0000000 --- a/components/icons/spreadsheet.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { SVGProps } from "react"; - -export const SpreadsheetIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/square-lines.tsx b/components/icons/square-lines.tsx deleted file mode 100644 index ab4d9ae..0000000 --- a/components/icons/square-lines.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const SquareLinesIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/stop.tsx b/components/icons/stop.tsx deleted file mode 100644 index 7f7d15c..0000000 --- a/components/icons/stop.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { SVGProps } from "react"; - -export function StopIcon(props: SVGProps) { - return ( - - ); -} diff --git a/components/icons/sun.tsx b/components/icons/sun.tsx deleted file mode 100644 index 0a61624..0000000 --- a/components/icons/sun.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { SVGProps } from "react"; - -// The source clipped to a 0,0,24,24 rect identical to the viewBox (a no-op), so -// the clipPath def is dropped — the rays already sit inside the box. -export const SunIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/trash.tsx b/components/icons/trash.tsx deleted file mode 100644 index 3fa7e58..0000000 --- a/components/icons/trash.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const TrashIcon = (props: SVGProps) => ( - -); diff --git a/components/model-selector.tsx b/components/model-selector.tsx index f49311d..830cc51 100644 --- a/components/model-selector.tsx +++ b/components/model-selector.tsx @@ -1,7 +1,6 @@ "use client"; +import { IconCheck, IconSelector } from "@tabler/icons-react"; -import { CheckMarkMediumIcon } from "@/components/icons/check-mark-medium"; -import { ChevronGrabberVerticalIcon } from "@/components/icons/chevron-grabber-vertical"; import { ClaudeIcon } from "@/components/icons/claude"; import { GeminiIcon } from "@/components/icons/gemini"; import { GrokIcon } from "@/components/icons/grok"; @@ -55,7 +54,7 @@ export const ModelSelector = ({ value, onValueChange }: ModelSelectorProps) => { } /> @@ -73,7 +72,7 @@ export const ModelSelector = ({ value, onValueChange }: ModelSelectorProps) => { {group.models.map((model) => { return ( onValueChange(model.id)}> - + {model.label} ); diff --git a/components/playground-settings.tsx b/components/playground-settings.tsx index 1aa8012..62f685c 100644 --- a/components/playground-settings.tsx +++ b/components/playground-settings.tsx @@ -1,4 +1,13 @@ "use client"; +import { + IconAdjustmentsHorizontal, + IconDeviceDesktop, + IconForms, + IconLayoutList, + IconMoon, + IconSettings, + IconSun, +} from "@tabler/icons-react"; // Playground settings — a Linear-style display-options popover behind a single // icon trigger in the chat area's top-right corner, on every chat page. The @@ -13,14 +22,7 @@ import { AnimatePresence, MotionConfig, motion } from "motion/react"; import { type ReactNode, useState } from "react"; import type { ComposerSubmitOn } from "@/components/ai/composer"; import type { ThreadAutoScrollMode } from "@/components/ai/thread"; -import { AppearanceIcon } from "@/components/icons/appearance"; -import { InputFormIcon } from "@/components/icons/input-form"; -import { MoonIcon } from "@/components/icons/moon"; import { OpenAIIcon } from "@/components/icons/openai"; -import { SettingsIcon } from "@/components/icons/settings"; -import { SettingsSliderThreeIcon } from "@/components/icons/settings-slider-three"; -import { SquareLinesIcon } from "@/components/icons/square-lines"; -import { SunIcon } from "@/components/icons/sun"; import Button from "@/components/ui/button"; import { ColorPill } from "@/components/ui/color-pill"; import { IconButton } from "@/components/ui/icon-button"; @@ -120,11 +122,11 @@ const CUSTOM_PRESET_VALUE = "__custom__"; const MODE_OPTIONS: ReadonlyArray<{ value: InterfaceThemeMode; label: string; - Icon: typeof SunIcon; + Icon: typeof IconSun; }> = [ - { value: "light", label: "Light", Icon: SunIcon }, - { value: "dark", label: "Dark", Icon: MoonIcon }, - { value: "system", label: "System", Icon: AppearanceIcon }, + { value: "light", label: "Light", Icon: IconSun }, + { value: "dark", label: "Dark", Icon: IconMoon }, + { value: "system", label: "System", Icon: IconDeviceDesktop }, ]; const ThemeTab = () => { @@ -516,9 +518,9 @@ const KeyTab = () => { }; const PLAYGROUND_TABS = [ - { value: "theme", label: "Theme", Icon: SettingsSliderThreeIcon, content: }, - { value: "thread", label: "Thread", Icon: SquareLinesIcon, content: }, - { value: "composer", label: "Composer", Icon: InputFormIcon, content: }, + { value: "theme", label: "Theme", Icon: IconAdjustmentsHorizontal, content: }, + { value: "thread", label: "Thread", Icon: IconLayoutList, content: }, + { value: "composer", label: "Composer", Icon: IconForms, content: }, { value: "key", label: "Key", Icon: OpenAIIcon, content: }, ] as const; @@ -570,7 +572,7 @@ export const PlaygroundSettings = () => { /> } > - + ( @@ -22,7 +22,7 @@ const Checkbox = ({ className, ...props }: CheckboxPrimitive.Root.Props) => ( data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none [&>svg]:size-3" > - + ); diff --git a/components/ui/color-pill.tsx b/components/ui/color-pill.tsx index dcf1e9f..a5659de 100644 --- a/components/ui/color-pill.tsx +++ b/components/ui/color-pill.tsx @@ -1,10 +1,9 @@ "use client"; +import { IconChevronDown, IconColorPicker } from "@tabler/icons-react"; import { useCallback, useState } from "react"; import { HexColorPicker } from "react-colorful"; -import { ChevronDownMediumIcon } from "@/components/icons/chevron-down-medium"; -import { EyedropperIcon } from "@/components/icons/eyedropper"; import { Popover } from "@/components/ui/popover"; import { HexSchema } from "@/lib/interface-theme"; import { cn } from "@/lib/utils"; @@ -58,7 +57,7 @@ export const ColorPill = ({ className="flex size-5 shrink-0 items-center justify-center rounded-sm outline-none transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-accent-bg/50 disabled:cursor-not-allowed disabled:opacity-50" aria-label="Pick color" > - + -
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx index 335b5d2..7f79f6b 100644 --- a/components/ui/dropdown-menu.tsx +++ b/components/ui/dropdown-menu.tsx @@ -1,10 +1,8 @@ "use client"; import { Menu as MenuPrimitive } from "@base-ui/react/menu"; +import { IconCheck, IconChevronRight, IconCircle } from "@tabler/icons-react"; import type { ComponentProps, ReactNode } from "react"; -import { ArrowTriangleRightIcon } from "@/components/icons/arrow-triangle-right"; -import { CheckMarkMediumIcon } from "@/components/icons/check-mark-medium"; -import { CircleIcon } from "@/components/icons/circle"; import { cn } from "@/lib/utils"; function DropdownMenuRoot({ ...props }: MenuPrimitive.Root.Props) { @@ -128,7 +126,7 @@ function DropdownMenuSubTrigger({ {...props} > {children} - + ); } @@ -187,7 +185,7 @@ function DropdownMenuCheckboxItem({ > - + {children} @@ -219,7 +217,7 @@ function DropdownMenuRadioItem({ > - + {children} diff --git a/components/ui/select.tsx b/components/ui/select.tsx index 930af61..9d79477 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -1,13 +1,10 @@ "use client"; import { Select as SelectPrimitive } from "@base-ui/react/select"; +import { IconCheck, IconChevronDown, IconChevronUp, IconSelector } from "@tabler/icons-react"; import { cva, type VariantProps } from "class-variance-authority"; import type * as React from "react"; import { cn } from "@/lib/utils"; -import { CheckMarkMediumIcon } from "../icons/check-mark-medium"; -import { ChevronDownMediumIcon } from "../icons/chevron-down-medium"; -import { ChevronGrabberVerticalIcon } from "../icons/chevron-grabber-vertical"; -import { ChevronTopMediumIcon } from "../icons/chevron-top-medium"; const selectTriggerVariants = cva( [ @@ -80,7 +77,7 @@ function SelectTrigger({ {...props} > {children} - } /> + } /> ); } @@ -196,7 +193,7 @@ const SelectItem = ({ className, children, ...props }: SelectPrimitive.Item.Prop
- +
@@ -234,7 +231,7 @@ const SelectScrollUpButton = ({ )} {...props} > - + ); }; @@ -259,7 +256,7 @@ const SelectScrollDownButton = ({ )} {...props} > - + ); }; diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx index 39b18b1..52b130c 100644 --- a/components/ui/sidebar.tsx +++ b/components/ui/sidebar.tsx @@ -2,6 +2,7 @@ import { useRender } from "@base-ui/react/use-render"; import { SHELL_SIDEBAR_WIDTH_VAR, Shell, useShell } from "@intentface/chat/shell"; +import { IconLayoutSidebar } from "@tabler/icons-react"; import { cva, type VariantProps } from "class-variance-authority"; import { type ComponentProps, @@ -12,7 +13,6 @@ import { useMemo, useState, } from "react"; -import { SidebarIcon } from "@/components/icons/sidebar"; import Drawer from "@/components/ui/drawer"; import Input from "@/components/ui/input"; import Separator from "@/components/ui/separator"; @@ -221,12 +221,11 @@ const SidebarRoot = ({ )} {...props} > -
+ {/* No right padding while flush: the viewport's own p-2 already supplies + the seam, and padding both sides of it would make that gap twice the + one at every screen edge. The floating card pads all four sides, + because then it has edges of its own. */} +
{children}
@@ -255,7 +254,7 @@ const SidebarTrigger = ({ className, onClick, ...props }: ComponentProps<"button className={cn("hover:bg-base-bg-hover", className)} {...props} > - + Toggle Sidebar ); @@ -274,7 +273,7 @@ const SidebarTrigger = ({ className, onClick, ...props }: ComponentProps<"button className={cn("hover:bg-base-bg-hover", className)} {...props} > - + Toggle Sidebar ) : ( @@ -332,7 +331,8 @@ const SidebarHeader = ({ className, ...props }: ComponentProps<"div">) => { return (
); diff --git a/content/docs/handbook/accessibility.mdx b/content/docs/handbook/accessibility.mdx index a818779..3dc0939 100644 --- a/content/docs/handbook/accessibility.mdx +++ b/content/docs/handbook/accessibility.mdx @@ -1,110 +1,69 @@ --- title: Accessibility -description: The accessibility features the primitives provide, and what your application still has to supply. +description: What the primitives guarantee, and what your application still has to supply. --- -The primitives handle ARIA attributes, roles, keyboard navigation, and focus -management. Because the package renders no styles, anything visual — contrast, -focus indication — is your application's responsibility. - -## Keyboard navigation +The primitives own roles, relationships, keyboard behaviour and focus movement. +They render no styles, so everything visual is yours — and some of it is not +optional. + +## What you have to supply + +- **Focus indication.** Focus moves correctly; nothing paints it. Style + `:focus-visible` yourself, and remember that in command lists and ask-user + options the highlight *is* the indicator, so a faint highlight is a missing + focus ring rather than a soft hover. +- **Contrast.** No colour ships with the package. +- **Speaker identity.** A message's `role` reaches the DOM as `data-role` only. + Alignment and colour carry it to sighted readers and to nobody else, so add a + visually hidden prefix or an `aria-label`. +- **Status copy.** Validation emits a code, not a message, and nothing announces + "generating" beyond a button label. Add your own `role="status"` region where + in-flight state matters, and `role="alert"` for failures. +- **Anything you build in a seam.** A surface lifted through a render prop + inherits that pattern's obligations — a hover preview still has to open on + focus and close on Escape. + +## Keyboard The primitives follow the [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/). -The composer's editor interprets keys by mode: an open command list, or an active -ask-user flow, takes priority over normal typing. A primitive that claims keys of -its own documents them in a key table on its page; one that relies on native focus -order does not. - -Anything draggable is also operable from the keyboard. The shell's grip resizes -the sidebar with the arrow keys once focused, so no layout a visitor can reach -with a pointer is closed to them without one. - -A strip or a tree is one tab stop, not one per item. Focus follows the arrow -keys without selecting, which is manual activation; a primitive that should -select as focus moves says so through a prop rather than by default. The nav is a roving-tabindex widget: -exactly one row is tabbable and the arrow keys move focus between rows. Before -that state is seeded — a server render, or the frame before hydration — the -active row carries the tab stop, which keeps a row rendered as a real anchor -reachable without JavaScript. A field inside the tree keeps its own arrow keys, -or its caret could never move. - -Command lists and ask-user options move a highlight rather than DOM focus, so the -caret stays in the editor while you navigate. Arrowing past the end of a list -keeps focus in place; arrowing up past the first ask-user option returns to the -editor. - -## Focus management - -Focus is managed automatically after interaction — the composer restores the -caret after selecting a command item, and ask-user recovers arrow navigation if a -click elsewhere drops focus to the body. - -In command lists and ask-user options the highlight *is* the focus indication — -focus follows it rather than moving separately, so styling the highlight faintly -removes the focus indicator rather than softening a hover effect. - -## Roles and relationships - -The composer's editor is wired to its command list as a combobox: `aria-expanded`, -`aria-controls`, and `aria-activedescendant` reference a `listbox`, and the active -row carries `aria-selected`. Ask-user renders a labelled `radiogroup`, or -checkboxes with `aria-checked` for multi-select questions. Disabled options are -announced but skipped by highlight and selection. - -`Steps` marks the active item with `aria-current="step"`. `Reasoning.Root` carries -`aria-busy` while streaming. - -Disclosure controls announce both their state and what they govern: the shell's -trigger is a ` + + + {preview} + + + ); +}; + +const chipClass = + "mx-0.5 inline-flex items-center gap-1 rounded-md border border-[#f0f0f0] bg-[#f4f4f4] px-1.5 py-0.5 align-baseline font-medium text-xs data-[variant=accent]:border-blue-200 data-[variant=accent]:bg-blue-50 data-[variant=accent]:text-blue-700 dark:border-[#2d2d2d] dark:bg-[#232323] dark:data-[variant=accent]:border-blue-900 dark:data-[variant=accent]:bg-blue-950 dark:data-[variant=accent]:text-blue-300"; diff --git a/content/docs/primitives/composer.mdx b/content/docs/primitives/composer.mdx index 3ff6b86..6b3fe35 100644 --- a/content/docs/primitives/composer.mdx +++ b/content/docs/primitives/composer.mdx @@ -20,6 +20,7 @@ import { Store } from './composer/demos/store'; - **Prefix commands** — type `/`, `@`, or other prefixes to open command lists. - **Panel** — hosts command results, live steps, or an ask-user prompt above the field. - **Headless** — behavior lives in `@intentface/chat`; the demos below show one way to style it, and every class in them is yours to change. +- **Isolate it from your stream** — see [Why the composer needs isolating](#why-the-composer-needs-isolating). The editor is the most expensive thing on the page to re-render per token. - **Get started** — see [Quick start](/quick-start) to add the package. ## Anatomy @@ -59,7 +60,7 @@ The bare nesting — every part is optional except `Composer` and `Container`: `Composer.Panel` takes plain children or a callback receiving the composer state, and shows only while its resolved content is non-empty. Gate each part on the state it belongs to (`commands.active`, `askUser.active`, …) and the panel -opens and closes to match — priority is just the order of your branches. +opens and closes to match — priority is the order of your branches. ## Examples @@ -111,24 +112,18 @@ back. } file="primitives/composer/demos/controlled.tsx" /> -### External store +### Reaching a composer from outside -`Composer.createStore()` returns a handle you own. Pass it via `store` and -drive the composer from anywhere — a toolbar, a shortcut — through -`store.controller`, with no context or ref threading. +Every `Composer.Root` creates its own isolated store, so several composers can +share a page with no wiring at all. Reaching into one from outside its tree is +the case that needs a handle: `Composer.createStore()` returns one you own, and +passing it as `store` lets a toolbar, a shortcut or a status bar drive the +composer through `store.controller` or `useComposerStore`, with no context or +ref threading. } file="primitives/composer/demos/store.tsx" /> -## Multiple instances - -Every `` creates its own isolated store, so several composers can -live on one page with no wiring. To reach a composer from outside its tree — -toolbars, keyboard shortcuts, status bars — create an explicit handle with -`Composer.createStore()`, pass it via the `store` prop, and use -`store.controller` (imperative) or `useComposerStore(store, selector)` -(reactive). - -## Performance +## Why the composer needs isolating Typing costs nothing outside the composer: editor state lives in the store and parts subscribe by slice with `useComposer(selector)`, so a keystroke @@ -166,98 +161,39 @@ ask-user being active, takes priority over normal typing. **Normal typing** export const normalKeys = [ - { attribute: "Enter", description: "Submit the form (requestSubmit)." }, - { attribute: "Shift + Enter", description: "Insert a soft line break." }, - { attribute: "Backspace", description: "When the editor is empty and attachments exist, remove the last attachment." }, + { keys: "Enter", description: "Submit the form (requestSubmit)." }, + { keys: "Shift + Enter", description: "Insert a soft line break." }, + { keys: "Backspace", description: "When the editor is empty and attachments exist, remove the last attachment." }, ]; - + **Command list open** export const commandKeys = [ - { attribute: "ArrowUp / ArrowDown", description: "Move the highlight through the items." }, - { attribute: "ArrowLeft / ArrowRight", description: "Move the caret within the trigger token." }, - { attribute: "Enter / Tab", description: "Select the highlighted item." }, - { attribute: "Escape", description: "Close the command list." }, + { keys: "ArrowUp / ArrowDown", description: "Move the highlight through the items." }, + { keys: "ArrowLeft / ArrowRight", description: "Move the caret within the trigger token." }, + { keys: "Enter / Tab", description: "Select the highlighted item." }, + { keys: "Escape", description: "Close the command list." }, ]; - + **Ask-user active** export const askUserKeys = [ - { attribute: "ArrowUp / ArrowDown", description: "Navigate options; up past the first refocuses the editor." }, - { attribute: "ArrowLeft / ArrowRight", description: "Go to the previous / next question." }, - { attribute: "Enter", description: "Select the highlighted option." }, - { attribute: "Escape", description: "Dismiss the current step." }, - { attribute: "Any character", description: "Focus the editor and start typing a free-text answer." }, + { keys: "ArrowUp / ArrowDown", description: "Navigate options; up past the first refocuses the editor." }, + { keys: "ArrowLeft / ArrowRight", description: "Go to the previous / next question." }, + { keys: "Enter", description: "Select the highlighted option." }, + { keys: "Escape", description: "Dismiss the current step." }, + { keys: "Any character", description: "Focus the editor and start typing a free-text answer." }, ]; - + While `isSubmitting`, `Submit` (via `useComposerSubmit`) also listens document-wide for `Escape` to call `onStop`, unless the event was already handled. -## Accessibility - -### Command popup (combobox) - -The editor is a `role="textbox"` with combobox wiring: `aria-autocomplete="list"`, -`aria-haspopup="listbox"`, plus `aria-controls` and `aria-activedescendant` -while a trigger popup is open. The popup renders `role="listbox"` (labelled -"Suggestions", overridable) with `role="option"` rows carrying stable ids and -`aria-selected` on the highlight — keyboard selection stays in the editor, so -rows are never tab stops. Grouped lists wrap in `role="group"` labelled by -their `CommandGroupLabel`. Async resolution sets `aria-busy` on the listbox and -the empty state is a `role="status"` region. Committed mention chips announce -as atomic tokens ("Label, @ mention"). - -One deliberate deviation from the strict APG combobox pattern: `aria-expanded` -is omitted — ARIA 1.2 forbids it on `textbox`, and switching to -`role="combobox"` would forbid `aria-multiline`, which matters more for a -multiline chat field. The popup announces through `aria-haspopup` and live -`aria-activedescendant` narration instead. Tab selects the highlighted option -while the popup is open (Linear-style) rather than moving focus. - -The placeholder overlay is `aria-hidden` — assistive tech hears the string -`placeholder` prop via `aria-placeholder`. When rich `children` replace the -string, keep a `placeholder` string alongside (children win visually) so the -hint still announces. - -### Ask-user questions - -While `questions` are active, the options form a labelled group: `AskUser.Options` -renders `role="radiogroup"` (single-select) or `role="group"` (multi-select), -named by `AskUser.Label` and described by `AskUser.StepLabel` automatically. -Each `AskUser.Option` is the real control — `role="radio"` / `role="checkbox"` -with `aria-checked` — and holds the group's single tab stop via roving -`tabindex`: DOM focus follows the highlight, entering the group on arrival and -moving with ArrowUp/ArrowDown. Enter and Space select; Escape dismisses the -step (from the options or the editor); typing any character returns focus to -the editor as a free-text answer. Never nest an interactive control inside an -option — the option itself is the control, and the composer's question-mode -key handling treats native inputs as foreign editables. - -- **Form semantics.** The root renders a `
`; `Enter` submits and - `Submit` is a real submit button, so the composer works with standard form - and assistive-tech expectations. -- **Click-to-focus.** `Container` is a mouse-only focus passthrough: clicking - its chrome focuses the editor, but clicks on nested buttons, links, and - inputs pass through. It carries no role and no tab stop — keyboard users tab - straight to the editor. -- **Ask-user focus.** Activating `questions` moves focus into the options - group; typing any character returns focus to the editor for a free-text - answer. -- **Default names.** `Submit` is named "Send message", flipping to - "Stop generating" while generating so the morphed control announces - correctly; `AttachmentTrigger` is named "Add attachment". Both are - overridable via `aria-label`. -- **Busy states.** Submitting/generating are exposed as `data-submitting` on - the root and the `Submit` label flip — the package emits no live-region copy; - add a consumer-owned `role="status"` region if you want in-flight - announcements beyond the button state. - ## API reference Every part accepts `className`, `style`, and `render` (see @@ -416,7 +352,7 @@ export const attachmentsProps = [ { name: "maxFiles", type: "number", default: "unlimited", description: "Maximum file count." }, { name: "maxFileSize", type: "number", default: "unlimited", description: "Maximum size per file." }, { name: "multiple", type: "boolean", default: "true", description: "Allow selecting multiple files." }, - { name: "globalDrop", type: "boolean", default: "false", description: "Accept drops anywhere in the document, not just over the composer." }, + { name: "globalDrop", type: "boolean", default: "false", description: "Accept drops anywhere in the document, not only over the composer." }, { name: "children", type: "ReactNode", description: "The visible strip / drop zone." }, ]; diff --git a/content/docs/primitives/composer/demos/ask-user-flow.tsx b/content/docs/primitives/composer/demos/ask-user-flow.tsx index 34cc75f..670fe45 100644 --- a/content/docs/primitives/composer/demos/ask-user-flow.tsx +++ b/content/docs/primitives/composer/demos/ask-user-flow.tsx @@ -7,7 +7,8 @@ import { type ComposerSubmitData, useComposer, } from "@intentface/chat/composer"; -import { type ComponentProps, useState } from "react"; +import { IconArrowUp } from "@tabler/icons-react"; +import { useState } from "react"; // Setting `questions` arms the flow and flips askUser.active. Answering or // skipping the last one fires onSubmit with { kind: "answers" }. @@ -78,7 +79,7 @@ export const AskUserFlow = () => { {done ? ( - + ) : ( @@ -204,20 +205,3 @@ const Controls = () => { ); }; - -const SendIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/composer/demos/attachments.tsx b/content/docs/primitives/composer/demos/attachments.tsx index 3fe732c..8e608f2 100644 --- a/content/docs/primitives/composer/demos/attachments.tsx +++ b/content/docs/primitives/composer/demos/attachments.tsx @@ -2,7 +2,7 @@ import { Attachments } from "@intentface/chat/attachments"; import { Composer, type ComposerSubmitData, useComposer } from "@intentface/chat/composer"; -import type { ComponentProps } from "react"; +import { IconArrowUp, IconPaperclip, IconX } from "@tabler/icons-react"; // Composer.Attachments carries the policy and the hidden file input; the strip // itself is yours. Files can be picked with the trigger or dropped on the composer. @@ -32,10 +32,10 @@ export const AttachmentsDemo = () => { aria-label="Attach a file" className="flex size-8 cursor-pointer items-center justify-center rounded-full text-[#949494] transition-colors hover:bg-[#f4f4f4] hover:text-[#1a1a1a] dark:text-[#6f6f6f] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc]" > - + - + @@ -67,7 +67,7 @@ const Strip = () => { filename={item.filename} className="flex size-5 cursor-pointer items-center justify-center rounded-full text-[#949494] transition-colors hover:bg-[#ececec] hover:text-[#1a1a1a] dark:hover:bg-[#2d2d2d] dark:hover:text-[#fcfcfc]" > - + ))} @@ -81,53 +81,3 @@ const formatFileSize = (bytes?: number) => { if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; - -const PaperclipIcon = (props: ComponentProps<"svg">) => ( - -); - -const CrossIcon = (props: ComponentProps<"svg">) => ( - -); - -const SendIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/composer/demos/basic.tsx b/content/docs/primitives/composer/demos/basic.tsx index a5571d0..bda6e7c 100644 --- a/content/docs/primitives/composer/demos/basic.tsx +++ b/content/docs/primitives/composer/demos/basic.tsx @@ -1,7 +1,7 @@ "use client"; import { Composer, type ComposerSubmitData } from "@intentface/chat/composer"; -import type { ComponentProps } from "react"; +import { IconArrowUp } from "@tabler/icons-react"; // Every owns an isolated store, so a bare composer needs no // setup beyond an onSubmit handler. @@ -25,27 +25,10 @@ export const Basic = () => { - + ); }; - -const SendIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/composer/demos/commands.tsx b/content/docs/primitives/composer/demos/commands.tsx index ac2ca46..7af54e9 100644 --- a/content/docs/primitives/composer/demos/commands.tsx +++ b/content/docs/primitives/composer/demos/commands.tsx @@ -1,7 +1,7 @@ "use client"; import { type CommandItemData, Composer, type ComposerSubmitData } from "@intentface/chat/composer"; -import type { ComponentProps } from "react"; +import { IconArrowUp } from "@tabler/icons-react"; const MENTIONS: CommandItemData[] = [ { value: "readme", label: "README.md", description: "Project overview" }, @@ -71,7 +71,7 @@ export const Commands = () => { - + @@ -79,20 +79,3 @@ export const Commands = () => {
); }; - -const SendIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/composer/demos/controlled.tsx b/content/docs/primitives/composer/demos/controlled.tsx index 12c8acf..5ed5ad8 100644 --- a/content/docs/primitives/composer/demos/controlled.tsx +++ b/content/docs/primitives/composer/demos/controlled.tsx @@ -1,7 +1,8 @@ "use client"; import { Composer, type ComposerSubmitData } from "@intentface/chat/composer"; -import { type ComponentProps, useState } from "react"; +import { IconArrowUp } from "@tabler/icons-react"; +import { useState } from "react"; // The Textarea's plain-text value is controlled by the parent: the buttons // drive it, and typing reports back through onValueChange. @@ -30,12 +31,12 @@ export const Controlled = () => { - + -
+
); }; - -const SendIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/composer/demos/popover.tsx b/content/docs/primitives/composer/demos/popover.tsx index 1dc6ac3..533646d 100644 --- a/content/docs/primitives/composer/demos/popover.tsx +++ b/content/docs/primitives/composer/demos/popover.tsx @@ -1,7 +1,7 @@ "use client"; import { type CommandItemData, Composer, type ComposerSubmitData } from "@intentface/chat/composer"; -import type { ComponentProps } from "react"; +import { IconArrowUp } from "@tabler/icons-react"; const MENTIONS: CommandItemData[] = [ { value: "readme", label: "README.md", description: "Project overview" }, @@ -65,27 +65,10 @@ export const Popover = () => { - + ); }; - -const SendIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/composer/demos/store.tsx b/content/docs/primitives/composer/demos/store.tsx index dff3e5e..1daf852 100644 --- a/content/docs/primitives/composer/demos/store.tsx +++ b/content/docs/primitives/composer/demos/store.tsx @@ -1,7 +1,7 @@ "use client"; import { Composer, type ComposerSubmitData } from "@intentface/chat/composer"; -import type { ComponentProps } from "react"; +import { IconArrowUp } from "@tabler/icons-react"; // A store handle created outside the tree. The button drives the composer // through store.controller — no context, no hook, no ref threading. @@ -26,7 +26,7 @@ export const Store = () => { - + @@ -41,20 +41,3 @@ export const Store = () => {
); }; - -const SendIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/message.mdx b/content/docs/primitives/message.mdx index d1bbc7a..2accd09 100644 --- a/content/docs/primitives/message.mdx +++ b/content/docs/primitives/message.mdx @@ -5,6 +5,8 @@ source: message --- import { Basic } from './message/demos/basic'; +import { Chips } from './message/demos/chips'; +import { Selection } from './message/demos/selection'; } file="primitives/message/demos/basic.tsx" /> @@ -13,6 +15,7 @@ import { Basic } from './message/demos/basic'; - **One turn's container** — `Message.Root` reports the role and position as data attributes and renders no layout of its own. - **Segmented text** — `Message.Text` reconstructs inline chips from the wire format and takes render callbacks for both runs and chips. - **Everything else is yours** — bubbles, avatars, copy buttons, source pills, attachment previews and markdown rendering are composed by you around these parts. +- **Memoise your row, not ours** — see [Why the memo boundary is yours](#why-the-memo-boundary-is-yours). Getting this wrong re-renders every message on every stream chunk. - **Get started** — see [Quick start](/quick-start) to add the package. ## Anatomy @@ -42,7 +45,27 @@ attributes: ``` -## Performance +## Examples + +### Reconstructing chips from the text + +The wire format is a markdown-shaped link, `[Label](chip:prefix:value)`, with +everything the chip needs inside the token. A stored message therefore rebuilds +its own chips from its text alone, with no sidecar metadata to keep in sync. +`renderChip` decides what one looks like at render time. + +} file="primitives/message/demos/chips.tsx" /> + +### Acting on a text selection + +Selection is a hook rather than a part, so the toolbar it drives stays yours. +`useMessageSelection` takes the element to scope to and reports the settled +selection inside it. Scoping is the point: a drag across two messages, or +anywhere else on the page, reports nothing. + +} file="primitives/message/demos/selection.tsx" /> + +## Why the memo boundary is yours Message is compositional — you pass its parts as children — which means the package cannot memoize rows for you: a parent re-render re-creates the children @@ -81,20 +104,6 @@ Three rules keep the memo effective while a reply streams: Done right, a stream chunk re-renders exactly one row. See [Composer performance](/primitives/composer#performance) for the full render model. -## Accessibility - -- **Role** drives `data-role`, and error/last state drive `data-error`/`data-last` - on the root, so styling and assistive context stay in sync. -- **Speaker identity is yours to announce.** `role` is an opaque string the - package only surfaces as `data-role` — alignment and colour are invisible to - assistive tech. Give each message a visually-hidden `{role} said` prefix, or - an `aria-label` on the root, so a transcript read top-to-bottom attributes - its turns. -- **Error, stopped and loading markers** are your markup: give an inline failure - marker `role="alert"` so it announces immediately, and quieter states - ("stopped", "generating…") `role="status"`. -- **Actions** you add should be real buttons with accessible names. - ## API reference All three parts accept `className`, `style`, and `render` @@ -102,7 +111,9 @@ All three parts accept `className`, `style`, and `render` ### Message.Root -The container. Renders `data-message`. +One turn's container. Reports the role and position as data attributes and +renders no layout of its own, so the bubble, avatar and actions around it stay +yours. Renders a `
` element. export const rootProps = [ { name: "role", type: "string", default: "(required)", description: "Opaque role string surfaced as data-role; you own the set (commonly system / user / assistant)." }, @@ -123,13 +134,14 @@ export const rootAttrs = [ ### Message.Turn -Groups consecutive messages from one role into a single visual turn. Renders -`data-message-turn` and takes no props of its own beyond the shared ones. +Groups consecutive messages from one role into a single visual turn. Renders a +`
` element, and takes no props of its own beyond the shared ones. ### Message.Text -Plain text with inline chips reconstructed from the wire format. Renders -`data-message-text`. +Message text with inline chips reconstructed from the wire format, so a stored +message rebuilds its own chips with no sidecar metadata. Renders a `` +element. export const textProps = [ { name: "children", type: "string", default: "(required)", description: "The message text; chip tokens are parsed out and rendered." }, diff --git a/content/docs/primitives/message/demos/basic.tsx b/content/docs/primitives/message/demos/basic.tsx index 9c499fc..f473111 100644 --- a/content/docs/primitives/message/demos/basic.tsx +++ b/content/docs/primitives/message/demos/basic.tsx @@ -1,7 +1,8 @@ "use client"; import { Message } from "@intentface/chat/message"; -import { type ComponentProps, useState } from "react"; +import { IconCheck, IconCopy } from "@tabler/icons-react"; +import { useState } from "react"; // Message.Root stamps data-role / data-last / data-error and imposes no layout; // the bubble, alignment, and actions are all yours. @@ -49,41 +50,7 @@ const CopyButton = ({ value }: { value: string }) => { aria-label="Copy message" className="flex size-7 cursor-pointer items-center justify-center rounded-md text-[#949494] transition-colors hover:bg-[#f4f4f4] hover:text-[#1a1a1a] dark:text-[#6f6f6f] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc]" > - {copied ? : } + {copied ? : } ); }; - -const CopyIcon = (props: ComponentProps<"svg">) => ( - -); - -const CheckIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/message/demos/chips.tsx b/content/docs/primitives/message/demos/chips.tsx new file mode 100644 index 0000000..73cb966 --- /dev/null +++ b/content/docs/primitives/message/demos/chips.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Chip } from "@intentface/chat/chip"; +import { Message, type MessageChipSegment } from "@intentface/chat/message"; +import { IconFile, IconFileText, IconTool } from "@tabler/icons-react"; +import type { ComponentProps } from "react"; + +/* + * A message whose text carries inline chip references, and the renderers that + * turn them back into chips. + * + * The wire format is a markdown-shaped link: `[Label](chip:prefix:value)`, with + * everything the chip needs riding along inside the token. That is the point — + * a stored message reconstructs its own chips from its text alone, with no + * sidecar array of metadata to keep in sync or migrate. + * + * `Message.Text` parses the tokens and calls `renderChip` for each one. What a + * chip looks like, and whether a prefix earns a different variant, is decided + * here at render time rather than baked into the stored text. + */ +const TEXT = + "I compared [pricing.tsx](chip:file:src/app/pricing.tsx) against " + + "[the Q3 brief](chip:doc:q3-brief) and pulled figures from " + + "[web-search](chip:tool:web-search). The deprecated rate in " + + "[legacy.ts](chip:file:src/legacy.ts) is the only mismatch."; + +export const Chips = () => ( +
+ {/* biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role */} + + + {TEXT} + + +
+); + +/** The prefix decides the variant and the icon; neither is stored in the text. */ +const renderChip = (chip: MessageChipSegment, index: number) => ( + + + {chip.prefix === "file" ? ( + + ) : chip.prefix === "doc" ? ( + + ) : ( + + )} + + {chip.label} + +); + +const chipClass = + "mx-0.5 inline-flex items-center gap-1 rounded-md border border-[#f0f0f0] bg-[#f4f4f4] px-1.5 py-0.5 align-baseline font-medium text-xs data-[variant=accent]:border-blue-200 data-[variant=accent]:bg-blue-50 data-[variant=accent]:text-blue-700 dark:border-[#2d2d2d] dark:bg-[#232323] dark:data-[variant=accent]:border-blue-900 dark:data-[variant=accent]:bg-blue-950 dark:data-[variant=accent]:text-blue-300"; + +const _icon = (props: ComponentProps<"svg">) => ({ + viewBox: "0 0 16 16", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.3, + className: "size-3", + "aria-hidden": true, + ...props, +}); diff --git a/content/docs/primitives/message/demos/selection.tsx b/content/docs/primitives/message/demos/selection.tsx new file mode 100644 index 0000000..b05f82a --- /dev/null +++ b/content/docs/primitives/message/demos/selection.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { Message, useMessageSelection } from "@intentface/chat/message"; +import { useState } from "react"; + +/* + * Selection is exposed as a hook rather than a part, so the toolbar it drives + * stays entirely yours — this one is a small bar, but a popover anchored to the + * range would read the same value. + * + * `useMessageSelection` takes the element to scope to and returns the settled + * selection inside it, or null. Scoping is the whole point: dragging across two + * messages, or selecting in the page around them, reports nothing here. + */ +export const Selection = () => { + const [scope, setScope] = useState(null); + const selection = useMessageSelection(scope); + const [quoted, setQuoted] = useState(null); + + return ( +
+ {/* biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role */} + + + useMemo caches a computed value and useCallback caches a function reference. Reach for + either only when something downstream is memoised, because the comparison itself is not + free. Select any of this sentence. + + + + {/* Rendered outside the message, and still scoped to it. */} +
+ {selection ? ( + <> + + “{selection.text}” + + + + ) : ( + + Nothing selected in this message. + + )} +
+ + {quoted && ( +
+ {quoted} +
+ )} +
+ ); +}; diff --git a/content/docs/primitives/nav.mdx b/content/docs/primitives/nav.mdx index 862ed6b..af655d1 100644 --- a/content/docs/primitives/nav.mdx +++ b/content/docs/primitives/nav.mdx @@ -250,7 +250,7 @@ These are the widget's own keys, not global ones: the handler sits on `Nav.Root`, so nothing fires unless focus is already inside the tree. export const keys = [ - { keys: "Arrow up / down", description: "Move focus to the previous or next visible row. Collapsed lists are unmounted, so their rows are simply not there to land on." }, + { keys: "Arrow up / down", description: "Move focus to the previous or next visible row. Collapsed lists are unmounted, so their rows are not there to land on." }, { keys: "Arrow right", description: "On a closed group, open it; on an open one, step into it. On a leaf, nothing." }, { keys: "Arrow left", description: "On an open group, close it; otherwise step out to the parent group's trigger — which is where the row came from." }, { keys: "Enter / Space", description: "Activate the focused row." }, diff --git a/content/docs/primitives/nav/demos/basic.tsx b/content/docs/primitives/nav/demos/basic.tsx index e93a9ab..b1ab475 100644 --- a/content/docs/primitives/nav/demos/basic.tsx +++ b/content/docs/primitives/nav/demos/basic.tsx @@ -1,6 +1,7 @@ "use client"; import { Nav } from "@intentface/chat/nav"; +import { IconBox, IconChevronDown, IconHome, IconInbox, IconMessage } from "@tabler/icons-react"; import { type ComponentProps, useState } from "react"; /* @@ -45,7 +46,7 @@ export const Basic = () => { render={link("/overview", () => setCurrent("overview"))} > - + Overview @@ -57,7 +58,7 @@ export const Basic = () => { render={link("/inbox", () => setCurrent("inbox"))} > - + Inbox @@ -75,7 +76,7 @@ export const Basic = () => { - + Intentface @@ -91,7 +92,7 @@ export const Basic = () => { render={link("/intentface/home", () => setCurrent("team-home"))} > - + Home @@ -103,7 +104,7 @@ export const Basic = () => { render={link("/intentface/issues", () => setCurrent("team-issues"))} > - + Issues @@ -114,7 +115,7 @@ export const Basic = () => { - + Chat @@ -145,7 +146,7 @@ export const Basic = () => { - + Website @@ -220,7 +221,7 @@ const rowClass = [ * reading `data-closed` off the enclosing trigger, so nothing is threaded down. */ const Chevron = () => ( - + ); /* @@ -282,77 +283,3 @@ const RailRecipe = () => ( } `} ); - -const HomeIcon = (props: ComponentProps<"svg">) => ( - -); - -const InboxIcon = (props: ComponentProps<"svg">) => ( - -); - -const BoxIcon = (props: ComponentProps<"svg">) => ( - -); - -const ChatIcon = (props: ComponentProps<"svg">) => ( - -); - -const ChevronIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/nav/demos/collapse.tsx b/content/docs/primitives/nav/demos/collapse.tsx index c9c550c..354f893 100644 --- a/content/docs/primitives/nav/demos/collapse.tsx +++ b/content/docs/primitives/nav/demos/collapse.tsx @@ -1,7 +1,7 @@ "use client"; import { Nav } from "@intentface/chat/nav"; -import type { ComponentProps } from "react"; +import { IconChevronDown } from "@tabler/icons-react"; /* * The collapse, slowed to 500ms so the mechanism is visible. @@ -75,7 +75,7 @@ const rowClass = [ ].join(" "); const Chevron = () => ( - + ); /* @@ -98,18 +98,3 @@ const CollapseRecipe = () => ( .collapse-demo [data-nav-list][data-indent] { margin-left: 15px; padding-left: 7px; } `} ); - -const ChevronIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/nav/demos/external.tsx b/content/docs/primitives/nav/demos/external.tsx index f53bad6..5de0255 100644 --- a/content/docs/primitives/nav/demos/external.tsx +++ b/content/docs/primitives/nav/demos/external.tsx @@ -1,7 +1,8 @@ "use client"; import { Nav, type NavStore, useNavStore } from "@intentface/chat/nav"; -import { type ComponentProps, useState } from "react"; +import { IconChevronDown } from "@tabler/icons-react"; +import { useState } from "react"; const GROUPS = ["workspace", "projects", "archive"]; @@ -97,7 +98,7 @@ const rowClass = [ ].join(" "); const Chevron = () => ( - + ); const CollapseRecipe = () => ( @@ -114,18 +115,3 @@ const CollapseRecipe = () => ( .external-nav-demo [data-nav-list][data-indent] { margin-left: 15px; padding-left: 7px; } `} ); - -const ChevronIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/nav/demos/guides.tsx b/content/docs/primitives/nav/demos/guides.tsx index 6180c23..f242621 100644 --- a/content/docs/primitives/nav/demos/guides.tsx +++ b/content/docs/primitives/nav/demos/guides.tsx @@ -1,7 +1,7 @@ "use client"; import { Nav } from "@intentface/chat/nav"; -import type { ComponentProps } from "react"; +import { IconBox, IconChevronDown, IconHome } from "@tabler/icons-react"; /* * The same tree four times, once per `guide` value, so the rungs can be @@ -44,7 +44,7 @@ const GuideTree = ({ guide, caption }: { guide: GuideValue; caption: string }) = - + Overview @@ -52,7 +52,7 @@ const GuideTree = ({ guide, caption }: { guide: GuideValue; caption: string }) = - + Chat @@ -101,7 +101,7 @@ const rowClass = [ ].join(" "); const Chevron = () => ( - + ); /* @@ -151,44 +151,3 @@ const RailRecipe = () => ( } `} ); - -const HomeIcon = (props: ComponentProps<"svg">) => ( - -); - -const BoxIcon = (props: ComponentProps<"svg">) => ( - -); - -const ChevronIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/reasoning.mdx b/content/docs/primitives/reasoning.mdx index ed36d4c..25d8742 100644 --- a/content/docs/primitives/reasoning.mdx +++ b/content/docs/primitives/reasoning.mdx @@ -5,6 +5,7 @@ source: reasoning --- import { Basic } from './reasoning/demos/basic'; +import { Streaming } from './reasoning/demos/streaming'; } file="primitives/reasoning/demos/basic.tsx" /> @@ -34,25 +35,20 @@ Driven from a streaming message, passing the reasoning parts' text: ``` -## useReasoning - -Read the disclosure state from anywhere inside ``: +## Examples -export const hookMembers = [ - { name: "isStreaming", type: "boolean", description: "Whether reasoning is still streaming in." }, - { name: "isOpen", type: "boolean", description: "Whether the panel is open." }, - { name: "setIsOpen", type: "(open: boolean) => void", description: "Toggle the panel." }, - { name: "duration", type: "number | undefined", description: "Tracked streaming duration in seconds." }, -]; +### Tracking the streaming state - +`isStreaming` is the only input. While it is true the root carries +`data-streaming` and `aria-busy`; the moment it flips false the elapsed time is +captured and handed back as `duration`, so the trigger's label changes without +you timing anything. -## Accessibility +} file="primitives/reasoning/demos/streaming.tsx" /> -Built on a disclosure: `Reasoning.Trigger` is a ` +
+
+ ); +}; + +/** + * The trigger ships no copy. `useReasoning` is how the label learns what to + * say: `isStreaming` while it runs, then `duration` once the package has + * captured it. + */ +const TriggerLabel = () => { + const { isStreaming, duration } = useReasoning(); + + return ( + + + + {isStreaming + ? "Thinking…" + : duration === undefined + ? "Reasoning" + : `Thought for ${duration}s`} + + + + ); +}; + +/* + * `--panel-height` is published only while a transition runs, and released once + * the panel settles open. That release is what lets the open panel keep growing + * as sections stream in: with the property gone the declaration is invalid at + * computed-value time and `height` lands back on `auto`. + */ +const PanelTransition = () => ( + +); diff --git a/content/docs/primitives/shell.mdx b/content/docs/primitives/shell.mdx index d45c9cb..0514d06 100644 --- a/content/docs/primitives/shell.mdx +++ b/content/docs/primitives/shell.mdx @@ -164,7 +164,7 @@ Taking the sidebar out of flow is not a styling preference, and the primitive does not work without it. All three states are one element morphing between three positions: flush while -expanded, off-canvas while collapsed, floating just inside the edge while the +expanded, off-canvas while collapsed, floating slightly inside the edge while the hotspot holds it out. A sidebar left in flow can only animate its own width. It can never float over the content, so the hotspot has nothing to slide across and the state has nowhere to exist. diff --git a/content/docs/primitives/shell/demos/basic.tsx b/content/docs/primitives/shell/demos/basic.tsx index 267d2f3..72bd03d 100644 --- a/content/docs/primitives/shell/demos/basic.tsx +++ b/content/docs/primitives/shell/demos/basic.tsx @@ -2,7 +2,13 @@ import { Nav } from "@intentface/chat/nav"; import { Shell } from "@intentface/chat/shell"; -import type { ComponentProps } from "react"; +import { + IconBox, + IconChevronDown, + IconHome, + IconInbox, + IconLayoutSidebar, +} from "@tabler/icons-react"; /* * A shell the way it is meant to be used: a sidebar that collapses, floats out @@ -68,7 +74,7 @@ export const Basic = () => ( @intentface/chat - +
@@ -84,13 +90,13 @@ export const Basic = () => ( - + Overview - + Inbox @@ -98,13 +104,13 @@ export const Basic = () => ( Workspace - + {["Initiatives", "Projects", "Views", "Loops"].map((label) => ( - + {label} @@ -144,7 +150,7 @@ export const Basic = () => (
Docs - + Overview @@ -180,77 +186,3 @@ const rowClass = [ "data-[active]:bg-[#ececec] data-[active]:text-[#1a1a1a] dark:data-[active]:bg-[#2d2d2d] dark:data-[active]:text-[#fcfcfc]", "[&_svg]:size-4 [&_svg]:shrink-0", ].join(" "); - -const PanelLeftIcon = (props: ComponentProps<"svg">) => ( - -); - -const HomeIcon = (props: ComponentProps<"svg">) => ( - -); - -const InboxIcon = (props: ComponentProps<"svg">) => ( - -); - -const BoxIcon = (props: ComponentProps<"svg">) => ( - -); - -const ChevronIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/steps.mdx b/content/docs/primitives/steps.mdx index 3c3c471..5d656bf 100644 --- a/content/docs/primitives/steps.mdx +++ b/content/docs/primitives/steps.mdx @@ -5,6 +5,7 @@ source: steps --- import { Basic } from './steps/demos/basic'; +import { Status } from './steps/demos/status'; } file="primitives/steps/demos/basic.tsx" /> @@ -12,8 +13,10 @@ import { Basic } from './steps/demos/basic'; - **Recursive disclosure tree** — every node is a `Steps.Item` with a `Trigger` and a `Panel`, and panels can hold further items, so timelines nest arbitrarily. - **Status-driven** — each item's `status` (`complete` / `active` / `pending`) flows to its `Icon` and `Label` via context; active items open by default. -- **Nesting** — a nested item surfaces `data-nested` for the indent rail; a static row is just an `Icon` + `Label` in a `
`. +- **Nesting** — a nested item surfaces `data-nested` for the indent rail; a static row is an `Icon` and a `Label` in a `
`. - **You compose the rows** — the primitive ships the disclosure + status plumbing; row content (icons, tool-call summaries) is yours to render. +- **No composite keyboard model** — each trigger is a real button, so the tree is plain sequential tab order with no roving focus to learn. +- **Panels animate from a published height** — see [Why the panel releases its height](#why-the-panel-releases-its-height), which is also why an open panel keeps growing. - **Get started** — see [Quick start](/quick-start) to add the package. ## Anatomy @@ -53,16 +56,18 @@ A timeline is a top-level item whose panel holds rows; a row is an `Icon` + ``` -## Status +## Examples -`status` is an opaque `string` — you own the set (commonly `"complete"`, -`"active"`, `"pending"`, but add `"error"`/`"skipped"`/anything). `Steps.Item` -takes a `status` and publishes it through context; `Steps.Icon` and -`Steps.Label` inherit it, or override with their own `status`. Resolution is -*own prop → inherited → `"complete"`*. Every status-aware part reflects it as -`data-status` for styling. +### Driving rows from status -## Height transitions +`status` is an opaque string. The package resolves it — own prop, then inherited +from the enclosing item, then `"complete"` — and reflects it as `data-status`. +It never decides what the set is, so the `"error"` below is a string this demo +invented and then styled. + +} file="primitives/steps/demos/status.tsx" /> + +## Why the panel releases its height The panel publishes its measured height as `--panel-height` while an open or close transition runs, and **releases it once the panel settles open**. So @@ -71,9 +76,9 @@ variable no longer written — becomes invalid at computed-value time and falls back to `auto`. That is what lets an open panel track content appearing inside it, rather than staying pinned to the height it had when it opened. -The demo at the top of this page uses it. Collapse and expand the timeline to -see the transition, then expand **Searched the web** while the timeline is -already open — the outer panel grows to fit the detail instead of clipping it. +The demo at the top of this page uses it. Collapse and expand the timeline, +then expand **Searched the web** while the timeline is already open — the outer +panel grows to fit the detail instead of clipping it. Two details in that demo are load-bearing. `data-starting-style` and `data-ending-style` clamp the height to `0` on the transitional frames, and they @@ -82,34 +87,6 @@ And `[&>*]:shrink-0` guards the measurement: a flex column clamped to `height: 0 puts every child under shrink pressure, and a child that collapses to nothing makes the panel measure itself as `0px`. -## Keyboard - -Each item is a standard disclosure: `Steps.Trigger` is a real button, so -`Tab` moves between triggers and `Enter`/`Space` toggle the nearest item. -Nested items nest their own triggers — there is no roving focus or composite -widget; the tree is plain sequential tab order. - -## Accessibility - -Disclosure semantics are inherited from the underlying Collapsible: -`aria-expanded`/`aria-controls` on the trigger, an id-linked, `hidden`-managed -panel. The item whose `status` is `"active"` additionally carries -`aria-current="step"` — the same "active" convention `defaultOpen` already -keys off. - -Status itself is invisible to assistive tech by default: `Steps.Icon` is -`aria-hidden` and color never announces. Mount `Steps.Status` inside rows -whose status matters — it renders a visually-hidden span speaking the resolved -status string, and takes `children` for localized copy: - -```tsx - - - Searching the web - - -``` - ## API reference Every part accepts `className`, `style`, and `render` (see @@ -117,11 +94,14 @@ Every part accepts `className`, `style`, and `render` (see ### Steps -The timeline root. Renders `data-steps`. No part-specific props. +The timeline container. Ships the disclosure and status plumbing and no row +content: what a tool call or a reasoning step looks like is yours. Renders a +`
` element. ### Steps.Item -One node of the tree (a disclosure). Renders `data-steps-item`, plus +One node of the tree, and the unit that nests: an item's panel may hold further +items, so a timeline goes as deep as the run did. Renders a `
` element, plus `aria-current="step"` while `status` is `"active"`. export const itemProps = [ @@ -145,14 +125,18 @@ export const itemAttrs = [ ### Steps.Trigger -Toggles the nearest item. Renders a ` +
+
+ ); +}; + +// Every rule here keys off data-status. The package supplies the attribute and +// takes no view on what the values mean. +const iconClass = + "grid size-4 shrink-0 place-items-center text-[#949494] data-[status=complete]:text-emerald-600 data-[status=active]:text-[#1a1a1a] data-[status=error]:text-red-600 dark:text-[#6f6f6f] dark:data-[status=complete]:text-emerald-400 dark:data-[status=active]:text-[#fcfcfc] dark:data-[status=error]:text-red-400"; + +const labelClass = + "text-sm text-[#949494] data-[status=complete]:text-[#686868] data-[status=active]:text-[#1a1a1a] data-[status=active]:font-medium data-[status=error]:text-red-600 dark:text-[#6f6f6f] dark:data-[status=complete]:text-[#9b9b9b] dark:data-[status=active]:text-[#fcfcfc] dark:data-[status=error]:text-red-400"; + +const _glyph = (props: ComponentProps<"svg">) => ({ + viewBox: "0 0 16 16", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.6, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + className: "size-3.5", + "aria-hidden": true, + ...props, +}); diff --git a/content/docs/primitives/tabs.mdx b/content/docs/primitives/tabs.mdx index f8ae5f9..8b38b17 100644 --- a/content/docs/primitives/tabs.mdx +++ b/content/docs/primitives/tabs.mdx @@ -18,6 +18,7 @@ import { ExternalTabs } from './tabs/demos/external'; - **Order is data** — `items` is an ordered array on the store, not something derived from the DOM. Drag it with whatever library you like through `render`; the result is a state change like any other. - **One viewport, not a panel per tab** — switching re-renders the same box, which is what lets a floating surface move rather than tear itself down. - **In the layout or anchored** — wrapping the viewport in `Portal` › `Positioner` › `Popup` is the entire difference between the two. +- **A toolbar, not a tablist** — see [Why a toolbar and not a tablist](#why-a-toolbar-and-not-a-tablist). ARIA's tablist cannot describe a closable, open-ended strip. - **Get started** — see [Quick start](/quick-start) to add the package. ## Anatomy diff --git a/content/docs/primitives/tabs/demos/anchored.tsx b/content/docs/primitives/tabs/demos/anchored.tsx index 15d2d23..c5c789b 100644 --- a/content/docs/primitives/tabs/demos/anchored.tsx +++ b/content/docs/primitives/tabs/demos/anchored.tsx @@ -4,7 +4,8 @@ import { Composer, type ComposerSubmitData } from "@intentface/chat/composer"; import { Message } from "@intentface/chat/message"; import { Tabs, useTabs } from "@intentface/chat/tabs"; import { Thread } from "@intentface/chat/thread"; -import { type ComponentProps, useState } from "react"; +import { IconArrowUp, IconMessage, IconMinus, IconSparkles, IconX } from "@tabler/icons-react"; +import { useState } from "react"; /* * A chat dock in the corner of a page. The same Root, List and Viewport as the @@ -159,7 +160,7 @@ export const Anchored = () => { {(id) => ( - + {title(id)} { aria-label={`Close ${title(id)}`} className="grid size-5 shrink-0 cursor-pointer select-none place-items-center rounded text-[#686868] transition-colors hover:bg-[#e4e4e4] hover:text-[#1a1a1a] dark:text-[#9b9b9b] dark:hover:bg-[#333333] dark:hover:text-[#fcfcfc]" > - + @@ -185,7 +186,7 @@ export const Anchored = () => { disclosure ARIA a tab does. */} - + Agent @@ -256,7 +257,7 @@ const DockHeader = ({ title }: { title: (id: string) => string }) => { onClick={() => select(null)} className={iconButtonClass} > - + ); @@ -326,7 +327,7 @@ const ChatThread = ({ const NewChat = ({ onStart }: { onStart: (text: string) => void }) => (
- +

Ask the agent

This is a draft — it becomes a tab once you send something. @@ -367,7 +368,7 @@ const DockComposer = ({ aria-label="Send" className="flex size-7 items-center justify-center rounded-full bg-[#1a1a1a] text-white transition-opacity disabled:opacity-30 dark:bg-[#fcfcfc] dark:text-[#111111]" > - + @@ -385,80 +386,3 @@ const dockTabClass = [ const iconButtonClass = "grid size-6 shrink-0 cursor-pointer select-none place-items-center rounded-md text-[#949494] transition-colors hover:bg-[#f4f4f4] hover:text-[#1a1a1a] focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:text-[#6f6f6f] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc] dark:focus-visible:outline-[#fcfcfc]"; - -const ChatIcon = (props: ComponentProps<"svg">) => ( - -); - -const AgentIcon = (props: ComponentProps<"svg">) => ( - -); - -const ArrowUpIcon = (props: ComponentProps<"svg">) => ( - -); - -const MinusIcon = (props: ComponentProps<"svg">) => ( - -); - -const CloseIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/tabs/demos/basic.tsx b/content/docs/primitives/tabs/demos/basic.tsx index 50e352a..7add451 100644 --- a/content/docs/primitives/tabs/demos/basic.tsx +++ b/content/docs/primitives/tabs/demos/basic.tsx @@ -1,7 +1,8 @@ "use client"; import { Tabs, useTabs } from "@intentface/chat/tabs"; -import { type ComponentProps, Fragment, type ReactNode, useEffect, useRef, useState } from "react"; +import { IconFile, IconPlus, IconX } from "@tabler/icons-react"; +import { Fragment, type ReactNode, useEffect, useRef, useState } from "react"; /* * Document tabs across the window chrome, with the content in a card beneath. @@ -80,7 +81,7 @@ export const Basic = () => { className={tabClass} > - + {documents[id]?.name ?? id} @@ -99,7 +100,7 @@ export const Basic = () => { aria-label={`Close ${documents[id]?.name ?? id}`} className="grid size-5 shrink-0 cursor-pointer select-none place-items-center rounded-md text-[#686868] transition-colors hover:bg-[#e4e4e4] hover:text-[#1a1a1a] dark:text-[#9b9b9b] dark:hover:bg-[#333333] dark:hover:text-[#fcfcfc]" > - + @@ -203,7 +204,7 @@ const NewDocument = ({ onCreate }: { onCreate: (id: string, document: Document) }} className="grid size-7 shrink-0 cursor-pointer select-none place-items-center rounded-md text-[#686868] transition-colors hover:bg-[#f4f4f4] hover:text-[#1a1a1a] focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:text-[#9b9b9b] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc] dark:focus-visible:outline-[#fcfcfc]" > - + ); }; @@ -255,47 +256,3 @@ const tabClass = [ "border border-transparent data-[selected]:border-[#f0f0f0] data-[selected]:bg-white data-[selected]:text-[#1a1a1a]", "dark:data-[selected]:border-[#262626] dark:data-[selected]:bg-[#181818] dark:data-[selected]:text-[#fcfcfc]", ].join(" "); - -const FileIcon = (props: ComponentProps<"svg">) => ( - -); - -const PlusIcon = (props: ComponentProps<"svg">) => ( - -); - -const CloseIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/tabs/demos/closing.tsx b/content/docs/primitives/tabs/demos/closing.tsx index 7c8f07e..98fa374 100644 --- a/content/docs/primitives/tabs/demos/closing.tsx +++ b/content/docs/primitives/tabs/demos/closing.tsx @@ -1,7 +1,8 @@ "use client"; import { Tabs } from "@intentface/chat/tabs"; -import { type ComponentProps, createElement, type ReactElement } from "react"; +import { IconArchive, IconInbox, IconPencil, IconSend, IconX } from "@tabler/icons-react"; +import { createElement } from "react"; /* * The three close policies side by side. Close the open tab in each strip and @@ -53,7 +54,7 @@ const Strip = ({ {(id) => ( - {createElement(TAB_ICONS[id] ?? InboxIcon)} + {createElement(TAB_ICONS[id] ?? IconInbox)} {id} @@ -62,7 +63,7 @@ const Strip = ({ aria-label={`Close ${id}`} className="grid size-5 shrink-0 cursor-pointer place-items-center rounded text-[#949494] transition-colors hover:bg-[#dcdcdc] hover:text-[#1a1a1a] dark:text-[#6f6f6f] dark:hover:bg-[#3d3d3d] dark:hover:text-[#fcfcfc]" > - + @@ -93,98 +94,12 @@ const tabClass = [ "dark:data-[selected]:bg-[#2d2d2d] dark:data-[selected]:text-[#fcfcfc]", ].join(" "); -const CloseIcon = (props: ComponentProps<"svg">) => ( - -); - /* Per-tab icons rather than one generic page glyph — a strip of identical icons carries no information, and the whole point of a tab icon is telling the tabs apart at a glance. */ -const TAB_ICONS: Record) => ReactElement> = { - Inbox: InboxIcon, - Drafts: PencilIcon, - Sent: SendIcon, - Archive: ArchiveIcon, +const TAB_ICONS: Record = { + Inbox: IconInbox, + Drafts: IconPencil, + Sent: IconSend, + Archive: IconArchive, }; - -function InboxIcon(props: ComponentProps<"svg">) { - return ( - - ); -} - -function PencilIcon(props: ComponentProps<"svg">) { - return ( - - ); -} - -function SendIcon(props: ComponentProps<"svg">) { - return ( - - ); -} - -function ArchiveIcon(props: ComponentProps<"svg">) { - return ( - - ); -} diff --git a/content/docs/primitives/tabs/demos/external.tsx b/content/docs/primitives/tabs/demos/external.tsx index 9a6ceb9..e4db5f0 100644 --- a/content/docs/primitives/tabs/demos/external.tsx +++ b/content/docs/primitives/tabs/demos/external.tsx @@ -1,7 +1,8 @@ "use client"; import { Tabs, type TabsStore, useTabsStore } from "@intentface/chat/tabs"; -import { type ComponentProps, createElement, type ReactElement, useState } from "react"; +import { IconArchive, IconInbox, IconPencil, IconSend, IconX } from "@tabler/icons-react"; +import { createElement, useState } from "react"; /* * Opening a tab from somewhere else in the app. @@ -30,7 +31,7 @@ export const ExternalTabs = () => { {(id) => ( - {createElement(TAB_ICONS[id] ?? InboxIcon)} + {createElement(TAB_ICONS[id] ?? IconInbox)} {id} @@ -38,7 +39,7 @@ export const ExternalTabs = () => { aria-label={`Close ${id}`} className="grid size-5 shrink-0 cursor-pointer place-items-center rounded text-[#949494] transition-colors hover:bg-[#dcdcdc] hover:text-[#1a1a1a] dark:text-[#6f6f6f] dark:hover:bg-[#3d3d3d] dark:hover:text-[#fcfcfc]" > - + @@ -62,7 +63,7 @@ const Launcher = ({ store }: { store: TabsStore }) => { const items = useTabsStore(store, (tabs) => tabs.items); return ( -

+
{DOCUMENTS.map((id) => ( + ))} +
+ +
+ + +
+ {/* The reserve the primitive publishes lands on the last child. */} + + {turns.map((turn, index) => ( + + + {turn.text} + + + ))} + +
+
+
+
+ +
+

{CAPTIONS[mode]}

+ +
+
+ ); +}; diff --git a/content/docs/primitives/thread/demos/basic.tsx b/content/docs/primitives/thread/demos/basic.tsx index 82e44f9..6e8b4ea 100644 --- a/content/docs/primitives/thread/demos/basic.tsx +++ b/content/docs/primitives/thread/demos/basic.tsx @@ -3,7 +3,8 @@ import { Composer, type ComposerSubmitData } from "@intentface/chat/composer"; import { Message } from "@intentface/chat/message"; import { Thread, useThread } from "@intentface/chat/thread"; -import { type ComponentProps, useState } from "react"; +import { IconArrowDown, IconArrowUp } from "@tabler/icons-react"; +import { useState } from "react"; type DemoMessage = { id: string; role: "user" | "assistant"; text: string }; @@ -73,7 +74,7 @@ export const Basic = () => { - + @@ -98,41 +99,7 @@ const ScrollButton = () => { aria-label="Scroll to latest" className="absolute -top-10 z-10 flex size-8 cursor-pointer items-center justify-center rounded-full border border-[#f0f0f0] bg-white text-[#686868] shadow-xs transition-colors hover:text-[#1a1a1a] dark:border-[#262626] dark:bg-[#181818] dark:text-[#9b9b9b] dark:hover:text-[#fcfcfc]" > - + ); }; - -const ArrowDownIcon = (props: ComponentProps<"svg">) => ( - -); - -const SendIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/content/docs/primitives/thread/demos/jump.tsx b/content/docs/primitives/thread/demos/jump.tsx new file mode 100644 index 0000000..17deadd --- /dev/null +++ b/content/docs/primitives/thread/demos/jump.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Message } from "@intentface/chat/message"; +import { Thread, useThread, useThreadVisibility } from "@intentface/chat/thread"; + +const TURNS = Array.from({ length: 14 }, (_, index) => ({ + id: `turn-${index + 1}`, + role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), + title: `Turn ${index + 1}`, + text: + index % 2 === 0 + ? `Question ${index / 2 + 1}: how does this behave when the transcript is long?` + : "It resolves the row lazily by its attribute, so nothing is registered up front and a long transcript costs no more than a short one.", +})); + +/* + * Addressing a row without registering it. + * + * `scrollToMessage` finds a row by the `data-message-id` attribute you put on + * it. There is no wrapper part and no registry: rows are resolved lazily at + * call time, so a transcript of ten thousand turns costs the same as this one. + * + * `useThreadVisibility` is the other half. It reads the same attribute to + * report which rows are on screen, and it creates its observers on the first + * subscriber — a thread that never calls it pays nothing. + */ +export const Jump = () => ( +
+
+ + + + {TURNS.map((turn) => ( + + + {turn.title} + + + {turn.text} + + + ))} + + + + {/* Both panels live inside the Root, which is how they reach the + scroll context; neither is a part of the package. */} + + +
+
+); + +/** An outline that jumps, and highlights whichever row is being read. */ +const Outline = () => { + const { scrollToMessage } = useThread(); + const { currentMessageId } = useThreadVisibility(); + + return ( + + ); +}; diff --git a/lib/ai/chip-icons.tsx b/lib/ai/chip-icons.tsx index 72bcf59..40c07d6 100644 --- a/lib/ai/chip-icons.tsx +++ b/lib/ai/chip-icons.tsx @@ -1,24 +1,33 @@ +import { + IconBrain, + IconBug, + IconChartBar, + IconCode, + IconFileText, + IconMap2, + IconMessageChatbot, + IconPhoto, + IconTable, + IconWorld, +} from "@tabler/icons-react"; import type { ReactNode } from "react"; -import { BrainIcon } from "@/components/icons/brain"; -import { BubbleWideSparkleIcon } from "@/components/icons/bubble-wide-sparkle"; -import { BugIcon } from "@/components/icons/bug"; -import { CodeIcon } from "@/components/icons/code"; -import { FileChartIcon } from "@/components/icons/file-chart"; -import { FileTextIcon } from "@/components/icons/file-text"; -import { GlobeIcon } from "@/components/icons/globe"; -import { ImageAltIcon } from "@/components/icons/image-alt"; -import { SpreadsheetIcon } from "@/components/icons/spreadsheet"; +/* + * Outlined throughout. Brain, code and chart have no filled variant in Tabler, + * and a set where three of ten are hollow reads as a mistake rather than a + * choice — so the whole set stays outlined. + */ export const CHIP_ICONS = { - brain: , - bug: , - bubbleWideSparkle: , - code: , - fileChart: , - fileText: , - globe: , - imageAlt: , - spreadsheet: , + brain: , + bug: , + bubbleWideSparkle: , + code: , + fileChart: , + fileText: , + globe: , + imageAlt: , + map: , + spreadsheet: , } satisfies Record; export type ChipIconKey = keyof typeof CHIP_ICONS; diff --git a/package.json b/package.json index a03ea9f..2ebba3b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@ai-sdk/react": "^3.0.220", "@base-ui/react": "^1.6.0", "@intentface/chat": "workspace:*", + "@tabler/icons-react": "^3.46.0", "ai": "^6.0.218", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/chat/README.md b/packages/chat/README.md index a497ef3..f600827 100644 --- a/packages/chat/README.md +++ b/packages/chat/README.md @@ -4,9 +4,44 @@ Headless chat UI primitives for React — the behavior, state, and wire formats for building AI chat interfaces, with no styling of their own. This is the Base UI model applied to chat: **the package owns behavior, you own -every class.** There is no pre-styled `@intentface/chat` package. The demos on -each [docs](https://ui.intentface.com) page show how the parts fit together -and are meant to be copied and restyled. +every class.** There is no styled companion package and no theme to opt out of. +The demos on each [docs](https://ui.intentface.com) page are self-contained +Tailwind, meant to be copied and restyled. + +```tsx +"use client"; +import { Composer } from "@intentface/chat/composer"; +import { Message } from "@intentface/chat/message"; +import { Thread } from "@intentface/chat/thread"; + +export const Chat = ({ messages, send }: ChatProps) => ( + + + + {messages.map((message) => ( + + {message.text} + + ))} + + + + + + + + + Send + + + + + +); +``` + +Every class above is yours; delete them and the chat still works, it just looks +like unstyled HTML. ## Status @@ -30,14 +65,18 @@ it beyond two small runtime deps (`@floating-ui/dom` for anchored positioning, Each primitive is a separate entry point, exporting an unstyled compound component that renders semantic DOM with `data-*` state attributes, context -hooks, and a `render` prop for swapping the underlying element: +hooks, and a `render` prop for swapping the underlying element. The last three +are app-shell parts rather than chat parts, and are useful on their own: | Import | What it is | | --- | --- | | `@intentface/chat/composer` | Rich-text input over a purpose-built contenteditable engine: `/` and `@` command palette, inline chips, attachments, ask-user flow. One store per ``, or bring your own via `Composer.createStore()`. | -| `@intentface/chat/thread` | Scroll container with at-bottom detection and auto-follow. | -| `@intentface/chat/message` | Message parts, turns, chip-segmented text, sources, actions. | +| `@intentface/chat/thread` | Scroll container with at-bottom detection, auto-follow, and docked-composer measurement. | +| `@intentface/chat/message` | Message parts, turns, chip-segmented text, selection hooks. | | `@intentface/chat/steps`, `/reasoning` | Tool-call timelines and reasoning disclosure. | +| `@intentface/chat/shell` | A collapsible, resizable sidebar beside the viewport it shares the screen with, with an edge hotspot. | +| `@intentface/chat/nav` | A nav tree with roving focus, typeahead, collapsible groups, and a guide ladder down the left edge. | +| `@intentface/chat/tabs` | An open-ended, closable tab collection whose panel renders in the layout or anchored to its own tab. | | `@intentface/chat/chip`, `/attachments`, `/ask-user` | The remaining building blocks. | | `@intentface/chat/types` | The structural message contract + part type guards. | | `@intentface/chat/message-utils` | Part segmentation, turn grouping, reasoning/source derivation. | diff --git a/packages/chat/src/attachments/attachments.tsx b/packages/chat/src/attachments/attachments.tsx index aad5f9b..e81bdad 100644 --- a/packages/chat/src/attachments/attachments.tsx +++ b/packages/chat/src/attachments/attachments.tsx @@ -86,6 +86,11 @@ export const matchesAccept = (file: File, accept: string): boolean => { export type AttachmentsRootProps = PrimitiveProps<"div">; +/** + * The tray container. Mount it only when there are items to show; it renders + * no layout of its own. + * Renders a `
` element. + */ export const AttachmentsRoot = ({ className, render, @@ -103,6 +108,11 @@ export type AttachmentsItemProps = PrimitiveProps<"div">; // Generic structural slot for one attachment. It carries no media taxonomy — // categorization (image/pdf/file, icons, thumbnails) belongs to the styled // layer, which reads the item's raw mediaType however it wants. +/** + * One attachment, as a structural slot with no media taxonomy of its own. + * Read the item's `mediaType` and decide what an image or a PDF looks like. + * Renders a `
` element. + */ export const AttachmentsItem = ({ className, render, @@ -121,6 +131,12 @@ export type AttachmentsRemoveProps = PrimitiveProps<"button"> & { filename?: string; }; +/** + * The removal affordance. Named "Remove attachment" by default, or + * "Remove {filename}" when `filename` is set, so a row of them does not + * announce identically. + * Renders a `