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/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/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/component-preview.tsx b/components/docs/component-preview.tsx deleted file mode 100644 index d7b0688..0000000 --- a/components/docs/component-preview.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { CodeBlock } from "./code-block"; -import { ComponentPreviewFrame } from "./component-preview-frame"; -import { previews } from "./previews"; - -type ComponentPreviewProps = { - name: keyof typeof previews | (string & {}); -}; - -// Server component: renders a demo from the explicit preview registry and shows -// the demo file's own source (read from disk at build time) in the code tab — -// the file that runs is the file that's displayed, so they can't drift. -export const ComponentPreview = async ({ name }: ComponentPreviewProps) => { - const entry = previews[name]; - if (!entry) { - return ( -
- Unknown preview: {name} -
- ); - } - - const source = await readFile(path.join(process.cwd(), entry.file), "utf8"); - const { Component } = entry; - - return ( - } - code={} - /> - ); -}; diff --git a/components/docs/demo.tsx b/components/docs/demo.tsx new file mode 100644 index 0000000..cba4c8a --- /dev/null +++ b/components/docs/demo.tsx @@ -0,0 +1,24 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { CodeBlock } from "./code-block"; +import { ComponentPreviewFrame } from "./component-preview-frame"; + +type DemoProps = { + component: ReactNode; + /** Path under content/docs, e.g. "primitives/composer/demos/basic.tsx". */ + file: string; +}; + +// Server component: renders a colocated demo and shows that same file's source, +// read from disk at build time — the file that runs is the file displayed. +export const Demo = async ({ component, file }: DemoProps) => { + const source = await readFile(path.join(process.cwd(), "content", "docs", file), "utf8"); + + return ( + } + /> + ); +}; diff --git a/components/docs/installation-block-tabs.tsx b/components/docs/installation-block-tabs.tsx new file mode 100644 index 0000000..bbc23d7 --- /dev/null +++ b/components/docs/installation-block-tabs.tsx @@ -0,0 +1,58 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useState } from "react"; +import { CheckMarkMediumIcon } from "@/components/icons/check-mark-medium"; +import { CopyIcon } from "@/components/icons/copy"; +import { useCopy } from "@/hooks/use-copy"; +import { cn } from "@/lib/utils"; + +export type InstallationEntry = { + manager: string; + command: string; + /** Pre-highlighted command, rendered on the server. */ + code: ReactNode; +}; + +type InstallationBlockTabsProps = { + entries: InstallationEntry[]; +}; + +// Client shell for the package-manager tabs. The server highlights every +// command up front; this only picks which one shows and owns the copy button. +export const InstallationBlockTabs = ({ entries }: InstallationBlockTabsProps) => { + const [manager, setManager] = useState(entries[0].manager); + const { copy, copied } = useCopy(); + const current = entries.find((entry) => entry.manager === manager) ?? entries[0]; + + return ( +
+
+ {entries.map((entry) => ( + + ))} + +
+
{current.code}
+
+ ); +}; diff --git a/components/docs/installation-block.tsx b/components/docs/installation-block.tsx new file mode 100644 index 0000000..29f389a --- /dev/null +++ b/components/docs/installation-block.tsx @@ -0,0 +1,18 @@ +import { PACKAGE_MANAGERS } from "@/lib/docs/package-managers"; +import { CodeBlock } from "./code-block"; +import { InstallationBlockTabs } from "./installation-block-tabs"; + +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/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) => (
- )} -
- ); -}; 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/components/docs/theme-source.tsx b/components/docs/theme-source.tsx deleted file mode 100644 index 64985f8..0000000 --- a/components/docs/theme-source.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { CodeBlock } from "./code-block"; - -// Server component: renders the Intentface theme token CSS from disk so the -// theming page shows the exact block to copy, with no duplication. -export const ThemeSource = async () => { - const css = await readFile( - path.join(process.cwd(), "components", "docs", "intentface.css"), - "utf8", - ); - - return ( -
- -
- ); -}; 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/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/index.mdx b/content/docs/index.mdx index 2db3764..39b5ef3 100644 --- a/content/docs/index.mdx +++ b/content/docs/index.mdx @@ -1,35 +1,32 @@ --- title: Introduction -description: Headless chat UI primitives with a copy-paste styled layer. +description: Headless chat UI primitives for React — behavior from npm, every class yours. --- **@intentface/chat** is a set of headless, unstyled chat primitives — the -behavior, state, and wire formats for building AI chat interfaces — paired with -a copy-paste layer of styled components you own. +behavior, state, and wire formats for building AI chat interfaces. -This is the Base UI model applied to chat: **install the logic from npm, copy -the look from the docs.** +This is the Base UI model applied to chat: **the package owns behavior, you own +every class.** -## Two layers +## What the package owns -The headless package ships the parts that are hard to get right and rarely need -restyling — the composer's rich-text editor and command palette, the thread's -scroll and auto-follow, message part segmentation, the chip wire format. These -render no styles of their own; they expose behavior through `data-*` attributes, -context hooks, and render props. +The parts that are hard to get right and rarely need restyling — the composer's +rich-text editor and command palette, the thread's scroll and auto-follow, +message part segmentation, the chip wire format. These render no styles of their +own; they expose behavior through `data-*` attributes, context hooks, and render +props. -The styled layer is thin wrappers around those primitives, distributed as -copy-paste source: each component page lists the dependencies and shows the -`.tsx` to drop into your repo, and you own every class from then on. +There is no pre-styled package and no theme to install. Every component page +carries demos that show one way to style the primitive — stock Tailwind, no +dependencies beyond this package — and they exist to be copied and changed. ## Get started -Install the headless package: - ```bash bun add @intentface/chat ``` -Then head to a primitive page — start with the [Composer](/docs/primitives/composer) — -to see it live, install its dependencies, and copy the styled source into your -project. +Then follow the [Quick start](/docs/quick-start) to set up portals and assemble +your first composer, or jump straight to a primitive — the +[Composer](/docs/primitives/composer) is the deepest one. diff --git a/content/docs/installation.mdx b/content/docs/installation.mdx deleted file mode 100644 index 0a7a0fa..0000000 --- a/content/docs/installation.mdx +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Installation -description: Install the headless package, then copy the styled components you want. ---- - -Intentface chat comes in two layers. The **headless package** — behavior, state, -and wire formats — installs from npm. The **styled components** are copy-paste: -each component page links to its source, which you copy into your project, so -you own every class from the start. - -## Prerequisites - -A React 19 app. The styled components assume **Tailwind v4** and the token set on -the [Theming](/docs/theming) page; the headless package itself has no styling and -works with any styling approach. - -## Headless package - -```bash -bun add @intentface/chat -# or: npm install @intentface/chat / pnpm add @intentface/chat -``` - -`react` and `react-dom` (v19+) are the only peer dependencies — no styling, no -animation library. Import primitives from their subpaths: - -```tsx -import { Composer } from "@intentface/chat/composer"; -import { Message } from "@intentface/chat/message"; -import { groupTurns } from "@intentface/chat/message-utils"; -``` - -Each primitive renders semantic DOM with `data-*` state attributes and exposes -behavior through context hooks and render props. See the -[headless concepts](/docs/headless) for the model. - -## Styled components - -If you want the Intentface look rather than styling from scratch, copy the styled -components. Every component page has a **View source** link at the top, pointing -to the component's source on GitHub — for example, the -[Composer](/docs/primitives/composer). Copy that file into your project, along -with any components and icons it imports; its import statements are its -dependencies. The styling is then yours to edit. - -## Theme - -The styled components reference a small set of design tokens. Copy the token CSS -from the [Theming](/docs/theming) page into your global stylesheet. diff --git a/content/docs/meta.json b/content/docs/meta.json index a9aceb7..859754e 100644 --- a/content/docs/meta.json +++ b/content/docs/meta.json @@ -1,4 +1,4 @@ { "title": "Documentation", - "pages": ["index", "installation", "theming", "headless", "primitives"] + "pages": ["index", "quick-start", "build-a-chat", "headless", "primitives"] } diff --git a/content/docs/primitives/attachments.mdx b/content/docs/primitives/attachments.mdx index ccb957f..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 @@ -12,7 +14,7 @@ source: attachments - **Model included** — accept matching, blob-URL lifecycle, and send preparation ship in the package; the styled tray animates items in and out. - **Media-aware** — each item exposes `data-media-type` (`image` / `pdf` / `file`) for per-type styling. - **Drop + pick** — a `Dropzone` overlay (inline, or `global` to project into the app shell) plus a `Trigger` file picker. -- **Get started** — see [Installation](/docs/installation) to add the package and copy the component. +- **Get started** — see [Quick start](/docs/quick-start) to add the package. ## Anatomy 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 b24352e..bc4b558 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 @@ -12,7 +14,7 @@ source: chip - **Two homes** — backs the composer's mention decorations and the chips a message reconstructs from its wire format. - **Variants** — `primary` / `accent` / `warning` tint the surface. - **Hover preview** — a `Chip.Preview` child promotes the chip to a hover card and is never rendered inline. -- **Get started** — see [Installation](/docs/installation) to add the package and copy the component. +- **Get started** — see [Quick start](/docs/quick-start) to add the package. ## Anatomy @@ -46,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/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 616f577..872e01c 100644 --- a/content/docs/primitives/composer.mdx +++ b/content/docs/primitives/composer.mdx @@ -4,15 +4,23 @@ 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 - **Chat input** — a hand-rolled contenteditable over a flat segment model: native typing and IME, inline chips, attachments. - **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 + styled** — behavior lives in `@intentface/chat`; the styled wrapper below is yours to copy and edit. -- **Get started** — see [Installation](/docs/installation) to add the package and copy the component. +- **Headless** — behavior lives in `@intentface/chat`; the demos below show one way to style it, and every class in them is yours to change. +- **Get started** — see [Quick start](/docs/quick-start) to add the package. ## Anatomy @@ -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 68874c3..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 @@ -12,7 +14,7 @@ source: message - **Rich text** — `Message.Markdown` for assistant markdown; `Message.Text` reconstructs inline chips from the wire format. - **Affordances** — copy, regenerate and other actions, source pills, timestamps, and error / stopped / loading markers. - **Selection → chat** — `Message.Selection` lifts highlighted text back into the composer. -- **Get started** — see [Installation](/docs/installation) to add the package and copy the component. +- **Get started** — see [Quick start](/docs/quick-start) to add the package. ## Anatomy 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 3ae1b73..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 @@ -12,7 +14,7 @@ source: reasoning - **Live label** — shimmers "Thinking…" while streaming and settles to "Thought for Ns" when it stops; the duration is tracked for you. - **Sectioned content** — bold `**Header**` lines split the text into labelled sections, rendered as markdown. - **State via `useReasoning`** — read streaming, open, and duration from anywhere inside. -- **Get started** — see [Installation](/docs/installation) to add the package and copy the component. +- **Get started** — see [Quick start](/docs/quick-start) to add the package. ## Anatomy 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 b223025..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 @@ -12,7 +14,7 @@ source: steps - **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 `
`. - **You compose the rows** — the primitive ships the disclosure + status plumbing; row content (icons, tool-call summaries) is yours to render. -- **Get started** — see [Installation](/docs/installation) to add the package and copy the component. +- **Get started** — see [Quick start](/docs/quick-start) to add the package. ## Anatomy 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 62833f8..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 @@ -12,7 +14,7 @@ source: thread - **Auto-scroll modes** — `off` / `bottom` / `jump` / `follow` via the `autoScroll` prop (see below). - **Composer inset** — measures the docked composer to reserve space; the overlays fade the top and bottom edges. - **Owns no data** — you map your messages in; rows are addressable by a `data-message-id` attribute. -- **Get started** — see [Installation](/docs/installation) to add the package and copy the component. +- **Get started** — see [Quick start](/docs/quick-start) to add the package. ## Anatomy 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 new file mode 100644 index 0000000..4eab3e8 --- /dev/null +++ b/content/docs/quick-start.mdx @@ -0,0 +1,93 @@ +--- +title: Quick start +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 chat. + +## Install the library + + + +`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 +positioning and `nanoid` for attachment ids. No styling, no editor framework, no +animation library. + +Each primitive is a separate entry point: + +```tsx +import { Composer } from "@intentface/chat/composer"; +import { Message } from "@intentface/chat/message"; +import { Thread } from "@intentface/chat/thread"; +import { groupTurns } from "@intentface/chat/message-utils"; +``` + +## Set up + +### Portals + +The composer's command popover, its panel, and the attachment preview all render +through portals into `document.body`, so they escape any overflow or transform on +your layout. To keep them above the rest of the page regardless of your own +stacking, give your app root its own stacking context. + +In your root layout: + +```tsx + +
{children}
+ +``` + +And in your global stylesheet: + +```css +.root { + isolation: isolate; +} +``` + +Without this, a `z-index` anywhere in your layout can paint over the command +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 +render. + +} file="quick-start/demos/hero.tsx" /> + +`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 + +There is no pre-styled `@intentface/chat` package, and no CSS to install. The +demos on each component page are the styled reference: they use stock Tailwind, +depend on nothing but this package, and are meant to be copied and edited. + +This site's own chat is built from components that live in the app, not the +package. They use design tokens, Motion, and local icon files, and they are not +published — read them as a reference implementation if you like, but they are not +a starting point. + +## Working with LLMs + +Every docs page has a **View as Markdown** link in the header that serves the page +as plain text, which pastes cleanly into an AI assistant. + +## Next steps + +- [Headless concepts](/docs/headless) — the state model, data attributes, and render props +- [Composer](/docs/primitives/composer) — commands, chips, attachments, and the ask-user flow +- [Thread](/docs/primitives/thread) — the scroll container and auto-follow behavior 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/content/docs/theming.mdx b/content/docs/theming.mdx deleted file mode 100644 index da75821..0000000 --- a/content/docs/theming.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: Theming -description: The token system the styled components paint against, and the CSS to copy. ---- - -The styled components paint against a small set of semantic tokens. Copy the -token CSS into your global stylesheet once and every component reads it. The -headless package has no styling of its own, so this only matters if you use the -styled components. - -## The token model - -Four **seeds** drive everything: - -```css -:root { - --bg: #ffffff; /* canvas */ - --fg: #1a1a1a; /* ink */ - --acc: #0169cc; /* accent */ - --con: 0.3; /* contrast — how far surfaces and states step from bg */ -} -``` - -Every other token derives from those with `oklch(from … calc(…))`: the surface -tiers (`--primary`, `--tertiary`, `--base`), their `-hover`/`-active`/`-border` -states, the accent chain, and the ink tiers. Because the derivations reference -the seeds, **dark mode is just a different set of seeds** — the `.dark` block -re-seeds `--bg`/`--fg`/`--acc` and flips the state-delta signs, and every derived -value recomputes. Tune the whole system by changing four values. - -## Copy the tokens - -Paste this into your `globals.css` (after `@import "tailwindcss"`). It defines -the seeds, the derived tokens, the `@theme inline` mappings, and the `.dark` -overrides: - - - -The `@source` line keeps Tailwind scanning the `streamdown` package for classes; -drop it if you don't render markdown. Adjust the four seeds to rebrand. 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/mdx-components.tsx b/mdx-components.tsx index bfc78b1..51fd0a1 100644 --- a/mdx-components.tsx +++ b/mdx-components.tsx @@ -3,9 +3,9 @@ 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 { ThemeSource } from "@/components/docs/theme-source"; import { ValuesTable } from "@/components/docs/values-table"; import { cn } from "@/lib/utils"; @@ -100,11 +100,11 @@ const proseComponents: MDXComponents = { // Docs components available inside every MDX page without an import. const docsComponents: MDXComponents = { - ComponentPreview, + Demo, + InstallationBlock, PropsTable, AttributesTable, ValuesTable, - ThemeSource, }; export const getMDXComponents = (components?: MDXComponents): MDXComponents => ({ diff --git a/packages/chat/README.md b/packages/chat/README.md index a8ba6e7..eff324d 100644 --- a/packages/chat/README.md +++ b/packages/chat/README.md @@ -1,12 +1,12 @@ # @intentface/chat Headless chat UI primitives for React — the behavior, state, and wire formats -for building AI chat interfaces, with no styling of their own. Pair them with -the shadcn-style styled layer from the [docs](https://intentface.dev/docs), or -bring your own. +for building AI chat interfaces, with no styling of their own. -This is the Base UI model applied to chat: **install the logic from npm, copy -the look from the docs.** +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://intentface.dev/docs) page show how the parts fit together +and are meant to be copied and restyled. ## Status @@ -100,7 +100,7 @@ directive and import fine anywhere. ## Docs -Full guides, live previews, and copy-paste styled source at +Full guides, live demos, and the API reference at [intentface.dev/docs](https://intentface.dev/docs). ## License diff --git a/tools/list-docs-pages.ts b/tools/list-docs-pages.ts index 7b3a16e..2f3f7d6 100644 --- a/tools/list-docs-pages.ts +++ b/tools/list-docs-pages.ts @@ -4,7 +4,7 @@ import { getPages } from "@/lib/docs/source"; export const listDocsPages = tool({ description: - "List the @intentface/chat documentation pages. Call this first when the user asks about the library — its primitives, installation, theming, or internals — to discover which pages to read. Each page's source field names its component under packages/chat/src for readSourceFile.", + "List the @intentface/chat documentation pages. Call this first when the user asks about the library — its primitives, installation, styling, or internals — to discover which pages to read. Each page's source field names its component under packages/chat/src for readSourceFile.", inputSchema: z.object({}), execute: async () => { const pages = getPages().map((page) => ({ 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"] +}