Sign In

@app-cn/ui

Package Overview
Dependencies
Maintainers
1
Versions
2
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@app-cn/ui - npm Package Compare versions

Comparing version
0.1.0
to
0.2.0
+72
src/ai/chat.meta.ts
import type { ComponentMeta } from "../lib/meta";
export const meta: ComponentMeta = {
slug: "chat",
title: "Chat",
category: "ai",
kind: "block",
description:
"The AI chat block — a complete, drop-in conversation: streaming assistant bubbles, a docked composer, reasoning, and starter prompts.",
anatomy:
"A full chat screen that composes StreamBubble (assistant turns), PromptInput (the composer), and ReasoningTrace (inline chain-of-thought) over a scrolling message list with user bubbles. Controlled by `messages` + `onSendMessage`. The header is a slot — default thin bar, `null` for immersive, or your own node. Reach for it when you want a finished AI chat instead of wiring the primitives by hand.",
delight:
"Magnetic stream-follow — while the assistant streams, the list eases to keep the newest line in view and releases the moment you scroll up; a scroll-to-bottom pill springs you back with a soft overshoot, and a success haptic fires the instant a reply settles.",
props: [
{ name: "messages", type: "ChatMessage[]", required: true, description: "The conversation. Each message has id, role, content, and (assistant-only) status/reasoning/tools." },
{ name: "onSendMessage", type: "(text: string) => void", required: true, description: "Called with the trimmed text when the user sends." },
{ name: "generating", type: "boolean", default: "false", description: "While true, the composer shows the stop control." },
{ name: "onStop", type: "() => void", description: "Called when the user taps stop while generating." },
{ name: "onRetry", type: "(id: string) => void", description: "Retry a failed (status: \"error\") assistant message." },
{ name: "onRegenerate", type: "(id: string) => void", description: "Regenerate an assistant message from its actions row." },
{ name: "starters", type: "string[]", description: "Empty-state suggested prompts; tapping one calls onSendMessage." },
{ name: "placeholder", type: "string", default: '"Message appCN…"', description: "Composer placeholder." },
{ name: "header", type: "React.ReactNode", description: "Header slot. undefined → default thin header; null → no header; node → custom." },
{ name: "title", type: "string", default: '"appCN"', description: "Title shown in the default header and empty state." },
{ name: "statusLabel", type: "string", description: "Status line under the title in the default header (e.g. \"Ready\")." },
{ name: "avatar", type: "React.ReactNode", description: "Avatar content for the default header disc." },
{ name: "onNewChat", type: "() => void", description: "When provided, the default header shows a + action that calls this." },
{ name: "emptyState", type: "React.ReactNode", description: "Override the default empty state entirely." },
{ name: "className", type: "string", description: "Extra NativeWind classes on the root container." },
],
examples: [
{
title: "Basic",
description: "A controlled chat. You own the messages and append the assistant's reply.",
code: `const [messages, setMessages] = React.useState<ChatMessage[]>([]);
const [generating, setGenerating] = React.useState(false);
const send = (text: string) => {
setMessages((m) => [...m, { id: \`u\${Date.now()}\`, role: "user", content: text }]);
setGenerating(true);
const id = \`a\${Date.now()}\`;
setMessages((m) => [...m, { id, role: "assistant", content: "Sure — here's the answer.", status: "streaming" }]);
setTimeout(() => {
setMessages((m) => m.map((x) => (x.id === id ? { ...x, status: "done" } : x)));
setGenerating(false);
}, 1800);
};
return (
<Chat
messages={messages}
generating={generating}
onSendMessage={send}
starters={["What can you build?", "Show me a button", "Explain reanimated"]}
/>
);`,
},
{
title: "Immersive (no header)",
description: "Pass header={null} for an edge-to-edge, chrome-free conversation.",
code: `<Chat header={null} messages={messages} onSendMessage={send} />`,
},
],
a11y: [
"Every control (composer, send/stop, starter chips, scroll-to-bottom pill, copy/regenerate, retry) has an accessibilityRole and label, hitSlop, and visible press feedback.",
"Bubbles use accessibilityRole=\"text\" so each message is announced as one unit.",
"Status (thinking/streaming/done/error) is conveyed by labels and text, never color alone.",
"Honors useReducedMotion() — entrance animations and the magnetic follow fall back to instant positioning.",
"The composer rises above the keyboard (KeyboardAvoidingView) and respects the home indicator via safe-area insets.",
],
addedAt: "2026-05-30",
};
import * as React from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
Text,
View,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import Animated, {
FadeIn,
FadeInDown,
useReducedMotion,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Clipboard from "expo-clipboard";
import { cn } from "../lib/cn";
import { duration, easing, spring } from "../lib/motion";
import { haptic } from "../lib/haptics";
import { StreamBubble } from "./stream-bubble";
import { PromptInput } from "./prompt-input";
import { ReasoningTrace } from "./reasoning-trace";
import { useColorScheme } from "nativewind";
export type ChatRole = "user" | "assistant";
export type ChatStatus = "thinking" | "streaming" | "done" | "error";
export interface ChatMessage {
id: string;
role: ChatRole;
content: string;
/** assistant-only; default "done". */
status?: ChatStatus;
/** assistant-only; collapsible chain-of-thought. */
reasoning?: string;
/** assistant-only; tool chips above the message. */
tools?: string[];
}
export interface ChatProps {
messages: ChatMessage[];
onSendMessage: (text: string) => void;
generating?: boolean;
onStop?: () => void;
onRetry?: (id: string) => void;
onRegenerate?: (id: string) => void;
starters?: string[];
placeholder?: string;
/** Header slot. undefined → default header; null → none; node → custom. */
header?: React.ReactNode;
title?: string;
statusLabel?: string;
avatar?: React.ReactNode;
onNewChat?: () => void;
emptyState?: React.ReactNode;
className?: string;
}
/**
* appCN Chat — the AI chat block. A complete, drop-in conversation: a scrolling
* message list (user bubbles + streaming assistant bubbles via StreamBubble), a
* docked PromptInput composer, thinking/streaming/error states, empty-state
* starter prompts, inline ReasoningTrace, and per-message actions.
*
* Delight detail: MAGNETIC STREAM-FOLLOW — while the assistant streams, the list
* eases to keep the newest line in view and releases the instant you scroll up;
* a scroll-to-bottom pill springs you back with a soft overshoot.
*/
export function Chat({
messages,
onSendMessage,
generating = false,
onStop,
onRetry,
onRegenerate,
starters,
placeholder = "Message appCN…",
header,
title = "appCN",
statusLabel,
avatar,
onNewChat,
emptyState,
className,
}: ChatProps) {
const reduced = useReducedMotion();
const insets = useSafeAreaInsets();
const scrollRef = React.useRef<ScrollView>(null);
const atBottomRef = React.useRef(true);
const [showPill, setShowPill] = React.useState(false);
const isEmpty = messages.length === 0;
const lastAssistantId = React.useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]!.role === "assistant") return messages[i]!.id;
}
return null;
}, [messages]);
const scrollToBottom = React.useCallback(
(animated = true) => {
scrollRef.current?.scrollToEnd({ animated: animated && !reduced });
},
[reduced]
);
// Magnetic follow: only auto-scroll when the user is pinned to the bottom.
const onContentSizeChange = React.useCallback(() => {
if (atBottomRef.current) scrollToBottom(true);
}, [scrollToBottom]);
const onScroll = React.useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
const distance =
contentSize.height - (contentOffset.y + layoutMeasurement.height);
const atBottom = distance < 24;
atBottomRef.current = atBottom;
setShowPill(!atBottom && contentSize.height > layoutMeasurement.height);
},
[]
);
// Completion haptic: fire success the moment the latest assistant settles.
const prevStatus = React.useRef<ChatStatus | undefined>(undefined);
React.useEffect(() => {
const last = messages[messages.length - 1];
const status = last?.role === "assistant" ? last.status ?? "done" : undefined;
if (prevStatus.current === "streaming" && status === "done") {
haptic.success();
}
prevStatus.current = status;
}, [messages]);
const headerEl =
header === undefined ? (
<ChatHeader
title={title}
statusLabel={statusLabel ?? (generating ? "Thinking…" : "Ready")}
avatar={avatar}
onNewChat={onNewChat}
topInset={insets.top}
/>
) : (
header
);
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className={cn("flex-1 bg-background", className)}
>
{headerEl}
<View className="flex-1">
{isEmpty ? (
emptyState ?? (
<EmptyState
title={title}
starters={starters}
onPick={onSendMessage}
/>
)
) : (
<ScrollView
ref={scrollRef}
onScroll={onScroll}
onContentSizeChange={onContentSizeChange}
scrollEventThrottle={16}
keyboardShouldPersistTaps="handled"
style={{ flex: 1, minHeight: 0 }}
contentContainerStyle={{
padding: 14,
gap: 12,
flexGrow: 1,
justifyContent: "flex-end",
}}
>
{messages.map((m) =>
m.role === "user" ? (
<UserBubble key={m.id} content={m.content} reduced={reduced} />
) : (
<AssistantMessage
key={m.id}
message={m}
isLatest={m.id === lastAssistantId}
onRetry={onRetry}
onRegenerate={onRegenerate}
reduced={reduced}
/>
)
)}
</ScrollView>
)}
{showPill ? (
<ScrollToBottomPill onPress={() => scrollToBottom(true)} reduced={reduced} />
) : null}
</View>
<View
className="border-t border-border bg-background px-3 pt-2"
style={{ paddingBottom: Math.max(insets.bottom, 8) }}
>
<PromptInput
placeholder={placeholder}
generating={generating}
onSubmit={onSendMessage}
onStop={onStop}
/>
</View>
</KeyboardAvoidingView>
);
}
/* ============================================================ */
/* Header */
/* ============================================================ */
function ChatHeader({
title,
statusLabel,
avatar,
onNewChat,
topInset,
}: {
title: string;
statusLabel: string;
avatar?: React.ReactNode;
onNewChat?: () => void;
topInset: number;
}) {
return (
<View
className="flex-row items-center gap-3 border-b border-border bg-background px-4 pb-3"
style={{ paddingTop: topInset + 10 }}
>
<View className="h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-primary">
{/* soft top tint gives the disc depth without a gradient dep */}
<View
aria-hidden
pointerEvents="none"
className="absolute inset-x-0 top-0 h-4 bg-white/15"
/>
{avatar ?? null}
</View>
<View className="flex-1">
<Text className="text-[15px] font-semibold text-foreground">{title}</Text>
<Text className="text-[11px] text-muted-foreground">{statusLabel}</Text>
</View>
{onNewChat ? (
<IconButton accessibilityLabel="New chat" onPress={onNewChat}>
<PlusGlyph />
</IconButton>
) : null}
</View>
);
}
/* ============================================================ */
/* User bubble */
/* ============================================================ */
function UserBubble({
content,
reduced,
}: {
content: string;
reduced: boolean;
}) {
return (
<Animated.View
entering={reduced ? undefined : FadeInDown.duration(duration.base).easing(easing.enter)}
style={{ alignSelf: "stretch", alignItems: "flex-end" }}
>
<View
accessibilityRole="text"
className="max-w-[82%] rounded-2xl rounded-br-md bg-primary px-3.5 py-2.5"
>
<Text className="text-[15px] leading-5 text-primary-foreground">{content}</Text>
</View>
</Animated.View>
);
}
/* ============================================================ */
/* Assistant message: reasoning + bubble/error + actions */
/* ============================================================ */
function AssistantMessage({
message,
isLatest,
onRetry,
onRegenerate,
reduced,
}: {
message: ChatMessage;
isLatest: boolean;
onRetry?: (id: string) => void;
onRegenerate?: (id: string) => void;
reduced: boolean;
}) {
const status = message.status ?? "done";
const active = isLatest && status !== "error";
return (
<Animated.View
entering={reduced ? undefined : FadeInDown.duration(duration.base).easing(easing.enter)}
className="w-full gap-2"
>
{message.reasoning ? (
<ReasoningTrace
reasoning={message.reasoning}
thinking={status === "thinking" || status === "streaming"}
/>
) : null}
{status === "error" ? (
<ErrorBubble onRetry={onRetry ? () => onRetry(message.id) : undefined} />
) : (
<View className="gap-1.5">
<StreamBubble
content={message.content}
tools={message.tools}
replayKey={message.id}
animate={active}
/>
{status === "done" ? (
<MessageActions
content={message.content}
onRegenerate={onRegenerate ? () => onRegenerate(message.id) : undefined}
/>
) : null}
</View>
)}
</Animated.View>
);
}
/* ============================================================ */
/* Error bubble + retry */
/* ============================================================ */
function ErrorBubble({ onRetry }: { onRetry?: () => void }) {
return (
<View
accessibilityRole="text"
className="max-w-[85%] self-start rounded-2xl rounded-bl-md border border-destructive/40 bg-destructive/10 px-3.5 py-3"
>
<Text className="text-[14px] leading-5 text-foreground">
Couldn't generate a reply.
</Text>
{onRetry ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Retry"
hitSlop={8}
onPress={() => {
haptic.medium();
onRetry();
}}
className="mt-1.5 self-start"
>
<Text className="text-[13px] font-semibold text-destructive">Retry</Text>
</Pressable>
) : null}
</View>
);
}
/* ============================================================ */
/* Message actions: copy (with "Copied" feedback) + regenerate */
/* ============================================================ */
function MessageActions({
content,
onRegenerate,
}: {
content: string;
onRegenerate?: () => void;
}) {
const [copied, setCopied] = React.useState(false);
const copy = async () => {
haptic.selection();
await Clipboard.setStringAsync(content);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
};
return (
<View className="flex-row items-center gap-3 pl-1">
<Pressable
accessibilityRole="button"
accessibilityLabel={copied ? "Copied" : "Copy message"}
hitSlop={8}
onPress={copy}
>
<Text className="text-[12px] font-medium text-muted-foreground">
{copied ? "Copied" : "Copy"}
</Text>
</Pressable>
{onRegenerate ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Regenerate"
hitSlop={8}
onPress={() => {
haptic.selection();
onRegenerate();
}}
>
<Text className="text-[12px] font-medium text-muted-foreground">
Regenerate
</Text>
</Pressable>
) : null}
</View>
);
}
/* ============================================================ */
/* Empty state + starter prompts */
/* ============================================================ */
function EmptyState({
title,
starters,
onPick,
}: {
title: string;
starters?: string[];
onPick: (text: string) => void;
}) {
return (
<Animated.View
entering={FadeIn.duration(duration.slow)}
className="flex-1 items-center justify-center gap-5 px-6"
>
<View className="items-center gap-2">
<View className="h-12 w-12 items-center justify-center overflow-hidden rounded-2xl bg-primary">
<View aria-hidden pointerEvents="none" className="absolute inset-x-0 top-0 h-6 bg-white/15" />
</View>
<Text className="text-lg font-semibold text-foreground">How can I help?</Text>
<Text className="text-center text-[13px] text-muted-foreground">
Ask {title} anything to get started.
</Text>
</View>
{starters && starters.length > 0 ? (
<View className="w-full gap-2">
{starters.map((s, i) => (
<StarterChip key={s} label={s} index={i} onPress={() => onPick(s)} />
))}
</View>
) : null}
</Animated.View>
);
}
function StarterChip({
label,
index,
onPress,
}: {
label: string;
index: number;
onPress: () => void;
}) {
return (
<Animated.View entering={FadeInDown.delay(index * 60).duration(duration.base)}>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={() => {
haptic.selection();
onPress();
}}
className="rounded-2xl border border-border bg-card px-4 py-3 active:opacity-80"
>
<Text className="text-[14px] text-foreground">{label}</Text>
</Pressable>
</Animated.View>
);
}
/* ============================================================ */
/* Scroll-to-bottom pill — springs in with a soft overshoot */
/* ============================================================ */
function ScrollToBottomPill({
onPress,
reduced,
}: {
onPress: () => void;
reduced: boolean;
}) {
return (
<Animated.View
entering={
reduced
? undefined
: FadeInDown.springify().damping(spring.bouncy.damping).mass(spring.bouncy.mass).stiffness(spring.bouncy.stiffness)
}
pointerEvents="box-none"
className="absolute inset-x-0 bottom-2 items-center"
>
<Pressable
accessibilityRole="button"
accessibilityLabel="Scroll to latest"
hitSlop={10}
onPress={() => {
haptic.selection();
onPress();
}}
className="h-9 flex-row items-center gap-1.5 rounded-full border border-border bg-card px-3.5 shadow-lg active:opacity-80"
>
<ChevronDownGlyph />
<Text className="text-[12px] font-medium text-foreground">Latest</Text>
</Pressable>
</Animated.View>
);
}
/* ============================================================ */
/* Shared: icon button + glyphs (drawn from Views, no icon dep) */
/* ============================================================ */
function IconButton({
children,
onPress,
accessibilityLabel,
}: {
children: React.ReactNode;
onPress: () => void;
accessibilityLabel: string;
}) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
hitSlop={8}
onPress={() => {
haptic.selection();
onPress();
}}
className="h-9 w-9 items-center justify-center rounded-full border border-border bg-secondary active:opacity-80"
>
{children}
</Pressable>
);
}
/** Plus — two centred rounded bars. */
function PlusGlyph() {
const { colorScheme } = useColorScheme();
const color =
colorScheme === "light"
? "rgba(20, 20, 25, 0.8)"
: "rgba(244, 244, 250, 0.85)";
return (
<View style={{ width: 16, height: 16 }}>
<View
style={{ position: "absolute", top: 7, left: 1, width: 14, height: 2.5, borderRadius: 9, backgroundColor: color }}
/>
<View
style={{ position: "absolute", top: 1, left: 7, width: 2.5, height: 14, borderRadius: 9, backgroundColor: color }}
/>
</View>
);
}
/** Chevron-down — a triangle via the borderWidth trick. */
function ChevronDownGlyph() {
const { colorScheme } = useColorScheme();
const color =
colorScheme === "light"
? "rgba(20, 20, 25, 0.8)"
: "rgba(244, 244, 250, 0.85)";
return (
<View style={{ width: 12, height: 12, alignItems: "center", justifyContent: "center" }}>
<View
style={{
width: 0,
height: 0,
borderLeftWidth: 5,
borderRightWidth: 5,
borderTopWidth: 6,
borderLeftColor: "transparent",
borderRightColor: "transparent",
borderTopColor: color,
}}
/>
</View>
);
}
+18
-0
# Changelog — @app-cn/ui
## 0.2.0
### Minor Changes
- [#13](https://github.com/Salah-XD/appCN/pull/13) [`9ac3433`](https://github.com/Salah-XD/appCN/commit/9ac3433e52a50a0b8d72e652aae7afdcc33e5818) Thanks [@Salah-XD](https://github.com/Salah-XD)! - Add the **Chat** component — the AI chat block. A complete, drop-in conversation that composes StreamBubble, PromptInput, and ReasoningTrace: controlled `messages` + `onSendMessage`, header-as-slot, starter prompts, scroll-to-bottom pill, inline reasoning, message actions, error/retry, keyboard + safe-area handling, and a magnetic stream-follow delight. Also adds a backward-compatible `animate` prop to StreamBubble for rendering settled/historical messages.
### Patch Changes
- [#13](https://github.com/Salah-XD/appCN/pull/13) [`67029de`](https://github.com/Salah-XD/appCN/commit/67029ded5b5b4938c80391fa839e012a1dc5ff0a) Thanks [@Salah-XD](https://github.com/Salah-XD)! - PromptInput: theme-aware glyph + placeholder colours. The send arrow, attachment "+", close "×", and the placeholder were hardcoded near-white and vanished on light backgrounds; they now switch with the colour scheme. Dark mode is unchanged.
- [#13](https://github.com/Salah-XD/appCN/pull/13) [`d5b0625`](https://github.com/Salah-XD/appCN/commit/d5b0625a63c1e056fbcbb8c98afb155be92dd56b) Thanks [@Salah-XD](https://github.com/Salah-XD)! - fix(reasoning-trace): expand reliably on Android. The chain-of-thought body measured its content height via `onLayout` on a child nested inside the collapsed (`height: 0` + `overflow: hidden`) container, which Android clamps to 0 — so the panel never gained height. The content is now measured at its natural height via absolute positioning, decoupled from the animated collapse.
- [#13](https://github.com/Salah-XD/appCN/pull/13) [`babe0f1`](https://github.com/Salah-XD/appCN/commit/babe0f141404c8051da619008f3b18a4a1d7d1ec) Thanks [@Salah-XD](https://github.com/Salah-XD)! - StreamBubble & Chat: light-mode support. StreamBubble's bubble, avatar, tool chips, caret, thinking dots and text were hardcoded dark; they now switch with the colour scheme via a light palette (dark values are byte-identical, so dark mode is unchanged). Chat's plus/chevron glyphs are likewise theme-aware. Both read correctly on light backgrounds now.
- [#13](https://github.com/Salah-XD/appCN/pull/13) [`bf222bd`](https://github.com/Salah-XD/appCN/commit/bf222bd66c4ae65fef864eb8fabc015743c539dc) Thanks [@Salah-XD](https://github.com/Salah-XD)! - VoiceSphere: add a `background` prop (default `#0A0A14`) so the sphere can blend into any surface — pass a page colour to match seamlessly, or `"transparent"` to float it on whatever is behind. Also tightened the default camera (`z: 4.2 → 3.4`) so the sphere fills more of its canvas, and sped up the active wave motion for a fiercer ripple. The additive-blending particles still need a dark backdrop, so the default colour is unchanged. Adds a `glow` prop (default `true`): set `false` for normal (non-additive) blending so the sphere reads as solid dots on a light or transparent background instead of needing a dark panel.
See the root [CHANGELOG.md](../../CHANGELOG.md) for the project-wide log;

@@ -12,2 +28,3 @@ this file tracks only changes to the published `@app-cn/ui` package surface

### Added
- **Components:** `Button` (base) + `StreamBubble`, `PromptInput`,

@@ -30,2 +47,3 @@ `ReasoningTrace` (AI collection).

### Notes
- Ships TS source only (no `dist/`). Metro / Next / Vite transpile TS

@@ -32,0 +50,0 @@ themselves — no build step required.

+1
-1
{
"name": "@app-cn/ui",
"version": "0.1.0",
"version": "0.2.0",
"description": "Copy-paste mobile components for React Native + Expo. Motion-first, dark-by-default, with a featured AI-native collection.",

@@ -5,0 +5,0 @@ "keywords": [

@@ -133,2 +133,3 @@ import type { ComponentMeta } from "../lib/meta";

],
addedAt: "2026-05-28",
};

@@ -26,2 +26,3 @@ import * as React from "react";

import { haptic } from "../lib/haptics";
import { useColorScheme } from "nativewind";

@@ -85,2 +86,3 @@ export interface PromptAttachment {

}: PromptInputProps) {
const { colorScheme } = useColorScheme();
const isControlled = valueProp !== undefined;

@@ -197,3 +199,7 @@ const [internal, setInternal] = React.useState(defaultValue ?? "");

placeholder={placeholder}
placeholderTextColor="rgba(255,255,255,0.35)"
placeholderTextColor={
colorScheme === "light"
? "rgba(0,0,0,0.35)"
: "rgba(255,255,255,0.35)"
}
multiline

@@ -513,3 +519,8 @@ editable={!disabled}

// resolve on a 0×0 View on web).
const color = filled ? "#FFFFFF" : "rgba(244, 244, 250, 0.55)";
const { colorScheme } = useColorScheme();
const color = filled
? "#FFFFFF"
: colorScheme === "light"
? "rgba(20, 20, 25, 0.5)"
: "rgba(244, 244, 250, 0.55)";

@@ -570,3 +581,7 @@ return (

function PlusGlyph() {
const color = "rgba(244, 244, 250, 0.85)";
const { colorScheme } = useColorScheme();
const color =
colorScheme === "light"
? "rgba(20, 20, 25, 0.8)"
: "rgba(244, 244, 250, 0.85)";
return (

@@ -602,3 +617,7 @@ <View style={{ width: 16, height: 16 }}>

function CloseGlyph() {
const color = "rgba(244, 244, 250, 0.85)";
const { colorScheme } = useColorScheme();
const color =
colorScheme === "light"
? "rgba(20, 20, 25, 0.8)"
: "rgba(244, 244, 250, 0.85)";
return (

@@ -605,0 +624,0 @@ <View style={{ width: 12, height: 12 }}>

@@ -105,2 +105,3 @@ import type { ComponentMeta } from "../lib/meta";

],
addedAt: "2026-05-28",
};

@@ -143,3 +143,11 @@ import * as React from "react";

<Animated.View style={bodyStyle} className="overflow-hidden">
<View onLayout={onContentLayout}>
{/* Measured at its natural height via absolute positioning. The body
collapses to height:0 + overflow:hidden, which on Android clamps a
nested in-flow child's onLayout to 0 — so contentH stayed 0 and the
panel could never expand. Absolute layout decouples the measurement
from the animated height. */}
<View
onLayout={onContentLayout}
style={{ position: "absolute", left: 0, right: 0, top: 0 }}
>
<View className="px-3.5 pb-3.5 pt-0.5">

@@ -146,0 +154,0 @@ <Text className="text-[13px] leading-5 text-muted-foreground">

@@ -49,2 +49,9 @@ import type { ComponentMeta } from "../lib/meta";

{
name: "animate",
type: "boolean",
default: "true",
description:
"When false, the bubble renders its settled final state immediately — no thinking, stream, or settle. Use for already-finished messages so they don't re-animate.",
},
{
name: "className",

@@ -87,2 +94,4 @@ type: "string",

],
addedAt: "2026-05-28",
updatedAt: "2026-05-30",
};

@@ -19,2 +19,3 @@ import * as React from "react";

import { duration, easing, spring } from "../lib/motion";
import { useColorScheme } from "nativewind";

@@ -28,3 +29,19 @@ type Phase = "thinking" | "streaming" | "done";

/* ============================================================ */
const COLOR = {
type StreamColors = {
primary: string;
primarySoft: string;
primaryGlow: string;
accent: string;
bubbleBg: string;
bubbleBgTop: string;
bubbleBorder: string;
text: string;
textMuted: string;
chipBg: string;
chipBorder: string;
avatarBg: string;
avatarBorder: string;
};
const DARK_COLORS: StreamColors = {
primary: "hsl(250, 90%, 66%)",

@@ -43,4 +60,27 @@ primarySoft: "hsla(250, 90%, 66%, 0.25)",

avatarBorder: "rgba(255, 255, 255, 0.10)",
} as const;
};
/** Light-mode mirror (same keys) so the bubble reads on a light background. */
const LIGHT_COLORS: StreamColors = {
primary: "hsl(250, 84%, 60%)",
primarySoft: "hsla(250, 84%, 60%, 0.22)",
primaryGlow: "hsla(250, 84%, 60%, 0.10)",
accent: "hsl(316, 80%, 56%)",
bubbleBg: "#F1F1F6",
bubbleBgTop: "rgba(0, 0, 0, 0.02)",
bubbleBorder: "rgba(0, 0, 0, 0.08)",
text: "#18181B",
textMuted: "rgba(24, 24, 27, 0.62)",
chipBg: "rgba(0, 0, 0, 0.05)",
chipBorder: "rgba(0, 0, 0, 0.10)",
avatarBg: "#ECECEF",
avatarBorder: "rgba(0, 0, 0, 0.10)",
};
/** Pick the bubble palette for the active colour scheme. */
function useStreamColors(): StreamColors {
const { colorScheme } = useColorScheme();
return colorScheme === "light" ? LIGHT_COLORS : DARK_COLORS;
}
export interface StreamBubbleProps {

@@ -65,2 +105,8 @@ /** Full assistant message that streams in token-by-token. */

replayKey?: string | number;
/**
* When false, the bubble renders its settled "done" state immediately — no
* thinking dots, no token reveal, no settle pulse. Use for already-finished
* messages (e.g. chat history) so they don't re-animate on mount/scroll.
*/
animate?: boolean;
className?: string;

@@ -84,2 +130,3 @@ }

replayKey,
animate = true,
className,

@@ -92,3 +139,3 @@ }: StreamBubbleProps) {

React.useEffect(() => {
if (reduced) {
if (reduced || !animate) {
setPhase("done");

@@ -102,3 +149,3 @@ setShown(content.length);

return () => clearTimeout(t);
}, [content, thinkingDuration, replayKey, reduced]);
}, [content, thinkingDuration, replayKey, reduced, animate]);

@@ -136,2 +183,3 @@ React.useEffect(() => {

shown={shown}
animate={animate}
className={className}

@@ -152,2 +200,3 @@ />

shown,
animate,
className,

@@ -159,8 +208,10 @@ }: {

shown: number;
animate: boolean;
className?: string;
}) {
const COLOR = useStreamColors();
// Settled-glow: when we transition to "done", briefly pulse a primary ring.
const settle = useSharedValue(0);
React.useEffect(() => {
if (phase === "done") {
if (phase === "done" && animate) {
settle.value = withSequence(

@@ -173,3 +224,3 @@ withTiming(1, { duration: duration.base, easing: easing.enter }),

}
}, [phase, settle]);
}, [phase, animate, settle]);

@@ -310,2 +361,3 @@ const glowStyle = useAnimatedStyle(() => ({ opacity: settle.value }));

}) {
const COLOR = useStreamColors();
const reduced = useReducedMotion();

@@ -440,2 +492,3 @@ const rot = useSharedValue(0);

function ThinkingDot({ index }: { index: number }) {
const COLOR = useStreamColors();
const progress = useSharedValue(0);

@@ -485,2 +538,3 @@

function StreamCaret() {
const COLOR = useStreamColors();
const blink = useSharedValue(1);

@@ -524,2 +578,3 @@

function ToolChip({ label, index }: { label: string; index: number }) {
const COLOR = useStreamColors();
const lift = useSharedValue(0);

@@ -526,0 +581,0 @@

@@ -89,2 +89,3 @@ import type { ComponentMeta } from "../lib/meta";

],
addedAt: "2026-05-28",
};

@@ -28,2 +28,14 @@ import * as React from "react";

colors?: { from: string; to: string };
/**
* Panel colour behind the sphere. Defaults to the dark `#0A0A14` the additive
* particles need to glow against. Pass a page colour to blend seamlessly, or
* `"transparent"` to float the sphere on whatever is behind it.
*/
background?: string;
/**
* Particle blending. `true` (default) = additive glow — needs a dark backdrop.
* `false` = normal blending, so the sphere reads as solid dots on a light or
* transparent background (no dark panel required).
*/
glow?: boolean;
className?: string;

@@ -61,4 +73,7 @@ }

colors,
background = "#0A0A14",
glow = true,
className,
}: VoiceSphereProps) {
const isTransparent = background === "transparent";
const reduced = useReducedMotion();

@@ -88,3 +103,3 @@ const count = DENSITY[density];

position: "relative",
backgroundColor: "#0A0A14",
backgroundColor: isTransparent ? "transparent" : background,
borderRadius: 24,

@@ -97,3 +112,3 @@ }}

style={{ width: "100%", height: "100%" }}
camera={{ position: [0, 0, 4.2], fov: 45 }}
camera={{ position: [0, 0, 3.4], fov: 45 }}
frameloop="always"

@@ -103,16 +118,23 @@ gl={{

powerPreference: "high-performance",
alpha: isTransparent,
}}
onCreated={(state: any) => {
// Opaque dark clear — particles use additive blending, so the
// background must be dark for them to read as glowing dots.
state.gl.setClearColor("#0A0A14", 1);
// Particles use additive blending, so the clear must be dark (or
// fully transparent) for them to read as glowing dots.
if (isTransparent) {
state.gl.setClearColor("#000000", 0);
} else {
state.gl.setClearColor(background, 1);
}
}}
>
<ambientLight intensity={0.4} />
<Halo
active={active}
amplitude={amplitude}
colorFrom={colorFrom}
reduced={!!reduced}
/>
{glow ? (
<Halo
active={active}
amplitude={amplitude}
colorFrom={colorFrom}
reduced={!!reduced}
/>
) : null}
<ParticleSphere

@@ -125,2 +147,3 @@ count={count}

reduced={!!reduced}
glow={glow}
/>

@@ -144,2 +167,3 @@ </Canvas>

reduced: boolean;
glow: boolean;
}

@@ -154,2 +178,3 @@

reduced,
glow,
}: SphereProps) {

@@ -190,3 +215,3 @@ const meshRef = React.useRef<THREE.Points>(null!);

const energy = u.uAmp.value;
u.uTime.value += delta * (active ? 0.4 + energy * 1.6 : 0.2);
u.uTime.value += delta * (active ? 0.9 + energy * 2.8 : 0.25);

@@ -218,3 +243,3 @@ const targetAmp = amplitude != null ? amplitude : active ? 0.18 : 0.06;

depthWrite={false}
blending={THREE.AdditiveBlending}
blending={glow ? THREE.AdditiveBlending : THREE.NormalBlending}
/>

@@ -221,0 +246,0 @@ </points>

@@ -86,2 +86,3 @@ import type { ComponentMeta } from "../lib/meta";

],
addedAt: "2026-05-28",
};

@@ -16,2 +16,5 @@ export { Button, buttonVariants, buttonTextVariants } from "./components/button";

export { Chat } from "./ai/chat";
export type { ChatProps, ChatMessage, ChatRole, ChatStatus } from "./ai/chat";
export { cn } from "./lib/cn";

@@ -28,2 +31,3 @@ export { duration, easing, spring, PRESS_SCALE } from "./lib/motion";

voiceSphereMeta,
chatMeta,
} from "./lib/meta";

@@ -42,2 +42,9 @@ /**

category: "base" | "ai";
/**
* "primitive" (the default when omitted) for single-purpose components;
* "block" for composite, drop-in screens assembled from primitives (the
* marketed "blocks"). Blocks are surfaced in their own section, separate
* from the primitive component lists.
*/
kind?: "primitive" | "block";
/** One-line description used in cards, headers, and meta tags. */

@@ -55,2 +62,12 @@ description: string;

a11y: string[];
/**
* ISO date (yyyy-mm-dd) the component first shipped. Used by the docs site
* to render a `NEW` badge for the first 30 days after this date.
*/
addedAt?: string;
/**
* ISO date of the last meaningful update (api / motion / design overhaul,
* not a typo fix). Used to render an `UPDATED` badge for 14 days.
*/
updatedAt?: string;
}

@@ -64,1 +81,2 @@

export { meta as voiceSphereMeta } from "../ai/voice-sphere.meta";
export { meta as chatMeta } from "../ai/chat.meta";