From a2f6b4d2ee51cc7ae1faaa3ba3194a0d1d531f06 Mon Sep 17 00:00:00 2001 From: Rasmus Viloma Date: Fri, 7 Aug 2026 12:39:08 +0300 Subject: [PATCH 1/4] docs: replace installation and theming with an honest quick start --- README.md | 59 +++++---- app/api/chat/route.ts | 2 +- components/docs/intentface.css | 155 ------------------------ components/docs/page-actions.tsx | 6 +- components/docs/theme-source.tsx | 18 --- content/docs/index.mdx | 35 +++--- content/docs/installation.mdx | 49 -------- content/docs/meta.json | 2 +- content/docs/primitives/attachments.mdx | 2 +- content/docs/primitives/chip.mdx | 2 +- content/docs/primitives/composer.mdx | 4 +- content/docs/primitives/message.mdx | 2 +- content/docs/primitives/reasoning.mdx | 2 +- content/docs/primitives/steps.mdx | 2 +- content/docs/primitives/thread.mdx | 2 +- content/docs/quick-start.mdx | 124 +++++++++++++++++++ content/docs/theming.mdx | 40 ------ mdx-components.tsx | 2 - packages/chat/README.md | 12 +- tools/list-docs-pages.ts | 2 +- 20 files changed, 196 insertions(+), 326 deletions(-) delete mode 100644 components/docs/intentface.css delete mode 100644 components/docs/theme-source.tsx delete mode 100644 content/docs/installation.mdx create mode 100644 content/docs/quick-start.mdx delete mode 100644 content/docs/theming.mdx diff --git a/README.md b/README.md index e215bc4..ca317a6 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,49 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# intentface-chat -## Getting Started +Monorepo for **[@intentface/chat](https://www.npmjs.com/package/@intentface/chat)** — headless chat UI +primitives for React — and the documentation site and playground at +[intentface.dev](https://intentface.dev). -First, run the development server: +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. -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +## Layout -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +| Path | What it is | +| --- | --- | +| `packages/chat` | The published package. Namespace exports per primitive, built per-module by `tsc`. | +| `content/docs` | Documentation pages (MDX, via fumadocs). | +| `components/docs` | Docs-site chrome: previews, code blocks, tables. | +| `components/ai`, `components/ui` | The playground's own styled layer. **App-private** — it uses design tokens, Motion, and local icons, and is not published or supported for copying. | +| `app/(chat)` | The playground chat. | +| `app/docs` | The documentation site. | -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Development -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +```bash +bun install # also generates .source via fumadocs-mdx (postinstall) +bun dev # http://localhost:3000 +bun run lint # biome check +bunx tsc --noEmit # typecheck +bun run build # production build +``` -## Learn More +The playground chat needs `.env.local` with `OPENAI_API_KEY`. The docs site renders without it. -To learn more about Next.js, take a look at the following resources: +Package tests and build: -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +```bash +cd packages/chat +bun test +bun run build +``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Contributing -## Deploy on Vercel +Conventions, architecture, and patterns live in [AGENTS.md](./AGENTS.md) — read it before opening a PR. +Changesets gate releases: run `bunx changeset` for any user-facing change to `packages/chat`. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## License -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +MIT diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index ac01965..bdec982 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -37,7 +37,7 @@ const SYSTEM_PROMPT = `You are the assistant in the Intentface Chat playground - Use the current date and time context when answering time-sensitive questions. ## Library Questions -When the user asks about @intentface/chat — its primitives (composer, thread, message, chip, steps, reasoning, attachments), installation, theming, state, or how this playground is built: +When the user asks about @intentface/chat — its primitives (composer, thread, message, chip, steps, reasoning, attachments), installation, styling, state, or how this playground is built: 1. Call listDocsPages to see the documentation index 2. Read the relevant pages with readDocsPage before answering 3. For implementation internals ("how does X work under the hood"), read the code with readSourceFile — each docs page's source field names its component under packages/chat/src diff --git a/components/docs/intentface.css b/components/docs/intentface.css deleted file mode 100644 index 3808cfd..0000000 --- a/components/docs/intentface.css +++ /dev/null @@ -1,155 +0,0 @@ -@source "../node_modules/streamdown/dist/*.js"; - -@custom-variant dark (&:is(.dark *)); - -:root { - font-feature-settings: - "liga" 1, - "calt" 1; - - --bg: #ffffff; - --fg: #1a1a1a; - --acc: #0169cc; - --con: 0.35; - - --mix-hover: calc(16% * var(--con)); - --mix-active: calc(20% * var(--con)); - --mix-border: calc(24% * var(--con)); - - /* Border states stack from the border token so a hovered border keeps its - separation from the hovered background. */ - --base-bg: color-mix(in oklch, var(--bg), var(--fg) calc(10% * var(--con))); - --base-bg-hover: color-mix(in oklch, var(--base-bg), var(--fg) var(--mix-hover)); - --base-bg-active: color-mix(in oklch, var(--base-bg), var(--fg) var(--mix-active)); - --base-border: color-mix(in oklch, var(--base-bg), var(--fg) var(--mix-border)); - --base-border-hover: color-mix(in oklch, var(--base-border), var(--fg) var(--mix-hover)); - --base-border-active: color-mix(in oklch, var(--base-border), var(--fg) var(--mix-active)); - - --secondary-bg: color-mix(in oklch, var(--bg), var(--fg) calc(4% * var(--con))); - --secondary-bg-hover: color-mix(in oklch, var(--secondary-bg), var(--fg) var(--mix-hover)); - --secondary-bg-active: color-mix(in oklch, var(--secondary-bg), var(--fg) var(--mix-active)); - --secondary-border: color-mix(in oklch, var(--secondary-bg), var(--fg) var(--mix-border)); - --secondary-border-hover: color-mix(in oklch, var(--secondary-border), var(--fg) var(--mix-hover)); - --secondary-border-active: color-mix( - in oklch, - var(--secondary-border), - var(--fg) var(--mix-active) - ); - - --primary-bg: var(--bg); - --primary-bg-hover: color-mix(in oklch, var(--primary-bg), var(--fg) var(--mix-hover)); - --primary-bg-active: color-mix(in oklch, var(--primary-bg), var(--fg) var(--mix-active)); - --primary-border: color-mix(in oklch, var(--primary-bg), var(--fg) var(--mix-border)); - --primary-border-hover: color-mix(in oklch, var(--primary-border), var(--fg) var(--mix-hover)); - --primary-border-active: color-mix(in oklch, var(--primary-border), var(--fg) var(--mix-active)); - - --tertiary-bg: color-mix(in oklch, var(--bg), var(--fg) calc(2% * var(--con))); - --tertiary-bg-hover: color-mix(in oklch, var(--tertiary-bg), var(--fg) var(--mix-hover)); - --tertiary-bg-active: color-mix(in oklch, var(--tertiary-bg), var(--fg) var(--mix-active)); - --tertiary-border: color-mix(in oklch, var(--tertiary-bg), var(--fg) var(--mix-border)); - --tertiary-border-hover: color-mix(in oklch, var(--tertiary-border), var(--fg) var(--mix-hover)); - --tertiary-border-active: color-mix(in oklch, var(--tertiary-border), var(--fg) var(--mix-active)); - - --quaternary-bg: color-mix(in oklch, var(--bg), var(--fg) calc(24% * var(--con))); - --quaternary-bg-hover: color-mix(in oklch, var(--quaternary-bg), var(--fg) var(--mix-hover)); - --quaternary-bg-active: color-mix(in oklch, var(--quaternary-bg), var(--fg) var(--mix-active)); - --quaternary-border: color-mix(in oklch, var(--quaternary-bg), var(--fg) var(--mix-border)); - --quaternary-border-hover: color-mix( - in oklch, - var(--quaternary-border), - var(--fg) var(--mix-hover) - ); - --quaternary-border-active: color-mix( - in oklch, - var(--quaternary-border), - var(--fg) var(--mix-active) - ); - - --accent-bg: color-mix(in oklch, var(--acc), var(--fg) calc(28% * var(--con))); - --accent-bg-hover: color-mix(in oklch, var(--acc), var(--fg) var(--mix-hover)); - --accent-bg-active: color-mix(in oklch, var(--acc), var(--fg) var(--mix-active)); - --accent-border: color-mix(in oklch, var(--acc), var(--fg) var(--mix-border)); - --accent-border-hover: color-mix(in oklch, var(--accent-border), var(--fg) var(--mix-hover)); - --accent-border-active: color-mix(in oklch, var(--accent-border), var(--fg) var(--mix-active)); - - --text-primary: color-mix(in oklch, var(--bg), var(--fg) 85%); - --text-secondary: color-mix(in oklch, var(--bg), var(--fg) 58%); - --text-tertiary: color-mix(in oklch, var(--bg), var(--fg) 40%); - - --radius: 8px; - --sidebar-width: 224px; - --thread-width: 672px; - --artifacts-panel-width: 512px; -} - -.dark { - --bg: #111111; - --fg: #fcfcfc; - --acc: #4a9eed; - - --base-bg: var(--bg); - --primary-bg: color-mix(in oklch, var(--bg), var(--fg) calc(12% * var(--con))); - --secondary-bg: color-mix(in oklch, var(--bg), var(--fg) calc(3% * var(--con))); - --tertiary-bg: color-mix(in oklch, var(--bg), var(--fg) calc(20% * var(--con))); - --quaternary-bg: color-mix(in oklch, var(--bg), var(--fg) calc(56% * var(--con))); - - --mix-hover: calc(16% * var(--con)); - --mix-active: calc(18% * var(--con)); - --mix-border: calc(20% * var(--con)); -} - -@theme inline { - --color-base-bg: var(--base-bg); - --color-base-bg-hover: var(--base-bg-hover); - --color-base-bg-active: var(--base-bg-active); - --color-base-border: var(--base-border); - --color-base-border-hover: var(--base-border-hover); - --color-base-border-active: var(--base-border-active); - --color-secondary-bg: var(--secondary-bg); - --color-secondary-bg-hover: var(--secondary-bg-hover); - --color-secondary-bg-active: var(--secondary-bg-active); - --color-secondary-border: var(--secondary-border); - --color-secondary-border-hover: var(--secondary-border-hover); - --color-secondary-border-active: var(--secondary-border-active); - --color-primary-bg: var(--primary-bg); - --color-primary-bg-hover: var(--primary-bg-hover); - --color-primary-bg-active: var(--primary-bg-active); - --color-primary-border: var(--primary-border); - --color-primary-border-hover: var(--primary-border-hover); - --color-primary-border-active: var(--primary-border-active); - --color-tertiary-bg: var(--tertiary-bg); - --color-tertiary-bg-hover: var(--tertiary-bg-hover); - --color-tertiary-bg-active: var(--tertiary-bg-active); - --color-tertiary-border: var(--tertiary-border); - --color-tertiary-border-hover: var(--tertiary-border-hover); - --color-tertiary-border-active: var(--tertiary-border-active); - --color-quaternary-bg: var(--quaternary-bg); - --color-quaternary-bg-hover: var(--quaternary-bg-hover); - --color-quaternary-bg-active: var(--quaternary-bg-active); - --color-quaternary-border: var(--quaternary-border); - --color-quaternary-border-hover: var(--quaternary-border-hover); - --color-quaternary-border-active: var(--quaternary-border-active); - --color-accent-bg: var(--accent-bg); - --color-accent-bg-hover: var(--accent-bg-hover); - --color-accent-bg-active: var(--accent-bg-active); - --color-accent-border: var(--accent-border); - --color-accent-border-hover: var(--accent-border-hover); - --color-accent-border-active: var(--accent-border-active); - --color-ink-primary: var(--text-primary); - --color-ink-secondary: var(--text-secondary); - --color-ink-tertiary: var(--text-tertiary); - --radius-xs: calc(var(--radius) - 4px); - --radius-sm: calc(var(--radius) - 2px); - --radius-md: var(--radius); - --radius-lg: calc(var(--radius) + 2px); - --radius-xl: calc(var(--radius) + 4px); - --radius-2xl: calc(var(--radius) + 8px); - --radius-3xl: calc(var(--radius) + 12px); - --radius-4xl: calc(var(--radius) + 16px); - --text-2xs: 11px; - --text-xs: 12px; - --text-sm: 13px; - --text-md: 14px; - --text-lg: 15px; - --text-xl: 16px; -} diff --git a/components/docs/page-actions.tsx b/components/docs/page-actions.tsx index 2da8cda..7028a2e 100644 --- a/components/docs/page-actions.tsx +++ b/components/docs/page-actions.tsx @@ -14,8 +14,8 @@ type PageActionsProps = { const actionButtonClass = "h-9 rounded-full px-4 text-md font-medium text-ink-secondary hover:bg-secondary-bg-hover hover:text-ink-primary"; -// "View as Markdown" (raw .mdx as text) + "View source" (the component on -// GitHub), mirroring Base UI's page header. +// "View as Markdown" (raw .mdx as text) + "Primitive source" (the headless +// component on GitHub — not the demos), mirroring Base UI's page header. export const PageActions = ({ slug, source }: PageActionsProps) => (
+ ))} + +
+
{current.code}
+ + ); +}; diff --git a/components/docs/installation-block.tsx b/components/docs/installation-block.tsx new file mode 100644 index 0000000..b60c982 --- /dev/null +++ b/components/docs/installation-block.tsx @@ -0,0 +1,24 @@ +import { CodeBlock } from "./code-block"; +import { InstallationBlockTabs } from "./installation-block-tabs"; + +const MANAGERS = [ + { manager: "npm", install: "npm install" }, + { manager: "pnpm", install: "pnpm add" }, + { manager: "yarn", install: "yarn add" }, + { manager: "bun", install: "bun add" }, +]; + +type InstallationBlockProps = { + packageName: string; +}; + +// Server component: highlights one install command per package manager and +// hands them all to the client shell, which shows the selected one. +export const InstallationBlock = ({ packageName }: InstallationBlockProps) => ( + { + const command = `${install} ${packageName}`; + return { manager, command, code: }; + })} + /> +); diff --git a/components/docs/previews/attachments-basic.tsx b/components/docs/previews/attachments-basic.tsx deleted file mode 100644 index fcf0d2e..0000000 --- a/components/docs/previews/attachments-basic.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { type AttachmentItem, Attachments } from "@/components/ai/attachments"; - -// A removable attachment strip. Non-image items so no remote image loading is -// needed; the strip collapses when the last item is removed. -const INITIAL: AttachmentItem[] = [ - { - id: "1", - filename: "quarterly-report.pdf", - mediaType: "application/pdf", - url: "#", - fileSize: 248_000, - }, - { - id: "2", - filename: "meeting-notes.txt", - mediaType: "text/plain", - url: "#", - fileSize: 1_200, - }, -]; - -export const AttachmentsBasic = () => { - const [items, setItems] = useState(INITIAL); - - return ( -
- 0}> - {items.map((item) => ( - - setItems((current) => current.filter((i) => i.id !== item.id))} - /> - - ))} - - {items.length === 0 && ( - - )} -
- ); -}; diff --git a/components/docs/previews/chip-basic.tsx b/components/docs/previews/chip-basic.tsx deleted file mode 100644 index d24ac83..0000000 --- a/components/docs/previews/chip-basic.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import { GlobeIcon } from "lucide-react"; -import { Chip } from "@/components/ai/chip"; - -// Chips flow inline with text. Variants tint the surface; Chip.Preview adds a -// hover card. -export const ChipBasic = () => ( -

- Pulled results from{" "} - - - - - web-search - {" "} - and a{" "} - - document - Hover shows a preview panel for the referenced item. - {" "} - reference, with one{" "} - - deprecated - {" "} - flag. -

-); diff --git a/components/docs/previews/composer-ask-user-flow.tsx b/components/docs/previews/composer-ask-user-flow.tsx deleted file mode 100644 index 6f7729a..0000000 --- a/components/docs/previews/composer-ask-user-flow.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client"; - -import type { AskUserQuestion } from "@intentface/chat/composer"; -import { useState } from "react"; -import { Composer, type ComposerSubmitData } from "@/components/ai/composer"; - -// The questions prop routes the panel to an ask-user prompt. Answering (or -// skipping) the last question fires onSubmit with { kind: "answers" }. Passing -// a fresh questions array re-arms the flow from the first step. -const QUESTIONS: AskUserQuestion[] = [ - { - question: "Which framework are you deploying to?", - options: [ - { label: "Next.js", description: "App Router on Vercel." }, - { label: "Vite", description: "SPA on any static host." }, - { label: "Remix", description: "Full-stack on a Node server." }, - ], - }, - { - question: "Which features do you need?", - multiSelect: true, - options: [ - { label: "Auth", description: "Sessions and sign-in." }, - { label: "Database", description: "Persistent storage." }, - { label: "File uploads", description: "Attachments and media." }, - { label: "Analytics", description: "Usage and events." }, - ], - }, - { - question: "What matters most for this project?", - options: [ - { label: "Speed", description: "Ship as fast as possible." }, - { label: "Scale", description: "Handle heavy traffic." }, - { label: "Cost", description: "Keep the bill low." }, - ], - }, -]; - -const previewButtonClass = - "cursor-pointer rounded-full border border-primary-border bg-primary-bg px-4 py-1.5 font-medium text-ink-secondary text-sm transition-colors hover:bg-primary-bg-hover"; - -export const ComposerAskUserFlow = () => { - const [questions, setQuestions] = useState(QUESTIONS); - const [done, setDone] = useState(false); - - const handleSubmit = (data: ComposerSubmitData) => { - if (data.kind === "answers") setDone(true); - }; - - const reset = () => { - setDone(false); - setQuestions([...QUESTIONS]); - }; - - return ( - // Reserve height and bottom-anchor so the ask-user panel opening (and the - // reset button appearing) never shifts the surrounding layout. -
- - {/* Plain children, gated with the preview's own state — no callback needed. */} - {!done && } - - - - - - {done ? ( - - ) : ( - <> - - - - )} - - - - {done && ( - - )} -
- ); -}; diff --git a/components/docs/previews/composer-ask-user.tsx b/components/docs/previews/composer-ask-user.tsx deleted file mode 100644 index 8da6ab3..0000000 --- a/components/docs/previews/composer-ask-user.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { AskUser } from "@/components/ai/ask-user"; - -const OPTIONS = [ - { value: "bun", label: "Bun", description: "Fastest installs; the repo default." }, - { value: "pnpm", label: "pnpm", description: "Strict, content-addressed store." }, - { value: "npm", label: "npm", description: "Ships with Node, zero setup." }, -]; - -// A single-select ask-user prompt. Options wraps its children in a RadioGroup -// when multiSelect is false; clicking a card selects it. -export const ComposerAskUser = () => { - const [value, setValue] = useState("bun"); - - return ( -
- - - Which package manager should the setup use? - - - {OPTIONS.map((option) => ( - setValue(option.value)} - > - - - {option.label} - {option.description} - - - ))} - - -
- ); -}; diff --git a/components/docs/previews/composer-attachments.tsx b/components/docs/previews/composer-attachments.tsx deleted file mode 100644 index 7e88254..0000000 --- a/components/docs/previews/composer-attachments.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import { Composer, type ComposerSubmitData } from "@/components/ai/composer"; -import { PaperClipIcon } from "@/components/icons/paperclip"; - -// Attachments sits inside the container, above the input: it renders the file -// strip and drop zone. The trigger opens the file dialog; files can also be -// dropped onto the composer. -export const ComposerAttachments = () => { - const handleSubmit = (_data: ComposerSubmitData) => {}; - - return ( - // Reserve height and bottom-anchor so the drop zone / file strip appearing - // grows the composer upward instead of shifting the layout. -
- - - - - - - - - - - - - - -
- ); -}; diff --git a/components/docs/previews/composer-basic.tsx b/components/docs/previews/composer-basic.tsx deleted file mode 100644 index 9384967..0000000 --- a/components/docs/previews/composer-basic.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { Composer, type ComposerSubmitData } from "@/components/ai/composer"; - -// A self-contained composer: every bare owns an isolated store. -export const ComposerBasic = () => { - const handleSubmit = (_data: ComposerSubmitData) => {}; - - return ( - - - - - - - - - - - ); -}; diff --git a/components/docs/previews/composer-commands.tsx b/components/docs/previews/composer-commands.tsx deleted file mode 100644 index 0a40f12..0000000 --- a/components/docs/previews/composer-commands.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { type CommandItemData, Composer, type ComposerSubmitData } from "@/components/ai/composer"; - -const MENTIONS: CommandItemData[] = [ - { value: "readme", label: "README.md", description: "Project overview" }, - { value: "package", label: "package.json", description: "Dependencies and scripts" }, - { value: "composer", label: "composer.tsx", description: "The composer primitive" }, -]; - -// Demonstrates the `@` mention command list. Type "@" in the field to trigger it. -// The Panel's children is a callback that receives composer state, so the command -// list shows only while a prefix is active. -export const ComposerCommands = () => { - const handleSubmit = (_data: ComposerSubmitData) => {}; - - return ( - // Reserve height and bottom-anchor the composer so opening the command list - // grows it upward into the reserved space instead of shifting the layout. -
- - - {(composer) => - composer.commands.active ? ( - - - - {(item) => ( - - {item.label} - {item.description && ( - - {item.description} - - )} - - )} - - - ) : null - } - - - - - - - - - - -
- ); -}; diff --git a/components/docs/previews/composer-controlled.tsx b/components/docs/previews/composer-controlled.tsx deleted file mode 100644 index 6646551..0000000 --- a/components/docs/previews/composer-controlled.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Composer, type ComposerSubmitData } from "@/components/ai/composer"; - -// The Textarea's plain-text value is controlled by the parent: the buttons -// drive it, and typing reports back through onValueChange. -const previewButtonClass = - "cursor-pointer rounded-full border border-primary-border bg-primary-bg px-4 py-1.5 font-medium text-ink-secondary text-sm transition-colors hover:bg-primary-bg-hover"; - -export const ComposerControlled = () => { - const [text, setText] = useState(""); - const handleSubmit = (_data: ComposerSubmitData) => {}; - - return ( -
- - - - - - - - - - -
- - -
-
- ); -}; diff --git a/components/docs/previews/composer-popover.tsx b/components/docs/previews/composer-popover.tsx deleted file mode 100644 index 60b7462..0000000 --- a/components/docs/previews/composer-popover.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client"; - -import { type CommandItemData, Composer, type ComposerSubmitData } from "@/components/ai/composer"; - -const MENTIONS: CommandItemData[] = [ - { value: "readme", label: "README.md", description: "Project overview" }, - { value: "package", label: "package.json", description: "Dependencies and scripts" }, - { value: "composer", label: "composer.tsx", description: "The composer primitive" }, -]; - -// The floating variant. Composer.Popover takes the same content as a Composer.Panel -// (including the state callback) but portals it above the field instead of growing -// it — it opens while the command list is active. -export const ComposerPopover = () => { - const handleSubmit = (_data: ComposerSubmitData) => {}; - - return ( - - - {(composer) => - composer.commands.active ? ( - - - - {(item) => ( - - {item.label} - {item.description && ( - - {item.description} - - )} - - )} - - - ) : null - } - - - - - - - - - - - ); -}; diff --git a/components/docs/previews/composer-store.tsx b/components/docs/previews/composer-store.tsx deleted file mode 100644 index 1914a92..0000000 --- a/components/docs/previews/composer-store.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { Composer, type ComposerSubmitData } from "@/components/ai/composer"; - -// A store handle created outside the tree. The button drives the composer -// through store.controller — no context, no hook, no ref threading. -const store = Composer.createStore(); - -export const ComposerStoreDemo = () => { - const handleSubmit = (_data: ComposerSubmitData) => {}; - - return ( -
- - - - - - - - - - - -
- ); -}; diff --git a/components/docs/previews/index.ts b/components/docs/previews/index.ts deleted file mode 100644 index 6faf61f..0000000 --- a/components/docs/previews/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ComponentType } from "react"; -import { AttachmentsBasic } from "./attachments-basic"; -import { ChipBasic } from "./chip-basic"; -import { ComposerAskUser } from "./composer-ask-user"; -import { ComposerAskUserFlow } from "./composer-ask-user-flow"; -import { ComposerAttachments } from "./composer-attachments"; -import { ComposerBasic } from "./composer-basic"; -import { ComposerCommands } from "./composer-commands"; -import { ComposerControlled } from "./composer-controlled"; -import { ComposerPopover } from "./composer-popover"; -import { ComposerStoreDemo } from "./composer-store"; -import { MessageBasic } from "./message-basic"; -import { ReasoningBasic } from "./reasoning-basic"; -import { StepsBasic } from "./steps-basic"; -import { ThreadBasic } from "./thread-basic"; - -// Explicit registry of live docs demos. Each entry pairs the rendered component -// with its own source path so ComponentPreview can show the exact file that -// runs (read from disk at build time). A generator would be overkill at this -// scale — add an entry per demo. -export type PreviewEntry = { - Component: ComponentType; - file: string; -}; - -const dir = "components/docs/previews"; - -export const previews: Record = { - "composer-basic": { Component: ComposerBasic, file: `${dir}/composer-basic.tsx` }, - "composer-commands": { Component: ComposerCommands, file: `${dir}/composer-commands.tsx` }, - "composer-ask-user": { Component: ComposerAskUser, file: `${dir}/composer-ask-user.tsx` }, - "composer-ask-user-flow": { - Component: ComposerAskUserFlow, - file: `${dir}/composer-ask-user-flow.tsx`, - }, - "composer-attachments": { - Component: ComposerAttachments, - file: `${dir}/composer-attachments.tsx`, - }, - "composer-controlled": { Component: ComposerControlled, file: `${dir}/composer-controlled.tsx` }, - "composer-popover": { Component: ComposerPopover, file: `${dir}/composer-popover.tsx` }, - "composer-store": { Component: ComposerStoreDemo, file: `${dir}/composer-store.tsx` }, - "thread-basic": { Component: ThreadBasic, file: `${dir}/thread-basic.tsx` }, - "message-basic": { Component: MessageBasic, file: `${dir}/message-basic.tsx` }, - "chip-basic": { Component: ChipBasic, file: `${dir}/chip-basic.tsx` }, - "reasoning-basic": { Component: ReasoningBasic, file: `${dir}/reasoning-basic.tsx` }, - "steps-basic": { Component: StepsBasic, file: `${dir}/steps-basic.tsx` }, - "attachments-basic": { Component: AttachmentsBasic, file: `${dir}/attachments-basic.tsx` }, -}; diff --git a/components/docs/previews/message-basic.tsx b/components/docs/previews/message-basic.tsx deleted file mode 100644 index 7f46121..0000000 --- a/components/docs/previews/message-basic.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { Message } from "@/components/ai/message"; -import type { MessageRole } from "@/lib/ai/types"; - -// A user question and an assistant reply with a copy action. Self-contained — -// plain message data, no chat transport. -const MESSAGES: { id: string; role: MessageRole; text: string }[] = [ - { id: "q", role: "user", text: "How do I center a div?" }, - { - id: "a", - role: "assistant", - text: "Use flexbox on the parent: `display: flex`, then `justify-content: center` and `align-items: center`.", - }, -]; - -export const MessageBasic = () => ( -
- {MESSAGES.map((message, index) => ( - - - {message.role === "user" ? ( - {message.text} - ) : ( - {message.text} - )} - - {message.role === "assistant" && ( - - - - )} - - ))} -
-); diff --git a/components/docs/previews/reasoning-basic.tsx b/components/docs/previews/reasoning-basic.tsx deleted file mode 100644 index 44db673..0000000 --- a/components/docs/previews/reasoning-basic.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import { Reasoning } from "@/components/ai/reasoning"; - -// A completed reasoning block. Bold **Headers** in the content split into -// labelled sections. Open by default here so the layout is visible at rest. -export const ReasoningBasic = () => ( -
- - - - { - "**Planning the approach**\nCheck the existing layout, then decide which axis needs centering.\n\n**Verifying**\nConfirm the element centers both horizontally and vertically." - } - - -
-); diff --git a/components/docs/previews/steps-basic.tsx b/components/docs/previews/steps-basic.tsx deleted file mode 100644 index 7776b58..0000000 --- a/components/docs/previews/steps-basic.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import { Steps } from "@/components/ai/steps"; -import { CheckMarkMediumIcon } from "@/components/icons/check-mark-medium"; -import { ChevronDownIcon } from "@/components/icons/chevron-down"; -import { CircleIcon } from "@/components/icons/circle"; - -// The recursive Steps primitive: a top-level item whose panel holds rows, and -// a nested item whose panel indents behind the rail (data-nested). Open by -// default so the timeline is visible at rest. -const Chevron = () => ( - -); - -export const StepsBasic = () => ( -
- - - - Worked for 3 seconds - - - -
- - - - Read the request -
- - - - - - - - - - Searched the web - - - - Found three relevant sources and skimmed each. - - - - -
- - - - Writing the answer -
-
-
-
-
-); diff --git a/components/docs/previews/thread-basic.tsx b/components/docs/previews/thread-basic.tsx deleted file mode 100644 index cc59abc..0000000 --- a/components/docs/previews/thread-basic.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Composer, type ComposerSubmitData } from "@/components/ai/composer"; -import { Message } from "@/components/ai/message"; -import { Thread } from "@/components/ai/thread"; - -type DemoMessage = { id: string; role: "user" | "assistant"; text: string }; - -// The full chat surface: a scrolling thread with a conversation already in -// progress and a docked composer that appends new turns — auto-scroll and the -// scroll-to-bottom button stay live. Its bare owns an isolated store. -const INITIAL: DemoMessage[] = [ - { id: "1", role: "user", text: "What's the difference between `useMemo` and `useCallback`?" }, - { - id: "2", - role: "assistant", - text: "`useMemo` caches a computed **value**; `useCallback` caches a **function** reference. In fact `useCallback(fn, deps)` is just `useMemo(() => fn, deps)`.", - }, - { id: "3", role: "user", text: "So when do I actually need useCallback?" }, - { - id: "4", - role: "assistant", - text: "Mainly when you pass a callback to a `memo`-wrapped child or as another hook's dependency — a fresh function each render would break their memoization. Otherwise you usually don't.", - }, -]; - -const REPLY = - "Good question — the short answer is it depends on what you're optimizing for. Want me to go deeper on any part?"; - -export const ThreadBasic = () => { - const [messages, setMessages] = useState(INITIAL); - - const handleSubmit = (data: ComposerSubmitData) => { - if (data.kind !== "message" || !data.text.trim()) return; - setMessages((current) => [ - ...current, - { id: `${current.length}-u`, role: "user", text: data.text }, - { id: `${current.length}-a`, role: "assistant", text: REPLY }, - ]); - }; - - return ( -
- - - {messages.map((message, index) => ( - - - {message.role === "user" ? ( - {message.text} - ) : ( - {message.text} - )} - - - ))} - - - - - - - - - - - - - - - -
- ); -}; diff --git a/content/docs/primitives/attachments.mdx b/content/docs/primitives/attachments.mdx index 80e4efb..b817b48 100644 --- a/content/docs/primitives/attachments.mdx +++ b/content/docs/primitives/attachments.mdx @@ -4,7 +4,9 @@ description: The file-attachment tray — item chips, a remove affordance, and i source: attachments --- - +import { Basic } from './attachments/demos/basic'; + +} file="primitives/attachments/demos/basic.tsx" /> ## Usage guidelines diff --git a/content/docs/primitives/attachments/demos/basic.tsx b/content/docs/primitives/attachments/demos/basic.tsx new file mode 100644 index 0000000..0365346 --- /dev/null +++ b/content/docs/primitives/attachments/demos/basic.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { type AttachmentItem, Attachments } from "@intentface/chat/attachments"; +import { type ComponentProps, useState } from "react"; + +// A removable strip driven by local state — the parts are structural slots and +// impose no media taxonomy, so icons and layout are yours to decide. +const INITIAL: AttachmentItem[] = [ + { + id: "1", + filename: "quarterly-report.pdf", + mediaType: "application/pdf", + url: "#", + fileSize: 248_000, + }, + { id: "2", filename: "meeting-notes.txt", mediaType: "text/plain", url: "#", fileSize: 1_200 }, +]; + +export const Basic = () => { + const [items, setItems] = useState(INITIAL); + + if (items.length === 0) { + return ( + + ); + } + + return ( + + {items.map((item) => ( + + + {item.filename} + + {formatFileSize(item.fileSize)} + + setItems((current) => current.filter((it) => it.id !== item.id))} + filename={item.filename} + className="flex size-5 cursor-pointer items-center justify-center rounded-full text-[#949494] transition-colors hover:bg-[#f4f4f4] hover:text-[#1a1a1a] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc]" + > + + + + ))} + + ); +}; + +const formatFileSize = (bytes?: number) => { + if (!bytes) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +const FileIcon = (props: ComponentProps<"svg">) => ( + +); + +const CrossIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/content/docs/primitives/chip.mdx b/content/docs/primitives/chip.mdx index a9e113c..b9928b1 100644 --- a/content/docs/primitives/chip.mdx +++ b/content/docs/primitives/chip.mdx @@ -4,7 +4,9 @@ description: An inline, text-flowing token — used for mentions in the composer source: chip --- - +import { Basic } from './chip/demos/basic'; + +} file="primitives/chip/demos/basic.tsx" /> ## Usage guidelines diff --git a/content/docs/primitives/chip/demos/basic.tsx b/content/docs/primitives/chip/demos/basic.tsx new file mode 100644 index 0000000..5f945ee --- /dev/null +++ b/content/docs/primitives/chip/demos/basic.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Chip } from "@intentface/chat/chip"; +import type { ComponentProps, ReactElement, ReactNode } from "react"; + +// Chips flow inline with text. `variant` is an opaque string surfaced as +// data-variant, so the tinting rules are entirely yours. +export const Basic = () => ( +

+ Pulled results from{" "} + + + + + web-search + {" "} + and a{" "} + + document + Hover shows a preview panel for the referenced item. + {" "} + reference, with one{" "} + + deprecated + {" "} + flag. +

+); + +const CHIP_CLASS = + "mx-0.5 inline-flex items-center gap-1 rounded-md border border-[#f0f0f0] bg-[#f4f4f4] px-1.5 py-0.5 align-baseline text-xs font-medium data-[variant=accent]:border-blue-200 data-[variant=accent]:bg-blue-50 data-[variant=accent]:text-blue-700 data-[variant=warning]:border-amber-200 data-[variant=warning]:bg-amber-50 data-[variant=warning]:text-amber-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 dark:data-[variant=warning]:border-amber-900 dark:data-[variant=warning]:bg-amber-950 dark:data-[variant=warning]:text-amber-300"; + +// Chip.Preview renders nothing on its own — Root hands you the badge and the +// preview content, and you compose whatever popup you want. This one is pure +// CSS so the demo needs no floating library. +const renderWithPreview = (badge: ReactElement, preview: ReactNode) => ( + + {badge} + + {preview} + + +); + +const GlobeIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/content/docs/primitives/composer.mdx b/content/docs/primitives/composer.mdx index 40ca18b..872e01c 100644 --- a/content/docs/primitives/composer.mdx +++ b/content/docs/primitives/composer.mdx @@ -4,7 +4,15 @@ description: Rich-text chat input with chips, slash/mention commands, attachment source: composer --- - +import { Basic } from './composer/demos/basic'; +import { Commands } from './composer/demos/commands'; +import { Popover } from './composer/demos/popover'; +import { AskUserFlow } from './composer/demos/ask-user-flow'; +import { AttachmentsDemo } from './composer/demos/attachments'; +import { Controlled } from './composer/demos/controlled'; +import { Store } from './composer/demos/store'; + +} file="primitives/composer/demos/basic.tsx" /> ## Usage guidelines @@ -60,7 +68,7 @@ opens and closes to match — priority is just the order of your branches. Type `@` to open the command list — the panel routes to it automatically while a prefix is active. `commands` maps each prefix to its config and items. - +} file="primitives/composer/demos/commands.tsx" /> ### Floating command popover @@ -71,26 +79,29 @@ growing the composer and needs no reserved height. It's collision-aware — near viewport edge it flips, shifts, and caps its height to stay on screen. Mount one or the other; the content is identical. - +} file="primitives/composer/demos/popover.tsx" /> ### Ask-user flow Setting the `questions` prop — typically from an assistant's clarifying -question — arms the ask-user flow and flips `askUser.active`; render -`` inside a `Panel` (or `Popover`) gated on that flag. The +question — arms the ask-user flow and flips `askUser.active`; compose the +`AskUser` parts from `@intentface/chat/ask-user` inside a `Panel` (or +`Popover`), reading the current step from `useComposer(c => c.askUser)`. The flow steps through each question (single- or multi-select), and answering or skipping the last one fires `onSubmit` with `{ kind: "answers" }`. Passing a fresh `questions` array re-arms it from the first step. - +} file="primitives/composer/demos/ask-user-flow.tsx" /> ### Attachments -`Composer.Attachments` renders the file strip and drop zone above the input; -`Composer.AttachmentTrigger` opens the file dialog. Files can also be dropped -onto the composer. +`Composer.Attachments` carries the accept/limit policy and the hidden file +input — it renders no strip of its own. The visible list is yours: read +`useComposer(c => c.attachments)` and lay the items out with the +`@intentface/chat/attachments` parts. `Composer.AttachmentTrigger` opens the +file dialog, and files can also be dropped onto the composer. - +} file="primitives/composer/demos/attachments.tsx" /> ### Controlled value @@ -98,7 +109,7 @@ onto the composer. `onValueChange`. Here the parent's buttons drive the field and typing reports back. - +} file="primitives/composer/demos/controlled.tsx" /> ### External store @@ -106,7 +117,7 @@ back. drive the composer from anywhere — a toolbar, a shortcut — through `store.controller`, with no context or ref threading. - +} file="primitives/composer/demos/store.tsx" /> ## Multiple instances diff --git a/content/docs/primitives/composer/demos/ask-user-flow.tsx b/content/docs/primitives/composer/demos/ask-user-flow.tsx new file mode 100644 index 0000000..34cc75f --- /dev/null +++ b/content/docs/primitives/composer/demos/ask-user-flow.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { AskUser } from "@intentface/chat/ask-user"; +import { + type AskUserQuestion, + Composer, + type ComposerSubmitData, + useComposer, +} from "@intentface/chat/composer"; +import { type ComponentProps, useState } from "react"; + +// Setting `questions` arms the flow and flips askUser.active. Answering or +// skipping the last one fires onSubmit with { kind: "answers" }. +const QUESTIONS: AskUserQuestion[] = [ + { + question: "Which framework are you deploying to?", + options: [ + { label: "Next.js", description: "App Router on Vercel." }, + { label: "Vite", description: "SPA on any static host." }, + { label: "Remix", description: "Full-stack on a Node server." }, + ], + }, + { + question: "Which features do you need?", + multiSelect: true, + options: [ + { label: "Auth", description: "Sessions and sign-in." }, + { label: "Database", description: "Persistent storage." }, + { label: "File uploads", description: "Attachments and media." }, + ], + }, + { + question: "What matters most for this project?", + options: [ + { label: "Speed", description: "Ship as fast as possible." }, + { label: "Scale", description: "Handle heavy traffic." }, + { label: "Cost", description: "Keep the bill low." }, + ], + }, +]; + +export const AskUserFlow = () => { + const [questions, setQuestions] = useState(QUESTIONS); + const [done, setDone] = useState(false); + + const handleSubmit = (data: ComposerSubmitData) => { + if (data.kind === "answers") setDone(true); + }; + + const reset = () => { + setDone(false); + setQuestions([...QUESTIONS]); + }; + + return ( + // Reserve height and bottom-anchor so the panel opening never shifts the page. +
+ + {/* anchor={false} makes the panel an in-flow block that grows the + composer upward; the default is a portaled overlay. */} + + {!done && } + + + + + + + {done ? ( + + + + ) : ( + + )} + + + + {done && ( + + )} +
+ ); +}; + +// The parts are structural; the current question and selections come from the +// composer's askUser slice. +const Prompt = () => { + const askUser = useComposer((composer) => composer.askUser); + const question = askUser.questions?.[askUser.step]; + + if (!question) return null; + + const entry = askUser.answers.get(askUser.step); + const total = askUser.questions?.length ?? 0; + + return ( + + + {question.question} + {!askUser.isSingle && total > 1 && ( + + + ‹ + + + {({ current, total: count }) => `${current} of ${count}`} + + + › + + + )} + + {question.options && ( + + {question.options.map((option) => { + const selected = Boolean(entry?.selected.has(option.label)); + return ( + askUser.toggleOption(option.label)} + className="flex cursor-pointer items-start gap-2 rounded-[10px] p-2 outline-none transition-colors data-highlighted:bg-[#f4f4f4] dark:data-highlighted:bg-[#232323]" + > + {/* Decorative: the Option itself carries the radio/checkbox role. */} + + + + {option.label} + + {option.description && ( + + {option.description} + + )} + + + ); + })} + + )} + + ); +}; + +// Dismiss is a plain button you wire up; Continue is type=submit, so the +// enclosing Composer.Root form drives it. +const Controls = () => { + const askUser = useComposer((composer) => composer.askUser); + + return ( + <> + + Skip + + + {askUser.isLastStep ? "Done" : "Continue"} + + + ); +}; + +const SendIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/content/docs/primitives/composer/demos/attachments.tsx b/content/docs/primitives/composer/demos/attachments.tsx new file mode 100644 index 0000000..3fe732c --- /dev/null +++ b/content/docs/primitives/composer/demos/attachments.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { Attachments } from "@intentface/chat/attachments"; +import { Composer, type ComposerSubmitData, useComposer } from "@intentface/chat/composer"; +import type { ComponentProps } from "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. +export const AttachmentsDemo = () => { + const handleSubmit = (data: ComposerSubmitData) => { + if (data.kind === "message") { + console.log(data.files); + } + }; + + return ( + // Reserve height so the strip appearing grows the composer upward. +
+ + + + + + + + + + + + + + + + + + +
+ ); +}; + +// The store holds the items; the parts are structural slots with no opinion +// about how a file should look. +const Strip = () => { + const attachments = useComposer((composer) => composer.attachments); + + if (attachments.items.length === 0) return null; + + return ( + + {attachments.items.map((item) => ( + + {item.filename ?? "file"} + + {formatFileSize(item.fileSize)} + + attachments.remove(item.id)} + 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]" + > + + + + ))} + + ); +}; + +const formatFileSize = (bytes?: number) => { + if (!bytes) return ""; + if (bytes < 1024) return `${bytes} B`; + 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 new file mode 100644 index 0000000..a5571d0 --- /dev/null +++ b/content/docs/primitives/composer/demos/basic.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Composer, type ComposerSubmitData } from "@intentface/chat/composer"; +import type { ComponentProps } from "react"; + +// Every owns an isolated store, so a bare composer needs no +// setup beyond an onSubmit handler. +export const Basic = () => { + const handleSubmit = (data: ComposerSubmitData) => { + if (data.kind === "message") { + console.log(data.text, data.files); + } + }; + + return ( + + + {/* The editable element is engine-owned and out of JSX reach, so it is + styled through the data-composer-editor variants. */} + + + + + + + + + + + ); +}; + +const SendIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/content/docs/primitives/composer/demos/commands.tsx b/content/docs/primitives/composer/demos/commands.tsx new file mode 100644 index 0000000..ac2ca46 --- /dev/null +++ b/content/docs/primitives/composer/demos/commands.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { type CommandItemData, Composer, type ComposerSubmitData } from "@intentface/chat/composer"; +import type { ComponentProps } from "react"; + +const MENTIONS: CommandItemData[] = [ + { value: "readme", label: "README.md", description: "Project overview" }, + { value: "package", label: "package.json", description: "Dependencies and scripts" }, + { value: "composer", label: "composer.tsx", description: "The composer primitive" }, +]; + +// Type "@" to open the list. Panel takes a callback receiving composer state, +// so the command list shows only while a prefix is active. +export const Commands = () => { + const handleSubmit = (data: ComposerSubmitData) => { + if (data.kind === "message") { + console.log(data.text); + } + }; + + return ( + // Reserve height and bottom-anchor so opening the list grows the composer + // upward instead of shifting the page. +
+ + {/* anchor={false} makes the panel an in-flow block that grows the + composer upward; the default is a portaled overlay. */} + + {(composer) => + composer.commands.active ? ( + // data-empty and data-loading land on Command; the group gates + // which child shows. + + + No files found. + + + {(item) => ( + + + {item.label} + + {item.description && ( + + {item.description} + + )} + + )} + + + ) : null + } + + + + + + + + + + + + +
+ ); +}; + +const SendIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/content/docs/primitives/composer/demos/controlled.tsx b/content/docs/primitives/composer/demos/controlled.tsx new file mode 100644 index 0000000..12c8acf --- /dev/null +++ b/content/docs/primitives/composer/demos/controlled.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Composer, type ComposerSubmitData } from "@intentface/chat/composer"; +import { type ComponentProps, useState } from "react"; + +// The Textarea's plain-text value is controlled by the parent: the buttons +// drive it, and typing reports back through onValueChange. +export const Controlled = () => { + const [text, setText] = useState(""); + + const handleSubmit = (data: ComposerSubmitData) => { + if (data.kind === "message") { + console.log(data.text); + } + }; + + return ( +
+ + + + + + + + + + + + +
+ + +
+
+ ); +}; + +const SendIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/content/docs/primitives/composer/demos/popover.tsx b/content/docs/primitives/composer/demos/popover.tsx new file mode 100644 index 0000000..1dc6ac3 --- /dev/null +++ b/content/docs/primitives/composer/demos/popover.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { type CommandItemData, Composer, type ComposerSubmitData } from "@intentface/chat/composer"; +import type { ComponentProps } from "react"; + +const MENTIONS: CommandItemData[] = [ + { value: "readme", label: "README.md", description: "Project overview" }, + { value: "package", label: "package.json", description: "Dependencies and scripts" }, + { value: "composer", label: "composer.tsx", description: "The composer primitive" }, +]; + +// The floating alternative to Panel: same children, but portalled and anchored +// to the active token, so the list overlays instead of growing the composer. +export const Popover = () => { + const handleSubmit = (data: ComposerSubmitData) => { + if (data.kind === "message") { + console.log(data.text); + } + }; + + return ( + + {/* Positioning sets --anchor-width and --anchor-available-height; the + width and max-height are yours to derive from them. */} + + {(composer) => + composer.commands.active ? ( + // data-empty and data-loading land on Command; the group gates + // which child shows. + + + No files found. + + + {(item) => ( + + + {item.label} + + {item.description && ( + + {item.description} + + )} + + )} + + + ) : null + } + + + + + + + + + + + + + ); +}; + +const SendIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/content/docs/primitives/composer/demos/store.tsx b/content/docs/primitives/composer/demos/store.tsx new file mode 100644 index 0000000..dff3e5e --- /dev/null +++ b/content/docs/primitives/composer/demos/store.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Composer, type ComposerSubmitData } from "@intentface/chat/composer"; +import type { ComponentProps } from "react"; + +// A store handle created outside the tree. The button drives the composer +// through store.controller — no context, no hook, no ref threading. +const store = Composer.createStore(); + +export const Store = () => { + const handleSubmit = (data: ComposerSubmitData) => { + if (data.kind === "message") { + console.log(data.text); + } + }; + + return ( +
+ + + + + + + + + + + + + +
+ ); +}; + +const SendIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/content/docs/primitives/message.mdx b/content/docs/primitives/message.mdx index d9fa7ff..d325f7e 100644 --- a/content/docs/primitives/message.mdx +++ b/content/docs/primitives/message.mdx @@ -4,7 +4,9 @@ description: Renders a single chat message — role-aware bubble, markdown, chip source: message --- - +import { Basic } from './message/demos/basic'; + +} file="primitives/message/demos/basic.tsx" /> ## Usage guidelines diff --git a/content/docs/primitives/message/demos/basic.tsx b/content/docs/primitives/message/demos/basic.tsx new file mode 100644 index 0000000..9c499fc --- /dev/null +++ b/content/docs/primitives/message/demos/basic.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Message } from "@intentface/chat/message"; +import { type ComponentProps, useState } from "react"; + +// Message.Root stamps data-role / data-last / data-error and imposes no layout; +// the bubble, alignment, and actions are all yours. +const MESSAGES = [ + { id: "q", role: "user", text: "How do I center a div?" }, + { + id: "a", + role: "assistant", + text: "Use flexbox on the parent: display: flex, then justify-content: center and align-items: center.", + }, +]; + +export const Basic = () => ( +
+ {MESSAGES.map((message, index) => ( + + {/* data-role sits on Root, so the bubble reads it through the group. */} + + {message.text} + + {message.role === "assistant" && } + + ))} +
+); + +const CopyButton = ({ value }: { value: string }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +}; + +const CopyIcon = (props: ComponentProps<"svg">) => ( + +); + +const CheckIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/content/docs/primitives/reasoning.mdx b/content/docs/primitives/reasoning.mdx index f16b5c4..4a8c40b 100644 --- a/content/docs/primitives/reasoning.mdx +++ b/content/docs/primitives/reasoning.mdx @@ -4,7 +4,9 @@ description: A collapsible "chain of thought" disclosure that tracks streaming d source: reasoning --- - +import { Basic } from './reasoning/demos/basic'; + +} file="primitives/reasoning/demos/basic.tsx" /> ## Usage guidelines diff --git a/content/docs/primitives/reasoning/demos/basic.tsx b/content/docs/primitives/reasoning/demos/basic.tsx new file mode 100644 index 0000000..406ad6c --- /dev/null +++ b/content/docs/primitives/reasoning/demos/basic.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Reasoning } from "@intentface/chat/reasoning"; +import type { ComponentProps } from "react"; + +// A completed reasoning block. The parts ship no copy of their own — the +// trigger label and the content rendering are both yours. +export const Basic = () => ( +
+ + + + Thought for 4 seconds + + + { + "Check the existing layout, then decide which axis needs centering.\n\nConfirm the element centers both horizontally and vertically." + } + + +
+); + +const ChevronIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/content/docs/primitives/steps.mdx b/content/docs/primitives/steps.mdx index 898389d..81f539e 100644 --- a/content/docs/primitives/steps.mdx +++ b/content/docs/primitives/steps.mdx @@ -4,7 +4,9 @@ description: A collapsible timeline of a run — reasoning, tool calls, and answ source: steps --- - +import { Basic } from './steps/demos/basic'; + +} file="primitives/steps/demos/basic.tsx" /> ## Usage guidelines diff --git a/content/docs/primitives/steps/demos/basic.tsx b/content/docs/primitives/steps/demos/basic.tsx new file mode 100644 index 0000000..fbd7eb8 --- /dev/null +++ b/content/docs/primitives/steps/demos/basic.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Steps } from "@intentface/chat/steps"; +import type { ComponentProps } from "react"; + +// Steps is recursive: an item's panel can hold rows and further items. A nested +// panel picks up data-nested, which is how the rail indent is drawn. +export const Basic = () => ( +
+ + + + Worked for 3 seconds + + + +
+ + + + Read the request +
+ + + + + + + Searched the web + + + + Found three relevant sources and skimmed each. + + + + +
+ + + + + Writing the answer + +
+
+
+
+
+); + +// Status is inherited from the enclosing item and surfaced as data-status, so +// one class string covers every state. +const ICON_CLASS = + "flex size-4 shrink-0 items-center justify-center data-[status=complete]:text-[#686868] data-[status=active]:text-[#1a1a1a] data-[status=pending]:text-[#949494] dark:data-[status=complete]:text-[#9b9b9b] dark:data-[status=active]:text-[#fcfcfc] dark:data-[status=pending]:text-[#6f6f6f]"; + +const LABEL_CLASS = + "text-left text-sm data-[status=complete]:text-[#686868] data-[status=active]:font-medium data-[status=active]:text-[#1a1a1a] data-[status=pending]:text-[#949494] dark:data-[status=complete]:text-[#9b9b9b] dark:data-[status=active]:text-[#fcfcfc] dark:data-[status=pending]:text-[#6f6f6f]"; + +const ChevronIcon = (props: ComponentProps<"svg">) => ( + +); + +const CheckIcon = (props: ComponentProps<"svg">) => ( + +); + +const CircleIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/content/docs/primitives/thread.mdx b/content/docs/primitives/thread.mdx index 64a62ec..44641d5 100644 --- a/content/docs/primitives/thread.mdx +++ b/content/docs/primitives/thread.mdx @@ -4,7 +4,9 @@ description: The scrolling message viewport with auto-follow, a docked composer, source: thread --- - +import { Basic } from './thread/demos/basic'; + +} file="primitives/thread/demos/basic.tsx" /> ## Usage guidelines diff --git a/content/docs/primitives/thread/demos/basic.tsx b/content/docs/primitives/thread/demos/basic.tsx new file mode 100644 index 0000000..82e44f9 --- /dev/null +++ b/content/docs/primitives/thread/demos/basic.tsx @@ -0,0 +1,138 @@ +"use client"; + +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"; + +type DemoMessage = { id: string; role: "user" | "assistant"; text: string }; + +const INITIAL: DemoMessage[] = [ + { id: "1", role: "user", text: "What's the difference between useMemo and useCallback?" }, + { + id: "2", + role: "assistant", + text: "useMemo caches a computed value; useCallback caches a function reference. In fact useCallback(fn, deps) is just useMemo(() => fn, deps).", + }, + { id: "3", role: "user", text: "So when do I actually need useCallback?" }, + { + id: "4", + role: "assistant", + text: "Mainly when you pass a callback to a memo-wrapped child or as another hook's dependency — a fresh function each render would break their memoization. Otherwise you usually don't.", + }, +]; + +const REPLY = "Good question — the short answer is it depends on what you're optimizing for."; + +// Thread measures its docked composer and publishes the reserve as +// --thread-overlay-bottom-height, so the scroll area never hides behind it. +export const Basic = () => { + const [messages, setMessages] = useState(INITIAL); + + const handleSubmit = (data: ComposerSubmitData) => { + if (data.kind !== "message" || !data.text.trim()) return; + setMessages((current) => [ + ...current, + { id: `${current.length}-u`, role: "user", text: data.text }, + { id: `${current.length}-a`, role: "assistant", text: REPLY }, + ]); + }; + + return ( +
+ + +
+ {/* The last child carries the auto-scroll reserve the primitive sets. */} + + {messages.map((message, index) => ( + + + {message.text} + + + ))} + +
+
+ +
+ + + + + + + + + + + + + +
+
+
+
+ ); +}; + +// useThread exposes the scroll state the viewport tracks; the button is yours. +const ScrollButton = () => { + const { isAtBottom, scrollToBottom } = useThread(); + + if (isAtBottom) return null; + + return ( + + ); +}; + +const ArrowDownIcon = (props: ComponentProps<"svg">) => ( + +); + +const SendIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/content/docs/quick-start.mdx b/content/docs/quick-start.mdx index 769bc34..4eab3e8 100644 --- a/content/docs/quick-start.mdx +++ b/content/docs/quick-start.mdx @@ -1,17 +1,16 @@ --- title: Quick start -description: Install the package, set it up, and assemble your first composer. +description: Install the package, set it up, and assemble your first chat. --- +import { Hero } from './quick-start/demos/hero'; + **@intentface/chat** ships the behavior of a chat interface and none of its -appearance. This page takes you from an empty React 19 app to a working composer. +appearance. This page takes you from an empty React 19 app to a working chat. ## Install the library -```bash -bun add @intentface/chat -# or: npm install @intentface/chat / pnpm add @intentface/chat -``` + `react` and `react-dom` (v19+) are the only peer dependencies. Nothing else ships with it beyond two small runtime deps — `@floating-ui/dom` for anchored @@ -57,49 +56,19 @@ popover. ## Assemble a component +Three primitives make a chat: `Thread` owns the scroll area and reserves space +for its docked composer, `Message` renders each turn, and `Composer` takes input. Every part renders semantic DOM with `data-*` state attributes and no classes of -its own. You pass `className` to each one, so the look is yours from the first +its own — you pass `className` to each one, so the look is yours from the first render. -```tsx -"use client"; - -import { Composer, type ComposerSubmitData } from "@intentface/chat/composer"; - -export function Chat() { - const handleSubmit = (data: ComposerSubmitData) => { - if (data.kind === "message") { - console.log(data.text, data.files); - } - }; - - return ( - - - - - - - - Send - - - - - ); -} -``` +} file="quick-start/demos/hero.tsx" /> -`Composer.Submit` disables itself while the field is empty and exposes the -submitting state through data attributes — see the -[Composer](/docs/primitives/composer) page for the full part list. +`Composer.Submit` disables itself while the field is empty, and `Thread` +publishes its docked-composer reserve as `--thread-overlay-bottom-height` so the +scroll area never hides behind it. See [Composer](/docs/primitives/composer), +[Thread](/docs/primitives/thread), and [Message](/docs/primitives/message) for +the full part lists. ## Pre-styled components diff --git a/content/docs/quick-start/demos/hero.tsx b/content/docs/quick-start/demos/hero.tsx new file mode 100644 index 0000000..7b11fa7 --- /dev/null +++ b/content/docs/quick-start/demos/hero.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Composer, type ComposerSubmitData } from "@intentface/chat/composer"; +import { Message } from "@intentface/chat/message"; +import { Thread } from "@intentface/chat/thread"; +import { type ComponentProps, useState } from "react"; + +type ChatMessage = { id: string; role: "user" | "assistant"; text: string }; + +const INITIAL: ChatMessage[] = [ + { id: "1", role: "user", text: "Can you summarise this thread?" }, + { + id: "2", + role: "assistant", + text: "Sure — it covers the composer's segment model, how chips serialise, and why the editor owns its own DOM.", + }, +]; + +// Three primitives assembled: Thread owns the scroll area and reserves space +// for its docked composer, Message renders each turn, Composer takes input. +export const Hero = () => { + const [messages, setMessages] = useState(INITIAL); + + const handleSubmit = (data: ComposerSubmitData) => { + if (data.kind !== "message" || !data.text.trim()) return; + setMessages((current) => [ + ...current, + { id: `${current.length}`, role: "user", text: data.text }, + ]); + }; + + return ( +
+ + +
+ + {messages.map((message, index) => ( + + + {message.text} + + + ))} + +
+
+ +
+ + + {/* The editable element is engine-owned and out of JSX reach, so + it is styled through the data-composer-editor variants. */} + + + + + + + + + + +
+
+
+
+ ); +}; + +const SendIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/mdx-components.tsx b/mdx-components.tsx index ea682b0..51fd0a1 100644 --- a/mdx-components.tsx +++ b/mdx-components.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import type { ComponentProps, ReactNode } from "react"; import { AttributesTable } from "@/components/docs/attributes-table"; import { CodeBlock } from "@/components/docs/code-block"; -import { ComponentPreview } from "@/components/docs/component-preview"; +import { Demo } from "@/components/docs/demo"; +import { InstallationBlock } from "@/components/docs/installation-block"; import { PropsTable } from "@/components/docs/props-table"; import { ValuesTable } from "@/components/docs/values-table"; import { cn } from "@/lib/utils"; @@ -99,7 +100,8 @@ const proseComponents: MDXComponents = { // Docs components available inside every MDX page without an import. const docsComponents: MDXComponents = { - ComponentPreview, + Demo, + InstallationBlock, PropsTable, AttributesTable, ValuesTable, From e26b42b2de06e8a812b33e4b71bc30de316f7c77 Mon Sep 17 00:00:00 2001 From: Rasmus Viloma Date: Fri, 7 Aug 2026 14:41:09 +0300 Subject: [PATCH 3/4] docs: expand demos inline for agents, add llms.txt and a demo portability gate --- .github/workflows/ci.yml | 6 +++ app/docs-markdown/[...slug]/route.ts | 10 ++-- app/llms.txt/route.ts | 75 ++++++++++++++++++++++++++ components/docs/installation-block.tsx | 10 +--- content/docs/headless/index.mdx | 30 +++++------ content/docs/primitives/chip.mdx | 2 +- lib/docs/expand-demos.ts | 55 +++++++++++++++++++ lib/docs/package-managers.ts | 8 +++ tools/read-docs-page.ts | 4 +- tsconfig.demos.json | 29 ++++++++++ 10 files changed, 199 insertions(+), 30 deletions(-) create mode 100644 app/llms.txt/route.ts create mode 100644 lib/docs/expand-demos.ts create mode 100644 lib/docs/package-managers.ts create mode 100644 tsconfig.demos.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc5b189..da60fb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,12 @@ jobs: bun run build bunx publint + # The docs demos are the styled reference people copy, so they must + # compile against the built package with no app aliases in scope. Runs + # after the build above, which produces the dist this typechecks against. + - name: Demo portability gate + run: bunx tsc -p tsconfig.demos.json + # The chat API route reads OPENAI_API_KEY at request time only; a dummy # value keeps the production build self-contained. - name: App build diff --git a/app/docs-markdown/[...slug]/route.ts b/app/docs-markdown/[...slug]/route.ts index c4bbcdf..e06a643 100644 --- a/app/docs-markdown/[...slug]/route.ts +++ b/app/docs-markdown/[...slug]/route.ts @@ -1,9 +1,11 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; +import { expandDemos } from "@/lib/docs/expand-demos"; -// Serves a doc page's raw .mdx as text/plain so "View as Markdown" opens the -// source in-browser (and pastes cleanly into an LLM). The slug maps directly to -// the file under content/docs, mirroring the fumadocs page route. +// Serves a doc page's .mdx as text/plain so "View as Markdown" opens the source +// in-browser (and pastes cleanly into an LLM). The slug maps directly to the +// file under content/docs, mirroring the fumadocs page route. Demos are inlined +// as code blocks so a reader that can't run the page still gets the source. export async function GET(_request: Request, { params }: { params: Promise<{ slug: string[] }> }) { const { slug } = await params; @@ -15,7 +17,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu const filePath = path.join(process.cwd(), "content", "docs", `${slug.join("/")}.mdx`); try { - const source = await readFile(filePath, "utf8"); + const source = await expandDemos(await readFile(filePath, "utf8")); return new Response(source, { headers: { "content-type": "text/plain; charset=utf-8" }, }); diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts new file mode 100644 index 0000000..d062d61 --- /dev/null +++ b/app/llms.txt/route.ts @@ -0,0 +1,75 @@ +import type { ReactNode } from "react"; +import { getPage, source } from "@/lib/docs/source"; + +const SITE = "https://intentface.dev"; + +const SUMMARY = + "Headless chat UI primitives for React — the behavior, state, and wire formats for building AI chat interfaces, with no styling of their own. Each documentation page below is served as plain markdown with every demo's source inlined."; + +type TreeNode = { + type: string; + name?: ReactNode; + url?: string; + children?: TreeNode[]; +}; + +// Page tree names are ReactNode; ours come from frontmatter, so they're strings. +const asText = (value: ReactNode): string => (typeof value === "string" ? value : ""); + +// "/docs/primitives/composer" -> "primitives/composer"; "/docs" -> "index" +const slugOf = (url: string) => url.replace(/^\/docs\/?/, "") || "index"; + +const lineFor = (url: string) => { + const slug = slugOf(url); + const page = getPage(slug === "index" ? [] : slug.split("/")); + if (!page) return null; + const description = page.data.description ? `: ${page.data.description}` : ""; + return `- [${page.data.title}](${SITE}/docs-markdown/${slug})${description}`; +}; + +// Groups follow the page tree, so the order matches the sidebar. +const sectionsOf = (nodes: TreeNode[]) => { + const loose: string[] = []; + const groups: { title: string; lines: string[] }[] = []; + + for (const node of nodes) { + if (node.type === "page" && node.url) { + const line = lineFor(node.url); + if (line) loose.push(line); + } + if (node.type === "folder") { + const lines = (node.children ?? []) + .filter((child) => child.type === "page" && child.url) + .map((child) => lineFor(child.url as string)) + .filter((line): line is string => line !== null); + if (lines.length) groups.push({ title: asText(node.name) || "Pages", lines }); + } + } + + return { loose, groups }; +}; + +// llms.txt — a flat, ordered index so an agent can find the docs without +// crawling rendered HTML. Pairs with /docs-markdown/, which serves each +// page as markdown with demo source expanded inline. +export const dynamic = "force-static"; + +export const GET = () => { + const { loose, groups } = sectionsOf(source.pageTree.children as TreeNode[]); + + const body = [ + "# @intentface/chat", + "", + `> ${SUMMARY}`, + "", + "## Documentation", + "", + ...loose, + ...groups.flatMap(({ title, lines }) => ["", `## ${title}`, "", ...lines]), + "", + ].join("\n"); + + return new Response(body, { + headers: { "content-type": "text/plain; charset=utf-8" }, + }); +}; diff --git a/components/docs/installation-block.tsx b/components/docs/installation-block.tsx index b60c982..29f389a 100644 --- a/components/docs/installation-block.tsx +++ b/components/docs/installation-block.tsx @@ -1,13 +1,7 @@ +import { PACKAGE_MANAGERS } from "@/lib/docs/package-managers"; import { CodeBlock } from "./code-block"; import { InstallationBlockTabs } from "./installation-block-tabs"; -const MANAGERS = [ - { manager: "npm", install: "npm install" }, - { manager: "pnpm", install: "pnpm add" }, - { manager: "yarn", install: "yarn add" }, - { manager: "bun", install: "bun add" }, -]; - type InstallationBlockProps = { packageName: string; }; @@ -16,7 +10,7 @@ type InstallationBlockProps = { // hands them all to the client shell, which shows the selected one. export const InstallationBlock = ({ packageName }: InstallationBlockProps) => ( { + entries={PACKAGE_MANAGERS.map(({ manager, install }) => { const command = `${install} ${packageName}`; return { manager, command, code: }; })} diff --git a/content/docs/headless/index.mdx b/content/docs/headless/index.mdx index ffcd4c7..ef382ac 100644 --- a/content/docs/headless/index.mdx +++ b/content/docs/headless/index.mdx @@ -1,10 +1,10 @@ --- title: Two layers -description: How the headless package and the styled copy-paste layer divide the work. +description: What the package owns, what your styling owns, and the seams between them. --- -Intentface chat is split the way Base UI and shadcn/ui are split, applied to -chat: a headless npm package for behavior, and a copy-paste layer for looks. +Intentface chat is split the way Base UI is split, applied to chat: a headless +npm package owns behavior, and every class belongs to you. ## The headless package @@ -24,24 +24,22 @@ expose behavior three ways: `data-*` attributes for state, context hooks render props / children for slotting your own elements in. Their only peer dependency is React. -## The styled layer +## Your styling -Each styled component is a thin wrapper around a headless primitive: it adds -Tailwind classes, icons, and motion, and re-exports the same API. You copy the -source from the docs into your repo, and you own every class from then on. -Because the wrapper only supplies presentation, restyling never risks the -behavior — that stays in the package, and package updates fix behavior without -touching your styles. +There is no pre-styled package. Each demo in these docs is a thin wrapper around +the primitives — Tailwind classes, inline icons, nothing else — and it exists to +be copied and changed. Because presentation and behavior never mix, restyling +can't break the primitive, and package updates fix behavior without touching +your classes. ## Why this split -One codebase, two audiences. Teams that want the Intentface look copy the -styled layer and start immediately. Teams with their own design system depend on -`@intentface/chat` directly and write their own thin wrappers — the behavior is -identical either way. +One codebase, two audiences. Teams that want a head start copy a demo and edit +it. Teams with their own design system depend on `@intentface/chat` directly and +write their own wrappers — the behavior is identical either way. The pages in this section cover the three seams that make the split work: the [owned types](/docs/headless/types) the components read, the [store model](/docs/headless/state) behind the composer, and the -[animation contract](/docs/headless/animation) that keeps motion in the styled -layer. +[animation contract](/docs/headless/animation) that keeps motion out of the +package. diff --git a/content/docs/primitives/chip.mdx b/content/docs/primitives/chip.mdx index b9928b1..bc4b558 100644 --- a/content/docs/primitives/chip.mdx +++ b/content/docs/primitives/chip.mdx @@ -48,7 +48,7 @@ bare words. The package never renders the hover preview — `renderWithPreview` is the seam. Whatever surface the styled layer lifts it into must honor the hover-card contract: open on keyboard focus as well as hover, and dismiss with Escape -(the styled layer's Base UI Hover Card does both). +(a Base UI Hover Card, for instance, does both). ## API reference diff --git a/lib/docs/expand-demos.ts b/lib/docs/expand-demos.ts new file mode 100644 index 0000000..5859cc8 --- /dev/null +++ b/lib/docs/expand-demos.ts @@ -0,0 +1,55 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { PACKAGE_MANAGERS } from "./package-managers"; + +const DOCS_DIR = path.join(process.cwd(), "content", "docs"); + +// +const INSTALL_TAG = //g; + +// } file="primitives/composer/demos/basic.tsx" /> +// `[\s\S]` rather than `[^>]`: the component prop holds JSX, so the tag has a +// nested `/>` before its own. +const DEMO_TAG = //g; + +// The MDX imports that only exist to feed those tags. +const DEMO_IMPORT = /^import\s+\{[^}]*\}\s+from\s+["']\.\/[^"']*\/demos\/[^"']*["'];?[ \t]*\r?\n/gm; + +/** + * Rewrites a docs page's raw MDX for non-browser readers: every `` becomes + * a fenced code block holding the demo's actual source, and the imports that fed + * those tags are dropped. Without this an agent reads an opaque tag and no code. + */ +export const expandDemos = async (markdown: string): Promise => { + const expanded = markdown.replace(INSTALL_TAG, (_tag, packageName: string) => { + const [first, ...rest] = PACKAGE_MANAGERS; + const alternatives = rest.map(({ install }) => `# ${install} ${packageName}`); + return ["```bash", `${first.install} ${packageName}`, ...alternatives, "```"].join("\n"); + }); + + const files = [...expanded.matchAll(DEMO_TAG)].map(([, file]) => file); + if (files.length === 0) return expanded; + + const sources = new Map(); + await Promise.all( + [...new Set(files)].map(async (file) => { + // Defence in depth: `file` comes from our own MDX, but never let a path + // escape content/docs. + const resolved = path.resolve(DOCS_DIR, file); + if (!resolved.startsWith(`${DOCS_DIR}${path.sep}`)) return; + try { + sources.set(file, await readFile(resolved, "utf8")); + } catch { + // Leave the tag untouched rather than emit a half-broken page. + } + }), + ); + + return expanded + .replace(DEMO_IMPORT, "") + .replace(DEMO_TAG, (tag, file: string) => { + const source = sources.get(file); + return source ? `\`\`\`tsx title="${file}"\n${source.trimEnd()}\n\`\`\`` : tag; + }) + .replace(/\n{3,}/g, "\n\n"); +}; diff --git a/lib/docs/package-managers.ts b/lib/docs/package-managers.ts new file mode 100644 index 0000000..47ca8f4 --- /dev/null +++ b/lib/docs/package-managers.ts @@ -0,0 +1,8 @@ +/** Install commands per package manager. Shared by the docs tab component and + * the markdown expander so the two can't drift. */ +export const PACKAGE_MANAGERS = [ + { manager: "npm", install: "npm install" }, + { manager: "pnpm", install: "pnpm add" }, + { manager: "yarn", install: "yarn add" }, + { manager: "bun", install: "bun add" }, +]; diff --git a/tools/read-docs-page.ts b/tools/read-docs-page.ts index 395b71a..5640933 100644 --- a/tools/read-docs-page.ts +++ b/tools/read-docs-page.ts @@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { tool } from "ai"; import { z } from "zod"; +import { expandDemos } from "@/lib/docs/expand-demos"; import { getPage } from "@/lib/docs/source"; export const readDocsPage = tool({ @@ -17,7 +18,8 @@ export const readDocsPage = tool({ } const filePath = page.absolutePath ?? path.join(process.cwd(), "content", "docs", page.path); - const markdown = await readFile(filePath, "utf8"); + // Demos are inlined as code blocks — the tag alone carries no source. + const markdown = await expandDemos(await readFile(filePath, "utf8")); return { slug, diff --git a/tsconfig.demos.json b/tsconfig.demos.json new file mode 100644 index 0000000..fa8d23e --- /dev/null +++ b/tsconfig.demos.json @@ -0,0 +1,29 @@ +{ + // Portability gate for the docs demos. Demos are the styled reference people + // copy, so they must compile against the *published* package and nothing + // else. Two things make that real here: + // • paths maps @intentface/chat/* to the built dist, not src — so an API + // that never made it into the build fails here instead of in a consumer's + // project. + // • there is deliberately no "@/*" alias — any reach into the app + // (components, lib, hooks) is an unresolved import, not a silent success. + // Run after `bun run build` in packages/chat; see .github/workflows/ci.yml. + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@intentface/chat/*": ["./packages/chat/dist/*"] + } + }, + "include": ["content/docs/**/demos/*.tsx"] +} From 3f529bacf4fd52899a7d987d2893f34daaec84a4 Mon Sep 17 00:00:00 2001 From: Rasmus Viloma Date: Fri, 7 Aug 2026 15:02:45 +0300 Subject: [PATCH 4/4] docs: add a staged build-a-chat guide --- content/docs/build-a-chat.mdx | 121 +++++++++++++++++++ content/docs/build-a-chat/demos/composer.tsx | 92 ++++++++++++++ content/docs/build-a-chat/demos/messages.tsx | 44 +++++++ content/docs/build-a-chat/demos/thread.tsx | 29 +++++ content/docs/meta.json | 2 +- 5 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 content/docs/build-a-chat.mdx create mode 100644 content/docs/build-a-chat/demos/composer.tsx create mode 100644 content/docs/build-a-chat/demos/messages.tsx create mode 100644 content/docs/build-a-chat/demos/thread.tsx diff --git a/content/docs/build-a-chat.mdx b/content/docs/build-a-chat.mdx new file mode 100644 index 0000000..ddc6b4f --- /dev/null +++ b/content/docs/build-a-chat.mdx @@ -0,0 +1,121 @@ +--- +title: Build a chat +description: Four stages from an empty page to a streaming chat, one primitive at a time. +--- + +import { ThreadStage } from './build-a-chat/demos/thread'; +import { MessagesStage } from './build-a-chat/demos/messages'; +import { ComposerStage } from './build-a-chat/demos/composer'; + +[Quick start](/docs/quick-start) gets a chat on screen in one go. This page takes +the same result apart and rebuilds it in four stages, so you can see what each +primitive contributes and where your own code belongs. + +The first three stages run on static data and local state — copy any of them and +they work immediately. The fourth connects a real model. + +## 1. Render a thread + +`Thread` is a scroll container with opinions about one thing: staying at the +bottom. It follows new content while you're already there and releases the moment +you scroll away, so a stream never yanks the page out from under you. + +} file="build-a-chat/demos/thread.tsx" /> + +`Thread.Viewport` is the scrolling element and `Thread.Content` is the column +inside it. Neither sets width, spacing, or colour. At this stage the rows are +plain paragraphs — nothing here knows what a message is. + +## 2. Add messages + +`Message.Root` renders a `div` carrying `data-role`, `data-last`, and +`data-error`, and nothing else. No bubble, no avatar, no alignment: the role is +reported, and you decide what it looks like. + +} file="build-a-chat/demos/messages.tsx" /> + +The bubble is styled by reading `data-role` through a Tailwind group on the root, +which is why the same `Message.Text` element renders as a card for the user and +as bare text for the assistant. `role` is an opaque string — the package never +enumerates the set, so `"system"` or `"tool"` work the same way. + +## 3. Wire the composer + +`Composer.Root` is a `form`. Submitting hands you a `ComposerSubmitData` and does +nothing else; appending the turn is your call. + +} file="build-a-chat/demos/composer.tsx" /> + +Two things happen automatically once the composer is docked inside +`Thread.Composer`. The thread measures it and publishes the height as +`--thread-overlay-bottom-height`, which the content column uses as bottom +padding — so messages never hide behind the input, at any composer height. And +the newest turn gets `--thread-turn-min-height` so it lands at the top of the +viewport rather than jumping. + +At this point everything is local: submitting appends a message, and no request +leaves the page. + +## 4. Stream responses + +This stage has no live demo. Docs pages here never call a model, so what follows +is the wiring, shown as code rather than run. + +Swap local state for `useChat`, which owns the message list and the request: + +```tsx +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { Composer, type ComposerSubmitData } from "@intentface/chat/composer"; + +export function Chat() { + const { messages, sendMessage, status, stop } = useChat(); + + const handleSubmit = (data: ComposerSubmitData) => { + if (data.kind === "message" && data.text.trim()) { + sendMessage({ text: data.text, files: data.files }); + } + }; + + return ( + + {/* … the same tree as stage 3 … */} + + + ); +} +``` + +`Composer.Submit` takes `isGenerating` and `onStop`: while generating it flips to +`type="button"`, relabels itself, and calls `onStop` instead of submitting, so +send-to-stop needs no branching of your own. + +The route is the standard AI SDK handler — nothing in this package is involved: + +```ts +// app/api/chat/route.ts +import { openai } from "@ai-sdk/openai"; +import { convertToModelMessages, streamText, type UIMessage } from "ai"; + +export async function POST(req: Request) { + const { messages }: { messages: UIMessage[] } = await req.json(); + + const result = streamText({ + model: openai("gpt-5"), + messages: await convertToModelMessages(messages), + }); + + return result.toUIMessageStreamResponse(); +} +``` + +`messages` from `useChat` are `UIMessage`s, which satisfy this package's +`ChatMessage` contract structurally — so they render through `Message` with no +adapter. See [owned types](/docs/headless/types) for why that works. + +## Next steps + +- [Streaming performance](/docs/headless/performance) — keeping the composer from re-rendering on every chunk +- [Composer](/docs/primitives/composer) — commands, chips, attachments, and the ask-user flow +- [Steps](/docs/primitives/steps) and [Reasoning](/docs/primitives/reasoning) — surfacing tool calls and thinking diff --git a/content/docs/build-a-chat/demos/composer.tsx b/content/docs/build-a-chat/demos/composer.tsx new file mode 100644 index 0000000..f5ca18c --- /dev/null +++ b/content/docs/build-a-chat/demos/composer.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { Composer, type ComposerSubmitData } from "@intentface/chat/composer"; +import { Message } from "@intentface/chat/message"; +import { Thread } from "@intentface/chat/thread"; +import { type ComponentProps, useState } from "react"; + +type ChatMessage = { id: string; role: "user" | "assistant"; text: string }; + +const INITIAL: ChatMessage[] = [ + { id: "1", role: "user", text: "Where does the composer put its reply?" }, + { + id: "2", + role: "assistant", + text: "Nowhere — onSubmit hands you the text and you decide. Here it just appends to local state.", + }, +]; + +// Stage 3 — a docked composer appending to local state. Thread measures the +// dock and publishes the reserve as --thread-overlay-bottom-height. +export const ComposerStage = () => { + const [messages, setMessages] = useState(INITIAL); + + const handleSubmit = (data: ComposerSubmitData) => { + if (data.kind !== "message" || !data.text.trim()) return; + setMessages((current) => [ + ...current, + { id: `${current.length}`, role: "user", text: data.text }, + ]); + }; + + return ( +
+ + +
+ + {messages.map((message, index) => ( + + + {message.text} + + + ))} + +
+
+ +
+ + + + + + + + + + + + +
+
+
+
+ ); +}; + +const SendIcon = (props: ComponentProps<"svg">) => ( + +); diff --git a/content/docs/build-a-chat/demos/messages.tsx b/content/docs/build-a-chat/demos/messages.tsx new file mode 100644 index 0000000..694daaf --- /dev/null +++ b/content/docs/build-a-chat/demos/messages.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Message } from "@intentface/chat/message"; +import { Thread } from "@intentface/chat/thread"; + +const MESSAGES = [ + { id: "1", role: "user", text: "What does Message actually render?" }, + { + id: "2", + role: "assistant", + text: "A div with data-role, data-last and data-error on it, plus whatever you put inside. No bubble, no avatar, no alignment.", + }, + { id: "3", role: "user", text: "So the bubble is mine?" }, + { + id: "4", + role: "assistant", + text: "Entirely. Read data-role through a group and style the two sides differently — that's the whole mechanism.", + }, +]; + +// Stage 2 — the same thread, with each row now a Message that reports its role. +export const MessagesStage = () => ( +
+ + + + {MESSAGES.map((message, index) => ( + + {/* data-role sits on Root, so the bubble reads it through the group. */} + + {message.text} + + + ))} + + + +
+); diff --git a/content/docs/build-a-chat/demos/thread.tsx b/content/docs/build-a-chat/demos/thread.tsx new file mode 100644 index 0000000..2b14412 --- /dev/null +++ b/content/docs/build-a-chat/demos/thread.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Thread } from "@intentface/chat/thread"; + +const LINES = [ + "Thread owns the scroll container and nothing else.", + "It tracks whether you are at the bottom, follows new content while you are, and releases the moment you scroll away.", + "Viewport is the scrolling element. Content is the column inside it.", + "Neither imposes width, spacing, or colour — that is all yours.", + "Scroll this box to see the follow behaviour release.", + "Everything below is plain text for now; messages come next.", +]; + +// Stage 1 — just the scroll container, filled with plain rows. +export const ThreadStage = () => ( +
+ + + + {LINES.map((line) => ( +

+ {line} +

+ ))} +
+
+
+
+); diff --git a/content/docs/meta.json b/content/docs/meta.json index 044804e..859754e 100644 --- a/content/docs/meta.json +++ b/content/docs/meta.json @@ -1,4 +1,4 @@ { "title": "Documentation", - "pages": ["index", "quick-start", "headless", "primitives"] + "pages": ["index", "quick-start", "build-a-chat", "headless", "primitives"] }